├── .github └── workflows │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSES ├── Apache-2.0.txt └── MIT.txt ├── README.md ├── examples ├── multipart_message.rs ├── nested_message.rs └── simple_message.rs └── src ├── encoders ├── base64.rs ├── encode.rs ├── mod.rs └── quoted_printable.rs ├── headers ├── address.rs ├── content_type.rs ├── date.rs ├── message_id.rs ├── mod.rs ├── raw.rs ├── text.rs └── url.rs ├── lib.rs └── mime.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@v2 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.eml 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | mail-builder 0.4.3 2 | ================================ 3 | - Fix: Duplicate semicolon in group addresses. 4 | 5 | mail-builder 0.4.2 6 | ================================ 7 | - Fix: Add semicolon at the end of group addresses. 8 | 9 | mail-builder 0.4.1 10 | ================================ 11 | - Fix: Try to avoid lines longer than 78 characters (#32) 12 | 13 | mail-builder 0.4.0 14 | ================================ 15 | - Removed `ludicrous` feature, the Rust compiler is smart enough to optimize array lookups. 16 | 17 | mail-builder 0.3.2 18 | ================================ 19 | - Made `gethostname` crate optional. 20 | 21 | mail-builder 0.3.1 22 | ================================ 23 | - Added `MimePart::transfer_encoding` method to disable automatic Content-Transfer-Encoding detection and treat it as a raw MIME part. 24 | 25 | mail-builder 0.3.0 26 | ================================ 27 | - Replaced all `Multipart::new*` methods with a single `Multipart::new` method. 28 | 29 | mail-builder 0.2.4 30 | ================================ 31 | - Added "Ludicrous mode" unsafe option for fast encoding. 32 | 33 | mail-builder 0.2.3 34 | ================================ 35 | - Removed chrono dependency. 36 | 37 | mail-builder 0.2.2 38 | ================================ 39 | - Fix: Generate valid Message-IDs 40 | 41 | mail-builder 0.2.1 42 | ================================ 43 | - Fixed URL serializing bug. 44 | - Headers are stored in a `Vec` instead of `BTreeMap`. 45 | 46 | mail-builder 0.2.0 47 | ================================ 48 | - Improved API 49 | - Added `write_to_vec` and `write_to_string`. 50 | 51 | mail-builder 0.1.3 52 | ================================ 53 | - Bug fixes. 54 | - Headers are written sorted alphabetically. 55 | - Improved ID boundary generation. 56 | - Encoding type detection for `[u8]` text parts. 57 | - Optimised quoted-printable encoding. 58 | 59 | mail-builder 0.1.2 60 | ================================ 61 | - All functions now take `impl Cow`. 62 | 63 | mail-builder 0.1.1 64 | ================================ 65 | - API improvements. 66 | 67 | mail-builder 0.1.0 68 | ================================ 69 | - Initial release. 70 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mail-builder" 3 | description = "E-mail builder library for Rust" 4 | version = "0.4.3" 5 | edition = "2021" 6 | authors = [ "Stalwart Labs "] 7 | license = "Apache-2.0 OR MIT" 8 | repository = "https://github.com/stalwartlabs/mail-builder" 9 | homepage = "https://github.com/stalwartlabs/mail-builder" 10 | keywords = ["email", "mime", "mail", "e-mail"] 11 | categories = ["email"] 12 | readme = "README.md" 13 | 14 | [features] 15 | default = ["gethostname"] 16 | gethostname = ["dep:gethostname"] 17 | 18 | [dependencies] 19 | gethostname = { version = "1.0.0", optional = true } 20 | 21 | [dev-dependencies] 22 | mail-parser = "0.10" 23 | serde = { version = "1.0", features = ["derive"]} 24 | serde_yaml = "0.9.10" 25 | serde_json = "1.0" 26 | 27 | [lib] 28 | doctest = false 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mail-builder 2 | 3 | [![crates.io](https://img.shields.io/crates/v/mail-builder)](https://crates.io/crates/mail-builder) 4 | [![build](https://github.com/stalwartlabs/mail-builder/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/mail-builder/actions/workflows/rust.yml) 5 | [![docs.rs](https://img.shields.io/docsrs/mail-builder)](https://docs.rs/mail-builder) 6 | [![crates.io](https://img.shields.io/crates/l/mail-builder)](http://www.apache.org/licenses/LICENSE-2.0) 7 | 8 | _mail-builder_ is a flexible **e-mail builder library** written in Rust. 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 | - **Fast Base64 encoding** based on Chromium's decoder ([the fastest non-SIMD encoder](https://github.com/lemire/fastbase64)). 13 | - No dependencies (`gethostname` is optional). 14 | 15 | Please note that this library does not support sending or parsing e-mail messages as these functionalities are provided by the crates [`mail-send`](https://crates.io/crates/mail-send) and [`mail-parser`](https://crates.io/crates/mail-parser). 16 | 17 | ## Usage Example 18 | 19 | Build a simple e-mail message with a text body and one attachment: 20 | 21 | ```rust 22 | // Build a simple text message with a single attachment 23 | let eml = MessageBuilder::new() 24 | .from(("John Doe", "john@doe.com")) 25 | .to("jane@doe.com") 26 | .subject("Hello, world!") 27 | .text_body("Message contents go here.") 28 | .attachment("image/png", "image.png", [1, 2, 3, 4].as_ref()) 29 | .write_to_string() 30 | .unwrap(); 31 | 32 | // Print raw message 33 | println!("{}", eml); 34 | ``` 35 | 36 | More complex messages with grouped addresses, inline parts and 37 | multipart/alternative sections can also be easily built: 38 | 39 | ```rust 40 | // Build a multipart message with text and HTML bodies, 41 | // inline parts and attachments. 42 | MessageBuilder::new() 43 | .from(("John Doe", "john@doe.com")) 44 | 45 | // To recipients 46 | .to(vec![ 47 | ("Antoine de Saint-Exupéry", "antoine@exupery.com"), 48 | ("안녕하세요 세계", "test@test.com"), 49 | ("Xin chào", "addr@addr.com"), 50 | ]) 51 | 52 | // BCC recipients using grouped addresses 53 | .bcc(vec![ 54 | ( 55 | "My Group", 56 | vec![ 57 | ("ASCII name", "addr1@addr7.com"), 58 | ("ハロー・ワールド", "addr2@addr6.com"), 59 | ("áéíóú", "addr3@addr5.com"), 60 | ("Γειά σου Κόσμε", "addr4@addr4.com"), 61 | ], 62 | ), 63 | ( 64 | "Another Group", 65 | vec![ 66 | ("שלום עולם", "addr5@addr3.com"), 67 | ("ñandú come ñoquis", "addr6@addr2.com"), 68 | ("Recipient", "addr7@addr1.com"), 69 | ], 70 | ), 71 | ]) 72 | 73 | // Set RFC and custom headers 74 | .subject("Testing multipart messages") 75 | .in_reply_to(vec!["message-id-1", "message-id-2"]) 76 | .header("List-Archive", URL::new("http://example.com/archive")) 77 | 78 | // Set HTML and plain text bodies 79 | .text_body("This is the text body!\n") 80 | .html_body("

HTML body with !

") 81 | 82 | // Include an embedded image as an inline part 83 | .inline("image/png", "cid:my-image", [0, 1, 2, 3, 4, 5].as_ref()) 84 | .attachment("text/plain", "my fíle.txt", "Attachment contents go here.") 85 | 86 | // Add text and binary attachments 87 | .attachment( 88 | "text/plain", 89 | "ハロー・ワールド", 90 | b"Binary contents go here.".as_ref(), 91 | ) 92 | 93 | // Write the message to a file 94 | .write_to(File::create("message.eml").unwrap()) 95 | .unwrap(); 96 | ``` 97 | 98 | Nested MIME body structures can be created using the `body` method: 99 | 100 | ```rust 101 | // Build a nested multipart message 102 | MessageBuilder::new() 103 | .from(Address::new_address("John Doe".into(), "john@doe.com")) 104 | .to(Address::new_address("Jane Doe".into(), "jane@doe.com")) 105 | .subject("Nested multipart message") 106 | 107 | // Define the nested MIME body structure 108 | .body(MimePart::new( 109 | "multipart/mixed", 110 | vec![ 111 | MimePart::new("text/plain", "Part A contents go here...").inline(), 112 | MimePart::new( 113 | "multipart/mixed", 114 | vec![ 115 | MimePart::new( 116 | "multipart/alternative", 117 | vec![ 118 | MimePart::new( 119 | "multipart/mixed", 120 | vec![ 121 | MimePart::new("text/plain", "Part B contents go here...").inline(), 122 | MimePart::new( 123 | "image/jpeg", 124 | "Part C contents go here...".as_bytes(), 125 | ) 126 | .inline(), 127 | MimePart::new("text/plain", "Part D contents go here...").inline(), 128 | ], 129 | ), 130 | MimePart::new( 131 | "multipart/related", 132 | vec![ 133 | MimePart::new("text/html", "Part E contents go here...").inline(), 134 | MimePart::new( 135 | "image/jpeg", 136 | "Part F contents go here...".as_bytes(), 137 | ), 138 | ], 139 | ), 140 | ], 141 | ), 142 | MimePart::new("image/jpeg", "Part G contents go here...".as_bytes()) 143 | .attachment("image_G.jpg"), 144 | MimePart::new( 145 | "application/x-excel", 146 | "Part H contents go here...".as_bytes(), 147 | ), 148 | MimePart::new( 149 | "x-message/rfc822", 150 | "Part J contents go here...".as_bytes(), 151 | ), 152 | ], 153 | ), 154 | MimePart::new("text/plain", "Part K contents go here...").inline(), 155 | ], 156 | )) 157 | 158 | // Write the message to a file 159 | .write_to(File::create("nested-message.eml").unwrap()) 160 | .unwrap(); 161 | ``` 162 | 163 | ## Testing 164 | 165 | To run the testsuite: 166 | 167 | ```bash 168 | $ cargo test --all-features 169 | ``` 170 | 171 | or, to run the testsuite with MIRI: 172 | 173 | ```bash 174 | $ cargo +nightly miri test --all-features 175 | ``` 176 | 177 | ## License 178 | 179 | Licensed under either of 180 | 181 | * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 182 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 183 | 184 | at your option. 185 | 186 | ## Copyright 187 | 188 | Copyright (C) 2020, Stalwart Labs LLC 189 | 190 | -------------------------------------------------------------------------------- /examples/multipart_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::fs::File; 8 | 9 | use mail_builder::{headers::url::URL, MessageBuilder}; 10 | 11 | fn main() { 12 | // Build a multipart message with text and HTML bodies, 13 | // inline parts and attachments. 14 | MessageBuilder::new() 15 | .from(("John Doe", "john@doe.com")) 16 | .to(vec![ 17 | // To recipients 18 | ("Antoine de Saint-Exupéry", "antoine@exupery.com"), 19 | ("안녕하세요 세계", "test@test.com"), 20 | ("Xin chào", "addr@addr.com"), 21 | ]) 22 | .bcc(vec![ 23 | // BCC recipients using grouped addresses 24 | ( 25 | "My Group", 26 | vec![ 27 | ("ASCII name", "addr1@addr7.com"), 28 | ("ハロー・ワールド", "addr2@addr6.com"), 29 | ("áéíóú", "addr3@addr5.com"), 30 | ("Γειά σου Κόσμε", "addr4@addr4.com"), 31 | ], 32 | ), 33 | ( 34 | "Another Group", 35 | vec![ 36 | ("שלום עולם", "addr5@addr3.com"), 37 | ("ñandú come ñoquis", "addr6@addr2.com"), 38 | ("Recipient", "addr7@addr1.com"), 39 | ], 40 | ), 41 | ]) 42 | .subject("Testing multipart messages") // Set RFC and custom headers 43 | .in_reply_to(vec!["message-id-1", "message-id-2"]) 44 | .header("List-Archive", URL::new("http://example.com/archive")) 45 | .text_body("This is the text body!\n") // Set HTML and plain text bodies 46 | .html_body("

HTML body with !

") // Include an embedded image as an inline part 47 | .inline("image/png", "cid:my-image", [0, 1, 2, 3, 4, 5].as_ref()) 48 | .attachment("text/plain", "my fíle.txt", "Attachment contents go here.") // Add a text and a binary attachment 49 | .attachment( 50 | "text/plain", 51 | "ハロー・ワールド", 52 | b"Binary contents go here.".as_ref(), 53 | ) 54 | // Write the message to a file 55 | .write_to(File::create("message.eml").unwrap()) 56 | .unwrap(); 57 | } 58 | -------------------------------------------------------------------------------- /examples/nested_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::fs::File; 8 | 9 | use mail_builder::{headers::address::Address, mime::MimePart, MessageBuilder}; 10 | 11 | fn main() { 12 | // Build a nested multipart message 13 | MessageBuilder::new() 14 | .from(Address::new_address("John Doe".into(), "john@doe.com")) 15 | .to(Address::new_address("Jane Doe".into(), "jane@doe.com")) 16 | .subject("Nested multipart message") 17 | // Define the nested MIME body structure 18 | .body(MimePart::new( 19 | "multipart/mixed", 20 | vec![ 21 | MimePart::new("text/plain", "Part A contents go here...").inline(), 22 | MimePart::new( 23 | "multipart/mixed", 24 | vec![ 25 | MimePart::new( 26 | "multipart/alternative", 27 | vec![ 28 | MimePart::new( 29 | "multipart/mixed", 30 | vec![ 31 | MimePart::new("text/plain", "Part B contents go here...") 32 | .inline(), 33 | MimePart::new( 34 | "image/jpeg", 35 | "Part C contents go here...".as_bytes(), 36 | ) 37 | .inline(), 38 | MimePart::new("text/plain", "Part D contents go here...") 39 | .inline(), 40 | ], 41 | ), 42 | MimePart::new( 43 | "multipart/related", 44 | vec![ 45 | MimePart::new("text/html", "Part E contents go here...") 46 | .inline(), 47 | MimePart::new( 48 | "image/jpeg", 49 | "Part F contents go here...".as_bytes(), 50 | ), 51 | ], 52 | ), 53 | ], 54 | ), 55 | MimePart::new("image/jpeg", "Part G contents go here...".as_bytes()) 56 | .attachment("image_G.jpg"), 57 | MimePart::new( 58 | "application/x-excel", 59 | "Part H contents go here...".as_bytes(), 60 | ), 61 | MimePart::new("x-message/rfc822", "Part J contents go here...".as_bytes()), 62 | ], 63 | ), 64 | MimePart::new("text/plain", "Part K contents go here...").inline(), 65 | ], 66 | )) 67 | // Write the message to a file 68 | .write_to(File::create("nested-message.eml").unwrap()) 69 | .unwrap(); 70 | } 71 | -------------------------------------------------------------------------------- /examples/simple_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_builder::MessageBuilder; 8 | 9 | fn main() { 10 | // Build a simple text message with a single attachment 11 | let eml = MessageBuilder::new() 12 | .from(("John Doe", "john@doe.com")) 13 | .to("jane@doe.com") 14 | .subject("Hello, world!") 15 | .text_body("Message contents go here.") 16 | .attachment("image/png", "image.png", [1, 2, 3, 4].as_ref()) 17 | .write_to_string() 18 | .unwrap(); 19 | 20 | // Print raw message 21 | println!("{}", eml); 22 | } 23 | -------------------------------------------------------------------------------- /src/encoders/base64.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::io::{self, Write}; 8 | 9 | const CHARPAD: u8 = b'='; 10 | 11 | #[inline(always)] 12 | pub fn base64_encode(input: &[u8]) -> io::Result> { 13 | let mut buf = Vec::with_capacity(4 * (input.len() / 3)); 14 | base64_encode_mime(input, &mut buf, true)?; 15 | Ok(buf) 16 | } 17 | 18 | pub fn base64_encode_mime( 19 | input: &[u8], 20 | mut output: impl Write, 21 | is_inline: bool, 22 | ) -> io::Result { 23 | let mut i = 0; 24 | let mut t1; 25 | let mut t2; 26 | let mut t3; 27 | let mut bytes_written = 0; 28 | 29 | if input.len() > 2 { 30 | while i < input.len() - 2 { 31 | t1 = input[i]; 32 | t2 = input[i + 1]; 33 | t3 = input[i + 2]; 34 | 35 | output.write_all(&[ 36 | E0[t1 as usize], 37 | E1[(((t1 & 0x03) << 4) | ((t2 >> 4) & 0x0F)) as usize], 38 | E1[(((t2 & 0x0F) << 2) | ((t3 >> 6) & 0x03)) as usize], 39 | E2[t3 as usize], 40 | ])?; 41 | 42 | bytes_written += 4; 43 | 44 | if !is_inline && bytes_written % 19 == 0 { 45 | output.write_all(b"\r\n")?; 46 | } 47 | 48 | i += 3; 49 | } 50 | } 51 | 52 | let remaining = input.len() - i; 53 | if remaining > 0 { 54 | t1 = input[i]; 55 | if remaining == 1 { 56 | output.write_all(&[ 57 | E0[t1 as usize], 58 | E1[((t1 & 0x03) << 4) as usize], 59 | CHARPAD, 60 | CHARPAD, 61 | ])?; 62 | } else { 63 | t2 = input[i + 1]; 64 | output.write_all(&[ 65 | E0[t1 as usize], 66 | E1[(((t1 & 0x03) << 4) | ((t2 >> 4) & 0x0F)) as usize], 67 | E2[((t2 & 0x0F) << 2) as usize], 68 | CHARPAD, 69 | ])?; 70 | } 71 | 72 | bytes_written += 4; 73 | 74 | if !is_inline && bytes_written % 19 == 0 { 75 | output.write_all(b"\r\n")?; 76 | } 77 | } 78 | 79 | if !is_inline && bytes_written % 19 != 0 { 80 | output.write_all(b"\r\n")?; 81 | } 82 | 83 | Ok(bytes_written) 84 | } 85 | 86 | #[cfg(test)] 87 | #[allow(clippy::items_after_test_module)] 88 | mod tests { 89 | 90 | #[test] 91 | fn encode_base64() { 92 | for (input, expected_result, is_inline) in [ 93 | ("Test".to_string(), "VGVzdA==\r\n", false), 94 | ("Ye".to_string(), "WWU=\r\n", false), 95 | ("A".to_string(), "QQ==\r\n", false), 96 | ("ro".to_string(), "cm8=\r\n", false), 97 | ( 98 | "Are you a Shimano or Campagnolo person?".to_string(), 99 | "QXJlIHlvdSBhIFNoaW1hbm8gb3IgQ2FtcGFnbm9sbyBwZXJzb24/\r\n", 100 | false, 101 | ), 102 | ( 103 | "\n\n\n\n\n".to_string(), 104 | "PCFET0NUWVBFIGh0bWw+CjxodG1sPgo8Ym9keT4KPC9ib2R5Pgo8L2h0bWw+Cg==\r\n", 105 | false, 106 | ), 107 | ("áéíóú".to_string(), "w6HDqcOtw7PDug==\r\n", false), 108 | ( 109 | " ".repeat(100), 110 | concat!( 111 | "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg", 112 | "ICAgICAgICAgICAgICAgICAgICAgICAgICAg\r\n", 113 | "ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg", 114 | "ICAgICAgICAgICAgIA==\r\n", 115 | ), 116 | false, 117 | ), 118 | ] { 119 | let mut output = Vec::new(); 120 | super::base64_encode_mime(input.as_bytes(), &mut output, is_inline).unwrap(); 121 | assert_eq!(std::str::from_utf8(&output).unwrap(), expected_result); 122 | } 123 | } 124 | } 125 | 126 | /* 127 | * Table adapted from Nick Galbreath's "High performance base64 encoder / decoder" 128 | * 129 | * Copyright 2005, 2006, 2007 Nick Galbreath -- nickg [at] modp [dot] com 130 | * All rights reserved. 131 | * 132 | * http://code.google.com/p/stringencoders/ 133 | * 134 | * Released under bsd license. 135 | * 136 | */ 137 | 138 | pub static E0: &[u8] = b"AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUUVVVVWWWWXXXXYYYYZZZZaaaabbbbccccddddeeeeffffgggghhhhiiiijjjjkkkkllllmmmmnnnnooooppppqqqqrrrrssssttttuuuuvvvvwwwwxxxxyyyyzzzz0000111122223333444455556666777788889999++++////"; 139 | pub static E1: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 140 | pub static E2: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 141 | -------------------------------------------------------------------------------- /src/encoders/encode.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::io::{self, Write}; 8 | 9 | use super::{base64::base64_encode_mime, quoted_printable::quoted_printable_encode}; 10 | 11 | pub enum EncodingType { 12 | Base64, 13 | QuotedPrintable(bool), 14 | None, 15 | } 16 | 17 | pub fn get_encoding_type(input: &[u8], is_inline: bool, is_body: bool) -> EncodingType { 18 | let base64_len = (input.len() * 4 / 3 + 3) & !3; 19 | let mut qp_len = if !is_inline { input.len() / 76 } else { 0 }; 20 | let mut is_ascii = true; 21 | let mut needs_encoding = false; 22 | let mut line_len = 0; 23 | let mut prev_ch = 0; 24 | 25 | for (pos, &ch) in input.iter().enumerate() { 26 | line_len += 1; 27 | 28 | if ch >= 127 29 | || ((ch == b' ' || ch == b'\t') 30 | && ((is_body 31 | && matches!(input.get(pos + 1..), Some([b'\n', ..] | [b'\r', b'\n', ..]))) 32 | || pos == input.len() - 1)) 33 | { 34 | qp_len += 3; 35 | if !needs_encoding { 36 | needs_encoding = true; 37 | } 38 | if is_ascii && ch >= 127 { 39 | is_ascii = false; 40 | } 41 | } else if ch == b'=' 42 | || (!is_body && ch == b'\r') 43 | || (is_inline && (ch == b'\t' || ch == b'\r' || ch == b'\n' || ch == b'?')) 44 | { 45 | qp_len += 3; 46 | } else if ch == b'\n' { 47 | if !needs_encoding && line_len > 77 { 48 | needs_encoding = true; 49 | } 50 | if is_body { 51 | if prev_ch != b'\r' { 52 | qp_len += 1; 53 | } 54 | qp_len += 1; 55 | } else { 56 | if !needs_encoding && prev_ch != b'\r' { 57 | needs_encoding = true; 58 | } 59 | qp_len += 3; 60 | } 61 | line_len = 0; 62 | } else { 63 | qp_len += 1; 64 | } 65 | 66 | prev_ch = ch; 67 | } 68 | 69 | if !needs_encoding { 70 | EncodingType::None 71 | } else if qp_len < base64_len { 72 | EncodingType::QuotedPrintable(is_ascii) 73 | } else { 74 | EncodingType::Base64 75 | } 76 | } 77 | 78 | pub fn rfc2047_encode(input: &str, mut output: impl Write) -> io::Result { 79 | Ok(match get_encoding_type(input.as_bytes(), true, false) { 80 | EncodingType::Base64 => { 81 | output.write_all(b"\"=?utf-8?B?")?; 82 | let bytes_written = base64_encode_mime(input.as_bytes(), &mut output, true)? + 14; 83 | output.write_all(b"?=\"")?; 84 | bytes_written 85 | } 86 | EncodingType::QuotedPrintable(is_ascii) => { 87 | if !is_ascii { 88 | output.write_all(b"\"=?utf-8?Q?")?; 89 | } else { 90 | output.write_all(b"\"=?us-ascii?Q?")?; 91 | } 92 | let bytes_written = 93 | quoted_printable_encode(input.as_bytes(), &mut output, true, false)? 94 | + if is_ascii { 19 } else { 14 }; 95 | output.write_all(b"?=\"")?; 96 | bytes_written 97 | } 98 | EncodingType::None => { 99 | let mut bytes_written = 2; 100 | output.write_all(b"\"")?; 101 | for &ch in input.as_bytes() { 102 | if ch == b'\\' || ch == b'"' { 103 | output.write_all(b"\\")?; 104 | bytes_written += 1; 105 | } else if ch == b'\r' || ch == b'\n' { 106 | continue; 107 | } 108 | output.write_all(&[ch])?; 109 | bytes_written += 1; 110 | } 111 | output.write_all(b"\"")?; 112 | bytes_written 113 | } 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /src/encoders/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | pub mod base64; 8 | pub mod encode; 9 | pub mod quoted_printable; 10 | -------------------------------------------------------------------------------- /src/encoders/quoted_printable.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::io::{self, Write}; 8 | 9 | pub fn quoted_printable_encode( 10 | input: &[u8], 11 | mut output: impl Write, 12 | is_inline: bool, 13 | is_body: bool, 14 | ) -> io::Result { 15 | let mut bytes_written = 0; 16 | if !is_inline { 17 | if is_body { 18 | let mut prev_ch = 0; 19 | for (pos, &ch) in input.iter().enumerate() { 20 | if ch == b'=' 21 | || ch >= 127 22 | || ((ch == b' ' || ch == b'\t') 23 | && (matches!(input.get(pos + 1..), Some([b'\n', ..] | [b'\r', b'\n', ..])) 24 | || (pos == input.len() - 1))) 25 | { 26 | if bytes_written + 3 > 76 { 27 | output.write_all(b"=\r\n")?; 28 | bytes_written = 0; 29 | } 30 | output.write_all(format!("={:02X}", ch).as_bytes())?; 31 | bytes_written += 3; 32 | } else if ch == b'\n' { 33 | if prev_ch != b'\r' { 34 | output.write_all(b"\r\n")?; 35 | } else { 36 | output.write_all(b"\n")?; 37 | } 38 | bytes_written = 0; 39 | } else { 40 | prev_ch = ch; 41 | if bytes_written + 1 > 76 { 42 | output.write_all(b"=\r\n")?; 43 | bytes_written = 0; 44 | } 45 | output.write_all(&[ch])?; 46 | bytes_written += 1; 47 | } 48 | } 49 | } else { 50 | for (pos, &ch) in input.iter().enumerate() { 51 | if ch == b'=' 52 | || ch >= 127 53 | || (ch == b'\r' || ch == b'\n') 54 | || ((ch == b' ' || ch == b'\t') && (pos == input.len() - 1)) 55 | { 56 | if bytes_written + 3 > 76 { 57 | output.write_all(b"=\r\n")?; 58 | bytes_written = 0; 59 | } 60 | output.write_all(format!("={:02X}", ch).as_bytes())?; 61 | bytes_written += 3; 62 | } else { 63 | if bytes_written + 1 > 76 { 64 | output.write_all(b"=\r\n")?; 65 | bytes_written = 0; 66 | } 67 | output.write_all(&[ch])?; 68 | bytes_written += 1; 69 | } 70 | } 71 | } 72 | } else { 73 | for &ch in input.iter() { 74 | if ch == b'=' || ch == b'?' || ch == b'\t' || ch == b'\r' || ch == b'\n' || ch >= 127 { 75 | output.write_all(format!("={:02X}", ch).as_bytes())?; 76 | bytes_written += 3; 77 | } else if ch == b' ' { 78 | output.write_all(b"_")?; 79 | bytes_written += 1; 80 | } else { 81 | output.write_all(&[ch])?; 82 | bytes_written += 1; 83 | } 84 | } 85 | } 86 | 87 | Ok(bytes_written) 88 | } 89 | 90 | #[cfg(test)] 91 | mod tests { 92 | 93 | #[test] 94 | fn encode_quoted_printable() { 95 | for (input, expected_result_body, expected_result_attachment, expected_result_inline) in [ 96 | ( 97 | "hello world".to_string(), 98 | "hello world", 99 | "hello world", 100 | "hello_world", 101 | ), 102 | ( 103 | "hello ? world ?".to_string(), 104 | "hello ? world ?", 105 | "hello ? world ?", 106 | "hello_=3F_world_=3F", 107 | ), 108 | ( 109 | "hello = world =".to_string(), 110 | "hello =3D world =3D", 111 | "hello =3D world =3D", 112 | "hello_=3D_world_=3D", 113 | ), 114 | ( 115 | "hello\nworld\n".to_string(), 116 | "hello\r\nworld\r\n", 117 | "hello=0Aworld=0A", 118 | "hello=0Aworld=0A", 119 | ), 120 | ( 121 | "hello \nworld \r\n ".to_string(), 122 | "hello =20\r\nworld =20\r\n =20", 123 | "hello =0Aworld =0D=0A =20", 124 | "hello___=0Aworld___=0D=0A___", 125 | ), 126 | ( 127 | "hello \nworld \n".to_string(), 128 | "hello =20\r\nworld =20\r\n", 129 | "hello =0Aworld =0A", 130 | "hello___=0Aworld___=0A", 131 | ), 132 | ( 133 | "áéíóú".to_string(), 134 | "=C3=A1=C3=A9=C3=AD=C3=B3=C3=BA", 135 | "=C3=A1=C3=A9=C3=AD=C3=B3=C3=BA", 136 | "=C3=A1=C3=A9=C3=AD=C3=B3=C3=BA", 137 | ), 138 | ( 139 | "안녕하세요 세계".to_string(), 140 | "=EC=95=88=EB=85=95=ED=95=98=EC=84=B8=EC=9A=94 =EC=84=B8=EA=B3=84", 141 | "=EC=95=88=EB=85=95=ED=95=98=EC=84=B8=EC=9A=94 =EC=84=B8=EA=B3=84", 142 | "=EC=95=88=EB=85=95=ED=95=98=EC=84=B8=EC=9A=94_=EC=84=B8=EA=B3=84", 143 | ), 144 | ( 145 | " ".repeat(100), 146 | concat!( 147 | " ", 148 | " =\r\n ", 149 | " =20" 150 | ), 151 | concat!( 152 | " ", 153 | " =\r\n ", 154 | " =20" 155 | ), 156 | concat!( 157 | "_________________________________________", 158 | "_____________________________________________", 159 | "______________" 160 | ), 161 | ), 162 | ] { 163 | let mut output = Vec::new(); 164 | super::quoted_printable_encode(input.as_bytes(), &mut output, false, true).unwrap(); 165 | assert_eq!( 166 | std::str::from_utf8(&output).unwrap(), 167 | expected_result_body, 168 | "body" 169 | ); 170 | 171 | let mut output = Vec::new(); 172 | super::quoted_printable_encode(input.as_bytes(), &mut output, false, false).unwrap(); 173 | assert_eq!( 174 | std::str::from_utf8(&output).unwrap(), 175 | expected_result_attachment, 176 | "attachment" 177 | ); 178 | 179 | let mut output = Vec::new(); 180 | super::quoted_printable_encode(input.as_bytes(), &mut output, true, false).unwrap(); 181 | assert_eq!( 182 | std::str::from_utf8(&output).unwrap(), 183 | expected_result_inline, 184 | "inline" 185 | ); 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/headers/address.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::borrow::Cow; 8 | 9 | use crate::encoders::encode::rfc2047_encode; 10 | 11 | use super::Header; 12 | 13 | /// RFC5322 e-mail address 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 15 | pub struct EmailAddress<'x> { 16 | pub name: Option>, 17 | pub email: Cow<'x, str>, 18 | } 19 | 20 | /// RFC5322 grouped e-mail addresses 21 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 22 | pub struct GroupedAddresses<'x> { 23 | pub name: Option>, 24 | pub addresses: Vec>, 25 | } 26 | 27 | /// RFC5322 address 28 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 29 | pub enum Address<'x> { 30 | Address(EmailAddress<'x>), 31 | Group(GroupedAddresses<'x>), 32 | List(Vec>), 33 | } 34 | 35 | impl<'x> Address<'x> { 36 | /// Create an RFC5322 e-mail address 37 | pub fn new_address( 38 | name: Option>>, 39 | email: impl Into>, 40 | ) -> Self { 41 | Address::Address(EmailAddress { 42 | name: name.map(|v| v.into()), 43 | email: email.into(), 44 | }) 45 | } 46 | 47 | /// Create an RFC5322 grouped e-mail address 48 | pub fn new_group(name: Option>>, addresses: Vec>) -> Self { 49 | Address::Group(GroupedAddresses { 50 | name: name.map(|v| v.into()), 51 | addresses, 52 | }) 53 | } 54 | 55 | /// Create an address list 56 | pub fn new_list(items: Vec>) -> Self { 57 | Address::List(items) 58 | } 59 | 60 | pub fn unwrap_address(&self) -> &EmailAddress<'x> { 61 | match self { 62 | Address::Address(address) => address, 63 | _ => panic!("Address is not an EmailAddress"), 64 | } 65 | } 66 | } 67 | 68 | impl<'x> From<(&'x str, &'x str)> for Address<'x> { 69 | fn from(value: (&'x str, &'x str)) -> Self { 70 | Address::Address(EmailAddress { 71 | name: Some(value.0.into()), 72 | email: value.1.into(), 73 | }) 74 | } 75 | } 76 | 77 | impl From<(String, String)> for Address<'_> { 78 | fn from(value: (String, String)) -> Self { 79 | Address::Address(EmailAddress { 80 | name: Some(value.0.into()), 81 | email: value.1.into(), 82 | }) 83 | } 84 | } 85 | 86 | impl<'x> From<&'x str> for Address<'x> { 87 | fn from(value: &'x str) -> Self { 88 | Address::Address(EmailAddress { 89 | name: None, 90 | email: value.into(), 91 | }) 92 | } 93 | } 94 | 95 | impl From for Address<'_> { 96 | fn from(value: String) -> Self { 97 | Address::Address(EmailAddress { 98 | name: None, 99 | email: value.into(), 100 | }) 101 | } 102 | } 103 | 104 | impl<'x, T> From> for Address<'x> 105 | where 106 | T: Into>, 107 | { 108 | fn from(value: Vec) -> Self { 109 | Address::new_list(value.into_iter().map(|x| x.into()).collect()) 110 | } 111 | } 112 | 113 | impl<'x, T, U> From<(U, Vec)> for Address<'x> 114 | where 115 | T: Into>, 116 | U: Into>, 117 | { 118 | fn from(value: (U, Vec)) -> Self { 119 | Address::Group(GroupedAddresses { 120 | name: Some(value.0.into()), 121 | addresses: value.1.into_iter().map(|x| x.into()).collect(), 122 | }) 123 | } 124 | } 125 | 126 | impl Header for Address<'_> { 127 | fn write_header( 128 | &self, 129 | mut output: impl std::io::Write, 130 | mut bytes_written: usize, 131 | ) -> std::io::Result { 132 | match self { 133 | Address::Address(address) => { 134 | address.write_header(&mut output, bytes_written)?; 135 | } 136 | Address::Group(group) => { 137 | group.write_header(&mut output, bytes_written)?; 138 | } 139 | Address::List(list) => { 140 | for (pos, address) in list.iter().enumerate() { 141 | if bytes_written 142 | + (match address { 143 | Address::Address(address) => { 144 | address.email.len() 145 | + address.name.as_ref().map_or(0, |n| n.len() + 3) 146 | + 2 147 | } 148 | Address::Group(group) => { 149 | group.name.as_ref().map_or(0, |name| name.len() + 2) 150 | } 151 | Address::List(_) => 0, 152 | }) 153 | >= 76 154 | { 155 | output.write_all(b"\r\n\t")?; 156 | bytes_written = 1; 157 | } 158 | 159 | match address { 160 | Address::Address(address) => { 161 | bytes_written += address.write_header(&mut output, bytes_written)?; 162 | if pos < list.len() - 1 { 163 | output.write_all(b", ")?; 164 | bytes_written += 1; 165 | } 166 | } 167 | Address::Group(group) => { 168 | bytes_written += group.write_header(&mut output, bytes_written)?; 169 | if pos < list.len() - 1 { 170 | output.write_all(b" ")?; 171 | bytes_written += 1; 172 | } 173 | } 174 | Address::List(_) => unreachable!(), 175 | } 176 | } 177 | } 178 | } 179 | output.write_all(b"\r\n")?; 180 | Ok(0) 181 | } 182 | } 183 | 184 | impl Header for EmailAddress<'_> { 185 | fn write_header( 186 | &self, 187 | mut output: impl std::io::Write, 188 | mut bytes_written: usize, 189 | ) -> std::io::Result { 190 | if let Some(name) = &self.name { 191 | bytes_written += rfc2047_encode(name, &mut output)?; 192 | if bytes_written + self.email.len() + 2 >= 76 { 193 | output.write_all(b"\r\n\t")?; 194 | bytes_written = 1; 195 | } else { 196 | output.write_all(b" ")?; 197 | bytes_written += 1; 198 | } 199 | } 200 | 201 | output.write_all(b"<")?; 202 | output.write_all(self.email.as_bytes())?; 203 | output.write_all(b">")?; 204 | 205 | Ok(bytes_written + self.email.len() + 2) 206 | } 207 | } 208 | 209 | impl Header for GroupedAddresses<'_> { 210 | fn write_header( 211 | &self, 212 | mut output: impl std::io::Write, 213 | mut bytes_written: usize, 214 | ) -> std::io::Result { 215 | if let Some(name) = &self.name { 216 | bytes_written += rfc2047_encode(name, &mut output)? + 2; 217 | output.write_all(b": ")?; 218 | } 219 | 220 | for (pos, address) in self.addresses.iter().enumerate() { 221 | let address = address.unwrap_address(); 222 | 223 | if bytes_written 224 | + address.email.len() 225 | + address.name.as_ref().map_or(0, |n| n.len() + 3) 226 | + 2 227 | >= 76 228 | { 229 | output.write_all(b"\r\n\t")?; 230 | bytes_written = 1; 231 | } 232 | 233 | bytes_written += address.write_header(&mut output, bytes_written)?; 234 | if pos < self.addresses.len() - 1 { 235 | output.write_all(b", ")?; 236 | bytes_written += 2; 237 | } 238 | } 239 | 240 | output.write_all(b";")?; 241 | bytes_written += 1; 242 | 243 | Ok(bytes_written) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/headers/content_type.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::borrow::Cow; 8 | 9 | use crate::encoders::encode::rfc2047_encode; 10 | 11 | use super::Header; 12 | 13 | /// MIME Content-Type or Content-Disposition header 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 15 | pub struct ContentType<'x> { 16 | pub c_type: Cow<'x, str>, 17 | pub attributes: Vec<(Cow<'x, str>, Cow<'x, str>)>, 18 | } 19 | 20 | impl<'x> ContentType<'x> { 21 | /// Create a new Content-Type or Content-Disposition header 22 | pub fn new(c_type: impl Into>) -> Self { 23 | Self { 24 | c_type: c_type.into(), 25 | attributes: Vec::new(), 26 | } 27 | } 28 | 29 | /// Set a Content-Type / Content-Disposition attribute 30 | pub fn attribute( 31 | mut self, 32 | key: impl Into>, 33 | value: impl Into>, 34 | ) -> Self { 35 | self.attributes.push((key.into(), value.into())); 36 | self 37 | } 38 | 39 | /// Returns true when the part is text/* 40 | pub fn is_text(&self) -> bool { 41 | self.c_type.starts_with("text/") 42 | } 43 | 44 | /// Returns true when the part is an attachment 45 | pub fn is_attachment(&self) -> bool { 46 | self.c_type == "attachment" 47 | } 48 | } 49 | 50 | impl Header for ContentType<'_> { 51 | fn write_header( 52 | &self, 53 | mut output: impl std::io::Write, 54 | mut bytes_written: usize, 55 | ) -> std::io::Result { 56 | output.write_all(self.c_type.as_bytes())?; 57 | bytes_written += self.c_type.len(); 58 | if !self.attributes.is_empty() { 59 | output.write_all(b"; ")?; 60 | bytes_written += 2; 61 | for (pos, (key, value)) in self.attributes.iter().enumerate() { 62 | if bytes_written + key.len() + value.len() + 3 >= 76 { 63 | output.write_all(b"\r\n\t")?; 64 | bytes_written = 1; 65 | } 66 | 67 | output.write_all(key.as_bytes())?; 68 | output.write_all(b"=")?; 69 | bytes_written += rfc2047_encode(value, &mut output)? + key.len() + 1; 70 | if pos < self.attributes.len() - 1 { 71 | output.write_all(b"; ")?; 72 | bytes_written += 2; 73 | } 74 | } 75 | } 76 | output.write_all(b"\r\n")?; 77 | Ok(0) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/headers/date.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 | io::{self, Write}, 9 | time::SystemTime, 10 | }; 11 | 12 | pub static DOW: &[&str] = &["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; 13 | pub static MONTH: &[&str] = &[ 14 | "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", 15 | ]; 16 | 17 | use super::Header; 18 | 19 | /// RFC5322 Date header 20 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 21 | pub struct Date { 22 | pub date: i64, 23 | } 24 | 25 | impl Date { 26 | /// Create a new Date header from a timestamp. 27 | pub fn new(date: i64) -> Self { 28 | Self { date } 29 | } 30 | 31 | /// Create a new Date header using the current time. 32 | pub fn now() -> Self { 33 | Self { 34 | date: SystemTime::now() 35 | .duration_since(SystemTime::UNIX_EPOCH) 36 | .map(|d| d.as_secs()) 37 | .unwrap_or(0) as i64, 38 | } 39 | } 40 | 41 | /// Returns an RFC822 date. 42 | pub fn to_rfc822(&self) -> String { 43 | // Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days 44 | let (z, seconds) = ((self.date / 86400) + 719468, self.date % 86400); 45 | let era: i64 = (if z >= 0 { z } else { z - 146096 }) / 146097; 46 | let doe: u64 = (z - era * 146097) as u64; // [0, 146096] 47 | let yoe: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] 48 | let y: i64 = (yoe as i64) + era * 400; 49 | let doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] 50 | let mp = (5 * doy + 2) / 153; // [0, 11] 51 | let d: u64 = doy - (153 * mp + 2) / 5 + 1; // [1, 31] 52 | let m: u64 = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] 53 | let (h, mn, s) = (seconds / 3600, (seconds / 60) % 60, seconds % 60); 54 | 55 | format!( 56 | "{}, {} {} {:04} {:02}:{:02}:{:02} +0000", //{}{:02}{:02}", 57 | DOW[(((self.date as f64 / 86400.0).floor() as i64 + 4).rem_euclid(7)) as usize], 58 | d, 59 | MONTH.get(m.saturating_sub(1) as usize).unwrap_or(&""), 60 | (y + i64::from(m <= 2)), 61 | h, 62 | mn, 63 | s, 64 | /*if self.tz_before_gmt && (self.tz_hour > 0 || self.tz_minute > 0) { 65 | "-" 66 | } else { 67 | "+" 68 | }, 69 | self.tz_hour, 70 | self.tz_minute*/ 71 | ) 72 | } 73 | } 74 | 75 | impl From for Date { 76 | fn from(datetime: i64) -> Self { 77 | Date::new(datetime) 78 | } 79 | } 80 | 81 | impl From for Date { 82 | fn from(datetime: u64) -> Self { 83 | Date::new(datetime as i64) 84 | } 85 | } 86 | 87 | impl Header for Date { 88 | fn write_header(&self, mut output: impl Write, _bytes_written: usize) -> io::Result { 89 | output.write_all(self.to_rfc822().as_bytes())?; 90 | output.write_all(b"\r\n")?; 91 | Ok(0) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/headers/message_id.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::borrow::Cow; 8 | 9 | use crate::mime::make_boundary; 10 | 11 | use super::Header; 12 | 13 | /// RFC5322 Message ID header 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 15 | pub struct MessageId<'x> { 16 | pub id: Vec>, 17 | } 18 | 19 | impl<'x> MessageId<'x> { 20 | /// Create a new Message ID header 21 | pub fn new(id: impl Into>) -> Self { 22 | Self { 23 | id: vec![id.into()], 24 | } 25 | } 26 | 27 | /// Create a new multi-value Message ID header 28 | pub fn new_list(ids: T) -> Self 29 | where 30 | T: Iterator, 31 | U: Into>, 32 | { 33 | Self { 34 | id: ids.map(|s| s.into()).collect(), 35 | } 36 | } 37 | } 38 | 39 | impl<'x> From<&'x str> for MessageId<'x> { 40 | fn from(value: &'x str) -> Self { 41 | Self::new(value) 42 | } 43 | } 44 | 45 | impl From for MessageId<'_> { 46 | fn from(value: String) -> Self { 47 | Self::new(value) 48 | } 49 | } 50 | 51 | impl<'x> From<&[&'x str]> for MessageId<'x> { 52 | fn from(value: &[&'x str]) -> Self { 53 | MessageId { 54 | id: value.iter().map(|&s| s.into()).collect(), 55 | } 56 | } 57 | } 58 | 59 | impl<'x> From<&'x [String]> for MessageId<'x> { 60 | fn from(value: &'x [String]) -> Self { 61 | MessageId { 62 | id: value.iter().map(|s| s.into()).collect(), 63 | } 64 | } 65 | } 66 | 67 | impl<'x, T> From> for MessageId<'x> 68 | where 69 | T: Into>, 70 | { 71 | fn from(value: Vec) -> Self { 72 | MessageId { 73 | id: value.into_iter().map(|s| s.into()).collect(), 74 | } 75 | } 76 | } 77 | 78 | pub fn generate_message_id_header( 79 | mut output: impl std::io::Write, 80 | hostname: &str, 81 | ) -> std::io::Result<()> { 82 | output.write_all(b"<")?; 83 | output.write_all(make_boundary(".").as_bytes())?; 84 | output.write_all(b"@")?; 85 | output.write_all(hostname.as_bytes())?; 86 | output.write_all(b">") 87 | } 88 | 89 | impl Header for MessageId<'_> { 90 | fn write_header( 91 | &self, 92 | mut output: impl std::io::Write, 93 | mut bytes_written: usize, 94 | ) -> std::io::Result { 95 | for (pos, id) in self.id.iter().enumerate() { 96 | if pos > 0 { 97 | if bytes_written + id.len() + 2 >= 76 { 98 | output.write_all(b"\r\n\t")?; 99 | bytes_written = 1; 100 | } else { 101 | output.write_all(b" ")?; 102 | bytes_written += 1; 103 | } 104 | } 105 | 106 | output.write_all(b"<")?; 107 | output.write_all(id.as_bytes())?; 108 | output.write_all(b">")?; 109 | bytes_written += id.len() + 2; 110 | } 111 | 112 | if bytes_written > 0 { 113 | output.write_all(b"\r\n")?; 114 | } 115 | 116 | Ok(0) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/headers/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | pub mod address; 8 | pub mod content_type; 9 | pub mod date; 10 | pub mod message_id; 11 | pub mod raw; 12 | pub mod text; 13 | pub mod url; 14 | 15 | use std::io::{self, Write}; 16 | 17 | use self::{ 18 | address::Address, content_type::ContentType, date::Date, message_id::MessageId, raw::Raw, 19 | text::Text, url::URL, 20 | }; 21 | 22 | pub trait Header { 23 | fn write_header(&self, output: impl Write, bytes_written: usize) -> io::Result; 24 | } 25 | 26 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 27 | pub enum HeaderType<'x> { 28 | Address(Address<'x>), 29 | Date(Date), 30 | MessageId(MessageId<'x>), 31 | Raw(Raw<'x>), 32 | Text(Text<'x>), 33 | URL(URL<'x>), 34 | ContentType(ContentType<'x>), 35 | } 36 | 37 | impl<'x> From> for HeaderType<'x> { 38 | fn from(value: Address<'x>) -> Self { 39 | HeaderType::Address(value) 40 | } 41 | } 42 | 43 | impl<'x> From> for HeaderType<'x> { 44 | fn from(value: ContentType<'x>) -> Self { 45 | HeaderType::ContentType(value) 46 | } 47 | } 48 | 49 | impl From for HeaderType<'_> { 50 | fn from(value: Date) -> Self { 51 | HeaderType::Date(value) 52 | } 53 | } 54 | impl<'x> From> for HeaderType<'x> { 55 | fn from(value: MessageId<'x>) -> Self { 56 | HeaderType::MessageId(value) 57 | } 58 | } 59 | impl<'x> From> for HeaderType<'x> { 60 | fn from(value: Raw<'x>) -> Self { 61 | HeaderType::Raw(value) 62 | } 63 | } 64 | impl<'x> From> for HeaderType<'x> { 65 | fn from(value: Text<'x>) -> Self { 66 | HeaderType::Text(value) 67 | } 68 | } 69 | 70 | impl<'x> From> for HeaderType<'x> { 71 | fn from(value: URL<'x>) -> Self { 72 | HeaderType::URL(value) 73 | } 74 | } 75 | 76 | impl Header for HeaderType<'_> { 77 | fn write_header(&self, output: impl Write, bytes_written: usize) -> io::Result { 78 | match self { 79 | HeaderType::Address(value) => value.write_header(output, bytes_written), 80 | HeaderType::Date(value) => value.write_header(output, bytes_written), 81 | HeaderType::MessageId(value) => value.write_header(output, bytes_written), 82 | HeaderType::Raw(value) => value.write_header(output, bytes_written), 83 | HeaderType::Text(value) => value.write_header(output, bytes_written), 84 | HeaderType::URL(value) => value.write_header(output, bytes_written), 85 | HeaderType::ContentType(value) => value.write_header(output, bytes_written), 86 | } 87 | } 88 | } 89 | 90 | impl HeaderType<'_> { 91 | pub fn as_content_type(&self) -> Option<&ContentType<'_>> { 92 | match self { 93 | HeaderType::ContentType(value) => Some(value), 94 | _ => None, 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/headers/raw.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::borrow::Cow; 8 | 9 | use super::Header; 10 | 11 | /// Raw e-mail header. 12 | /// Raw headers are not encoded, only line-wrapped. 13 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 14 | pub struct Raw<'x> { 15 | pub raw: Cow<'x, str>, 16 | } 17 | 18 | impl<'x> Raw<'x> { 19 | /// Create a new raw header 20 | pub fn new(raw: impl Into>) -> Self { 21 | Self { raw: raw.into() } 22 | } 23 | } 24 | 25 | impl<'x, T> From for Raw<'x> 26 | where 27 | T: Into>, 28 | { 29 | fn from(value: T) -> Self { 30 | Self::new(value) 31 | } 32 | } 33 | 34 | impl Header for Raw<'_> { 35 | fn write_header( 36 | &self, 37 | mut output: impl std::io::Write, 38 | mut bytes_written: usize, 39 | ) -> std::io::Result { 40 | for (pos, &ch) in self.raw.as_bytes().iter().enumerate() { 41 | if bytes_written >= 76 && ch.is_ascii_whitespace() && pos < self.raw.len() - 1 { 42 | output.write_all(b"\r\n\t")?; 43 | bytes_written = 1; 44 | } 45 | output.write_all(&[ch])?; 46 | bytes_written += 1; 47 | } 48 | output.write_all(b"\r\n")?; 49 | Ok(0) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/headers/text.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::borrow::Cow; 8 | 9 | use crate::encoders::{ 10 | base64::base64_encode_mime, 11 | encode::{get_encoding_type, EncodingType}, 12 | quoted_printable::quoted_printable_encode, 13 | }; 14 | 15 | use super::Header; 16 | 17 | /// Unstructured text e-mail header. 18 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 19 | pub struct Text<'x> { 20 | pub text: Cow<'x, str>, 21 | } 22 | 23 | impl<'x> Text<'x> { 24 | /// Create a new unstructured text header 25 | pub fn new(text: impl Into>) -> Self { 26 | Self { text: text.into() } 27 | } 28 | } 29 | 30 | impl<'x, T> From for Text<'x> 31 | where 32 | T: Into>, 33 | { 34 | fn from(value: T) -> Self { 35 | Self::new(value) 36 | } 37 | } 38 | 39 | impl Header for Text<'_> { 40 | fn write_header( 41 | &self, 42 | mut output: impl std::io::Write, 43 | mut bytes_written: usize, 44 | ) -> std::io::Result { 45 | match get_encoding_type(self.text.as_bytes(), true, false) { 46 | EncodingType::Base64 => { 47 | for (pos, chunk) in self.text.as_bytes().chunks(76 - bytes_written).enumerate() { 48 | if pos > 0 { 49 | output.write_all(b"\t")?; 50 | } 51 | output.write_all(b"=?utf-8?B?")?; 52 | base64_encode_mime(chunk, &mut output, true)?; 53 | output.write_all(b"?=\r\n")?; 54 | } 55 | } 56 | EncodingType::QuotedPrintable(is_ascii) => { 57 | for (pos, chunk) in self.text.as_bytes().chunks(76 - bytes_written).enumerate() { 58 | if pos > 0 { 59 | output.write_all(b"\t")?; 60 | } 61 | if !is_ascii { 62 | output.write_all(b"=?utf-8?Q?")?; 63 | } else { 64 | output.write_all(b"=?us-ascii?Q?")?; 65 | } 66 | quoted_printable_encode(chunk, &mut output, true, false)?; 67 | output.write_all(b"?=\r\n")?; 68 | } 69 | } 70 | EncodingType::None => { 71 | for (pos, &ch) in self.text.as_bytes().iter().enumerate() { 72 | if bytes_written >= 76 && ch.is_ascii_whitespace() && pos < self.text.len() - 1 73 | { 74 | output.write_all(b"\r\n\t")?; 75 | bytes_written = 1; 76 | } 77 | output.write_all(&[ch])?; 78 | bytes_written += 1; 79 | } 80 | output.write_all(b"\r\n")?; 81 | } 82 | } 83 | Ok(0) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/headers/url.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 OR MIT 5 | */ 6 | 7 | use std::borrow::Cow; 8 | 9 | use super::Header; 10 | 11 | /// URL header, used mostly on List-* headers 12 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 13 | pub struct URL<'x> { 14 | pub url: Vec>, 15 | } 16 | 17 | impl<'x> URL<'x> { 18 | /// Create a new URL header 19 | pub fn new(url: impl Into>) -> Self { 20 | Self { 21 | url: vec![url.into()], 22 | } 23 | } 24 | 25 | /// Create a new multi-value URL header 26 | pub fn new_list(urls: T) -> Self 27 | where 28 | T: Iterator, 29 | U: Into>, 30 | { 31 | Self { 32 | url: urls.map(|s| s.into()).collect(), 33 | } 34 | } 35 | } 36 | 37 | impl<'x> From<&'x str> for URL<'x> { 38 | fn from(value: &'x str) -> Self { 39 | Self::new(value) 40 | } 41 | } 42 | 43 | impl From for URL<'_> { 44 | fn from(value: String) -> Self { 45 | Self::new(value) 46 | } 47 | } 48 | 49 | impl<'x> From<&[&'x str]> for URL<'x> { 50 | fn from(value: &[&'x str]) -> Self { 51 | URL { 52 | url: value.iter().map(|&s| s.into()).collect(), 53 | } 54 | } 55 | } 56 | 57 | impl<'x> From<&'x [String]> for URL<'x> { 58 | fn from(value: &'x [String]) -> Self { 59 | URL { 60 | url: value.iter().map(|s| s.into()).collect(), 61 | } 62 | } 63 | } 64 | 65 | impl<'x, T> From> for URL<'x> 66 | where 67 | T: Into>, 68 | { 69 | fn from(value: Vec) -> Self { 70 | URL { 71 | url: value.into_iter().map(|s| s.into()).collect(), 72 | } 73 | } 74 | } 75 | 76 | impl Header for URL<'_> { 77 | fn write_header( 78 | &self, 79 | mut output: impl std::io::Write, 80 | mut bytes_written: usize, 81 | ) -> std::io::Result { 82 | for (pos, url) in self.url.iter().enumerate() { 83 | if pos > 0 { 84 | if bytes_written + url.len() + 2 >= 76 { 85 | output.write_all(b"\r\n\t")?; 86 | bytes_written = 1; 87 | } else { 88 | output.write_all(b" ")?; 89 | bytes_written += 1; 90 | } 91 | } 92 | output.write_all(b"<")?; 93 | output.write_all(url.as_bytes())?; 94 | if pos < self.url.len() - 1 { 95 | output.write_all(b">,")?; 96 | bytes_written += url.len() + 3; 97 | } else { 98 | output.write_all(b">")?; 99 | bytes_written += url.len() + 2; 100 | } 101 | } 102 | 103 | if bytes_written > 0 { 104 | output.write_all(b"\r\n")?; 105 | } 106 | 107 | Ok(0) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /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 | #![deny(rust_2018_idioms)] 9 | #[forbid(unsafe_code)] 10 | pub mod encoders; 11 | pub mod headers; 12 | pub mod mime; 13 | 14 | use std::{ 15 | borrow::Cow, 16 | io::{self, Write}, 17 | }; 18 | 19 | use headers::{ 20 | address::Address, 21 | content_type::ContentType, 22 | date::Date, 23 | message_id::{generate_message_id_header, MessageId}, 24 | text::Text, 25 | Header, HeaderType, 26 | }; 27 | use mime::{BodyPart, MimePart}; 28 | 29 | /// Builds an RFC5322 compliant MIME email message. 30 | #[derive(Clone, Debug)] 31 | pub struct MessageBuilder<'x> { 32 | pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>, 33 | pub html_body: Option>, 34 | pub text_body: Option>, 35 | pub attachments: Option>>, 36 | pub body: Option>, 37 | } 38 | 39 | impl Default for MessageBuilder<'_> { 40 | fn default() -> Self { 41 | Self::new() 42 | } 43 | } 44 | 45 | impl<'x> MessageBuilder<'x> { 46 | /// Create a new MessageBuilder. 47 | pub fn new() -> Self { 48 | MessageBuilder { 49 | headers: Vec::new(), 50 | html_body: None, 51 | text_body: None, 52 | attachments: None, 53 | body: None, 54 | } 55 | } 56 | 57 | /// Set the Message-ID header. If no Message-ID header is set, one will be 58 | /// generated automatically. 59 | pub fn message_id(self, value: impl Into>) -> Self { 60 | self.header("Message-ID", value.into()) 61 | } 62 | 63 | /// Set the In-Reply-To header. 64 | pub fn in_reply_to(self, value: impl Into>) -> Self { 65 | self.header("In-Reply-To", value.into()) 66 | } 67 | 68 | /// Set the References header. 69 | pub fn references(self, value: impl Into>) -> Self { 70 | self.header("References", value.into()) 71 | } 72 | 73 | /// Set the Sender header. 74 | pub fn sender(self, value: impl Into>) -> Self { 75 | self.header("Sender", value.into()) 76 | } 77 | 78 | /// Set the From header. 79 | pub fn from(self, value: impl Into>) -> Self { 80 | self.header("From", value.into()) 81 | } 82 | 83 | /// Set the To header. 84 | pub fn to(self, value: impl Into>) -> Self { 85 | self.header("To", value.into()) 86 | } 87 | 88 | /// Set the Cc header. 89 | pub fn cc(self, value: impl Into>) -> Self { 90 | self.header("Cc", value.into()) 91 | } 92 | 93 | /// Set the Bcc header. 94 | pub fn bcc(self, value: impl Into>) -> Self { 95 | self.header("Bcc", value.into()) 96 | } 97 | 98 | /// Set the Reply-To header. 99 | pub fn reply_to(self, value: impl Into>) -> Self { 100 | self.header("Reply-To", value.into()) 101 | } 102 | 103 | /// Set the Subject header. 104 | pub fn subject(self, value: impl Into>) -> Self { 105 | self.header("Subject", value.into()) 106 | } 107 | 108 | /// Set the Date header. If no Date header is set, one will be generated 109 | /// automatically. 110 | pub fn date(self, value: impl Into) -> Self { 111 | self.header("Date", value.into()) 112 | } 113 | 114 | /// Add a custom header. 115 | pub fn header( 116 | mut self, 117 | header: impl Into>, 118 | value: impl Into>, 119 | ) -> Self { 120 | self.headers.push((header.into(), value.into())); 121 | self 122 | } 123 | 124 | /// Set custom headers. 125 | pub fn headers(mut self, header: T, values: U) -> Self 126 | where 127 | T: Into>, 128 | U: IntoIterator, 129 | V: Into>, 130 | { 131 | let header = header.into(); 132 | 133 | for value in values { 134 | self.headers.push((header.clone(), value.into())); 135 | } 136 | 137 | self 138 | } 139 | 140 | /// Set the plain text body of the message. Note that only one plain text body 141 | /// per message can be set using this function. 142 | /// To build more complex MIME body structures, use the `body` method instead. 143 | pub fn text_body(mut self, value: impl Into>) -> Self { 144 | self.text_body = Some(MimePart::new("text/plain", BodyPart::Text(value.into()))); 145 | self 146 | } 147 | 148 | /// Set the HTML body of the message. Note that only one HTML body 149 | /// per message can be set using this function. 150 | /// To build more complex MIME body structures, use the `body` method instead. 151 | pub fn html_body(mut self, value: impl Into>) -> Self { 152 | self.html_body = Some(MimePart::new("text/html", BodyPart::Text(value.into()))); 153 | self 154 | } 155 | 156 | /// Add a binary attachment to the message. 157 | pub fn attachment( 158 | mut self, 159 | content_type: impl Into>, 160 | filename: impl Into>, 161 | value: impl Into>, 162 | ) -> Self { 163 | self.attachments 164 | .get_or_insert_with(Vec::new) 165 | .push(MimePart::new(content_type, value).attachment(filename)); 166 | self 167 | } 168 | 169 | /// Add an inline binary to the message. 170 | pub fn inline( 171 | mut self, 172 | content_type: impl Into>, 173 | cid: impl Into>, 174 | value: impl Into>, 175 | ) -> Self { 176 | self.attachments 177 | .get_or_insert_with(Vec::new) 178 | .push(MimePart::new(content_type, value).inline().cid(cid)); 179 | self 180 | } 181 | 182 | /// Set a custom MIME body structure. 183 | pub fn body(mut self, value: MimePart<'x>) -> Self { 184 | self.body = Some(value); 185 | self 186 | } 187 | 188 | /// Build the message. 189 | pub fn write_to(self, mut output: impl Write) -> io::Result<()> { 190 | let mut has_date = false; 191 | let mut has_message_id = false; 192 | let mut has_mime_version = false; 193 | 194 | for (header_name, header_value) in &self.headers { 195 | if !has_date && header_name == "Date" { 196 | has_date = true; 197 | } else if !has_message_id && header_name == "Message-ID" { 198 | has_message_id = true; 199 | } else if !has_mime_version && header_name == "MIME-Version" { 200 | has_mime_version = true; 201 | } 202 | 203 | output.write_all(header_name.as_bytes())?; 204 | output.write_all(b": ")?; 205 | header_value.write_header(&mut output, header_name.len() + 2)?; 206 | } 207 | 208 | if !has_message_id { 209 | output.write_all(b"Message-ID: ")?; 210 | 211 | #[cfg(feature = "gethostname")] 212 | generate_message_id_header( 213 | &mut output, 214 | gethostname::gethostname().to_str().unwrap_or("localhost"), 215 | )?; 216 | 217 | #[cfg(not(feature = "gethostname"))] 218 | generate_message_id_header(&mut output, "localhost")?; 219 | 220 | output.write_all(b"\r\n")?; 221 | } 222 | 223 | if !has_date { 224 | output.write_all(b"Date: ")?; 225 | output.write_all(Date::now().to_rfc822().as_bytes())?; 226 | output.write_all(b"\r\n")?; 227 | } 228 | 229 | if !has_mime_version { 230 | output.write_all(b"MIME-Version: 1.0\r\n")?; 231 | } 232 | 233 | self.write_body(output) 234 | } 235 | 236 | /// Write the message body without headers. 237 | pub fn write_body(self, output: impl Write) -> io::Result<()> { 238 | (if let Some(body) = self.body { 239 | body 240 | } else { 241 | match (self.text_body, self.html_body, self.attachments) { 242 | (Some(text), Some(html), Some(attachments)) => { 243 | let mut parts = Vec::with_capacity(attachments.len() + 1); 244 | parts.push(MimePart::new("multipart/alternative", vec![text, html])); 245 | parts.extend(attachments); 246 | 247 | MimePart::new("multipart/mixed", parts) 248 | } 249 | (Some(text), Some(html), None) => { 250 | MimePart::new("multipart/alternative", vec![text, html]) 251 | } 252 | (Some(text), None, Some(attachments)) => { 253 | let mut parts = Vec::with_capacity(attachments.len() + 1); 254 | parts.push(text); 255 | parts.extend(attachments); 256 | MimePart::new("multipart/mixed", parts) 257 | } 258 | (Some(text), None, None) => text, 259 | (None, Some(html), Some(attachments)) => { 260 | let mut parts = Vec::with_capacity(attachments.len() + 1); 261 | parts.push(html); 262 | parts.extend(attachments); 263 | MimePart::new("multipart/mixed", parts) 264 | } 265 | (None, Some(html), None) => html, 266 | (None, None, Some(attachments)) => MimePart::new("multipart/mixed", attachments), 267 | (None, None, None) => MimePart::new("text/plain", "\n"), 268 | } 269 | }) 270 | .write_part(output)?; 271 | 272 | Ok(()) 273 | } 274 | 275 | /// Build message to a Vec. 276 | pub fn write_to_vec(self) -> io::Result> { 277 | let mut output = Vec::new(); 278 | self.write_to(&mut output)?; 279 | Ok(output) 280 | } 281 | 282 | /// Build message to a String. 283 | pub fn write_to_string(self) -> io::Result { 284 | let mut output = Vec::new(); 285 | self.write_to(&mut output)?; 286 | String::from_utf8(output).map_err(|err| io::Error::new(io::ErrorKind::Other, err)) 287 | } 288 | } 289 | 290 | #[cfg(test)] 291 | mod tests { 292 | 293 | use mail_parser::MessageParser; 294 | 295 | use crate::{ 296 | headers::{address::Address, url::URL}, 297 | mime::MimePart, 298 | MessageBuilder, 299 | }; 300 | 301 | #[test] 302 | fn build_nested_message() { 303 | let output = MessageBuilder::new() 304 | .from(Address::new_address("John Doe".into(), "john@doe.com")) 305 | .to(Address::new_address("Jane Doe".into(), "jane@doe.com")) 306 | .subject("RFC 8621 Section 4.1.4 test") 307 | .body(MimePart::new( 308 | "multipart/mixed", 309 | vec![ 310 | MimePart::new("text/plain", "Part A contents go here...").inline(), 311 | MimePart::new( 312 | "multipart/mixed", 313 | vec![ 314 | MimePart::new( 315 | "multipart/alternative", 316 | vec![ 317 | MimePart::new( 318 | "multipart/mixed", 319 | vec![ 320 | MimePart::new( 321 | "text/plain", 322 | "Part B contents go here...", 323 | ) 324 | .inline(), 325 | MimePart::new( 326 | "image/jpeg", 327 | "Part C contents go here...".as_bytes(), 328 | ) 329 | .inline(), 330 | MimePart::new( 331 | "text/plain", 332 | "Part D contents go here...", 333 | ) 334 | .inline(), 335 | ], 336 | ), 337 | MimePart::new( 338 | "multipart/related", 339 | vec![ 340 | MimePart::new( 341 | "text/html", 342 | "Part E contents go here...", 343 | ) 344 | .inline(), 345 | MimePart::new( 346 | "image/jpeg", 347 | "Part F contents go here...".as_bytes(), 348 | ), 349 | ], 350 | ), 351 | ], 352 | ), 353 | MimePart::new("image/jpeg", "Part G contents go here...".as_bytes()) 354 | .attachment("image_G.jpg"), 355 | MimePart::new( 356 | "application/x-excel", 357 | "Part H contents go here...".as_bytes(), 358 | ), 359 | MimePart::new( 360 | "x-message/rfc822", 361 | "Part J contents go here...".as_bytes(), 362 | ), 363 | ], 364 | ), 365 | MimePart::new("text/plain", "Part K contents go here...").inline(), 366 | ], 367 | )) 368 | .write_to_vec() 369 | .unwrap(); 370 | MessageParser::new().parse(&output).unwrap(); 371 | //fs::write("test.yaml", &serde_yaml::to_string(&message).unwrap()).unwrap(); 372 | } 373 | 374 | #[test] 375 | fn build_message() { 376 | let output = MessageBuilder::new() 377 | .from(("John Doe", "john@doe.com")) 378 | .to(vec![ 379 | ("Antoine de Saint-Exupéry", "antoine@exupery.com"), 380 | ("안녕하세요 세계", "test@test.com"), 381 | ("Xin chào", "addr@addr.com"), 382 | ]) 383 | .bcc(vec![ 384 | ( 385 | "Привет, мир", 386 | vec![ 387 | ("ASCII recipient", "addr1@addr7.com"), 388 | ("ハロー・ワールド", "addr2@addr6.com"), 389 | ("áéíóú", "addr3@addr5.com"), 390 | ("Γειά σου Κόσμε", "addr4@addr4.com"), 391 | ], 392 | ), 393 | ( 394 | "Hello world", 395 | vec![ 396 | ("שלום עולם", "addr5@addr3.com"), 397 | ("¡El ñandú comió ñoquis!", "addr6@addr2.com"), 398 | ("Recipient", "addr7@addr1.com"), 399 | ], 400 | ), 401 | ]) 402 | .header("List-Archive", URL::new("http://example.com/archive")) 403 | .subject("Hello world!") 404 | .text_body("Hello, world!\n".repeat(20)) 405 | .html_body("

¡Hola Mundo!

".repeat(20)) 406 | .inline("image/png", "cid:image", [0, 1, 2, 3, 4, 5].as_ref()) 407 | .attachment("text/plain", "my fíle.txt", "안녕하세요 세계".repeat(20)) 408 | .attachment( 409 | "text/plain", 410 | "ハロー・ワールド", 411 | "ハロー・ワールド".repeat(20).into_bytes(), 412 | ) 413 | .write_to_vec() 414 | .unwrap(); 415 | MessageParser::new().parse(&output).unwrap(); 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /src/mime.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 | cell::Cell, 10 | collections::hash_map::DefaultHasher, 11 | hash::{Hash, Hasher}, 12 | io::{self, Write}, 13 | thread, 14 | time::{Duration, SystemTime, UNIX_EPOCH}, 15 | }; 16 | 17 | use crate::{ 18 | encoders::{ 19 | base64::base64_encode_mime, 20 | encode::{get_encoding_type, EncodingType}, 21 | quoted_printable::quoted_printable_encode, 22 | }, 23 | headers::{ 24 | content_type::ContentType, message_id::MessageId, raw::Raw, text::Text, Header, HeaderType, 25 | }, 26 | }; 27 | 28 | /// MIME part of an e-mail. 29 | #[derive(Clone, Debug)] 30 | pub struct MimePart<'x> { 31 | pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>, 32 | pub contents: BodyPart<'x>, 33 | } 34 | 35 | #[derive(Clone, Debug)] 36 | pub enum BodyPart<'x> { 37 | Text(Cow<'x, str>), 38 | Binary(Cow<'x, [u8]>), 39 | Multipart(Vec>), 40 | } 41 | 42 | impl<'x> From<&'x str> for BodyPart<'x> { 43 | fn from(value: &'x str) -> Self { 44 | BodyPart::Text(value.into()) 45 | } 46 | } 47 | 48 | impl<'x> From<&'x [u8]> for BodyPart<'x> { 49 | fn from(value: &'x [u8]) -> Self { 50 | BodyPart::Binary(value.into()) 51 | } 52 | } 53 | 54 | impl From for BodyPart<'_> { 55 | fn from(value: String) -> Self { 56 | BodyPart::Text(value.into()) 57 | } 58 | } 59 | 60 | impl<'x> From<&'x String> for BodyPart<'x> { 61 | fn from(value: &'x String) -> Self { 62 | BodyPart::Text(value.as_str().into()) 63 | } 64 | } 65 | 66 | impl<'x> From> for BodyPart<'x> { 67 | fn from(value: Cow<'x, str>) -> Self { 68 | BodyPart::Text(value) 69 | } 70 | } 71 | 72 | impl From> for BodyPart<'_> { 73 | fn from(value: Vec) -> Self { 74 | BodyPart::Binary(value.into()) 75 | } 76 | } 77 | 78 | impl<'x> From>> for BodyPart<'x> { 79 | fn from(value: Vec>) -> Self { 80 | BodyPart::Multipart(value) 81 | } 82 | } 83 | 84 | impl<'x> From<&'x str> for ContentType<'x> { 85 | fn from(value: &'x str) -> Self { 86 | ContentType::new(value) 87 | } 88 | } 89 | 90 | impl From for ContentType<'_> { 91 | fn from(value: String) -> Self { 92 | ContentType::new(value) 93 | } 94 | } 95 | 96 | impl<'x> From<&'x String> for ContentType<'x> { 97 | fn from(value: &'x String) -> Self { 98 | ContentType::new(value.as_str()) 99 | } 100 | } 101 | 102 | thread_local!(static COUNTER: Cell = const { Cell::new(0) }); 103 | 104 | pub fn make_boundary(separator: &str) -> String { 105 | // Create a pseudo-unique boundary 106 | let mut s = DefaultHasher::new(); 107 | ((&s as *const DefaultHasher) as usize).hash(&mut s); 108 | thread::current().id().hash(&mut s); 109 | let hash = s.finish(); 110 | 111 | format!( 112 | "{:x}{}{:x}{}{:x}", 113 | SystemTime::now() 114 | .duration_since(UNIX_EPOCH) 115 | .unwrap_or_else(|_| Duration::new(0, 0)) 116 | .as_nanos(), 117 | separator, 118 | COUNTER.with(|c| { 119 | hash.wrapping_add(c.replace(c.get() + 1)) 120 | .wrapping_mul(11400714819323198485u64) 121 | }), 122 | separator, 123 | hash, 124 | ) 125 | } 126 | 127 | impl<'x> MimePart<'x> { 128 | /// Create a new MIME part. 129 | pub fn new( 130 | content_type: impl Into>, 131 | contents: impl Into>, 132 | ) -> Self { 133 | let mut content_type = content_type.into(); 134 | let contents = contents.into(); 135 | 136 | if matches!(contents, BodyPart::Text(_)) && content_type.attributes.is_empty() { 137 | content_type 138 | .attributes 139 | .push((Cow::from("charset"), Cow::from("utf-8"))); 140 | } 141 | 142 | Self { 143 | contents, 144 | headers: vec![("Content-Type".into(), content_type.into())], 145 | } 146 | } 147 | 148 | /// Create a new raw MIME part that includes both headers and body. 149 | pub fn raw(contents: impl Into>) -> Self { 150 | Self { 151 | contents: contents.into(), 152 | headers: vec![], 153 | } 154 | } 155 | 156 | /// Set the attachment filename of a MIME part. 157 | pub fn attachment(mut self, filename: impl Into>) -> Self { 158 | self.headers.push(( 159 | "Content-Disposition".into(), 160 | ContentType::new("attachment") 161 | .attribute("filename", filename) 162 | .into(), 163 | )); 164 | self 165 | } 166 | 167 | /// Set the MIME part as inline. 168 | pub fn inline(mut self) -> Self { 169 | self.headers.push(( 170 | "Content-Disposition".into(), 171 | ContentType::new("inline").into(), 172 | )); 173 | self 174 | } 175 | 176 | /// Set the Content-Language header of a MIME part. 177 | pub fn language(mut self, value: impl Into>) -> Self { 178 | self.headers 179 | .push(("Content-Language".into(), Text::new(value).into())); 180 | self 181 | } 182 | 183 | /// Set the Content-ID header of a MIME part. 184 | pub fn cid(mut self, value: impl Into>) -> Self { 185 | self.headers 186 | .push(("Content-ID".into(), MessageId::new(value).into())); 187 | self 188 | } 189 | 190 | /// Set the Content-Location header of a MIME part. 191 | pub fn location(mut self, value: impl Into>) -> Self { 192 | self.headers 193 | .push(("Content-Location".into(), Raw::new(value).into())); 194 | self 195 | } 196 | 197 | /// Disable automatic Content-Transfer-Encoding detection and treat this as a raw MIME part 198 | pub fn transfer_encoding(mut self, value: impl Into>) -> Self { 199 | self.headers 200 | .push(("Content-Transfer-Encoding".into(), Raw::new(value).into())); 201 | self 202 | } 203 | 204 | /// Set custom headers of a MIME part. 205 | pub fn header( 206 | mut self, 207 | header: impl Into>, 208 | value: impl Into>, 209 | ) -> Self { 210 | self.headers.push((header.into(), value.into())); 211 | self 212 | } 213 | 214 | /// Returns the part's size 215 | pub fn size(&self) -> usize { 216 | match &self.contents { 217 | BodyPart::Text(b) => b.len(), 218 | BodyPart::Binary(b) => b.len(), 219 | BodyPart::Multipart(bl) => bl.iter().map(|b| b.size()).sum(), 220 | } 221 | } 222 | 223 | /// Add a body part to a multipart/* MIME part. 224 | pub fn add_part(&mut self, part: MimePart<'x>) { 225 | if let BodyPart::Multipart(ref mut parts) = self.contents { 226 | parts.push(part); 227 | } 228 | } 229 | 230 | /// Write the MIME part to a writer. 231 | pub fn write_part(self, mut output: impl Write) -> io::Result { 232 | let mut stack = Vec::new(); 233 | let mut it = vec![self].into_iter(); 234 | let mut boundary: Option> = None; 235 | 236 | loop { 237 | while let Some(part) = it.next() { 238 | if let Some(boundary) = boundary.as_ref() { 239 | output.write_all(b"\r\n--")?; 240 | output.write_all(boundary.as_bytes())?; 241 | output.write_all(b"\r\n")?; 242 | } 243 | match part.contents { 244 | BodyPart::Text(text) => { 245 | let mut is_attachment = false; 246 | let mut is_raw = part.headers.is_empty(); 247 | 248 | for (header_name, header_value) in &part.headers { 249 | output.write_all(header_name.as_bytes())?; 250 | output.write_all(b": ")?; 251 | if !is_attachment && header_name == "Content-Disposition" { 252 | is_attachment = header_value 253 | .as_content_type() 254 | .map(|v| v.is_attachment()) 255 | .unwrap_or(false); 256 | } else if !is_raw && header_name == "Content-Transfer-Encoding" { 257 | is_raw = true; 258 | } 259 | header_value.write_header(&mut output, header_name.len() + 2)?; 260 | } 261 | if !is_raw { 262 | detect_encoding(text.as_bytes(), &mut output, !is_attachment)?; 263 | } else { 264 | if !part.headers.is_empty() { 265 | output.write_all(b"\r\n")?; 266 | } 267 | output.write_all(text.as_bytes())?; 268 | } 269 | } 270 | BodyPart::Binary(binary) => { 271 | let mut is_text = false; 272 | let mut is_attachment = false; 273 | let mut is_raw = part.headers.is_empty(); 274 | 275 | for (header_name, header_value) in &part.headers { 276 | output.write_all(header_name.as_bytes())?; 277 | output.write_all(b": ")?; 278 | if !is_text && header_name == "Content-Type" { 279 | is_text = header_value 280 | .as_content_type() 281 | .map(|v| v.is_text()) 282 | .unwrap_or(false); 283 | } else if !is_attachment && header_name == "Content-Disposition" { 284 | is_attachment = header_value 285 | .as_content_type() 286 | .map(|v| v.is_attachment()) 287 | .unwrap_or(false); 288 | } else if !is_raw && header_name == "Content-Transfer-Encoding" { 289 | is_raw = true; 290 | } 291 | header_value.write_header(&mut output, header_name.len() + 2)?; 292 | } 293 | 294 | if !is_raw { 295 | if !is_text { 296 | output.write_all(b"Content-Transfer-Encoding: base64\r\n\r\n")?; 297 | base64_encode_mime(binary.as_ref(), &mut output, false)?; 298 | } else { 299 | detect_encoding(binary.as_ref(), &mut output, !is_attachment)?; 300 | } 301 | } else { 302 | if !part.headers.is_empty() { 303 | output.write_all(b"\r\n")?; 304 | } 305 | output.write_all(binary.as_ref())?; 306 | } 307 | } 308 | BodyPart::Multipart(parts) => { 309 | if boundary.is_some() { 310 | stack.push((it, boundary.take())); 311 | } 312 | 313 | let mut found_ct = false; 314 | for (header_name, header_value) in part.headers { 315 | output.write_all(header_name.as_bytes())?; 316 | output.write_all(b": ")?; 317 | 318 | if !found_ct && header_name.eq_ignore_ascii_case("Content-Type") { 319 | boundary = match header_value { 320 | HeaderType::ContentType(mut ct) => { 321 | let bpos = if let Some(pos) = ct 322 | .attributes 323 | .iter() 324 | .position(|(a, _)| a.eq_ignore_ascii_case("boundary")) 325 | { 326 | pos 327 | } else { 328 | let pos = ct.attributes.len(); 329 | ct.attributes.push(( 330 | "boundary".into(), 331 | make_boundary("_").into(), 332 | )); 333 | pos 334 | }; 335 | ct.write_header(&mut output, 14)?; 336 | ct.attributes.swap_remove(bpos).1.into() 337 | } 338 | HeaderType::Raw(raw) => { 339 | if let Some(pos) = raw.raw.find("boundary=\"") { 340 | if let Some(boundary) = raw.raw[pos..].split('"').nth(1) 341 | { 342 | Some(boundary.to_string().into()) 343 | } else { 344 | Some(make_boundary("_").into()) 345 | } 346 | } else { 347 | let boundary = make_boundary("_"); 348 | output.write_all(raw.raw.as_bytes())?; 349 | output.write_all(b"; boundary=\"")?; 350 | output.write_all(boundary.as_bytes())?; 351 | output.write_all(b"\"\r\n")?; 352 | Some(boundary.into()) 353 | } 354 | } 355 | _ => panic!("Unsupported Content-Type header value."), 356 | }; 357 | found_ct = true; 358 | } else { 359 | header_value.write_header(&mut output, header_name.len() + 2)?; 360 | } 361 | } 362 | 363 | if !found_ct { 364 | output.write_all(b"Content-Type: ")?; 365 | let boundary_ = make_boundary("_"); 366 | ContentType::new("multipart/mixed") 367 | .attribute("boundary", &boundary_) 368 | .write_header(&mut output, 14)?; 369 | boundary = Some(boundary_.into()); 370 | } 371 | 372 | output.write_all(b"\r\n")?; 373 | it = parts.into_iter(); 374 | } 375 | } 376 | } 377 | if let Some(boundary) = boundary { 378 | output.write_all(b"\r\n--")?; 379 | output.write_all(boundary.as_bytes())?; 380 | output.write_all(b"--\r\n")?; 381 | } 382 | if let Some((prev_it, prev_boundary)) = stack.pop() { 383 | it = prev_it; 384 | boundary = prev_boundary; 385 | } else { 386 | break; 387 | } 388 | } 389 | Ok(0) 390 | } 391 | } 392 | 393 | fn detect_encoding(input: &[u8], mut output: impl Write, is_body: bool) -> io::Result<()> { 394 | match get_encoding_type(input, false, is_body) { 395 | EncodingType::Base64 => { 396 | output.write_all(b"Content-Transfer-Encoding: base64\r\n\r\n")?; 397 | base64_encode_mime(input, &mut output, false)?; 398 | } 399 | EncodingType::QuotedPrintable(_) => { 400 | output.write_all(b"Content-Transfer-Encoding: quoted-printable\r\n\r\n")?; 401 | quoted_printable_encode(input, &mut output, false, is_body)?; 402 | } 403 | EncodingType::None => { 404 | output.write_all(b"Content-Transfer-Encoding: 7bit\r\n\r\n")?; 405 | if is_body { 406 | let mut prev_ch = 0; 407 | for ch in input { 408 | if *ch == b'\n' && prev_ch != b'\r' { 409 | output.write_all(b"\r")?; 410 | } 411 | output.write_all(&[*ch])?; 412 | prev_ch = *ch; 413 | } 414 | } else { 415 | output.write_all(input)?; 416 | } 417 | } 418 | } 419 | Ok(()) 420 | } 421 | 422 | #[cfg(test)] 423 | mod tests { 424 | use super::*; 425 | 426 | #[test] 427 | fn test_detect_encoding() { 428 | let mut output = Vec::new(); 429 | detect_encoding(b"a b c\r\n", &mut output, false).unwrap(); 430 | assert_eq!(output, b"Content-Transfer-Encoding: 7bit\r\n\r\na b c\r\n"); 431 | 432 | let mut output = Vec::new(); 433 | detect_encoding( 434 | b"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\r\n", 435 | &mut output, 436 | false, 437 | ) 438 | .unwrap(); 439 | assert_eq!(output, b"Content-Transfer-Encoding: 7bit\r\n\r\na a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\r\n"); 440 | 441 | let mut output = Vec::new(); 442 | detect_encoding( 443 | b"a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a\r\n", 444 | &mut output, 445 | false, 446 | ) 447 | .unwrap(); 448 | assert_eq!(output, b"Content-Transfer-Encoding: quoted-printable\r\n\r\na a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a =\r\na=0D=0A"); 449 | } 450 | } 451 | --------------------------------------------------------------------------------