├── .github └── workflows │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples ├── smtp_auth.rs ├── smtp_dkim.rs ├── smtp_raw_message.rs ├── smtp_relay.rs └── smtp_tls_relay.rs └── src ├── lib.rs └── smtp ├── auth.rs ├── builder.rs ├── client.rs ├── ehlo.rs ├── envelope.rs ├── message.rs ├── mod.rs └── tls.rs /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | mail-send 0.5.1 2 | ================================ 3 | - Bump `mail-parser` dependency to 0.11 4 | - Bump `mail-auth` dependency to 0.7 5 | - Bump `webpki-roots` dependency to 1.0 6 | 7 | mail-send 0.5.0 8 | ================================ 9 | - Bump `mail-parser` dependency to 0.10 10 | - Bump `mail-builder` dependency to 0.4 11 | - Bump `mail-auth` dependency to 0.6 12 | 13 | mail-send 0.4.9 14 | ================================ 15 | - Bump `rustls` dependency to 0.23 16 | - Bump `tokio-rustls` dependency to 0.26 17 | 18 | mail-send 0.4.8 19 | ================================ 20 | - Bump `mail-auth` dependency to 0.4 21 | - Bump `base64` dependency to 0.22 22 | 23 | mail-send 0.4.7 24 | ================================ 25 | - Added 'parser feature for `Message` conversion. 26 | 27 | mail-send 0.4.6 28 | ================================ 29 | - Improved transparency procedure to also escape . 30 | - Removed `skip-ehlo` feature. 31 | 32 | mail-send 0.4.4 33 | ================================ 34 | - Updated transparency procedure to escape . as well as . to prevent SMTP smuggling on vulnerable servers. 35 | 36 | mail-send 0.4.3 37 | ================================ 38 | - Bump `rustls` dependency to 0.22 39 | 40 | mail-send 0.4.2 41 | ================================ 42 | - Bump `webpki-roots` dependency to 0.26 43 | 44 | mail-send 0.4.1 45 | ================================ 46 | - Bump `webpki-roots` dependency to 0.25 47 | 48 | mail-send 0.4.0 49 | ================================ 50 | - Bump `mail-builder` dependency to 0.3 51 | 52 | mail-send 0.3.3 53 | ================================ 54 | - Bump `rustls` dependency to 0.21 55 | 56 | mail-send 0.3.2 57 | ================================ 58 | - Fix: Extend buffer from bytes read while reading EHLO (#12). 59 | - Add an impl std::error::Error for mail_send::Error (#11) 60 | 61 | mail-send 0.3.1 62 | ================================ 63 | - Fix: plain text connect issues (#10). 64 | 65 | mail-send 0.3.0 66 | ================================ 67 | - Use of generics on TCP streams instead of static dispatch with enums. 68 | - Switch to `mail-auth` for DKIM authentication. 69 | 70 | mail-send 0.2.3 71 | ================================ 72 | - Fix: Send gets stuck when the message has a binary attachment (#7) 73 | 74 | mail-send 0.2.2 75 | ================================ 76 | - Bump up to mail-builder v0.2.2 77 | 78 | mail-send 0.2.1 79 | ================================ 80 | - Fixes to support mail-builder v0.2.1 81 | 82 | mail-send 0.2.0 83 | ================================ 84 | - Removed HTTP support. 85 | - API cleanup. 86 | 87 | mail-send 0.1.0 88 | ================================ 89 | - Initial release. 90 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mail-send" 3 | description = "E-mail delivery library with SMTP and DKIM support" 4 | version = "0.5.1" 5 | edition = "2021" 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.1" } 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.0", optional = true } 24 | md5 = { version = "0.7.0", optional = true } 25 | tokio = { version = "1.23", 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 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /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-2022, Stalwart Labs Ltd. 111 | -------------------------------------------------------------------------------- /examples/smtp_auth.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. See the COPYING 3 | * file at the top-level directory of this distribution. 4 | * 5 | * Licensed under the Apache License, Version 2.0 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use mail_builder::MessageBuilder; 13 | use mail_send::SmtpClientBuilder; 14 | 15 | #[tokio::main] 16 | async fn main() { 17 | // Build a simple multipart message 18 | // More examples of how to build messages available at 19 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 20 | let message = MessageBuilder::new() 21 | .from(("John Doe", "john@example.com")) 22 | .to(vec![ 23 | ("Jane Doe", "jane@example.com"), 24 | ("James Smith", "james@test.com"), 25 | ]) 26 | .subject("Hi!") 27 | .html_body("

Hello, world!

") 28 | .text_body("Hello world!"); 29 | 30 | // Connect to the SMTP submissions port, upgrade to TLS and 31 | // authenticate using the provided credentials. 32 | SmtpClientBuilder::new("smtp.gmail.com", 587) 33 | .implicit_tls(false) 34 | .credentials(("john", "p4ssw0rd")) 35 | .connect() 36 | .await 37 | .unwrap() 38 | .send(message) 39 | .await 40 | .unwrap(); 41 | } 42 | -------------------------------------------------------------------------------- /examples/smtp_dkim.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. See the COPYING 3 | * file at the top-level directory of this distribution. 4 | * 5 | * Licensed under the Apache License, Version 2.0 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use mail_auth::{ 13 | common::crypto::{RsaKey, Sha256}, 14 | dkim::DkimSigner, 15 | }; 16 | use mail_builder::MessageBuilder; 17 | use mail_send::SmtpClientBuilder; 18 | 19 | const TEST_KEY: &str = r#"-----BEGIN RSA PRIVATE KEY----- 20 | MIICXwIBAAKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFC 21 | jxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gb 22 | to/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB 23 | AoGBALmn+XwWk7akvkUlqb+dOxyLB9i5VBVfje89Teolwc9YJT36BGN/l4e0l6QX 24 | /1//6DWUTB3KI6wFcm7TWJcxbS0tcKZX7FsJvUz1SbQnkS54DJck1EZO/BLa5ckJ 25 | gAYIaqlA9C0ZwM6i58lLlPadX/rtHb7pWzeNcZHjKrjM461ZAkEA+itss2nRlmyO 26 | n1/5yDyCluST4dQfO8kAB3toSEVc7DeFeDhnC1mZdjASZNvdHS4gbLIA1hUGEF9m 27 | 3hKsGUMMPwJBAPW5v/U+AWTADFCS22t72NUurgzeAbzb1HWMqO4y4+9Hpjk5wvL/ 28 | eVYizyuce3/fGke7aRYw/ADKygMJdW8H/OcCQQDz5OQb4j2QDpPZc0Nc4QlbvMsj 29 | 7p7otWRO5xRa6SzXqqV3+F0VpqvDmshEBkoCydaYwc2o6WQ5EBmExeV8124XAkEA 30 | qZzGsIxVP+sEVRWZmW6KNFSdVUpk3qzK0Tz/WjQMe5z0UunY9Ax9/4PVhp/j61bf 31 | eAYXunajbBSOLlx4D+TunwJBANkPI5S9iylsbLs6NkaMHV6k5ioHBBmgCak95JGX 32 | GMot/L2x0IYyMLAz6oLWh2hm7zwtb0CgOrPo1ke44hFYnfc= 33 | -----END RSA PRIVATE KEY-----"#; 34 | 35 | #[tokio::main] 36 | async fn main() { 37 | // Build a simple text message with a single attachment 38 | // More examples of how to build messages available at 39 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 40 | let message = MessageBuilder::new() 41 | .from(("John Doe", "john@example.com")) 42 | .to("jane@example.com") 43 | .subject("Howdy!") 44 | .text_body("These pretzels are making me thirsty.") 45 | .attachment("image/png", "pretzels.png", [1, 2, 3, 4].as_ref()); 46 | 47 | // Sign an e-mail message using RSA-SHA256 48 | let pk_rsa = RsaKey::::from_rsa_pem(TEST_KEY).unwrap(); 49 | let signer = DkimSigner::from_key(pk_rsa) 50 | .domain("example.com") 51 | .selector("default") 52 | .headers(["From", "To", "Subject"]) 53 | .expiration(60 * 60 * 7); // Number of seconds before this signature expires (optional) 54 | 55 | // Connect to an SMTP relay server over TLS. 56 | // Signs each message with the configured DKIM signer. 57 | SmtpClientBuilder::new("smtp.gmail.com", 465) 58 | .connect() 59 | .await 60 | .unwrap() 61 | .send_signed(message, &signer) 62 | .await 63 | .unwrap(); 64 | } 65 | -------------------------------------------------------------------------------- /examples/smtp_raw_message.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. See the COPYING 3 | * file at the top-level directory of this distribution. 4 | * 5 | * Licensed under the Apache License, Version 2.0 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use mail_send::smtp::message::Message; 13 | use mail_send::SmtpClientBuilder; 14 | 15 | #[tokio::main] 16 | async fn main() { 17 | // Build a raw message 18 | let message = Message::empty() 19 | .from("jdoe@example.com") 20 | .to("jane@example.com") 21 | .to("james@smith.com") 22 | .body(&b"From: jdoe@example.com\nTo: jane@example.com\nSubject: Hi!\n\nHello, world!"[..]); 23 | 24 | // Connect to an SMTP relay server. 25 | // The library will upgrade the connection to TLS if the server supports it. 26 | SmtpClientBuilder::new("mail.smtp2go.com", 2525) 27 | .implicit_tls(false) 28 | .connect() 29 | .await 30 | .unwrap() 31 | .send(message) 32 | .await 33 | .unwrap(); 34 | } 35 | -------------------------------------------------------------------------------- /examples/smtp_relay.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. See the COPYING 3 | * file at the top-level directory of this distribution. 4 | * 5 | * Licensed under the Apache License, Version 2.0 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use mail_builder::MessageBuilder; 13 | use mail_send::SmtpClientBuilder; 14 | 15 | #[tokio::main] 16 | async fn main() { 17 | // Build a simple text message with a single attachment 18 | // More examples of how to build messages available at 19 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 20 | let message = MessageBuilder::new() 21 | .from(("John Doe", "john@example.com")) 22 | .to("jane@example.com") 23 | .subject("Hello, world!") 24 | .text_body("Hello, world!") 25 | .attachment("image/png", "kittens.png", [1, 2, 3, 4].as_ref()); 26 | 27 | // Connect to an SMTP relay server. 28 | // The library will upgrade the connection to TLS if the server supports it. 29 | SmtpClientBuilder::new("mail.smtp2go.com", 2525) 30 | .implicit_tls(false) 31 | .connect() 32 | .await 33 | .unwrap() 34 | .send(message) 35 | .await 36 | .unwrap(); 37 | } 38 | -------------------------------------------------------------------------------- /examples/smtp_tls_relay.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. See the COPYING 3 | * file at the top-level directory of this distribution. 4 | * 5 | * Licensed under the Apache License, Version 2.0 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use mail_builder::MessageBuilder; 13 | use mail_send::SmtpClientBuilder; 14 | 15 | #[tokio::main] 16 | async fn main() { 17 | // Build a simple html message with a single attachment 18 | // More examples of how to build messages available at 19 | // https://github.com/stalwartlabs/mail-builder/tree/main/examples 20 | let message = MessageBuilder::new() 21 | .from(("John Doe", "john@example.com")) 22 | .to("jane@example.com") 23 | .subject("Hello, world!") 24 | .html_body("

Hello, world!

") 25 | .attachment("image/png", "kittens.png", [1, 2, 3, 4].as_ref()); 26 | 27 | // Connect to an SMTP relay server over TLS 28 | SmtpClientBuilder::new("smtp.gmail.com", 465) 29 | .connect() 30 | .await 31 | .unwrap() 32 | .send(message) 33 | .await 34 | .unwrap(); 35 | } 36 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | //! # mail-send 12 | //! 13 | //! [![crates.io](https://img.shields.io/crates/v/mail-send)](https://crates.io/crates/mail-send) 14 | //! [![build](https://github.com/stalwartlabs/mail-send/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/mail-send/actions/workflows/rust.yml) 15 | //! [![docs.rs](https://img.shields.io/docsrs/mail-send)](https://docs.rs/mail-send) 16 | //! [![crates.io](https://img.shields.io/crates/l/mail-send)](http://www.apache.org/licenses/LICENSE-2.0) 17 | //! 18 | //! _mail-send_ is a Rust library to build, sign and send e-mail messages via SMTP. It includes the following features: 19 | //! 20 | //! - Generates **e-mail** messages conforming to the Internet Message Format standard (_RFC 5322_). 21 | //! - Full **MIME** support (_RFC 2045 - 2049_) with automatic selection of the most optimal encoding for each message body part. 22 | //! - DomainKeys Identified Mail (**DKIM**) Signatures (_RFC 6376_) with ED25519-SHA256, RSA-SHA256 and RSA-SHA1 support. 23 | //! - Simple Mail Transfer Protocol (**SMTP**; _RFC 5321_) delivery. 24 | //! - SMTP Service Extension for Secure SMTP over **TLS** (_RFC 3207_). 25 | //! - SMTP Service Extension for Authentication (_RFC 4954_) with automatic mechanism negotiation (from most secure to least secure): 26 | //! - CRAM-MD5 (_RFC 2195_) 27 | //! - DIGEST-MD5 (_RFC 2831_; obsolete but still supported) 28 | //! - XOAUTH2 (Google proprietary) 29 | //! - LOGIN 30 | //! - PLAIN 31 | //! - Full async (requires Tokio). 32 | //! 33 | //! ## Usage Example 34 | //! 35 | //! Send a message via an SMTP server that requires authentication: 36 | //! 37 | //! ```rust 38 | //! // Build a simple multipart message 39 | //! let message = MessageBuilder::new() 40 | //! .from(("John Doe", "john@example.com")) 41 | //! .to(vec![ 42 | //! ("Jane Doe", "jane@example.com"), 43 | //! ("James Smith", "james@test.com"), 44 | //! ]) 45 | //! .subject("Hi!") 46 | //! .html_body("

Hello, world!

") 47 | //! .text_body("Hello world!"); 48 | //! 49 | //! // Connect to the SMTP submissions port, upgrade to TLS and 50 | //! // authenticate using the provided credentials. 51 | //! SmtpClientBuilder::new("smtp.gmail.com", 587) 52 | //! .implicit_tls(false) 53 | //! .credentials(("john", "p4ssw0rd")) 54 | //! .connect() 55 | //! .await 56 | //! .unwrap() 57 | //! .send(message) 58 | //! .await 59 | //! .unwrap(); 60 | //! ``` 61 | //! 62 | //! Sign a message with DKIM and send it via an SMTP relay server: 63 | //! 64 | //! ```rust 65 | //! // Build a simple text message with a single attachment 66 | //! let message = MessageBuilder::new() 67 | //! .from(("John Doe", "john@example.com")) 68 | //! .to("jane@example.com") 69 | //! .subject("Howdy!") 70 | //! .text_body("These pretzels are making me thirsty.") 71 | //! .attachment("image/png", "pretzels.png", [1, 2, 3, 4].as_ref()); 72 | //! 73 | //! // Sign an e-mail message using RSA-SHA256 74 | //! let pk_rsa = RsaKey::::from_rsa_pem(TEST_KEY).unwrap(); 75 | //! let signer = DkimSigner::from_key(pk_rsa) 76 | //! .domain("example.com") 77 | //! .selector("default") 78 | //! .headers(["From", "To", "Subject"]) 79 | //! .expiration(60 * 60 * 7); // Number of seconds before this signature expires (optional) 80 | //! 81 | //! // Connect to an SMTP relay server over TLS. 82 | //! // Signs each message with the configured DKIM signer. 83 | //! SmtpClientBuilder::new("smtp.gmail.com", 465) 84 | //! .connect() 85 | //! .await 86 | //! .unwrap() 87 | //! .send_signed(message, &signer) 88 | //! .await 89 | //! .unwrap(); 90 | //! ``` 91 | //! 92 | //! More examples of how to build messages are available in the [`mail-builder`](https://crates.io/crates/mail-builder) crate. 93 | //! 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. 94 | //! 95 | //! ## Testing 96 | //! 97 | //! To run the testsuite: 98 | //! 99 | //! ```bash 100 | //! $ cargo test --all-features 101 | //! ``` 102 | //! 103 | //! or, to run the testsuite with MIRI: 104 | //! 105 | //! ```bash 106 | //! $ cargo +nightly miri test --all-features 107 | //! ``` 108 | //! 109 | //! ## License 110 | //! 111 | //! Licensed under either of 112 | //! 113 | //! * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) 114 | //! * MIT license ([LICENSE-MIT](LICENSE-MIT) or ) 115 | //! 116 | //! at your option. 117 | //! 118 | //! ## Copyright 119 | //! 120 | //! Copyright (C) 2020-2022, Stalwart Labs Ltd. 121 | //! 122 | //! See [COPYING] for the license. 123 | //! 124 | //! [COPYING]: https://github.com/stalwartlabs/mail-send/blob/main/COPYING 125 | //! 126 | 127 | pub mod smtp; 128 | use std::{fmt::Display, hash::Hash, time::Duration}; 129 | use tokio::io::{AsyncRead, AsyncWrite}; 130 | use tokio_rustls::TlsConnector; 131 | 132 | #[cfg(feature = "builder")] 133 | pub use mail_builder; 134 | 135 | #[cfg(feature = "dkim")] 136 | pub use mail_auth; 137 | 138 | #[derive(Debug)] 139 | pub enum Error { 140 | /// I/O error 141 | Io(std::io::Error), 142 | 143 | /// TLS error 144 | Tls(Box), 145 | 146 | /// Base64 decode error 147 | Base64(base64::DecodeError), 148 | 149 | // SMTP authentication error. 150 | Auth(smtp::auth::Error), 151 | 152 | /// Failure parsing SMTP reply 153 | UnparseableReply, 154 | 155 | /// Unexpected SMTP reply. 156 | UnexpectedReply(smtp_proto::Response), 157 | 158 | /// SMTP authentication failure. 159 | AuthenticationFailed(smtp_proto::Response), 160 | 161 | /// Invalid TLS name provided. 162 | InvalidTLSName, 163 | 164 | /// Missing authentication credentials. 165 | MissingCredentials, 166 | 167 | /// Missing message sender. 168 | MissingMailFrom, 169 | 170 | /// Missing message recipients. 171 | MissingRcptTo, 172 | 173 | /// The server does no support any of the available authentication methods. 174 | UnsupportedAuthMechanism, 175 | 176 | /// Connection timeout. 177 | Timeout, 178 | 179 | /// STARTTLS not available 180 | MissingStartTls, 181 | } 182 | 183 | impl std::error::Error for Error { 184 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 185 | match self { 186 | Error::Io(ref err) => err.source(), 187 | Error::Tls(ref err) => err.source(), 188 | Error::Base64(ref err) => err.source(), 189 | _ => None, 190 | } 191 | } 192 | } 193 | 194 | pub type Result = std::result::Result; 195 | 196 | /// SMTP client builder 197 | #[derive(Clone)] 198 | pub struct SmtpClientBuilder + PartialEq + Eq + Hash> { 199 | pub timeout: Duration, 200 | pub tls_connector: TlsConnector, 201 | pub tls_hostname: T, 202 | pub tls_implicit: bool, 203 | pub credentials: Option>, 204 | pub addr: String, 205 | pub is_lmtp: bool, 206 | pub say_ehlo: bool, 207 | pub local_host: String, 208 | } 209 | 210 | /// SMTP client builder 211 | pub struct SmtpClient { 212 | pub stream: T, 213 | pub timeout: Duration, 214 | } 215 | 216 | #[derive(Clone, PartialEq, Eq, Hash)] 217 | pub enum Credentials + PartialEq + Eq + Hash> { 218 | Plain { username: T, secret: T }, 219 | OAuthBearer { token: T }, 220 | XOauth2 { username: T, secret: T }, 221 | } 222 | 223 | impl Default for Credentials { 224 | fn default() -> Self { 225 | Credentials::Plain { 226 | username: String::new(), 227 | secret: String::new(), 228 | } 229 | } 230 | } 231 | 232 | impl Display for Error { 233 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 234 | match self { 235 | Error::Io(e) => write!(f, "I/O error: {e}"), 236 | Error::Tls(e) => write!(f, "TLS error: {e}"), 237 | Error::Base64(e) => write!(f, "Base64 decode error: {e}"), 238 | Error::Auth(e) => write!(f, "SMTP authentication error: {e}"), 239 | Error::UnparseableReply => write!(f, "Unparseable SMTP reply"), 240 | Error::UnexpectedReply(e) => write!(f, "Unexpected reply: {e}"), 241 | Error::AuthenticationFailed(e) => write!(f, "Authentication failed: {e}"), 242 | Error::InvalidTLSName => write!(f, "Invalid TLS name provided"), 243 | Error::MissingCredentials => write!(f, "Missing authentication credentials"), 244 | Error::MissingMailFrom => write!(f, "Missing message sender"), 245 | Error::MissingRcptTo => write!(f, "Missing message recipients"), 246 | Error::UnsupportedAuthMechanism => write!( 247 | f, 248 | "The server does no support any of the available authentication methods" 249 | ), 250 | Error::Timeout => write!(f, "Connection timeout"), 251 | Error::MissingStartTls => write!(f, "STARTTLS extension unavailable"), 252 | } 253 | } 254 | } 255 | 256 | impl From for Error { 257 | fn from(err: std::io::Error) -> Self { 258 | Error::Io(err) 259 | } 260 | } 261 | 262 | impl From for Error { 263 | fn from(err: base64::DecodeError) -> Self { 264 | Error::Base64(err) 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/smtp/auth.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use std::{fmt::Display, hash::Hash}; 12 | 13 | use base64::{engine, Engine}; 14 | use smtp_proto::{ 15 | response::generate::BitToString, EhloResponse, AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN, 16 | AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, 17 | }; 18 | use tokio::io::{AsyncRead, AsyncWrite}; 19 | 20 | use crate::{Credentials, SmtpClient}; 21 | 22 | impl SmtpClient { 23 | pub async fn authenticate( 24 | &mut self, 25 | credentials: impl AsRef>, 26 | capabilities: impl AsRef>, 27 | ) -> crate::Result<&mut Self> 28 | where 29 | U: AsRef + PartialEq + Eq + Hash, 30 | { 31 | let credentials = credentials.as_ref(); 32 | let capabilities = capabilities.as_ref(); 33 | let mut available_mechanisms = match &credentials { 34 | Credentials::Plain { .. } => AUTH_CRAM_MD5 | AUTH_DIGEST_MD5 | AUTH_LOGIN | AUTH_PLAIN, 35 | Credentials::OAuthBearer { .. } => AUTH_OAUTHBEARER, 36 | Credentials::XOauth2 { .. } => AUTH_XOAUTH2, 37 | } & capabilities.auth_mechanisms; 38 | 39 | // Try authenticating from most secure to least secure 40 | let mut has_err = None; 41 | let mut has_failed = false; 42 | 43 | while available_mechanisms != 0 && !has_failed { 44 | let mechanism = 1 << ((63 - available_mechanisms.leading_zeros()) as u64); 45 | available_mechanisms ^= mechanism; 46 | match self.auth(mechanism, credentials).await { 47 | Ok(_) => { 48 | return Ok(self); 49 | } 50 | Err(err) => match err { 51 | crate::Error::UnexpectedReply(reply) => { 52 | has_failed = reply.code() == 535; 53 | has_err = reply.into(); 54 | } 55 | crate::Error::UnsupportedAuthMechanism => (), 56 | _ => return Err(err), 57 | }, 58 | } 59 | } 60 | 61 | if let Some(has_err) = has_err { 62 | Err(crate::Error::AuthenticationFailed(has_err)) 63 | } else { 64 | Err(crate::Error::UnsupportedAuthMechanism) 65 | } 66 | } 67 | 68 | pub(crate) async fn auth( 69 | &mut self, 70 | mechanism: u64, 71 | credentials: &Credentials, 72 | ) -> crate::Result<()> 73 | where 74 | U: AsRef + PartialEq + Eq + Hash, 75 | { 76 | let mut reply = if (mechanism & (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER)) != 0 { 77 | self.cmd( 78 | format!( 79 | "AUTH {} {}\r\n", 80 | mechanism.to_mechanism(), 81 | credentials.encode(mechanism, "")?, 82 | ) 83 | .as_bytes(), 84 | ) 85 | .await? 86 | } else { 87 | self.cmd(format!("AUTH {}\r\n", mechanism.to_mechanism()).as_bytes()) 88 | .await? 89 | }; 90 | 91 | for _ in 0..3 { 92 | match reply.code() { 93 | 334 => { 94 | reply = self 95 | .cmd( 96 | format!("{}\r\n", credentials.encode(mechanism, reply.message())?) 97 | .as_bytes(), 98 | ) 99 | .await?; 100 | } 101 | 235 => { 102 | return Ok(()); 103 | } 104 | _ => { 105 | return Err(crate::Error::UnexpectedReply(reply)); 106 | } 107 | } 108 | } 109 | 110 | Err(crate::Error::UnexpectedReply(reply)) 111 | } 112 | } 113 | 114 | #[derive(Debug, Clone)] 115 | pub enum Error { 116 | InvalidChallenge, 117 | } 118 | 119 | impl + PartialEq + Eq + Hash> Credentials { 120 | /// Creates a new `Credentials` instance. 121 | pub fn new(username: T, secret: T) -> Credentials { 122 | Credentials::Plain { username, secret } 123 | } 124 | 125 | /// Creates a new XOAuth2 `Credentials` instance. 126 | pub fn new_xoauth2(username: T, secret: T) -> Credentials { 127 | Credentials::XOauth2 { username, secret } 128 | } 129 | 130 | /// Creates a new OAuthBearer `Credentials` instance. 131 | pub fn new_oauth(payload: T) -> Credentials { 132 | Credentials::OAuthBearer { token: payload } 133 | } 134 | 135 | /// Creates a new OAuthBearer `Credentials` instance from a Bearer token. 136 | pub fn new_oauth_from_token(token: T) -> Credentials { 137 | Credentials::OAuthBearer { 138 | token: format!("auth=Bearer {}\x01\x01", token.as_ref()), 139 | } 140 | } 141 | 142 | pub fn encode(&self, mechanism: u64, challenge: &str) -> crate::Result { 143 | Ok(engine::general_purpose::STANDARD.encode( 144 | match (mechanism, self) { 145 | (AUTH_PLAIN, Credentials::Plain { username, secret }) => { 146 | format!("\u{0}{}\u{0}{}", username.as_ref(), secret.as_ref()) 147 | } 148 | 149 | (AUTH_LOGIN, Credentials::Plain { username, secret }) => { 150 | let challenge = engine::general_purpose::STANDARD.decode(challenge)?; 151 | let username = username.as_ref(); 152 | let secret = secret.as_ref(); 153 | 154 | if b"user name" 155 | .eq_ignore_ascii_case(challenge.get(0..9).ok_or(Error::InvalidChallenge)?) 156 | || b"username".eq_ignore_ascii_case( 157 | // Because Google makes its own standards 158 | challenge.get(0..8).ok_or(Error::InvalidChallenge)?, 159 | ) 160 | { 161 | &username 162 | } else if b"password" 163 | .eq_ignore_ascii_case(challenge.get(0..8).ok_or(Error::InvalidChallenge)?) 164 | { 165 | &secret 166 | } else { 167 | return Err(Error::InvalidChallenge.into()); 168 | } 169 | .to_string() 170 | } 171 | 172 | #[cfg(feature = "digest-md5")] 173 | (AUTH_DIGEST_MD5, Credentials::Plain { username, secret }) => { 174 | let mut buf = Vec::with_capacity(10); 175 | let mut key = None; 176 | let mut in_quote = false; 177 | let mut values = std::collections::HashMap::new(); 178 | let challenge = engine::general_purpose::STANDARD.decode(challenge)?; 179 | let challenge_len = challenge.len(); 180 | let username = username.as_ref(); 181 | let secret = secret.as_ref(); 182 | 183 | for (pos, byte) in challenge.into_iter().enumerate() { 184 | let add_key = match byte { 185 | b'=' if !in_quote => { 186 | if key.is_none() && !buf.is_empty() { 187 | key = String::from_utf8_lossy(&buf).into_owned().into(); 188 | buf.clear(); 189 | } else { 190 | return Err(Error::InvalidChallenge.into()); 191 | } 192 | false 193 | } 194 | b',' if !in_quote => true, 195 | b'"' => { 196 | in_quote = !in_quote; 197 | false 198 | } 199 | _ => { 200 | buf.push(byte); 201 | false 202 | } 203 | }; 204 | 205 | if (add_key || pos == challenge_len - 1) && key.is_some() && !buf.is_empty() 206 | { 207 | values.insert( 208 | key.take().unwrap(), 209 | String::from_utf8_lossy(&buf).into_owned(), 210 | ); 211 | buf.clear(); 212 | } 213 | } 214 | 215 | let (digest_uri, realm, realm_response) = 216 | if let Some(realm) = values.get("realm") { 217 | ( 218 | format!("smtp/{realm}"), 219 | realm.as_str(), 220 | format!(",realm=\"{realm}\""), 221 | ) 222 | } else { 223 | ("smtp/localhost".to_string(), "", "".to_string()) 224 | }; 225 | 226 | let credentials = 227 | md5::compute(format!("{username}:{realm}:{secret}").as_bytes()); 228 | 229 | let a2 = md5::compute( 230 | if values.get("qpop").is_some_and(|v| v == "auth") { 231 | format!("AUTHENTICATE:{digest_uri}") 232 | } else { 233 | format!("AUTHENTICATE:{digest_uri}:00000000000000000000000000000000") 234 | } 235 | .as_bytes(), 236 | ); 237 | 238 | #[allow(unused_variables)] 239 | let cnonce = { 240 | use rand::RngCore; 241 | let mut buf = [0u8; 16]; 242 | rand::rng().fill_bytes(&mut buf); 243 | engine::general_purpose::STANDARD.encode(buf) 244 | }; 245 | 246 | #[cfg(test)] 247 | let cnonce = "OA6MHXh6VqTrRk".to_string(); 248 | let nonce = values.remove("nonce").unwrap_or_default(); 249 | let qop = values.remove("qop").unwrap_or_default(); 250 | let charset = values 251 | .remove("charset") 252 | .unwrap_or_else(|| "utf-8".to_string()); 253 | 254 | format!( 255 | concat!( 256 | "charset={},username=\"{}\",realm=\"{}\",nonce=\"{}\",nc=00000001,", 257 | "cnonce=\"{}\",digest-uri=\"{}\",response={:x},qop={}" 258 | ), 259 | charset, 260 | username, 261 | realm_response, 262 | nonce, 263 | cnonce, 264 | digest_uri, 265 | md5::compute( 266 | format!("{credentials:x}:{nonce}:00000001:{cnonce}:{qop}:{a2:x}") 267 | .as_bytes() 268 | ), 269 | qop 270 | ) 271 | } 272 | 273 | #[cfg(feature = "cram-md5")] 274 | (AUTH_CRAM_MD5, Credentials::Plain { username, secret }) => { 275 | let mut secret_opad: Vec = vec![0x5c; 64]; 276 | let mut secret_ipad: Vec = vec![0x36; 64]; 277 | let username = username.as_ref(); 278 | let secret = secret.as_ref(); 279 | 280 | if secret.len() < 64 { 281 | for (pos, byte) in secret.as_bytes().iter().enumerate() { 282 | secret_opad[pos] = *byte ^ 0x5c; 283 | secret_ipad[pos] = *byte ^ 0x36; 284 | } 285 | } else { 286 | for (pos, byte) in md5::compute(secret.as_bytes()).iter().enumerate() { 287 | secret_opad[pos] = *byte ^ 0x5c; 288 | secret_ipad[pos] = *byte ^ 0x36; 289 | } 290 | } 291 | 292 | secret_ipad 293 | .extend_from_slice(&engine::general_purpose::STANDARD.decode(challenge)?); 294 | secret_opad.extend_from_slice(&md5::compute(&secret_ipad).0); 295 | 296 | format!("{} {:x}", username, md5::compute(&secret_opad)) 297 | } 298 | 299 | (AUTH_XOAUTH2, Credentials::XOauth2 { username, secret }) => format!( 300 | "user={}\x01auth=Bearer {}\x01\x01", 301 | username.as_ref(), 302 | secret.as_ref() 303 | ), 304 | (AUTH_OAUTHBEARER, Credentials::OAuthBearer { token }) => { 305 | token.as_ref().to_string() 306 | } 307 | _ => return Err(crate::Error::UnsupportedAuthMechanism), 308 | } 309 | .as_bytes(), 310 | )) 311 | } 312 | } 313 | 314 | impl<'x> From<(&'x str, &'x str)> for Credentials<&'x str> { 315 | fn from(credentials: (&'x str, &'x str)) -> Self { 316 | Credentials::Plain { 317 | username: credentials.0, 318 | secret: credentials.1, 319 | } 320 | } 321 | } 322 | 323 | impl From<(String, String)> for Credentials { 324 | fn from(credentials: (String, String)) -> Self { 325 | Credentials::Plain { 326 | username: credentials.0, 327 | secret: credentials.1, 328 | } 329 | } 330 | } 331 | 332 | impl + PartialEq + Eq + Hash> AsRef> for Credentials { 333 | fn as_ref(&self) -> &Credentials { 334 | self 335 | } 336 | } 337 | 338 | impl Display for Error { 339 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 340 | match self { 341 | Error::InvalidChallenge => write!(f, "Invalid challenge received."), 342 | } 343 | } 344 | } 345 | 346 | #[cfg(test)] 347 | mod test { 348 | 349 | use smtp_proto::{AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN, AUTH_PLAIN, AUTH_XOAUTH2}; 350 | 351 | use crate::smtp::auth::Credentials; 352 | 353 | #[test] 354 | fn auth_encode() { 355 | // Digest-MD5 356 | #[cfg(feature = "digest-md5")] 357 | assert_eq!( 358 | Credentials::new("chris", "secret") 359 | .encode( 360 | AUTH_DIGEST_MD5, 361 | concat!( 362 | "cmVhbG09ImVsd29vZC5pbm5vc29mdC5jb20iLG5vbmNlPSJPQTZNRzl0", 363 | "RVFHbTJoaCIscW9wPSJhdXRoIixhbGdvcml0aG09bWQ1LXNlc3MsY2hh", 364 | "cnNldD11dGYtOA==" 365 | ), 366 | ) 367 | .unwrap(), 368 | concat!( 369 | "Y2hhcnNldD11dGYtOCx1c2VybmFtZT0iY2hyaXMiLHJlYWxtPSIscmVhbG0", 370 | "9ImVsd29vZC5pbm5vc29mdC5jb20iIixub25jZT0iT0E2TUc5dEVRR20yaG", 371 | "giLG5jPTAwMDAwMDAxLGNub25jZT0iT0E2TUhYaDZWcVRyUmsiLGRpZ2Vzd", 372 | "C11cmk9InNtdHAvZWx3b29kLmlubm9zb2Z0LmNvbSIscmVzcG9uc2U9NDQ2", 373 | "NjIxODg3MzlmYzcxOGNlYmYyZjA4MTk4MWI4ZDIscW9wPWF1dGg=", 374 | ) 375 | ); 376 | 377 | // Challenge-Response Authentication Mechanism (CRAM) 378 | #[cfg(feature = "cram-md5")] 379 | assert_eq!( 380 | Credentials::new("tim", "tanstaaftanstaaf") 381 | .encode( 382 | AUTH_CRAM_MD5, 383 | "PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+", 384 | ) 385 | .unwrap(), 386 | "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw" 387 | ); 388 | 389 | // SASL XOAUTH2 390 | assert_eq!( 391 | Credentials::XOauth2 { 392 | username: "someuser@example.com", 393 | secret: "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg" 394 | } 395 | .encode(AUTH_XOAUTH2, "",) 396 | .unwrap(), 397 | concat!( 398 | "dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5Ln", 399 | "ZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ==" 400 | ) 401 | ); 402 | 403 | // Login 404 | assert_eq!( 405 | Credentials::new("tim", "tanstaaftanstaaf") 406 | .encode(AUTH_LOGIN, "VXNlciBOYW1lAA==",) 407 | .unwrap(), 408 | "dGlt" 409 | ); 410 | assert_eq!( 411 | Credentials::new("tim", "tanstaaftanstaaf") 412 | .encode(AUTH_LOGIN, "UGFzc3dvcmQA",) 413 | .unwrap(), 414 | "dGFuc3RhYWZ0YW5zdGFhZg==" 415 | ); 416 | 417 | // Plain 418 | assert_eq!( 419 | Credentials::new("tim", "tanstaaftanstaaf") 420 | .encode(AUTH_PLAIN, "",) 421 | .unwrap(), 422 | "AHRpbQB0YW5zdGFhZnRhbnN0YWFm" 423 | ); 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /src/smtp/builder.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use smtp_proto::{EhloResponse, EXT_START_TLS}; 12 | use std::hash::Hash; 13 | use std::time::Duration; 14 | use tokio::{ 15 | io::{AsyncRead, AsyncWrite}, 16 | net::TcpStream, 17 | }; 18 | use tokio_rustls::client::TlsStream; 19 | 20 | use crate::{Credentials, SmtpClient, SmtpClientBuilder}; 21 | 22 | use super::{tls::build_tls_connector, AssertReply}; 23 | 24 | impl + PartialEq + Eq + Hash> SmtpClientBuilder { 25 | pub fn new(hostname: T, port: u16) -> Self { 26 | SmtpClientBuilder { 27 | addr: format!("{}:{}", hostname.as_ref(), port), 28 | timeout: Duration::from_secs(60 * 60), 29 | tls_connector: build_tls_connector(false), 30 | tls_hostname: hostname, 31 | tls_implicit: true, 32 | is_lmtp: false, 33 | local_host: gethostname::gethostname() 34 | .to_str() 35 | .unwrap_or("[127.0.0.1]") 36 | .to_string(), 37 | credentials: None, 38 | say_ehlo: true, 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 | /// Connect over TLS 85 | pub async fn connect(&self) -> crate::Result>> { 86 | tokio::time::timeout(self.timeout, async { 87 | let mut client = SmtpClient { 88 | stream: TcpStream::connect(&self.addr).await?, 89 | timeout: self.timeout, 90 | }; 91 | 92 | let mut client = if self.tls_implicit { 93 | let mut client = client 94 | .into_tls(&self.tls_connector, self.tls_hostname.as_ref()) 95 | .await?; 96 | // Read greeting 97 | client.read().await?.assert_positive_completion()?; 98 | client 99 | } else { 100 | // Read greeting 101 | client.read().await?.assert_positive_completion()?; 102 | 103 | // Send EHLO 104 | let response = if !self.is_lmtp { 105 | client.ehlo(&self.local_host).await? 106 | } else { 107 | client.lhlo(&self.local_host).await? 108 | }; 109 | if response.has_capability(EXT_START_TLS) { 110 | client 111 | .start_tls(&self.tls_connector, self.tls_hostname.as_ref()) 112 | .await? 113 | } else { 114 | return Err(crate::Error::MissingStartTls); 115 | } 116 | }; 117 | 118 | if self.say_ehlo { 119 | // Obtain capabilities 120 | let capabilities = client.capabilities(&self.local_host, self.is_lmtp).await?; 121 | // Authenticate 122 | if let Some(credentials) = &self.credentials { 123 | client.authenticate(&credentials, &capabilities).await?; 124 | } 125 | } 126 | 127 | Ok(client) 128 | }) 129 | .await 130 | .map_err(|_| crate::Error::Timeout)? 131 | } 132 | 133 | /// Connect over clear text (should not be used) 134 | pub async fn connect_plain(&self) -> crate::Result> { 135 | let mut client = SmtpClient { 136 | stream: tokio::time::timeout(self.timeout, async { 137 | TcpStream::connect(&self.addr).await 138 | }) 139 | .await 140 | .map_err(|_| crate::Error::Timeout)??, 141 | timeout: self.timeout, 142 | }; 143 | 144 | // Read greeting 145 | client.read().await?.assert_positive_completion()?; 146 | 147 | if self.say_ehlo { 148 | // Obtain capabilities 149 | let capabilities = client.capabilities(&self.local_host, self.is_lmtp).await?; 150 | // Authenticate 151 | if let Some(credentials) = &self.credentials { 152 | client.authenticate(&credentials, &capabilities).await?; 153 | } 154 | } 155 | 156 | Ok(client) 157 | } 158 | } 159 | 160 | impl SmtpClient { 161 | pub async fn capabilities( 162 | &mut self, 163 | local_host: &str, 164 | is_lmtp: bool, 165 | ) -> crate::Result> { 166 | if !is_lmtp { 167 | self.ehlo(local_host).await 168 | } else { 169 | self.lhlo(local_host).await 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/smtp/client.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use std::{ 12 | net::{IpAddr, SocketAddr}, 13 | time::Duration, 14 | }; 15 | 16 | use smtp_proto::{response::parser::ResponseReceiver, Response}; 17 | use tokio::{ 18 | io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, 19 | net::{TcpSocket, TcpStream}, 20 | }; 21 | 22 | use crate::SmtpClient; 23 | 24 | impl SmtpClient { 25 | pub async fn read(&mut self) -> crate::Result> { 26 | let mut buf = vec![0u8; 1024]; 27 | let mut parser = ResponseReceiver::default(); 28 | 29 | loop { 30 | let br = self.stream.read(&mut buf).await?; 31 | 32 | if br > 0 { 33 | match parser.parse(&mut buf[..br].iter()) { 34 | Ok(reply) => return Ok(reply), 35 | Err(err) => match err { 36 | smtp_proto::Error::NeedsMoreData { .. } => (), 37 | _ => { 38 | return Err(crate::Error::UnparseableReply); 39 | } 40 | }, 41 | } 42 | } else { 43 | return Err(crate::Error::UnparseableReply); 44 | } 45 | } 46 | } 47 | 48 | pub async fn read_many(&mut self, num: usize) -> crate::Result>> { 49 | let mut buf = vec![0u8; 1024]; 50 | let mut response = Vec::with_capacity(num); 51 | let mut parser = ResponseReceiver::default(); 52 | 53 | 'outer: loop { 54 | let br = self.stream.read(&mut buf).await?; 55 | 56 | if br > 0 { 57 | let mut iter = buf[..br].iter(); 58 | 59 | loop { 60 | match parser.parse(&mut iter) { 61 | Ok(reply) => { 62 | response.push(reply); 63 | if response.len() != num { 64 | parser.reset(); 65 | } else { 66 | break 'outer; 67 | } 68 | } 69 | Err(err) => match err { 70 | smtp_proto::Error::NeedsMoreData { .. } => break, 71 | _ => { 72 | return Err(crate::Error::UnparseableReply); 73 | } 74 | }, 75 | } 76 | } 77 | } else { 78 | return Err(crate::Error::UnparseableReply); 79 | } 80 | } 81 | 82 | Ok(response) 83 | } 84 | 85 | /// Sends a command to the SMTP server and waits for a reply. 86 | pub async fn cmd(&mut self, cmd: impl AsRef<[u8]>) -> crate::Result> { 87 | tokio::time::timeout(self.timeout, async { 88 | self.stream.write_all(cmd.as_ref()).await?; 89 | self.stream.flush().await?; 90 | self.read().await 91 | }) 92 | .await 93 | .map_err(|_| crate::Error::Timeout)? 94 | } 95 | 96 | /// Pipelines multiple command to the SMTP server and waits for a reply. 97 | pub async fn cmds( 98 | &mut self, 99 | cmds: impl IntoIterator>, 100 | ) -> crate::Result>> { 101 | tokio::time::timeout(self.timeout, async { 102 | let mut num_replies = 0; 103 | for cmd in cmds { 104 | self.stream.write_all(cmd.as_ref()).await?; 105 | num_replies += 1; 106 | } 107 | self.stream.flush().await?; 108 | self.read_many(num_replies).await 109 | }) 110 | .await 111 | .map_err(|_| crate::Error::Timeout)? 112 | } 113 | } 114 | 115 | impl SmtpClient { 116 | /// Connects to a remote host address 117 | pub async fn connect(remote_addr: SocketAddr, timeout: Duration) -> crate::Result { 118 | tokio::time::timeout(timeout, async { 119 | Ok(SmtpClient { 120 | stream: TcpStream::connect(remote_addr).await?, 121 | timeout, 122 | }) 123 | }) 124 | .await 125 | .map_err(|_| crate::Error::Timeout)? 126 | } 127 | 128 | /// Connects to a remote host address using the provided local IP 129 | pub async fn connect_using( 130 | local_ip: IpAddr, 131 | remote_addr: SocketAddr, 132 | timeout: Duration, 133 | ) -> crate::Result { 134 | tokio::time::timeout(timeout, async { 135 | let socket = if local_ip.is_ipv4() { 136 | TcpSocket::new_v4()? 137 | } else { 138 | TcpSocket::new_v6()? 139 | }; 140 | socket.bind(SocketAddr::new(local_ip, 0))?; 141 | 142 | Ok(SmtpClient { 143 | stream: socket.connect(remote_addr).await?, 144 | timeout, 145 | }) 146 | }) 147 | .await 148 | .map_err(|_| crate::Error::Timeout)? 149 | } 150 | } 151 | 152 | #[cfg(test)] 153 | mod test { 154 | use std::time::Duration; 155 | 156 | use tokio::io::{AsyncRead, AsyncWrite}; 157 | 158 | use crate::{SmtpClient, SmtpClientBuilder}; 159 | 160 | #[tokio::test] 161 | async fn smtp_basic() { 162 | // StartTLS test 163 | env_logger::init(); 164 | let client = SmtpClientBuilder::new("mail.smtp2go.com", 2525) 165 | .implicit_tls(false) 166 | .connect() 167 | .await 168 | .unwrap(); 169 | client.quit().await.unwrap(); 170 | let client = SmtpClientBuilder::new("mail.smtp2go.com", 2525) 171 | .allow_invalid_certs() 172 | .implicit_tls(false) 173 | .connect() 174 | .await 175 | .unwrap(); 176 | client.quit().await.unwrap(); 177 | 178 | // Say hello to Google over TLS and quit 179 | let client = SmtpClientBuilder::new("smtp.gmail.com", 465) 180 | .connect() 181 | .await 182 | .unwrap(); 183 | client.quit().await.unwrap(); 184 | 185 | // Say hello to Google over TLS and quit 186 | let client = SmtpClientBuilder::new("smtp.gmail.com", 465) 187 | .allow_invalid_certs() 188 | .connect() 189 | .await 190 | .unwrap(); 191 | client.quit().await.unwrap(); 192 | } 193 | 194 | #[derive(Default)] 195 | struct AsyncBufWriter { 196 | buf: Vec, 197 | } 198 | 199 | impl AsyncRead for AsyncBufWriter { 200 | fn poll_read( 201 | self: std::pin::Pin<&mut Self>, 202 | _cx: &mut std::task::Context<'_>, 203 | _buf: &mut tokio::io::ReadBuf<'_>, 204 | ) -> std::task::Poll> { 205 | unreachable!() 206 | } 207 | } 208 | 209 | impl AsyncWrite for AsyncBufWriter { 210 | fn poll_write( 211 | mut self: std::pin::Pin<&mut Self>, 212 | _cx: &mut std::task::Context<'_>, 213 | buf: &[u8], 214 | ) -> std::task::Poll> { 215 | self.buf.extend_from_slice(buf); 216 | std::task::Poll::Ready(Ok(buf.len())) 217 | } 218 | 219 | fn poll_flush( 220 | self: std::pin::Pin<&mut Self>, 221 | _cx: &mut std::task::Context<'_>, 222 | ) -> std::task::Poll> { 223 | std::task::Poll::Ready(Ok(())) 224 | } 225 | 226 | fn poll_shutdown( 227 | self: std::pin::Pin<&mut Self>, 228 | _cx: &mut std::task::Context<'_>, 229 | ) -> std::task::Poll> { 230 | std::task::Poll::Ready(Ok(())) 231 | } 232 | } 233 | 234 | #[tokio::test] 235 | async fn transparency_procedure() { 236 | const SMUGGLER: &str = r#"From: Joe SixPack 237 | To: Suzie Q 238 | Subject: Is dinner ready? 239 | 240 | Hi. 241 | 242 | We lost the game. Are you hungry yet? 243 | 244 | Joe. 245 | 246 | . 247 | MAIL FROM: 248 | RCPT TO: 249 | DATA 250 | From: Joe SixPack 251 | To: Suzie Q 252 | Subject: smuggled message 253 | 254 | This is a smuggled message 255 | "#; 256 | 257 | for (test, result) in [ 258 | ( 259 | "A: b\r\n.\r\n".to_string(), 260 | "A: b\r\n..\r\n\r\n.\r\n".to_string(), 261 | ), 262 | ("A: b\r\n.".to_string(), "A: b\r\n..\r\n.\r\n".to_string()), 263 | ( 264 | "A: b\r\n..\r\n".to_string(), 265 | "A: b\r\n...\r\n\r\n.\r\n".to_string(), 266 | ), 267 | ("A: ...b".to_string(), "A: ...b\r\n.\r\n".to_string()), 268 | ( 269 | "A: \n.\r\nMAIL FROM:<>".to_string(), 270 | "A: \n..\r\nMAIL FROM:<>\r\n.\r\n".to_string(), 271 | ), 272 | ( 273 | "A: \r.\r\nMAIL FROM:<>".to_string(), 274 | "A: \r..\r\nMAIL FROM:<>\r\n.\r\n".to_string(), 275 | ), 276 | ( 277 | SMUGGLER 278 | .replace('\r', "") 279 | .replace('\n', "\r\n") 280 | .replace("", "\r"), 281 | SMUGGLER 282 | .replace('\r', "") 283 | .replace('\n', "\r\n") 284 | .replace("", "\r.") 285 | + "\r\n.\r\n", 286 | ), 287 | ( 288 | SMUGGLER 289 | .replace('\r', "") 290 | .replace('\n', "\r\n") 291 | .replace("", "\n"), 292 | SMUGGLER 293 | .replace('\r', "") 294 | .replace('\n', "\r\n") 295 | .replace("", "\n.") 296 | + "\r\n.\r\n", 297 | ), 298 | ] { 299 | let mut client = SmtpClient { 300 | stream: AsyncBufWriter::default(), 301 | timeout: Duration::from_secs(30), 302 | }; 303 | client.write_message(test.as_bytes()).await.unwrap(); 304 | assert_eq!(String::from_utf8(client.stream.buf).unwrap(), result); 305 | } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/smtp/ehlo.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use smtp_proto::{ 12 | response::parser::{ResponseReceiver, MAX_RESPONSE_LENGTH}, 13 | EhloResponse, 14 | }; 15 | use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; 16 | 17 | use crate::SmtpClient; 18 | 19 | impl SmtpClient { 20 | /// Sends a EHLO command to the server. 21 | pub async fn ehlo(&mut self, hostname: &str) -> crate::Result> { 22 | tokio::time::timeout(self.timeout, async { 23 | self.stream 24 | .write_all(format!("EHLO {hostname}\r\n").as_bytes()) 25 | .await?; 26 | self.stream.flush().await?; 27 | self.read_ehlo().await 28 | }) 29 | .await 30 | .map_err(|_| crate::Error::Timeout)? 31 | } 32 | 33 | /// Sends a LHLO command to the server. 34 | pub async fn lhlo(&mut self, hostname: &str) -> crate::Result> { 35 | tokio::time::timeout(self.timeout, async { 36 | self.stream 37 | .write_all(format!("LHLO {hostname}\r\n").as_bytes()) 38 | .await?; 39 | self.stream.flush().await?; 40 | self.read_ehlo().await 41 | }) 42 | .await 43 | .map_err(|_| crate::Error::Timeout)? 44 | } 45 | 46 | pub async fn read_ehlo(&mut self) -> crate::Result> { 47 | let mut buf = vec![0u8; 1024]; 48 | let mut buf_concat = Vec::with_capacity(0); 49 | 50 | loop { 51 | let br = self.stream.read(&mut buf).await?; 52 | 53 | if br == 0 { 54 | return Err(crate::Error::UnparseableReply); 55 | } 56 | let mut iter = if buf_concat.is_empty() { 57 | buf[..br].iter() 58 | } else if br + buf_concat.len() < MAX_RESPONSE_LENGTH { 59 | buf_concat.extend_from_slice(&buf[..br]); 60 | buf_concat.iter() 61 | } else { 62 | return Err(crate::Error::UnparseableReply); 63 | }; 64 | 65 | match EhloResponse::parse(&mut iter) { 66 | Ok(reply) => return Ok(reply), 67 | Err(err) => match err { 68 | smtp_proto::Error::NeedsMoreData { .. } => { 69 | if buf_concat.is_empty() { 70 | buf_concat = buf[..br].to_vec(); 71 | } 72 | } 73 | smtp_proto::Error::InvalidResponse { code } => { 74 | match ResponseReceiver::from_code(code).parse(&mut iter) { 75 | Ok(response) => { 76 | return Err(crate::Error::UnexpectedReply(response)); 77 | } 78 | Err(smtp_proto::Error::NeedsMoreData { .. }) => { 79 | if buf_concat.is_empty() { 80 | buf_concat = buf[..br].to_vec(); 81 | } 82 | } 83 | Err(_) => return Err(crate::Error::UnparseableReply), 84 | } 85 | } 86 | _ => { 87 | return Err(crate::Error::UnparseableReply); 88 | } 89 | }, 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/smtp/envelope.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; 12 | 13 | use crate::SmtpClient; 14 | 15 | use super::{message::Parameters, AssertReply}; 16 | 17 | impl SmtpClient { 18 | /// Sends a MAIL FROM command to the server. 19 | pub async fn mail_from(&mut self, addr: &str, params: &Parameters<'_>) -> crate::Result<()> { 20 | self.cmd(format!("MAIL FROM:<{addr}>{params}\r\n").as_bytes()) 21 | .await? 22 | .assert_positive_completion() 23 | } 24 | 25 | /// Sends a RCPT TO command to the server. 26 | pub async fn rcpt_to(&mut self, addr: &str, params: &Parameters<'_>) -> crate::Result<()> { 27 | self.cmd(format!("RCPT TO:<{addr}>{params}\r\n").as_bytes()) 28 | .await? 29 | .assert_positive_completion() 30 | } 31 | 32 | /// Sends a DATA command to the server. 33 | pub async fn data(&mut self, message: impl AsRef<[u8]>) -> crate::Result<()> { 34 | self.cmd(b"DATA\r\n").await?.assert_code(354)?; 35 | tokio::time::timeout(self.timeout, async { 36 | // Write message 37 | self.write_message(message.as_ref()).await?; 38 | self.read().await 39 | }) 40 | .await 41 | .map_err(|_| crate::Error::Timeout)?? 42 | .assert_positive_completion() 43 | } 44 | 45 | /// Sends a BDAT command to the server. 46 | pub async fn bdat(&mut self, message: impl AsRef<[u8]>) -> crate::Result<()> { 47 | let message = message.as_ref(); 48 | tokio::time::timeout(self.timeout, async { 49 | self.stream 50 | .write_all(format!("BDAT {} LAST\r\n", message.len()).as_bytes()) 51 | .await?; 52 | self.stream.write_all(message).await?; 53 | self.stream.flush().await?; 54 | self.read().await 55 | }) 56 | .await 57 | .map_err(|_| crate::Error::Timeout)?? 58 | .assert_positive_completion() 59 | } 60 | 61 | /// Sends a RSET command to the server. 62 | pub async fn rset(&mut self) -> crate::Result<()> { 63 | self.cmd(b"RSET\r\n").await?.assert_positive_completion() 64 | } 65 | 66 | /// Sends a NOOP command to the server. 67 | pub async fn noop(&mut self) -> crate::Result<()> { 68 | self.cmd(b"NOOP\r\n").await?.assert_positive_completion() 69 | } 70 | 71 | /// Sends a QUIT command to the server. 72 | pub async fn quit(mut self) -> crate::Result<()> { 73 | self.cmd(b"QUIT\r\n").await?.assert_positive_completion() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/smtp/message.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use std::{ 12 | borrow::Cow, 13 | fmt::{Debug, Display}, 14 | }; 15 | 16 | #[cfg(feature = "builder")] 17 | use mail_builder::{ 18 | headers::{address, HeaderType}, 19 | MessageBuilder, 20 | }; 21 | #[cfg(feature = "parser")] 22 | use mail_parser::{HeaderName, HeaderValue}; 23 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; 24 | 25 | use crate::SmtpClient; 26 | 27 | #[derive(Debug, Default, Clone)] 28 | pub struct Message<'x> { 29 | pub mail_from: Address<'x>, 30 | pub rcpt_to: Vec>, 31 | pub body: Cow<'x, [u8]>, 32 | } 33 | 34 | #[derive(Debug, Default, Clone)] 35 | pub struct Address<'x> { 36 | pub email: Cow<'x, str>, 37 | pub parameters: Parameters<'x>, 38 | } 39 | 40 | #[derive(Debug, Default, Clone)] 41 | pub struct Parameters<'x> { 42 | params: Vec>, 43 | } 44 | 45 | #[derive(Debug, Default, Clone)] 46 | pub struct Parameter<'x> { 47 | key: Cow<'x, str>, 48 | value: Option>, 49 | } 50 | 51 | impl SmtpClient { 52 | /// Sends a message to the server. 53 | pub async fn send<'x>(&mut self, message: impl IntoMessage<'x>) -> crate::Result<()> { 54 | // Send mail-from 55 | let message = message.into_message()?; 56 | self.mail_from( 57 | message.mail_from.email.as_ref(), 58 | &message.mail_from.parameters, 59 | ) 60 | .await?; 61 | 62 | // Send rcpt-to 63 | for rcpt in &message.rcpt_to { 64 | self.rcpt_to(rcpt.email.as_ref(), &rcpt.parameters).await?; 65 | } 66 | 67 | // Send message 68 | self.data(message.body.as_ref()).await 69 | } 70 | 71 | /// Sends a message to the server. 72 | #[cfg(feature = "dkim")] 73 | pub async fn send_signed<'x, V: mail_auth::common::crypto::SigningKey>( 74 | &mut self, 75 | message: impl IntoMessage<'x>, 76 | signer: &mail_auth::dkim::DkimSigner, 77 | ) -> crate::Result<()> { 78 | // Send mail-from 79 | 80 | use mail_auth::common::headers::HeaderWriter; 81 | let message = message.into_message()?; 82 | self.mail_from( 83 | message.mail_from.email.as_ref(), 84 | &message.mail_from.parameters, 85 | ) 86 | .await?; 87 | 88 | // Send rcpt-to 89 | for rcpt in &message.rcpt_to { 90 | self.rcpt_to(rcpt.email.as_ref(), &rcpt.parameters).await?; 91 | } 92 | 93 | // Sign message 94 | let signature = signer 95 | .sign(message.body.as_ref()) 96 | .map_err(|_| crate::Error::MissingCredentials)?; 97 | let mut signed_message = Vec::with_capacity(message.body.len() + 64); 98 | signature.write_header(&mut signed_message); 99 | signed_message.extend_from_slice(message.body.as_ref()); 100 | 101 | // Send message 102 | self.data(&signed_message).await 103 | } 104 | 105 | pub async fn write_message(&mut self, message: &[u8]) -> tokio::io::Result<()> { 106 | // Transparency procedure 107 | let mut is_cr_or_lf = false; 108 | 109 | // As per RFC 5322bis, section 2.3: 110 | // CR and LF MUST only occur together as CRLF; they MUST NOT appear 111 | // independently in the body. 112 | // For this reason, we apply the transparency procedure when there is 113 | // a CR or LF followed by a dot. 114 | 115 | let mut last_pos = 0; 116 | for (pos, byte) in message.iter().enumerate() { 117 | if *byte == b'.' && is_cr_or_lf { 118 | if let Some(bytes) = message.get(last_pos..pos) { 119 | self.stream.write_all(bytes).await?; 120 | self.stream.write_all(b".").await?; 121 | last_pos = pos; 122 | } 123 | is_cr_or_lf = false; 124 | } else { 125 | is_cr_or_lf = *byte == b'\n' || *byte == b'\r'; 126 | } 127 | } 128 | if let Some(bytes) = message.get(last_pos..) { 129 | self.stream.write_all(bytes).await?; 130 | } 131 | self.stream.write_all("\r\n.\r\n".as_bytes()).await?; 132 | self.stream.flush().await 133 | } 134 | } 135 | 136 | impl<'x> Message<'x> { 137 | /// Create a new message 138 | pub fn new(from: T, to: U, body: V) -> Self 139 | where 140 | T: Into>, 141 | U: IntoIterator, 142 | V: Into>, 143 | { 144 | Message { 145 | mail_from: from.into(), 146 | rcpt_to: to.into_iter().map(Into::into).collect(), 147 | body: body.into(), 148 | } 149 | } 150 | 151 | /// Create a new empty message. 152 | pub fn empty() -> Self { 153 | Message { 154 | mail_from: Address::default(), 155 | rcpt_to: Vec::new(), 156 | body: Default::default(), 157 | } 158 | } 159 | 160 | /// Set the sender of the message. 161 | pub fn from(mut self, address: impl Into>) -> Self { 162 | self.mail_from = address.into(); 163 | self 164 | } 165 | 166 | /// Add a message recipient. 167 | pub fn to(mut self, address: impl Into>) -> Self { 168 | self.rcpt_to.push(address.into()); 169 | self 170 | } 171 | 172 | /// Set the message body. 173 | pub fn body(mut self, body: impl Into>) -> Self { 174 | self.body = body.into(); 175 | self 176 | } 177 | } 178 | 179 | impl<'x> From<&'x str> for Address<'x> { 180 | fn from(email: &'x str) -> Self { 181 | Address { 182 | email: email.into(), 183 | parameters: Parameters::default(), 184 | } 185 | } 186 | } 187 | 188 | impl From for Address<'_> { 189 | fn from(email: String) -> Self { 190 | Address { 191 | email: email.into(), 192 | parameters: Parameters::default(), 193 | } 194 | } 195 | } 196 | 197 | impl<'x> Address<'x> { 198 | pub fn new(email: impl Into>, parameters: Parameters<'x>) -> Self { 199 | Address { 200 | email: email.into(), 201 | parameters, 202 | } 203 | } 204 | } 205 | 206 | impl<'x> Parameters<'x> { 207 | pub fn new() -> Self { 208 | Self { params: Vec::new() } 209 | } 210 | 211 | pub fn add(&mut self, param: impl Into>) -> &mut Self { 212 | self.params.push(param.into()); 213 | self 214 | } 215 | } 216 | 217 | impl<'x> From<&'x str> for Parameter<'x> { 218 | fn from(value: &'x str) -> Self { 219 | Parameter { 220 | key: value.into(), 221 | value: None, 222 | } 223 | } 224 | } 225 | 226 | impl<'x> From<(&'x str, &'x str)> for Parameter<'x> { 227 | fn from(value: (&'x str, &'x str)) -> Self { 228 | Parameter { 229 | key: value.0.into(), 230 | value: Some(value.1.into()), 231 | } 232 | } 233 | } 234 | 235 | impl From<(String, String)> for Parameter<'_> { 236 | fn from(value: (String, String)) -> Self { 237 | Parameter { 238 | key: value.0.into(), 239 | value: Some(value.1.into()), 240 | } 241 | } 242 | } 243 | 244 | impl From for Parameter<'_> { 245 | fn from(value: String) -> Self { 246 | Parameter { 247 | key: value.into(), 248 | value: None, 249 | } 250 | } 251 | } 252 | 253 | impl Display for Parameters<'_> { 254 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 255 | if !self.params.is_empty() { 256 | for param in &self.params { 257 | f.write_str(" ")?; 258 | Display::fmt(¶m, f)?; 259 | } 260 | } 261 | Ok(()) 262 | } 263 | } 264 | 265 | impl Display for Parameter<'_> { 266 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 267 | if let Some(value) = &self.value { 268 | write!(f, "{}={}", self.key, value) 269 | } else { 270 | f.write_str(&self.key) 271 | } 272 | } 273 | } 274 | 275 | pub trait IntoMessage<'x> { 276 | fn into_message(self) -> crate::Result>; 277 | } 278 | 279 | impl<'x> IntoMessage<'x> for Message<'x> { 280 | fn into_message(self) -> crate::Result> { 281 | Ok(self) 282 | } 283 | } 284 | 285 | #[cfg(feature = "builder")] 286 | impl<'x> IntoMessage<'x> for MessageBuilder<'_> { 287 | fn into_message(self) -> crate::Result> { 288 | let mut mail_from = None; 289 | let mut rcpt_to = std::collections::HashSet::new(); 290 | 291 | for (key, value) in self.headers.iter() { 292 | if key.eq_ignore_ascii_case("from") { 293 | if let HeaderType::Address(address::Address::Address(addr)) = value { 294 | let email = addr.email.trim(); 295 | if !email.is_empty() { 296 | mail_from = email.to_string().into(); 297 | } 298 | } 299 | } else if key.eq_ignore_ascii_case("to") 300 | || key.eq_ignore_ascii_case("cc") 301 | || key.eq_ignore_ascii_case("bcc") 302 | { 303 | if let HeaderType::Address(addr) = value { 304 | match addr { 305 | address::Address::Address(addr) => { 306 | let email = addr.email.trim(); 307 | if !email.is_empty() { 308 | rcpt_to.insert(email.to_string()); 309 | } 310 | } 311 | address::Address::Group(group) => { 312 | for addr in &group.addresses { 313 | if let address::Address::Address(addr) = addr { 314 | let email = addr.email.trim(); 315 | if !email.is_empty() { 316 | rcpt_to.insert(email.to_string()); 317 | } 318 | } 319 | } 320 | } 321 | address::Address::List(list) => { 322 | for addr in list { 323 | if let address::Address::Address(addr) = addr { 324 | let email = addr.email.trim(); 325 | if !email.is_empty() { 326 | rcpt_to.insert(email.to_string()); 327 | } 328 | } 329 | } 330 | } 331 | } 332 | } 333 | } 334 | } 335 | 336 | if rcpt_to.is_empty() { 337 | return Err(crate::Error::MissingRcptTo); 338 | } 339 | 340 | Ok(Message { 341 | mail_from: mail_from.ok_or(crate::Error::MissingMailFrom)?.into(), 342 | rcpt_to: rcpt_to 343 | .into_iter() 344 | .map(|email| Address { 345 | email: email.into(), 346 | parameters: Parameters::default(), 347 | }) 348 | .collect(), 349 | body: self.write_to_vec()?.into(), 350 | }) 351 | } 352 | } 353 | 354 | #[cfg(feature = "parser")] 355 | impl<'x> IntoMessage<'x> for mail_parser::Message<'x> { 356 | fn into_message(self) -> crate::Result> { 357 | let mut mail_from = None; 358 | let mut rcpt_to = std::collections::HashSet::new(); 359 | 360 | let find_address = |addr: &mail_parser::Addr| -> Option { 361 | addr.address 362 | .as_ref() 363 | .filter(|address| !address.trim().is_empty()) 364 | .map(|address| address.trim().to_string()) 365 | }; 366 | 367 | for header in self.headers() { 368 | match &header.name { 369 | HeaderName::From => match header.value() { 370 | HeaderValue::Address(mail_parser::Address::List(addrs)) => { 371 | if let Some(email) = addrs.iter().find_map(find_address) { 372 | mail_from = email.to_string().into(); 373 | } 374 | } 375 | HeaderValue::Address(mail_parser::Address::Group(groups)) => { 376 | if let Some(grps) = groups.first() { 377 | if let Some(email) = grps.addresses.iter().find_map(find_address) { 378 | mail_from = email.to_string().into(); 379 | } 380 | } 381 | } 382 | _ => (), 383 | }, 384 | HeaderName::To | HeaderName::Cc | HeaderName::Bcc => match header.value() { 385 | HeaderValue::Address(mail_parser::Address::List(addrs)) => { 386 | rcpt_to.extend(addrs.iter().filter_map(find_address)); 387 | } 388 | HeaderValue::Address(mail_parser::Address::Group(grps)) => { 389 | rcpt_to.extend( 390 | grps.iter() 391 | .flat_map(|grp| grp.addresses.iter()) 392 | .filter_map(find_address), 393 | ); 394 | } 395 | _ => (), 396 | }, 397 | _ => (), 398 | }; 399 | } 400 | 401 | if rcpt_to.is_empty() { 402 | return Err(crate::Error::MissingRcptTo); 403 | } 404 | 405 | Ok(Message { 406 | mail_from: mail_from.ok_or(crate::Error::MissingMailFrom)?.into(), 407 | rcpt_to: rcpt_to 408 | .into_iter() 409 | .map(|email| Address { 410 | email: email.into(), 411 | parameters: Parameters::default(), 412 | }) 413 | .collect(), 414 | body: self.raw_message, 415 | }) 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /src/smtp/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use smtp_proto::{Response, Severity}; 12 | 13 | pub mod auth; 14 | pub mod builder; 15 | pub mod client; 16 | pub mod ehlo; 17 | pub mod envelope; 18 | pub mod message; 19 | pub mod tls; 20 | 21 | impl From for crate::Error { 22 | fn from(err: auth::Error) -> Self { 23 | crate::Error::Auth(err) 24 | } 25 | } 26 | 27 | pub trait AssertReply: Sized { 28 | fn is_positive_completion(&self) -> bool; 29 | fn assert_positive_completion(self) -> crate::Result<()>; 30 | fn assert_severity(self, severity: Severity) -> crate::Result<()>; 31 | fn assert_code(self, code: u16) -> crate::Result<()>; 32 | } 33 | 34 | impl AssertReply for Response { 35 | /// Returns `true` if the reply is a positive completion. 36 | #[inline(always)] 37 | fn is_positive_completion(&self) -> bool { 38 | (200..=299).contains(&self.code) 39 | } 40 | 41 | /// Returns Ok if the reply has the specified severity. 42 | #[inline(always)] 43 | fn assert_severity(self, severity: Severity) -> crate::Result<()> { 44 | if self.severity() == severity { 45 | Ok(()) 46 | } else { 47 | Err(crate::Error::UnexpectedReply(self)) 48 | } 49 | } 50 | 51 | /// Returns Ok if the reply returned a 2xx code. 52 | #[inline(always)] 53 | fn assert_positive_completion(self) -> crate::Result<()> { 54 | if (200..=299).contains(&self.code) { 55 | Ok(()) 56 | } else { 57 | Err(crate::Error::UnexpectedReply(self)) 58 | } 59 | } 60 | 61 | /// Returns Ok if the reply has the specified status code. 62 | #[inline(always)] 63 | fn assert_code(self, code: u16) -> crate::Result<()> { 64 | if self.code() == code { 65 | Ok(()) 66 | } else { 67 | Err(crate::Error::UnexpectedReply(self)) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/smtp/tls.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Stalwart Labs Ltd. 3 | * 4 | * Licensed under the Apache License, Version 2.0 or the MIT license 6 | * , at your 7 | * option. This file may not be copied, modified, or distributed 8 | * except according to those terms. 9 | */ 10 | 11 | use std::{convert::TryFrom, io, sync::Arc}; 12 | 13 | use rustls::{ 14 | client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, 15 | ClientConfig, ClientConnection, RootCertStore, SignatureScheme, 16 | }; 17 | use rustls_pki_types::{ServerName, TrustAnchor}; 18 | use tokio::net::TcpStream; 19 | use tokio_rustls::{client::TlsStream, TlsConnector}; 20 | 21 | use crate::{Error, SmtpClient}; 22 | 23 | use super::AssertReply; 24 | 25 | impl SmtpClient { 26 | /// Upgrade the connection to TLS. 27 | pub async fn start_tls( 28 | mut self, 29 | tls_connector: &TlsConnector, 30 | hostname: &str, 31 | ) -> crate::Result>> { 32 | // Send STARTTLS command 33 | self.cmd(b"STARTTLS\r\n") 34 | .await? 35 | .assert_positive_completion()?; 36 | 37 | self.into_tls(tls_connector, hostname).await 38 | } 39 | 40 | pub async fn into_tls( 41 | self, 42 | tls_connector: &TlsConnector, 43 | hostname: &str, 44 | ) -> crate::Result>> { 45 | tokio::time::timeout(self.timeout, async { 46 | Ok(SmtpClient { 47 | stream: tls_connector 48 | .connect( 49 | ServerName::try_from(hostname) 50 | .map_err(|_| crate::Error::InvalidTLSName)? 51 | .to_owned(), 52 | self.stream, 53 | ) 54 | .await 55 | .map_err(|err| { 56 | let kind = err.kind(); 57 | if let Some(inner) = err.into_inner() { 58 | match inner.downcast::() { 59 | Ok(error) => Error::Tls(error), 60 | Err(error) => Error::Io(io::Error::new(kind, error)), 61 | } 62 | } else { 63 | Error::Io(io::Error::new(kind, "Unspecified")) 64 | } 65 | })?, 66 | timeout: self.timeout, 67 | }) 68 | }) 69 | .await 70 | .map_err(|_| crate::Error::Timeout)? 71 | } 72 | } 73 | 74 | impl SmtpClient> { 75 | pub fn tls_connection(&self) -> &ClientConnection { 76 | self.stream.get_ref().1 77 | } 78 | } 79 | 80 | pub fn build_tls_connector(allow_invalid_certs: bool) -> TlsConnector { 81 | let config = if !allow_invalid_certs { 82 | let mut root_cert_store = RootCertStore::empty(); 83 | 84 | root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().map(|ta| TrustAnchor { 85 | subject: ta.subject.clone(), 86 | subject_public_key_info: ta.subject_public_key_info.clone(), 87 | name_constraints: ta.name_constraints.clone(), 88 | })); 89 | 90 | ClientConfig::builder() 91 | .with_root_certificates(root_cert_store) 92 | .with_no_client_auth() 93 | } else { 94 | ClientConfig::builder() 95 | .dangerous() 96 | .with_custom_certificate_verifier(Arc::new(DummyVerifier {})) 97 | .with_no_client_auth() 98 | }; 99 | 100 | TlsConnector::from(Arc::new(config)) 101 | } 102 | 103 | #[doc(hidden)] 104 | #[derive(Debug)] 105 | struct DummyVerifier; 106 | 107 | impl ServerCertVerifier for DummyVerifier { 108 | fn verify_server_cert( 109 | &self, 110 | _end_entity: &rustls_pki_types::CertificateDer<'_>, 111 | _intermediates: &[rustls_pki_types::CertificateDer<'_>], 112 | _server_name: &rustls_pki_types::ServerName<'_>, 113 | _ocsp_response: &[u8], 114 | _now: rustls_pki_types::UnixTime, 115 | ) -> Result { 116 | Ok(ServerCertVerified::assertion()) 117 | } 118 | 119 | fn verify_tls12_signature( 120 | &self, 121 | _message: &[u8], 122 | _cert: &rustls_pki_types::CertificateDer<'_>, 123 | _dss: &rustls::DigitallySignedStruct, 124 | ) -> Result { 125 | Ok(HandshakeSignatureValid::assertion()) 126 | } 127 | 128 | fn verify_tls13_signature( 129 | &self, 130 | _message: &[u8], 131 | _cert: &rustls_pki_types::CertificateDer<'_>, 132 | _dss: &rustls::DigitallySignedStruct, 133 | ) -> Result { 134 | Ok(HandshakeSignatureValid::assertion()) 135 | } 136 | 137 | fn supported_verify_schemes(&self) -> Vec { 138 | vec![ 139 | SignatureScheme::RSA_PKCS1_SHA1, 140 | SignatureScheme::ECDSA_SHA1_Legacy, 141 | SignatureScheme::RSA_PKCS1_SHA256, 142 | SignatureScheme::ECDSA_NISTP256_SHA256, 143 | SignatureScheme::RSA_PKCS1_SHA384, 144 | SignatureScheme::ECDSA_NISTP384_SHA384, 145 | SignatureScheme::RSA_PKCS1_SHA512, 146 | SignatureScheme::ECDSA_NISTP521_SHA512, 147 | SignatureScheme::RSA_PSS_SHA256, 148 | SignatureScheme::RSA_PSS_SHA384, 149 | SignatureScheme::RSA_PSS_SHA512, 150 | SignatureScheme::ED25519, 151 | SignatureScheme::ED448, 152 | ] 153 | } 154 | } 155 | --------------------------------------------------------------------------------