├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── normalize.rs └── personal-mda.rs ├── src ├── decode.rs ├── deliver.rs ├── lib.rs ├── normalize.rs ├── processing.rs └── regex.rs └── tests ├── test_boundaries.rs ├── test_charset.rs ├── test_deliver.rs ├── test_encoded_words.rs ├── test_encoding.rs ├── test_fields.rs ├── test_processing.rs └── test_regex.rs /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Build 17 | run: cargo build --verbose 18 | - name: Run tests 19 | run: cargo test --verbose 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.1.1 (2019-10-06) 5 | ------------------ 6 | 7 | ### Changed 8 | 9 | - Improve performance of base64 decoding. 10 | 11 | ### Fixed 12 | 13 | - Fix boundary lines folding with last line of preceding decoded text. 14 | - Fix panics in malformed emails that use MIME boundaries improperly. 15 | 16 | 0.1.0 (2019-10-01) 17 | ------------------ 18 | 19 | Initial release. 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mda" 3 | version = "0.1.1" 4 | authors = ["Alexandros Frantzis "] 5 | edition = "2018" 6 | description = "A library for creating custom Mail Delivery Agents" 7 | license = "MPL-2.0" 8 | repository = "https://github.com/afrantzis/mda-rs" 9 | documentation = "https://docs.rs/mda" 10 | homepage = "https://github.com/afrantzis/mda-rs" 11 | readme = "README.md" 12 | categories = ["email"] 13 | exclude = ["/.github/**"] 14 | 15 | [dependencies] 16 | regex = "1" 17 | libc = "0.2" 18 | gethostname = "0.2" 19 | memchr = "2.2" 20 | charset = "0.1" 21 | lazy_static = "1.4" 22 | 23 | [dev-dependencies] 24 | tempfile = "3" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | mda-rs 2 | ====== 3 | 4 | mda-rs is a Rust library for writing custom Mail Deliver Agents. 5 | 6 | ![](https://github.com/afrantzis/mda-rs/workflows/build/badge.svg) 7 | 8 | ### Documentation 9 | 10 | The detailed module documentation, including code examples for all features, 11 | can be found at [https://docs.rs/mda](https://docs.rs/mda). 12 | 13 | ### Usage 14 | 15 | Add this to your `Cargo.toml`: 16 | 17 | ```toml 18 | [dependencies] 19 | mda = "0.1" 20 | ``` 21 | 22 | If you are using Rust 2015 add the following to your crate root file (Rust 2018 23 | doesn't require this): 24 | 25 | ```rust 26 | extern crate mda; 27 | ``` 28 | 29 | See [examples/personal-mda.rs](examples/personal-mda.rs) for an example that 30 | uses mda-rs. 31 | 32 | ### License 33 | 34 | This project is licensed under the Mozilla Public License Version 2.0 35 | ([LICENSE](LICENSE) or https://www.mozilla.org/en-US/MPL/2.0/). 36 | -------------------------------------------------------------------------------- /examples/normalize.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! Writes out the normalized form of an email. 10 | 11 | use std::io::{self, Write}; 12 | use mda::{Email, Result}; 13 | 14 | fn main() -> Result<()> { 15 | let email = Email::from_stdin()?; 16 | io::stdout().lock().write_all(email.data())?; 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /examples/personal-mda.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! An example of a custom MDA. 10 | 11 | use std::path::PathBuf; 12 | 13 | use mda::{Email, EmailRegex, Result, DeliveryDurability}; 14 | 15 | fn main() -> Result<()> { 16 | // Just some random path to make it highly unlikely that this example will 17 | // indvertently mess up something. 18 | let root = PathBuf::from("/tmp/my-personal-mail-96f29eb6375cfa37"); 19 | 20 | // If we are sure bogofilter is available, the below can be better written as: 21 | // let mut email = Email::from_stdin_filtered(&["/usr/bin/bogofilter", "-ep"])?; 22 | let mut email = Email::from_stdin()?; 23 | if let Ok(new_email) = email.filter(&["/usr/bin/bogofilter", "-ep"]) { 24 | email = new_email; 25 | } 26 | 27 | // Quicker (but possibly less durable) delivery. 28 | email.set_delivery_durability(DeliveryDurability::FileSyncOnly); 29 | 30 | let from = email.header_field("From").unwrap_or(""); 31 | let bogosity = email.header_field("X-Bogosity").unwrap_or(""); 32 | 33 | if bogosity.contains("Spam, tests=bogofilter") || 34 | from.contains("@banneddomain.com") { 35 | email.deliver_to_maildir(root.join("spam"))?; 36 | return Ok(()); 37 | } 38 | 39 | let cc = email.header_field("Cc").unwrap_or(""); 40 | let to = email.header_field("To").unwrap_or(""); 41 | 42 | if to.contains("myworkemail@example.com") || 43 | cc.contains("myworkemail@example.com") { 44 | if email.body().search("URGENCY RATING: (CRITICAL|URGENT)")? { 45 | email.deliver_to_maildir(root.join("inbox/myemail/urgent"))?; 46 | } else { 47 | email.deliver_to_maildir(root.join("inbox/myemail/normal"))?; 48 | } 49 | return Ok(()); 50 | } 51 | 52 | email.deliver_to_maildir(root.join("inbox/unsorted"))?; 53 | 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /src/decode.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! Base64 and quoted-printable decoding. 10 | 11 | use crate::Result; 12 | 13 | const PAD: u8 = 64; // The pseudo-index of the PAD character. 14 | const INV: u8 = 99; // An invalid index. 15 | 16 | static BASE64_INDICES: &'static [u8] = &[ 17 | // 0 1 2 3 4 5 6 7 8 9 A B C D E F 18 | /* 0 */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 19 | /* 1 */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 20 | /* 2 */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 62, INV, INV, INV, 63, 21 | /* 3 */ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, INV, INV, INV, PAD, INV, INV, 22 | /* 4 */ INV, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 23 | /* 5 */ 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, INV, INV, INV, INV, INV, 24 | /* 6 */ INV, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 25 | /* 7 */ 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, INV, INV, INV, INV, INV, 26 | /* 8 */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 27 | /* 9 */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 28 | /* A */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 29 | /* B */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 30 | /* C */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 31 | /* D */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 32 | /* E */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 33 | /* F */ INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, INV, 34 | ]; 35 | 36 | /// A base64 value. 37 | enum Base64Value { 38 | /// A valid base64 numeric value. 39 | Some(u8), 40 | /// The pad symbol. 41 | Pad, 42 | /// No value. 43 | None, 44 | } 45 | 46 | /// Returns the value of the next base64 character. Skips invalid 47 | /// characters (rfc2045: All line breaks or other characters not 48 | /// found in Table 1 must be ignored by decoding software). 49 | fn next_valid_base64_value(iter: &mut dyn Iterator) -> Base64Value { 50 | while let Some(c) = iter.next() { 51 | let b = BASE64_INDICES[*c as usize]; 52 | if b < PAD { 53 | return Base64Value::Some(b); 54 | } 55 | if b == PAD { 56 | return Base64Value::Pad; 57 | } 58 | } 59 | return Base64Value::None; 60 | } 61 | 62 | /// Decodes base64 encoded data, appending the decoded data to a Vec. 63 | /// 64 | /// During decoding all line breaks and invalid characters are ignored. 65 | /// Decoding is finished at the first pad character or end of input. If an 66 | /// error is encountered during decoding, the already decoded data in the output 67 | /// buffer is left intact. It's up to the caller to deal with the partial 68 | /// decoded data in case of failure 69 | pub fn base64_decode_into_buf(input: &[u8], output: &mut Vec) -> Result<()> { 70 | let mut iter = input.iter(); 71 | 72 | let expected_paddings = 73 | loop { 74 | let c0 = match next_valid_base64_value(&mut iter) { 75 | Base64Value::Some(c) => c, 76 | Base64Value::Pad => return Err("Invalid base64 padding".into()), 77 | Base64Value::None => return Ok(()), 78 | }; 79 | 80 | let c1 = match next_valid_base64_value(&mut iter) { 81 | Base64Value::Some(c) => { output.push((c0 << 2) | ((c & 0x3f) >> 4)); c } 82 | Base64Value::Pad => return Err("Invalid base64 padding".into()), 83 | Base64Value::None => return Err("Invalid base64 encoding".into()), 84 | }; 85 | 86 | let c2 = match next_valid_base64_value(&mut iter) { 87 | Base64Value::Some(c) => { output.push((c1 << 4) | ((c & 0x3f) >> 2)); c } 88 | Base64Value::Pad => break 1, 89 | Base64Value::None => return Err("Invalid base64 padding".into()), 90 | }; 91 | 92 | match next_valid_base64_value(&mut iter) { 93 | Base64Value::Some(c) => { output.push((c2 << 6) | ((c & 0x3f))); } 94 | Base64Value::Pad => break 0, 95 | Base64Value::None => return Err("Invalid base64 padding".into()), 96 | }; 97 | }; 98 | 99 | let mut found_paddings = 0; 100 | 101 | while let Some(c) = iter.next() { 102 | if *c == b'=' { 103 | found_paddings += 1; 104 | continue; 105 | } 106 | let b = BASE64_INDICES[*c as usize]; 107 | if b < PAD { 108 | return Err("Unexpected characters after base64 padding".into()); 109 | } 110 | } 111 | 112 | if found_paddings != expected_paddings { 113 | return Err("Invalid base64 padding".into()); 114 | } 115 | 116 | Ok(()) 117 | } 118 | 119 | /// Converts an ascii byte representing a hex digit to it's numerical value. 120 | fn hexdigit_to_num(mut a: u8) -> Option { 121 | if a.is_ascii_digit() { 122 | return Some(a - b'0'); 123 | } 124 | 125 | a.make_ascii_lowercase(); 126 | 127 | if a >= b'a' && a <= b'f' { 128 | return Some(a - b'a' + 10); 129 | } 130 | 131 | None 132 | } 133 | 134 | /// Decodes quoted-printable encoded data, appending the decoding data to a 135 | /// Vec. 136 | /// 137 | /// During decoding all line breaks and invalid characters are ignored. 138 | /// If an error is encountered during decoding, the already decoded data in the 139 | /// output buffer is left intact. It's up to the caller to deal with the partial 140 | /// decoded data in case of failure. 141 | pub fn qp_decode_into_buf(input: &[u8], output: &mut Vec) -> Result<()> { 142 | let mut iter = input.iter().peekable(); 143 | 144 | 'outer: loop { 145 | loop { 146 | match iter.next() { 147 | Some(b'=') => break, 148 | Some(c) => output.push(*c), 149 | None => break 'outer, 150 | } 151 | } 152 | 153 | // At this point we have encountered a '=', so check 154 | // to see what follows. 155 | if let Some(&first) = iter.next() { 156 | // A CRLF/LF after '=' marks a line continuation, and 157 | // is effectively dropped. 158 | if first == b'\r' { 159 | if iter.peek() == Some(&&b'\n') { 160 | iter.next(); 161 | continue; 162 | } 163 | } else if first == b'\n' { 164 | continue; 165 | } else if let Some(first_num) = hexdigit_to_num(first) { 166 | // A valid pair of hexdigits represent the raw byte value. 167 | if let Some(&&second) = iter.peek() { 168 | if let Some(second_num) = hexdigit_to_num(second) { 169 | output.push(first_num * 16 + second_num); 170 | iter.next(); 171 | continue; 172 | } 173 | } 174 | } 175 | 176 | // Emit the raw sequence if it's not one of the special 177 | // special cases checked above. 178 | output.extend(&[b'=', first]); 179 | } else { 180 | // Last character in the input was an '=', just emit it. 181 | output.push(b'='); 182 | } 183 | } 184 | 185 | 186 | Ok(()) 187 | } 188 | 189 | #[cfg(test)] 190 | mod test_base64 { 191 | use crate::decode::base64_decode_into_buf; 192 | 193 | #[test] 194 | fn decodes_full_length() { 195 | let mut decoded = Vec::new(); 196 | assert!(base64_decode_into_buf("YWJj".as_bytes(), &mut decoded).is_ok()); 197 | assert_eq!(decoded, &[b'a', b'b', b'c']); 198 | } 199 | 200 | #[test] 201 | fn decodes_with_two_padding() { 202 | let mut decoded = Vec::new(); 203 | assert!(base64_decode_into_buf("YWJjZA==".as_bytes(), &mut decoded).is_ok()); 204 | assert_eq!(decoded, &[b'a', b'b', b'c', b'd']); 205 | } 206 | 207 | #[test] 208 | fn decodes_with_one_padding() { 209 | let mut decoded = Vec::new(); 210 | assert!(base64_decode_into_buf("YWJjZGU=".as_bytes(), &mut decoded).is_ok()); 211 | assert_eq!(decoded, &[b'a', b'b', b'c', b'd', b'e']); 212 | } 213 | 214 | #[test] 215 | fn decodes_with_ignored_characters() { 216 | let mut decoded = Vec::new(); 217 | assert!(base64_decode_into_buf(" Y\t WJ\njZA=\r\n = ".as_bytes(), &mut decoded).is_ok()); 218 | assert_eq!(decoded, &[b'a', b'b', b'c', b'd']); 219 | } 220 | 221 | #[test] 222 | fn error_with_invalid_paddings() { 223 | let mut decoded = Vec::new(); 224 | assert!(base64_decode_into_buf("YWJj====".as_bytes(), &mut decoded).is_err()); 225 | assert!(base64_decode_into_buf("YWJjZ===".as_bytes(), &mut decoded).is_err()); 226 | assert!(base64_decode_into_buf("====".as_bytes(), &mut decoded).is_err()); 227 | } 228 | 229 | #[test] 230 | fn error_with_unpadded_input() { 231 | let mut decoded = Vec::new(); 232 | assert!(base64_decode_into_buf("YWJjZA=".as_bytes(), &mut decoded).is_err()); 233 | } 234 | 235 | #[test] 236 | fn error_with_characters_after_padding() { 237 | let mut decoded = Vec::new(); 238 | assert!(base64_decode_into_buf("YWJjZA=a".as_bytes(), &mut decoded).is_err()); 239 | assert!(base64_decode_into_buf("YWJjZA==b=".as_bytes(), &mut decoded).is_err()); 240 | } 241 | } 242 | 243 | #[cfg(test)] 244 | mod test_qp { 245 | use crate::decode::qp_decode_into_buf; 246 | 247 | #[test] 248 | fn decodes_byte() { 249 | let mut decoded = Vec::new(); 250 | assert!(qp_decode_into_buf("a=62c=64".as_bytes(), &mut decoded).is_ok()); 251 | assert_eq!(decoded, &[b'a', b'b', b'c', b'd']); 252 | } 253 | 254 | #[test] 255 | fn decodes_soft_break() { 256 | let mut decoded = Vec::new(); 257 | assert!(qp_decode_into_buf("a=\r\nb=\nc".as_bytes(), &mut decoded).is_ok()); 258 | assert_eq!(decoded, &[b'a', b'b', b'c']); 259 | } 260 | 261 | #[test] 262 | fn invalid_sequences_are_untouched() { 263 | let mut decoded = Vec::new(); 264 | let invalid_sequence = "a=6t= c=".as_bytes(); 265 | assert!(qp_decode_into_buf(invalid_sequence, &mut decoded).is_ok()); 266 | assert_eq!(decoded, invalid_sequence); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/deliver.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! Email delivery functionality. 10 | 11 | use std::fs::{self, File}; 12 | use std::io::ErrorKind; 13 | use std::io::prelude::*; 14 | use std::os::unix::prelude::*; 15 | use std::path::{PathBuf, Path}; 16 | use std::process; 17 | use std::sync::{Arc, Mutex}; 18 | use std::time::{SystemTime, UNIX_EPOCH}; 19 | 20 | use crate::{DeliveryDurability, Result}; 21 | 22 | use gethostname::gethostname; 23 | use libc; 24 | 25 | /// A generator for likely unique maildir email filenames. 26 | /// 27 | /// Using it as an iterator gets a filename that can be used in a maildir 28 | /// and is likely to be unique. 29 | pub struct EmailFilenameGenerator { 30 | count: usize, 31 | max_seen_unix_time: u64, 32 | hostname: String, 33 | } 34 | 35 | impl EmailFilenameGenerator { 36 | pub fn new() -> Self { 37 | // From https://cr.yp.to/proto/maildir.html: 38 | // "To deal with invalid host names, replace / with \057 and : with \072" 39 | let hostname = 40 | gethostname() 41 | .to_string_lossy() 42 | .into_owned() 43 | .replace("/", r"\057") 44 | .replace(":", r"\072"); 45 | 46 | EmailFilenameGenerator{ 47 | count: 0, 48 | max_seen_unix_time: 0, 49 | hostname: hostname, 50 | } 51 | } 52 | } 53 | 54 | impl Iterator for EmailFilenameGenerator { 55 | type Item = String; 56 | 57 | fn next(&mut self) -> Option { 58 | let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); 59 | let pid = process::id(); 60 | 61 | if self.max_seen_unix_time < unix_time { 62 | self.max_seen_unix_time = unix_time; 63 | self.count = 0; 64 | } else { 65 | self.count += 1; 66 | } 67 | 68 | Some(format!("{}.{}_{}.{}", unix_time, pid, self.count, self.hostname)) 69 | } 70 | } 71 | 72 | /// A representation of a maildir. 73 | pub struct Maildir { 74 | root: PathBuf, 75 | email_filename_gen: Arc>, 76 | } 77 | 78 | impl Maildir { 79 | /// Opens, or creates if it doesn't a exist, a maildir directory structure 80 | /// at the specified path. 81 | pub fn open_or_create( 82 | mailbox: &Path, 83 | email_filename_gen: Arc> 84 | ) -> Result { 85 | let root = PathBuf::from(mailbox); 86 | for s in &["tmp", "new", "cur"] { 87 | let path = root.join(&s); 88 | fs::create_dir_all(&path)?; 89 | } 90 | 91 | Ok(Maildir{root, email_filename_gen}) 92 | } 93 | 94 | /// Delivers an email to the maildir by creating a new file with the email data, 95 | /// and using the specified DeliveryDurability method. 96 | pub fn deliver( 97 | &self, 98 | data: &[u8], 99 | delivery_durability: DeliveryDurability 100 | ) -> Result { 101 | loop { 102 | let tmp_dir = self.root.join("tmp"); 103 | let new_dir = self.root.join("new"); 104 | 105 | let tmp_email = self.write_email_to_dir(data, &tmp_dir)?; 106 | let new_email = new_dir.join( 107 | tmp_email.file_name().ok_or("")?.to_str().ok_or("")?); 108 | 109 | let result = fs::hard_link(&tmp_email, &new_email); 110 | fs::remove_file(&tmp_email)?; 111 | 112 | match result { 113 | Ok(_) => { 114 | if delivery_durability == DeliveryDurability::FileAndDirSync { 115 | File::open(&new_dir)?.sync_all()?; 116 | File::open(&tmp_dir)?.sync_all()?; 117 | } 118 | return Ok(new_email); 119 | }, 120 | Err(ref err) if err.kind() == ErrorKind::AlreadyExists => {}, 121 | Err(err) => return Err(err.into()), 122 | } 123 | } 124 | } 125 | 126 | /// Delivers an email to the maildir by hard-linking with an existing file, 127 | /// and using the specified DeliveryDurability method. 128 | pub fn deliver_with_hard_link( 129 | &self, 130 | src: &Path, 131 | delivery_durability: DeliveryDurability 132 | ) -> Result { 133 | loop { 134 | let new_dir = self.root.join("new"); 135 | let new_email = new_dir.join(self.next_email_filename_candidate()?); 136 | 137 | match fs::hard_link(&src, &new_email) { 138 | Ok(_) => { 139 | if delivery_durability == DeliveryDurability::FileAndDirSync { 140 | File::open(&new_dir)?.sync_all()?; 141 | } 142 | return Ok(new_email); 143 | }, 144 | Err(ref err) if err.kind() == ErrorKind::AlreadyExists => {}, 145 | Err(err) => return Err(err.into()), 146 | } 147 | } 148 | } 149 | 150 | /// Writes email data to a new file in the specified directory. 151 | fn write_email_to_dir(&self, data: &[u8], dir: &Path) -> Result { 152 | loop { 153 | let email = dir.join(self.next_email_filename_candidate()?); 154 | let result = fs::OpenOptions::new() 155 | .create_new(true) 156 | .write(true) 157 | .custom_flags(libc::O_SYNC) 158 | .open(&email); 159 | 160 | match result { 161 | Ok(mut f) => { 162 | f.write_all(&data)?; 163 | return Ok(email); 164 | }, 165 | Err(ref err) if err.kind() == ErrorKind::AlreadyExists => {}, 166 | Err(err) => return Err(err.into()), 167 | } 168 | } 169 | } 170 | 171 | /// Gets the next email filename candidate from the EmailFilenameGenerator. 172 | fn next_email_filename_candidate(&self) -> Result { 173 | let mut gen = self.email_filename_gen.lock().map_err(|_| "")?; 174 | gen.next().ok_or("".into()) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! The mda crate provides a library for writing custom Mail Deliver Agents. It 10 | //! supports local delivery to maildirs, access to normalized email byte 11 | //! data for easier processing, and access to individual header fields. 12 | //! 13 | //! Email data normalization involves ensuring header fields are in single 14 | //! lines, decoding text parts of the message that use some kind of transfer 15 | //! encoding (e.g., base64), and converting all text to UTF-8. The original 16 | //! (non-normalized) email data is used during delivery. 17 | //! 18 | //! This crate also exposes convenience methods for regular expression searching 19 | //! and processing/filtering of emails. 20 | //! 21 | //! # Email construction 22 | //! 23 | //! The [Email struct](struct.Email.html) is the basic abstraction of the `mda` 24 | //! crate. To construct an Email use the 25 | //! [Email::from_stdin](struct.Email.html#method.from_stdin) or 26 | //! [Email::from_vec](struct.Email.html#method.from_vec) method. 27 | //! 28 | //! ```no_run 29 | //! use mda::Email; 30 | //! let email = Email::from_stdin()?; 31 | //! let email = Email::from_vec(vec![97, 98, 99])?; 32 | //! # Ok::<(), Box>(()) 33 | //! ``` 34 | //! 35 | //! # Email delivery 36 | //! 37 | //! Use the 38 | //! [Email::deliver_to_maildir](struct.Email.html#method.deliver_to_maildir) 39 | //! method to deliver the email to local maildir directories. Note that 40 | //! the original (non-normalized) email data is used during delivery. 41 | //! 42 | //! ```no_run 43 | //! use mda::Email; 44 | //! let email = Email::from_stdin()?; 45 | //! email.deliver_to_maildir("/my/maildir/path")?; 46 | //! email.deliver_to_maildir("/my/other/maildir/path")?; 47 | //! # Ok::<(), Box>(()) 48 | //! ``` 49 | //! 50 | //! # Accessing email header fields 51 | //! 52 | //! Use the [Email::header_field](struct.Email.html#method.header_field) and 53 | //! [Email::header_field_all_occurrences](struct.Email.html#method.header_field_all_occurrences) 54 | //! methods to access the email header fields. Any MIME encoded words in the 55 | //! header field values are decoded and the field value is converted to UTF-8. 56 | //! 57 | //! ```no_run 58 | //! use mda::Email; 59 | //! let email = Email::from_stdin()?; 60 | //! let to = email.header_field("To").unwrap_or(""); 61 | //! if to.contains("me@example.com") { 62 | //! email.deliver_to_maildir("/my/maildir/path")?; 63 | //! } 64 | //! # Ok::<(), Box>(()) 65 | //! ``` 66 | //! 67 | //! # Searching with regular expressions 68 | //! 69 | //! The [EmailRegex](trait.EmailRegex.html) trait provides convenience methods 70 | //! for searching the header, the body or the whole email with regular 71 | //! expressions. The convenience functions use case-insensitive, multi-line 72 | //! search (`^` and `$` match beginning and end of lines). If the above don't 73 | //! match your needs, or you require additional functionality, you can perform 74 | //! manual regex search using the email data. 75 | //! 76 | //! ```no_run 77 | //! use mda::{Email, EmailRegex}; 78 | //! let email = Email::from_stdin()?; 79 | //! if email.header().search(r"^To:.*me@example.com")? { 80 | //! email.deliver_to_maildir("/my/maildir/path")?; 81 | //! } 82 | //! # Ok::<(), Box>(()) 83 | //! ``` 84 | //! 85 | //! # Processing and filtering the email with external programs 86 | //! 87 | //! Use the [Email::filter](struct.Email.html#method.filter) and 88 | //! [Email::from_stdin_filtered](struct.Email.html#method.from_stdin_filtered) 89 | //! methods to filter the email, in both cases creating a new email. 90 | //! 91 | //! ```no_run 92 | //! use mda::Email; 93 | //! // Filtering directly from stdin is more efficient. 94 | //! let email = Email::from_stdin_filtered(&["bogofilter", "-ep"])?; 95 | //! let bogosity = email.header_field("X-Bogosity").unwrap_or(""); 96 | //! if bogosity.contains("Spam, tests=bogofilter") { 97 | //! email.deliver_to_maildir("/my/spam/path")?; 98 | //! } 99 | //! // We can also filter at any other time. 100 | //! let email = email.filter(&["bogofilter", "-ep"])?; 101 | //! # Ok::<(), Box>(()) 102 | //! ``` 103 | //! 104 | //! To perform more general processing use the 105 | //! [Email::process](struct.Email.html#method.process) 106 | //! method: 107 | //! 108 | //! ```no_run 109 | //! use mda::Email; 110 | //! let email = Email::from_stdin()?; 111 | //! let output = email.process(&["bogofilter"])?; 112 | //! if let Some(0) = output.status.code() { 113 | //! email.deliver_to_maildir("/my/spam/path")?; 114 | //! } 115 | //! # Ok::<(), Box>(()) 116 | //! ``` 117 | //! 118 | //! # Access to byte data 119 | //! 120 | //! Use the [Email::header](struct.Email.html#method.header), 121 | //! [Email::body](struct.Email.html#method.body), 122 | //! [Email::data](struct.Email.html#method.data) methods to access the 123 | //! normalized byte data of the header, body and whole email respectively. 124 | //! 125 | //! Normalization involves ensuring header fields are in single lines, decoding 126 | //! text parts of the message that use some kind of transfer encoding (e.g., 127 | //! base64), and converting all text to UTF-8 character encoding. 128 | //! 129 | //! If for some reason you need access to non-normalized data use 130 | //! [Email::raw_data](struct.Email.html#method.raw_data). 131 | //! 132 | //! ```no_run 133 | //! use std::str; 134 | //! use mda::Email; 135 | //! let email = Email::from_stdin()?; 136 | //! let body_str = String::from_utf8_lossy(email.header()); 137 | //! 138 | //! if body_str.contains("FREE BEER") { 139 | //! email.deliver_to_maildir("/my/spam/path")?; 140 | //! } 141 | //! # Ok::<(), Box>(()) 142 | //! ``` 143 | //! 144 | //! # Decide delivery durability vs speed trade-off 145 | //! 146 | //! Use the [Email::set_delivery_durability](struct.Email.html#method.set_delivery_durability) 147 | //! to decide which [DeliveryDurability](enum.DeliveryDurability.html) method to use. 148 | //! By default the most durable (but also slower) method is used. 149 | //! 150 | //! ```no_run 151 | //! use mda::{Email, DeliveryDurability}; 152 | //! let mut email = Email::from_stdin()?; 153 | //! email.set_delivery_durability(DeliveryDurability::FileSyncOnly); 154 | //! # Ok::<(), Box>(()) 155 | //! ``` 156 | 157 | mod deliver; 158 | mod regex; 159 | mod processing; 160 | mod normalize; 161 | mod decode; 162 | 163 | use std::io; 164 | use std::io::prelude::*; 165 | use std::path::{PathBuf, Path}; 166 | use std::sync:: {Arc, Mutex, RwLock}; 167 | use std::collections::HashMap; 168 | 169 | use deliver::{Maildir, EmailFilenameGenerator}; 170 | use normalize::normalize_email; 171 | 172 | pub use crate::regex::EmailRegex; 173 | 174 | pub type Result = std::result::Result>; 175 | 176 | fn find_empty_line(data: &[u8]) -> Option { 177 | data.windows(2).position(|w| w[0]== b'\n' && (w[1] == b'\n' || w[1] == b'\r')) 178 | } 179 | 180 | /// The method to use to try to guarantee durable email delivery. 181 | #[derive(PartialEq, Copy, Clone)] 182 | pub enum DeliveryDurability { 183 | /// Perform both file and directory syncing during delivery. 184 | /// This is the default delivery durability method. 185 | FileAndDirSync, 186 | /// Perform only file sync during delivery. This method is 187 | /// potentially much faster, and is used by many existing 188 | /// MDAs, but, depending on the used filesystem, may not 189 | /// provide the required delivery durability guarantees. 190 | FileSyncOnly, 191 | } 192 | 193 | /// A representation of an email. 194 | pub struct Email { 195 | data: Vec, 196 | normalized_data: Vec, 197 | body_index: usize, 198 | deliver_path: RwLock>, 199 | fields: HashMap>, 200 | email_filename_gen: Arc>, 201 | delivery_durability: DeliveryDurability, 202 | } 203 | 204 | impl Email { 205 | /// Creates an `Email` by reading data from stdin. 206 | /// 207 | /// # Example 208 | /// 209 | /// ```no_run 210 | /// # use mda::Email; 211 | /// let email = Email::from_stdin()?; 212 | /// # Ok::<(), Box>(()) 213 | /// ``` 214 | pub fn from_stdin() -> Result { 215 | let stdin = io::stdin(); 216 | let mut data = Vec::new(); 217 | stdin.lock().read_to_end(&mut data)?; 218 | Email::from_vec(data) 219 | } 220 | 221 | /// Creates an `Email` by using data passed in a `Vec`. 222 | /// 223 | /// # Example 224 | /// 225 | /// ```no_run 226 | /// # use mda::Email; 227 | /// let email = Email::from_vec(vec![1, 2, 3])?; 228 | /// # Ok::<(), Box>(()) 229 | /// ``` 230 | pub fn from_vec(data: Vec) -> Result { 231 | let (normalized_data, fields) = normalize_email(&data); 232 | let body_index = find_empty_line(&normalized_data).unwrap_or(normalized_data.len()); 233 | let email_filename_gen = Arc::new(Mutex::new(EmailFilenameGenerator::new())); 234 | 235 | Ok( 236 | Email{ 237 | data: data, 238 | normalized_data: normalized_data, 239 | body_index: body_index, 240 | deliver_path: RwLock::new(None), 241 | fields: fields, 242 | email_filename_gen: email_filename_gen, 243 | delivery_durability: DeliveryDurability::FileAndDirSync, 244 | } 245 | ) 246 | } 247 | 248 | /// Sets the durability method for delivery of this email. 249 | /// 250 | /// # Example 251 | /// 252 | /// ```no_run 253 | /// # use mda::{DeliveryDurability, Email}; 254 | /// let mut email = Email::from_stdin()?; 255 | /// email.set_delivery_durability(DeliveryDurability::FileSyncOnly); 256 | /// # Ok::<(), Box>(()) 257 | /// ``` 258 | pub fn set_delivery_durability(&mut self, delivery_durability: DeliveryDurability) { 259 | self.delivery_durability = delivery_durability; 260 | } 261 | 262 | /// Returns the value of a header field, if present. If a field occurs 263 | /// multiple times, the value of the first occurrence is returned. 264 | /// 265 | /// # Example 266 | /// 267 | /// ```no_run 268 | /// # use mda::Email; 269 | /// let email = Email::from_stdin()?; 270 | /// let to = email.header_field("To").unwrap_or(""); 271 | /// # Ok::<(), Box>(()) 272 | /// ``` 273 | pub fn header_field(&self, name: &str) -> Option<&str> { 274 | self.fields.get(&name.to_lowercase()).map(|v| v[0].as_str()) 275 | } 276 | 277 | /// Returns the values from all occurrences of a header field, if present. 278 | /// 279 | /// # Example 280 | /// 281 | /// ```no_run 282 | /// # use mda::Email; 283 | /// let email = Email::from_stdin()?; 284 | /// if let Some(all_received) = email.header_field_all_occurrences("Received") { 285 | /// // process all_received 286 | /// } 287 | /// # Ok::<(), Box>(()) 288 | /// ``` 289 | pub fn header_field_all_occurrences(&self, name: &str) -> Option<&Vec> { 290 | self.fields.get(&name.to_lowercase()).map(|v| v) 291 | } 292 | 293 | /// Delivers the email to the specified maildir. If the maildir isn't 294 | /// present it is created. 295 | /// 296 | /// The first delivery of an email involves writing the email data to 297 | /// the target file, whereas subsequent deliveries try to use a hard link 298 | /// to the first delivery, falling back to a normal write if needed. 299 | /// 300 | /// The email is delivered durably by syncing both the file and the 301 | /// associated directories (`DeliveryDurability::FileAndDirSync`), 302 | /// unless a different durability method is specified with 303 | /// `set_delivery_durability`. 304 | /// 305 | /// # Example 306 | /// 307 | /// ```no_run 308 | /// # use mda::Email; 309 | /// let email = Email::from_stdin()?; 310 | /// email.deliver_to_maildir("/path/to/maildir/")?; 311 | /// # Ok::<(), Box>(()) 312 | /// ``` 313 | pub fn deliver_to_maildir(&self, path: impl AsRef) -> Result { 314 | self.deliver_to_maildir_path(path.as_ref()) 315 | } 316 | 317 | fn deliver_to_maildir_path(&self, path: &Path) -> Result { 318 | let maildir = Maildir::open_or_create(&path, self.email_filename_gen.clone())?; 319 | 320 | if let Some(deliver_path) = self.deliver_path.read().unwrap().as_ref() { 321 | let email_path_result = 322 | maildir.deliver_with_hard_link( 323 | deliver_path, 324 | self.delivery_durability); 325 | 326 | if email_path_result.is_ok() { 327 | return email_path_result; 328 | } 329 | } 330 | 331 | let email_path = maildir.deliver(&self.data, self.delivery_durability)?; 332 | 333 | *self.deliver_path.write().unwrap() = Some(email_path.clone()); 334 | 335 | Ok(email_path) 336 | } 337 | 338 | /// Returns whether the email has been delivered to at least one maildir. 339 | /// 340 | /// # Example 341 | /// 342 | /// ```no_run 343 | /// # use mda::Email; 344 | /// let email = Email::from_stdin()?; 345 | /// if !email.has_been_delivered() { 346 | /// email.deliver_to_maildir("/fallback/maildir/")?; 347 | /// } 348 | /// # Ok::<(), Box>(()) 349 | /// ``` 350 | pub fn has_been_delivered(&self) -> bool { 351 | self.deliver_path.read().unwrap().is_some() 352 | } 353 | 354 | /// Provides access to the normalized email byte data. 355 | pub fn data(&self) -> &[u8] { 356 | &self.normalized_data 357 | } 358 | 359 | /// Provides access to the normalized email header byte data. 360 | pub fn header(&self) -> &[u8] { 361 | &self.normalized_data[..self.body_index] 362 | } 363 | 364 | /// Provides access to the normalized email body byte data. 365 | pub fn body(&self) -> &[u8] { 366 | &self.normalized_data[self.body_index..] 367 | } 368 | 369 | /// Provides access to the raw (non-normalized) email byte data. 370 | pub fn raw_data(&self) -> &[u8] { 371 | &self.data 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/normalize.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! Normalization of email data for easier processing. 10 | //! 11 | //! Normalization includes: 12 | //! 13 | //! * Placing multi-line header fields on a single line 14 | //! * Decoding base64 or quoted-printable encoded text data, including 15 | //! MIME encoded-words in the header. 16 | //! * Converting all text data to UTF-8. 17 | 18 | use ::regex::bytes::{RegexBuilder, Regex, Captures}; 19 | use std::collections::HashMap; 20 | use std::iter::Peekable; 21 | use memchr::{memchr, memchr_iter}; 22 | use charset::Charset; 23 | use std::borrow::Cow; 24 | use lazy_static::lazy_static; 25 | 26 | use crate::decode::{base64_decode_into_buf, qp_decode_into_buf}; 27 | 28 | /// An element recognized by the [EmailParser](struct.EmailParser.html). 29 | enum Element { 30 | HeaderField{data: Vec}, 31 | Body{ 32 | data: Vec, 33 | encoding: Option, 34 | content_type: Option, 35 | charset: Option 36 | }, 37 | Verbatim{data: Vec}, 38 | } 39 | 40 | /// Information about a part in a multi-part email message. 41 | /// The top-level is also considered a part. 42 | struct Part { 43 | encoding: Option, 44 | content_type: Option, 45 | charset: Option, 46 | subpart_boundary: Option>, 47 | } 48 | 49 | impl Part { 50 | fn new() -> Self { 51 | Part{ 52 | encoding: None, 53 | content_type: None, 54 | charset: None, 55 | subpart_boundary: None, 56 | } 57 | } 58 | } 59 | 60 | /// Iterator for the lines contained in a slice of [u8]. 61 | pub struct SliceLines<'a> { 62 | buf: &'a [u8], 63 | last: usize, 64 | } 65 | 66 | impl<'a> Iterator for SliceLines<'a> { 67 | type Item = &'a [u8]; 68 | 69 | fn next(&mut self) -> Option<&'a [u8]> { 70 | match memchr(b'\n', &self.buf[self.last..]) { 71 | Some(m) => { 72 | let line = &self.buf[self.last..=(self.last + m)]; 73 | self.last = self.last + m + 1; 74 | Some(line) 75 | }, 76 | None => { 77 | let line = &self.buf[self.last..]; 78 | if line.is_empty() { 79 | None 80 | } else { 81 | self.last = self.buf.len(); 82 | Some(line) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | /// A parser for the elements contained in an email. 90 | /// 91 | /// The parsed elements are accessible by iterating over the parser. 92 | /// 93 | /// Every line in the email is contained in a MIME part (which itself may be 94 | /// nested in another part). The top level of the email is also considered 95 | /// to be a part for convenience of processing. 96 | struct EmailParser<'a> { 97 | lines: Peekable>, 98 | // The stack of nested parts the line we are processing is contained in. 99 | part_stack: Vec, 100 | // Whether we currently parsing header lines. 101 | in_header: bool, 102 | // The active multi-part boundary. 103 | active_boundary: Vec, 104 | content_encoding_regex: Regex, 105 | content_type_regex: Regex, 106 | boundary_regex: Regex, 107 | } 108 | 109 | impl<'a> EmailParser<'a> { 110 | fn new(buf: &'a [u8]) -> Self { 111 | let content_encoding_regex = 112 | RegexBuilder::new(r"Content-Transfer-Encoding:\s*([[:alnum:]-]+)") 113 | .case_insensitive(true) 114 | .build().unwrap(); 115 | let content_type_regex = 116 | RegexBuilder::new(r#"^Content-Type:\s*([^;]+)\s*(?:;\s*charset\s*=\s*"?([[:alnum:]_:\-\.]+))?"?"#) 117 | .case_insensitive(true) 118 | .build().unwrap(); 119 | 120 | let boundary_regex = 121 | RegexBuilder::new(r#"^Content-Type:\s*multipart/.*boundary\s*=\s*"?([[:alnum:]'_,/:=\(\)\+\-\.\?]+)"?"#) 122 | .case_insensitive(true) 123 | .build().unwrap(); 124 | 125 | EmailParser{ 126 | lines: SliceLines{buf, last: 0}.peekable(), 127 | // All emails have the top-level part. 128 | part_stack: vec![Part::new()], 129 | in_header: true, 130 | active_boundary: Vec::new(), 131 | content_encoding_regex: content_encoding_regex, 132 | content_type_regex: content_type_regex, 133 | boundary_regex: boundary_regex, 134 | } 135 | } 136 | 137 | // Returns the content type of the active part. 138 | fn active_content_type(&self) -> Option { 139 | self.part_stack.last()?.content_type.clone() 140 | } 141 | 142 | // Returns the encoding of the active part. 143 | fn active_encoding(&self) -> Option { 144 | self.part_stack.last()?.encoding.clone() 145 | } 146 | 147 | // Returns the charset of the active part. 148 | fn active_charset(&self) -> Option { 149 | self.part_stack.last()?.charset.clone() 150 | } 151 | 152 | fn begin_part(&mut self) { 153 | let part = self.part_stack.last().unwrap(); 154 | 155 | // We need to differentiate between the first and subsequent parts in a 156 | // multipart message. The first part creates a new subpart in the 157 | // part_stack... 158 | if part.subpart_boundary.as_ref().is_some() && 159 | part.subpart_boundary.as_ref().unwrap() == &self.active_boundary { 160 | self.part_stack.push(Part::new()) 161 | } else { 162 | // ...whereas subsequent sibling parts just replace the existing 163 | // part in the stack. 164 | let part = self.part_stack.last_mut().unwrap(); 165 | *part = Part::new(); 166 | } 167 | } 168 | 169 | fn end_part(&mut self) { 170 | match &self.part_stack.last().unwrap().subpart_boundary { 171 | // If last part is top part (i.e., we just had a boundary end line 172 | // without a preceding boundary start line) do nothing. 173 | Some(b) if b == &self.active_boundary => {}, 174 | // Otherwise, remove the active part. 175 | _ => { self.part_stack.pop(); } 176 | } 177 | 178 | // Remove boundary info from top part. 179 | self.part_stack.last_mut().unwrap().subpart_boundary = None; 180 | self.active_boundary.clear(); 181 | 182 | for p in self.part_stack.iter().rev() { 183 | if let Some(b) = &p.subpart_boundary { 184 | self.active_boundary = b.clone(); 185 | } 186 | } 187 | } 188 | 189 | fn update_active_part_from_header_field(&mut self, field: &[u8]) { 190 | let mut part = self.part_stack.last_mut().unwrap(); 191 | 192 | if let Some(captures) = self.content_encoding_regex.captures(&field) { 193 | let enc_bytes = captures.get(1).unwrap().as_bytes(); 194 | part.encoding = Some(std::str::from_utf8(&enc_bytes).unwrap().to_lowercase()); 195 | } else if let Some(captures) = self.boundary_regex.captures(&field) { 196 | part.subpart_boundary = Some(captures.get(1).unwrap().as_bytes().to_vec()); 197 | self.active_boundary = part.subpart_boundary.as_ref().unwrap().clone(); 198 | } 199 | else if let Some(captures) = self.content_type_regex.captures(&field) { 200 | let type_bytes = captures.get(1).unwrap().as_bytes(); 201 | part.content_type = Some(std::str::from_utf8(&type_bytes).unwrap().to_lowercase()); 202 | if let Some(charset) = captures.get(2) { 203 | part.charset = Some(std::str::from_utf8(charset.as_bytes()).unwrap().to_lowercase()); 204 | } 205 | } 206 | } 207 | } 208 | 209 | /// Removes newline characters from the end of a byte vector. 210 | fn vec_trim_end_newline(line: &mut Vec) { 211 | while let Some(&b) = line.last() { 212 | if b != b'\n' && b != b'\r' { 213 | break; 214 | } 215 | line.pop(); 216 | } 217 | } 218 | 219 | /// Returns a new slice not including any newline characters from the 220 | /// end of an existing slice. 221 | fn slice_trim_end_newline(mut line: &[u8]) -> &[u8] { 222 | while let Some(&b) = line.last() { 223 | if b != b'\n' && b != b'\r' { 224 | break; 225 | } 226 | line = &line[..line.len()-1]; 227 | } 228 | line 229 | } 230 | 231 | /// Returns whether a line of bytes is a multi-part boundary line for the 232 | /// specified boundary string. 233 | fn is_boundary_line(line: &[u8], boundary: &[u8]) -> bool { 234 | if line.starts_with(b"--") && !boundary.is_empty() { 235 | let line = slice_trim_end_newline(&line); 236 | let line = if line.ends_with(b"--") { &line[..line.len()-2] } else { &line[..] }; 237 | return line.len() > 2 && &line[2..] == boundary; 238 | } 239 | 240 | false 241 | } 242 | 243 | 244 | impl Iterator for EmailParser<'_> { 245 | type Item = Element; 246 | 247 | fn next(&mut self) -> Option { 248 | let mut inprogress = Vec::new(); 249 | let mut element = None; 250 | 251 | // Loop until we recognize an element (or reach end of input). 252 | loop { 253 | let line = match self.lines.next() { 254 | Some(l) => l, 255 | None => break, 256 | }; 257 | 258 | if self.in_header { 259 | match line[0] { 260 | // Empty lines denote the end of header. 261 | b'\n' | b'\r' => { 262 | self.in_header = false; 263 | element = Some(Element::Verbatim{data: line.to_vec()}); 264 | break; 265 | }, 266 | // Lines beginning with are continuation lines. 267 | b' ' | b'\t' => { 268 | vec_trim_end_newline(&mut inprogress); 269 | inprogress.extend(line); 270 | }, 271 | _ => inprogress = line.to_vec(), 272 | }; 273 | 274 | // If the next line is not a continuation line, break 275 | // to emit the current header field. 276 | if let Some(next_line) = self.lines.peek() { 277 | if next_line[0] != b' ' && next_line[0] != b'\t' { 278 | break; 279 | } 280 | } 281 | 282 | continue; 283 | } 284 | 285 | if is_boundary_line(&line, &self.active_boundary) { 286 | if slice_trim_end_newline(&line).ends_with(b"--") { 287 | self.end_part(); 288 | } else { 289 | self.begin_part(); 290 | // After a boundary start line we expect a header. 291 | self.in_header = true; 292 | } 293 | 294 | element = Some(Element::Verbatim{data: line.to_vec()}); 295 | break; 296 | } 297 | 298 | // If we reached this point, this line is a body line. Append 299 | // it to the inprogress data. 300 | inprogress.extend(line); 301 | 302 | // If next line is a boundary line, break to emit the current 303 | // body. 304 | if let Some(next_line) = self.lines.peek() { 305 | if is_boundary_line(next_line, &self.active_boundary) { 306 | break; 307 | } 308 | } 309 | } 310 | 311 | // Breaking out the loop happens in three cases: 312 | // 1. End of input 313 | // 2. We have recognized a verbatim element. 314 | // 3. We have inprogress data that we have recognized as a header field 315 | // or body. 316 | 317 | // If we have inprogress data, emit it as header or body. 318 | if !inprogress.is_empty() { 319 | // We shouldn't have set an element at this point, since we have 320 | // inprogress data, and this would lead to loss of data. 321 | assert!(element.is_none()); 322 | 323 | if self.in_header { 324 | element = Some(Element::HeaderField{data: inprogress}); 325 | } else { 326 | element = Some( 327 | Element::Body{ 328 | data: inprogress, 329 | encoding: self.active_encoding(), 330 | content_type: self.active_content_type(), 331 | charset: self.active_charset(), 332 | } 333 | ); 334 | } 335 | } 336 | 337 | if let Some(Element::HeaderField{data: field}) = element.as_ref() { 338 | self.update_active_part_from_header_field(&field); 339 | } 340 | 341 | element 342 | } 343 | } 344 | 345 | /// Decodes a byte array slice with the specified content encoding and charset 346 | /// to utf-8 byte data, appending to the specified Vec. 347 | fn decode_text_data_to_buf( 348 | data: &[u8], 349 | encoding: Option<&str>, 350 | charset: Option<&str>, 351 | mut out: &mut Vec, 352 | ) { 353 | let should_decode = encoding.is_some(); 354 | let mut should_convert_charset = true; 355 | let initial_len = out.len(); 356 | 357 | if should_decode { 358 | let result = match encoding.unwrap().as_ref() { 359 | "base64" => base64_decode_into_buf(&data, &mut out), 360 | "quoted-printable" => qp_decode_into_buf(&data, &mut out), 361 | "8bit" | "binary" => { out.extend(data); Ok(()) }, 362 | _ => Err("unknown encoding".into()), 363 | }; 364 | 365 | if result.is_ok() { 366 | // During decoding the final CRLF/LF in the data may be dropped. 367 | // Restore it to ensure that subsequent lines don't get folded 368 | // with the decoded data. 369 | const CRLF: &[u8] = &[b'\r', b'\n']; 370 | const LF: &[u8] = &[b'\n']; 371 | if data.ends_with(CRLF) && !out.ends_with(CRLF) { 372 | out.extend(CRLF); 373 | } else if data.ends_with(LF) && !out.ends_with(LF) { 374 | out.extend(LF); 375 | } 376 | } else { 377 | out.resize(initial_len, 0); 378 | should_convert_charset = false; 379 | } 380 | } 381 | 382 | if out.len() == initial_len { 383 | out.extend(data); 384 | } 385 | 386 | if should_convert_charset { 387 | if let Some(chr) = Charset::for_label(charset.unwrap_or("us-ascii").as_bytes()) { 388 | let (cow, _, _) = chr.decode(&out[initial_len..]); 389 | if let Cow::Owned(c) = cow { 390 | out.resize(initial_len, 0); 391 | out.extend(c.bytes()); 392 | } 393 | } 394 | } 395 | } 396 | 397 | /// Returns whether a byte array slice could contain an MIME encoded-word. 398 | /// 399 | /// This function could return a false positive, but never a false negative. 400 | fn maybe_contains_encoded_word(data: &[u8]) -> bool { 401 | for spacepos in memchr_iter(b'?', &data) { 402 | if spacepos + 1 < data.len() && data[spacepos + 1] == b'=' { 403 | return true; 404 | } 405 | } 406 | 407 | false 408 | } 409 | 410 | /// Decodes a MIME encoded-word represented as regex captures. 411 | fn decode_encoded_word_from_captures(caps: &Captures) -> Vec { 412 | let charset = String::from_utf8_lossy(&caps[1]).to_lowercase(); 413 | let encoding = match &caps[2] { 414 | b"q" | b"Q" => "quoted-printable", 415 | b"b" | b"B" => "base64", 416 | _ => "", 417 | }; 418 | let mut data = Cow::from(&caps[3]); 419 | 420 | // Quoted-printable in encoded-words may use underscores for spaces. 421 | if encoding == "quoted-printable" { 422 | let space_positions: Vec<_> = memchr_iter(b'_', &data).collect(); 423 | for pos in space_positions { 424 | data.to_mut()[pos] = b' '; 425 | } 426 | } 427 | 428 | let mut decoded = Vec::new(); 429 | decode_text_data_to_buf(&data, Some(encoding), Some(&charset), &mut decoded); 430 | decoded 431 | } 432 | 433 | /// Normalizes an email and parses header fields. 434 | /// 435 | /// See module documentation about what is involved in normalization. 436 | /// 437 | /// Returns the normalized data and a map of header field names to values. 438 | pub fn normalize_email(data: &[u8]) -> (Vec, HashMap>) { 439 | lazy_static! { 440 | static ref ENCODED_WORD_REGEX: Regex = 441 | RegexBuilder::new(r"=\?([^?]+)\?([^?]+)\?([^? \t]+)\?=") 442 | .case_insensitive(true) 443 | .build().unwrap(); 444 | static ref ENCODED_WORD_WSP_REGEX: Regex = 445 | RegexBuilder::new(r"\?([^?]+)\?=\s*=\?([^?]+)\?") 446 | .case_insensitive(true) 447 | .build().unwrap(); 448 | } 449 | let parser = EmailParser::new(&data); 450 | let mut normalized = Vec::new(); 451 | let mut fields = HashMap::new(); 452 | 453 | for element in parser { 454 | match element { 455 | Element::HeaderField{data} => { 456 | let initial_len = normalized.len(); 457 | 458 | if maybe_contains_encoded_word(&data) { 459 | // First remove whitespace between consecutive encoded-words 460 | // as required by the RFC, then decode. 461 | let data = ENCODED_WORD_WSP_REGEX.replace_all( 462 | &data, "?$1?==?$2?".as_bytes()); 463 | let data = ENCODED_WORD_REGEX.replace_all( 464 | &data, decode_encoded_word_from_captures); 465 | normalized.extend(data.as_ref()); 466 | } else { 467 | normalized.extend(&data); 468 | } 469 | 470 | // Populate the fields map. 471 | let field_str = String::from_utf8_lossy(&normalized[initial_len..]); 472 | let field_str = field_str.trim(); 473 | let mut split = field_str.splitn(2, ':'); 474 | let name = split.next().map(|n| n.to_lowercase()).unwrap(); 475 | let value = split.next().unwrap_or("").to_owned(); 476 | fields.entry(name).or_insert(Vec::new()).push(value); 477 | }, 478 | Element::Body{data, encoding, content_type, charset} => { 479 | // Only decode text content. 480 | match content_type { 481 | Some(ref content_type) if !content_type.starts_with("text/") => { 482 | normalized.extend(&data); 483 | }, 484 | _ => { 485 | decode_text_data_to_buf( 486 | &data, 487 | encoding.as_ref().map(String::as_str), 488 | charset.as_ref().map(String::as_str), 489 | &mut normalized); 490 | } 491 | }; 492 | }, 493 | Element::Verbatim{data} => { 494 | normalized.extend(&data); 495 | }, 496 | } 497 | } 498 | 499 | (normalized, fields) 500 | } 501 | -------------------------------------------------------------------------------- /src/processing.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! Email processing and filtering. 10 | 11 | use std::io::Write; 12 | use std::process::{Command, Output, Stdio}; 13 | 14 | use crate::{Email, Result}; 15 | 16 | impl Email { 17 | /// Filters the contents of the email using an external command, 18 | /// returning a new email with the filtered contents. 19 | /// 20 | /// The command is expected to be provided as a `&str` array, with the 21 | /// first element being the command name and the remaining elements the 22 | /// command arguments. 23 | /// 24 | /// # Example 25 | /// 26 | /// ```no_run 27 | /// use mda::Email; 28 | /// let email = Email::from_stdin()?; 29 | /// let email = email.filter(&["bogofilter", "-ep"])?; 30 | /// # Ok::<(), Box>(()) 31 | /// ``` 32 | pub fn filter(&self, cmd: &[&str]) -> Result { 33 | Email::from_vec(self.process(cmd)?.stdout) 34 | } 35 | 36 | /// Process the contents of the email using an external command, 37 | /// returning a `std::process::Output` for the executed command. 38 | /// 39 | /// The command is expected to be provided as a `&str` array, with the 40 | /// first element being the command name and the remaining elements the 41 | /// command arguments. 42 | /// 43 | /// # Example 44 | /// 45 | /// ```no_run 46 | /// use mda::Email; 47 | /// let email = Email::from_stdin()?; 48 | /// let output = email.process(&["bogofilter"])?; 49 | /// if let Some(0) = output.status.code() { 50 | /// email.deliver_to_maildir("/my/spam/path")?; 51 | /// } 52 | /// # Ok::<(), Box>(()) 53 | /// ``` 54 | pub fn process(&self, cmd: &[&str]) -> Result { 55 | let mut child = 56 | Command::new(cmd[0]) 57 | .args(&cmd[1..]) 58 | .stdin(Stdio::piped()) 59 | .stdout(Stdio::piped()) 60 | .spawn()?; 61 | 62 | child.stdin 63 | .as_mut() 64 | .ok_or("Failed to write to stdin")? 65 | .write_all(&self.data)?; 66 | 67 | Ok(child.wait_with_output()?) 68 | } 69 | 70 | /// Creates an `Email` by filtering the contents from stdin. 71 | /// 72 | /// This can be more efficient than creating an `Email` from stdin and 73 | /// filtering separately, since it can avoid an extra data copy. 74 | /// 75 | /// The command is expected to be provided as a `&str` array, with the 76 | /// first element being the command name and the remaining elements the 77 | /// command arguments. 78 | /// 79 | /// # Example 80 | /// 81 | /// ```no_run 82 | /// use mda::Email; 83 | /// let email = Email::from_stdin_filtered(&["bogofilter", "-ep"])?; 84 | /// # Ok::<(), Box>(()) 85 | /// ``` 86 | pub fn from_stdin_filtered(cmd: &[&str]) -> Result { 87 | let output = 88 | Command::new(cmd[0]) 89 | .args(&cmd[1..]) 90 | .stdin(Stdio::inherit()) 91 | .output()?; 92 | 93 | Email::from_vec(output.stdout) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/regex.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | //! Convenience functionality for regex searches of email data. 10 | 11 | use std::str; 12 | 13 | use regex::bytes::{RegexBuilder, RegexSetBuilder, SetMatches, Captures}; 14 | 15 | use crate::Result; 16 | 17 | /// Trait providing convenience methods for regular expression searching 18 | /// in emails. The trait methods can be use with the byte data returned by 19 | /// the `Email::header`, `Email::body` and `Email::data` methods. 20 | /// 21 | /// This trait treats and searches the email contents as bytes. The regular 22 | /// expression parsing is configured for case-insensitive and multi-line 23 | /// search (i.e., `^` and `$` match beginning and end of lines respectively). 24 | /// 25 | /// In addition to the single regular expression searching, a method for 26 | /// matching regular expression sets is provided. This can be more 27 | /// efficient than matching multiple regular expressions independently. 28 | /// 29 | /// All the trait methods will fail if the regular expression is 30 | /// invalid, or the searched email data isn't valid utf-8. 31 | pub trait EmailRegex { 32 | /// Returns whether the contents match a regular expression. 33 | /// 34 | /// # Example 35 | /// 36 | /// ```no_run 37 | /// use mda::{Email, EmailRegex}; 38 | /// let email = Email::from_stdin()?; 39 | /// if email.header().search(r"^To:.*me@example.com")? { 40 | /// email.deliver_to_maildir("/my/maildir/path")?; 41 | /// } 42 | /// # Ok::<(), Box>(()) 43 | /// ``` 44 | fn search(&self, regex: &str) -> Result; 45 | 46 | /// Returns the capture groups matched from a regular expression. 47 | /// 48 | /// # Example 49 | /// 50 | /// ```no_run 51 | /// use std::path::Path; 52 | /// use mda::{Email, EmailRegex}; 53 | /// let email = Email::from_stdin()?; 54 | /// if let Some(captures) = email.header().search_with_captures(r"^X-Product: name=(\w+)")? { 55 | /// let name = std::str::from_utf8(captures.get(1).unwrap().as_bytes()).unwrap(); 56 | /// email.deliver_to_maildir(Path::new("/my/maildir/").join(name))?; 57 | /// } 58 | /// # Ok::<(), Box>(()) 59 | /// ``` 60 | fn search_with_captures(&self, regex: &str) -> Result>; 61 | 62 | /// Returns the matches from a set of regular expression. This can be 63 | /// more efficient than matching multiple regular expressions independently. 64 | /// 65 | /// # Example 66 | /// 67 | /// ```no_run 68 | /// use mda::{Email, EmailRegex}; 69 | /// let email = Email::from_stdin()?; 70 | /// let matched_sets = email.header().search_set( 71 | /// &[ 72 | /// r"^To: confidential ", 73 | /// r"^X-Confidential: true", 74 | /// ] 75 | /// )?; 76 | /// if matched_sets.matched_any() { 77 | /// email.deliver_to_maildir("/my/mail/confidential/")?; 78 | /// } 79 | /// # Ok::<(), Box>(()) 80 | /// ``` 81 | fn search_set(&self, regex_set: &[&str]) -> Result; 82 | } 83 | 84 | impl EmailRegex for &[u8] { 85 | fn search(&self, regex: &str) -> Result { 86 | Ok( 87 | RegexBuilder::new(regex) 88 | .multi_line(true) 89 | .case_insensitive(true) 90 | .build()? 91 | .is_match(self) 92 | ) 93 | } 94 | 95 | fn search_with_captures(&self, regex: &str) -> Result> { 96 | Ok( 97 | RegexBuilder::new(regex) 98 | .multi_line(true) 99 | .case_insensitive(true) 100 | .build()? 101 | .captures(self) 102 | ) 103 | } 104 | 105 | fn search_set(&self, regex_set: &[&str]) -> Result { 106 | Ok( 107 | RegexSetBuilder::new(regex_set) 108 | .multi_line(true) 109 | .case_insensitive(true) 110 | .build()? 111 | .matches(self) 112 | ) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/test_boundaries.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::{Email, EmailRegex}; 10 | 11 | static TEST_EMAIL_FAKE_BOUNDARY: &'static str = r#"Return-Path: 12 | To: Destination 13 | Content-type: multipart/alternative; boundary="QWFCYkN" 14 | 15 | --QWFCYkN 16 | Content-transfer-encoding: base64 17 | 18 | --QWFCYkNj 19 | 20 | --QWFCYkN 21 | "#; 22 | 23 | static TEST_EMAIL_BOUNDARY_BEGIN_AFTER_END: &'static str = r#"Return-Path: 24 | To: Destination 25 | Content-type: multipart/alternative; boundary="XtT01VFrJIenjlg+ZCXSSWq4" 26 | 27 | --XtT01VFrJIenjlg+ZCXSSWq4-- 28 | 29 | --XtT01VFrJIenjlg+ZCXSSWq4 30 | "#; 31 | 32 | #[test] 33 | fn only_exact_boundary_lines_are_parsed() { 34 | // The "--QWFCYkNj" line should be parsed as part of the body not as a boundary. 35 | let email = 36 | Email::from_vec( 37 | TEST_EMAIL_FAKE_BOUNDARY.to_string().into_bytes() 38 | ).unwrap(); 39 | assert!(email.body().search("AaBbCc").unwrap()); 40 | } 41 | 42 | #[test] 43 | fn boundary_begin_after_end_is_parsed() { 44 | assert!( 45 | Email::from_vec( 46 | TEST_EMAIL_BOUNDARY_BEGIN_AFTER_END.to_string().into_bytes() 47 | ).is_ok() 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /tests/test_charset.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::{Email, EmailRegex}; 10 | 11 | static TEST_EMAIL_ISO_BASE64: &'static str = r#"Return-Path: 12 | To: Destination 13 | Content-Type: text/plain; charset="iso-8859-7" 14 | Content-Transfer-Encoding: base64 15 | 16 | tuvr4SDm5/Tl3yDnIPj1994g8+/1LCDj6Scg3Ovr4SDq6+Hf5em3CvTv7SDd8OHp7e8g9O/1IMTe 17 | 7O/1IOrh6SD0+e0g0+/26fP0/u0sCvThIOT98+rv6+Eg6uHpIPQnIOHt5er03+zn9OEgxf3j5bcK 18 | 9OftIMHj7/HcLCD07yDI3eH08e8sIOrh6SD07/XyINP05fbc7e/18i4= 19 | "#; 20 | 21 | static TEST_EMAIL_ISO_8BIT: &'static [u8] = &[ 22 | b'C', b'o', b'n', b't', b'e', b'n', b't', b'-', b'T', b'y', b'p', b'e', 23 | b':', b' ', b't', b'e', b'x', b't', b'/', b'p', b'l', b'a', b'i', b'n', 24 | b';', b' ', b'c', b'h', b'a', b'r', b's', b'e', b't', b'=', b'"', b'i', 25 | b's', b'o', b'-', b'8', b'8', b'5', b'9', b'-', b'7', b'"', b'\r', b'\n', 26 | b'C', b'o', b'n', b't', b'e', b'n', b't', b'-', b'T', b'r', b'a', b'n', 27 | b's', b'f', b'e', b'r', b'-', b'E', b'n', b'c', b'o', b'd', b'i', b'n', 28 | b'g', b':', b' ', b'8', b'b', b'i', b't', b'\r', b'\n', 29 | b'\r', b'\n', 30 | 0xb6, 0xeb, 0xe1, 0x20, 0xe6, 0xe7, 0xf4, 0xe5, 0xdf, 0x20, 0xe7, 0x20, 31 | 0xf8, 0xf5, 0xf7, 0xde, 0x20, 0xf3, 0xef, 0xf5, 0x2c, 0x20, 0xe3, 0xe9, 32 | 0x27, 0x20, 0xdc, 0xeb, 0xe1, 0x20, 0xea, 0xeb, 0xe1, 0xdf, 0xe5, 0xe9, 33 | 0xb7, 0x0a, 0xf4, 0xef, 0xed, 0x20, 0xdd, 0xf0, 0xe1, 0xe9, 0xed, 0xef, 34 | 0x20, 0xf4, 0xef, 0xf5, 0x20, 0xc4, 0xde, 0xec, 0xef, 0xf5, 0x20, 0xea, 35 | 0xe1, 0xe9, 0x20, 0xf4, 0xf9, 0xed, 0x20, 0xd3, 0xef, 0xf6, 0xe9, 0xf3, 36 | 0xf4, 0xfe, 0xed, 0x2c, 0x0a, 0xf4, 0xe1, 0x20, 0xe4, 0xfd, 0xf3, 0xea, 37 | 0xef, 0xeb, 0xe1, 0x20, 0xea, 0xe1, 0xe9, 0x20, 0xf4, 0x27, 0x20, 0xe1, 38 | 0xed, 0xe5, 0xea, 0xf4, 0xdf, 0xec, 0xe7, 0xf4, 0xe1, 0x20, 0xc5, 0xfd, 39 | 0xe3, 0xe5, 0xb7, 0x0a, 0xf4, 0xe7, 0xed, 0x20, 0xc1, 0xe3, 0xef, 0xf1, 40 | 0xdc, 0x2c, 0x20, 0xf4, 0xef, 0x20, 0xc8, 0xdd, 0xe1, 0xf4, 0xf1, 0xef, 41 | 0x2c, 0x20, 0xea, 0xe1, 0xe9, 0x20, 0xf4, 0xef, 0xf5, 0xf2, 0x20, 0xd3, 42 | 0xf4, 0xe5, 0xf6, 0xdc, 0xed, 0xef, 0xf5, 0xf2, 0x2e 43 | ]; 44 | 45 | static TEST_EMAIL_MULTIPART_ISO: &'static str = r#"Return-Path: 46 | To: Destination 47 | Content-type: multipart/alternative; boundary="XtT01VFrJIenjlg+ZCXSSWq4" 48 | 49 | --XtT01VFrJIenjlg+ZCXSSWq4 50 | Content-Type: text/plain; charset="us-ascii" 51 | Content-Transfer-Encoding: base64 52 | 53 | Sample US-ASCII text. 54 | --XtT01VFrJIenjlg+ZCXSSWq4 55 | Content-type: multipart/alternative; boundary="2c+OeCbICgJrtINI5EFlsI6G" 56 | 57 | --2c+OeCbICgJrtINI5EFlsI6G 58 | Content-Type: text/plain; charset="utf-8" 59 | Content-Transfer-Encoding: base64 60 | 61 | zprOuSDhvILOvSDPgM+Ez4nPh865zrrhvbQgz4ThvbTOvSDOss+B4b+Hz4IsIOG8oSDhvLjOuM6s 62 | zrrOtyDOtOG9ss69IM+D4b2yIM6zzq3Ou86xz4POtS4K4bycz4TPg865IM+Dzr/PhuG9uM+CIM+A 63 | zr/hvbog4byUzrPOuc69zrXPgiwgzrzhvbIgz4TPjM+Dzrcgz4DOtc6vz4HOsSwK4bykzrTOtyDO 64 | uOG9sCDPhOG9uCDOus6xz4TOrM67zrHOss61z4Ig4b6RIOG8uM64zqzOus61z4Igz4TOryDPg863 65 | zrzOsc6vzr3Ov8+Fzr0uCg== 66 | --2c+OeCbICgJrtINI5EFlsI6G 67 | Content-Type: image/jpeg; 68 | Content-Transfer-Encoding: base64 69 | 70 | SSBhbSBzb3JyeSBEYXZlLCBJbSBhZnJhaWQgSSBjYW50IGRvIHRoYXQK 71 | 72 | --2c+OeCbICgJrtINI5EFlsI6G-- 73 | 74 | --XtT01VFrJIenjlg+ZCXSSWq4 75 | Content-Type: text/plain; charset="iso-8859-7" 76 | Content-Transfer-Encoding: base64 77 | 78 | tuvr4SDm5/Tl3yDnIPj1994g8+/1LCDj6Scg3Ovr4SDq6+Hf5em3CvTv7SDd8OHp7e8g9O/1IMTe 79 | 7O/1IOrh6SD0+e0g0+/26fP0/u0sCvThIOT98+rv6+Eg6uHpIPQnIOHt5er03+zn9OEgxf3j5bcK 80 | 9OftIMHj7/HcLCD07yDI3eH08e8sIOrh6SD07/XyINP05fbc7e/18i4= 81 | --XtT01VFrJIenjlg+ZCXSSWq4-- 82 | "#; 83 | 84 | #[test] 85 | fn email_with_charset_is_decoded() { 86 | let email = Email::from_vec(TEST_EMAIL_ISO_BASE64.to_string().into_bytes()).unwrap(); 87 | 88 | assert!(email.body().search(r"τα δύσκολα και τ' ανεκτίμητα Εύγε·").unwrap()); 89 | } 90 | 91 | #[test] 92 | fn email_with_charset_8bit_is_decoded() { 93 | let email = Email::from_vec(TEST_EMAIL_ISO_8BIT.to_vec()).unwrap(); 94 | 95 | assert!(email.body().search(r"τα δύσκολα και τ' ανεκτίμητα Εύγε·").unwrap()); 96 | } 97 | 98 | #[test] 99 | fn email_part_with_charset_is_decoded() { 100 | let email = Email::from_vec(TEST_EMAIL_MULTIPART_ISO.as_bytes().to_vec()).unwrap(); 101 | 102 | assert!(email.body().search(r"Sample US-ASCII text.").unwrap()); 103 | assert!(email.body().search(r"τα δύσκολα και τ' ανεκτίμητα Εύγε·").unwrap()); 104 | } 105 | -------------------------------------------------------------------------------- /tests/test_deliver.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::Email; 10 | use tempfile; 11 | use std::fs; 12 | use std::os::unix::fs as unix_fs; 13 | 14 | #[test] 15 | fn creates_maildir_dir_structure() { 16 | let tmpdir = tempfile::tempdir().unwrap(); 17 | 18 | let email = Email::from_vec(Vec::new()).unwrap(); 19 | email.deliver_to_maildir(tmpdir.path()).unwrap(); 20 | 21 | let entries: Vec<_> = fs::read_dir(tmpdir.path()).unwrap().collect(); 22 | 23 | let dir_named = |x,e: &fs::DirEntry| e.path() == tmpdir.path().join(x) && 24 | e.metadata().unwrap().is_dir(); 25 | 26 | assert_eq!(entries.len(), 3); 27 | assert_eq!(entries.iter().filter(|e| dir_named("new", e.as_ref().unwrap())).count(), 1); 28 | assert_eq!(entries.iter().filter(|e| dir_named("tmp", e.as_ref().unwrap())).count(), 1); 29 | assert_eq!(entries.iter().filter(|e| dir_named("cur", e.as_ref().unwrap())).count(), 1); 30 | } 31 | 32 | #[test] 33 | fn delivers_to_maildir_new() { 34 | let tmpdir = tempfile::tempdir().unwrap(); 35 | let data = [1, 3, 5, 7, 11]; 36 | 37 | let email = Email::from_vec(data.to_vec()).unwrap(); 38 | email.deliver_to_maildir(tmpdir.path()).unwrap(); 39 | 40 | let new_entries: Vec<_> = fs::read_dir(tmpdir.path().join("new")).unwrap().collect(); 41 | let tmp_entries: Vec<_> = fs::read_dir(tmpdir.path().join("tmp")).unwrap().collect(); 42 | let cur_entries: Vec<_> = fs::read_dir(tmpdir.path().join("cur")).unwrap().collect(); 43 | 44 | assert_eq!(new_entries.len(), 1); 45 | 46 | let file_contents = fs::read(new_entries[0].as_ref().unwrap().path()).unwrap(); 47 | assert_eq!(file_contents, &data); 48 | 49 | assert_eq!(tmp_entries.len(), 0); 50 | assert_eq!(cur_entries.len(), 0); 51 | } 52 | 53 | #[test] 54 | fn keeps_old_maildir_data() { 55 | let tmpdir = tempfile::tempdir().unwrap(); 56 | 57 | let data1 = [1, 3, 5, 7, 11]; 58 | let email1 = Email::from_vec(data1.to_vec()).unwrap(); 59 | let path1 = email1.deliver_to_maildir(tmpdir.path()).unwrap(); 60 | 61 | let data2 = [2, 4, 6, 8, 12]; 62 | let email2 = Email::from_vec(data2.to_vec()).unwrap(); 63 | let path2 = email2.deliver_to_maildir(tmpdir.path()).unwrap(); 64 | 65 | let new_entries: Vec<_> = fs::read_dir(tmpdir.path().join("new")).unwrap().collect(); 66 | 67 | assert_eq!(new_entries.len(), 2); 68 | assert_eq!(new_entries.iter().filter(|e| e.as_ref().unwrap().path() == path1).count(), 1); 69 | assert_eq!(new_entries.iter().filter(|e| e.as_ref().unwrap().path() == path2).count(), 1); 70 | 71 | assert_eq!(fs::read(path1).unwrap(), &data1); 72 | assert_eq!(fs::read(path2).unwrap(), &data2); 73 | } 74 | 75 | #[test] 76 | fn deals_with_soft_link_path() { 77 | let tmpdir = tempfile::tempdir().unwrap(); 78 | let subdir = tmpdir.path().join("subdir"); 79 | let symlink = tmpdir.path().join("symlink"); 80 | 81 | fs::create_dir(&subdir).unwrap(); 82 | unix_fs::symlink(&subdir, &symlink).unwrap(); 83 | 84 | let email = Email::from_vec(Vec::new()).unwrap(); 85 | email.deliver_to_maildir(&symlink).unwrap(); 86 | } 87 | -------------------------------------------------------------------------------- /tests/test_encoded_words.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::{Email, EmailRegex}; 10 | 11 | static TEST_EMAIL_MULTIPART: &'static str = r#"Return-Path: 12 | To: =?iso-8859-1?q?=C0a_b=DF?= , 13 | =?utf-8?b?zqXOps6nzqjOqQo=?= , 14 | Cc: =?iso-8859-1?q?=C0 b?= 15 | Bcc: =?utf8?B?zpbOl86YCg=?= 16 | Content-type: multipart/alternative; boundary="XtT01VFrJIenjlg+ZCXSSWq4" 17 | 18 | --XtT01VFrJIenjlg+ZCXSSWq4 19 | Content-Type: text/plain; charset="us-ascii" 20 | Content-Transfer-Encoding: base64 21 | X-header-field: =?UTF-8?B?zpHOks6TCg==?= 22 | 23 | --XtT01VFrJIenjlg+ZCXSSWq4-- 24 | "#; 25 | 26 | static TEST_EMAIL_INVALID_UTF8: &'static str = 27 | r#"Subject: =?utf-8?B?zojOus60zr/Pg863IGUtzrvOv86zzrHPgc65zrHPg868zr/P?="#; 28 | 29 | static TEST_EMAIL_MULTI_ENC_WORD: &'static str = r#"Return-Path: 30 | Subject: =?utf-8?b?TXkgbXVsdGkgZW5jb2RlZC0=?= 31 | =?utf-8?b?d29yZCBzdWJqZWN0IGw=?= 32 | =?utf-8?b?aW5l?= 33 | "#; 34 | 35 | #[test] 36 | fn encoded_word_is_decoded() { 37 | let email = Email::from_vec(TEST_EMAIL_MULTIPART.to_string().into_bytes()).unwrap(); 38 | 39 | assert!(email.data().search(r"Àa bß").unwrap()); 40 | assert!(email.header_field("To").unwrap().contains(r"Àa bß")); 41 | assert!(!email.data().search(r"=C0a_b=DF").unwrap()); 42 | assert!(!email.header_field("To").unwrap().contains(r"=C0a_b=DF")); 43 | 44 | assert!(email.data().search(r"ΥΦΧΨΩ").unwrap()); 45 | assert!(email.header_field("To").unwrap().contains(r"ΥΦΧΨΩ")); 46 | assert!(!email.data().search(r"zqXOps6nzqjOqQo=").unwrap()); 47 | assert!(!email.header_field("To").unwrap().contains(r"zqXOps6nzqjOqQo=")); 48 | 49 | assert!(email.data().search(r"ΑΒΓ").unwrap()); 50 | assert!(!email.data().search(r"zpHOks6TCg==").unwrap()); 51 | } 52 | 53 | #[test] 54 | fn invalid_encoded_word_is_not_decoded() { 55 | let email = Email::from_vec(TEST_EMAIL_MULTIPART.to_string().into_bytes()).unwrap(); 56 | 57 | assert!(!email.data().search(r"À b").unwrap()); 58 | assert!(email.data().search(r"=C0 b").unwrap()); 59 | 60 | assert!(!email.data().search(r"ΖΗΘ").unwrap()); 61 | assert!(email.data().search(r"zpbOl86YCg=").unwrap()); 62 | } 63 | 64 | #[test] 65 | fn invalid_charset_encoding_in_encoded_word_is_partially_decoded() { 66 | let email = Email::from_vec(TEST_EMAIL_INVALID_UTF8.to_string().into_bytes()).unwrap(); 67 | 68 | assert!(email.data().search("Έκδοση e-λογαριασμο\u{FFFD}").unwrap()); 69 | assert!(email.header_field("Subject").unwrap().contains("Έκδοση e-λογαριασμο\u{FFFD}")); 70 | } 71 | 72 | #[test] 73 | fn multpile_encoded_words_are_concatenated() { 74 | let email = Email::from_vec(TEST_EMAIL_MULTI_ENC_WORD.to_string().into_bytes()).unwrap(); 75 | 76 | assert!(email.data().search("My multi encoded-word subject line").unwrap()); 77 | assert!(email.header_field("Subject").unwrap().contains("My multi encoded-word subject line")); 78 | } 79 | -------------------------------------------------------------------------------- /tests/test_encoding.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::{Email, EmailRegex}; 10 | 11 | static TEST_EMAIL_BASE64: &'static str = r#"Return-Path: 12 | To: Destination 13 | Content-Type: text/plain; charset="utf-8" 14 | Content-Transfer-Encoding: base64 15 | 16 | VGhlIGFudGVjaGFwZWwgd2hlcmUgdGhlIHN0YXR1ZSBzdG9vZApPZiBOZXd0b24gd2l0aCBoaXMg 17 | cHJpc20gYW5kIHNpbGVudCBmYWNlLApUaGUgbWFyYmxlIGluZGV4IG9mIGEgbWluZCBmb3IgZXZl 18 | cgpWb3lhZ2luZyB0aHJvdWdoIHN0cmFuZ2Ugc2VhcyBvZiBUaG91Z2h0LCBhbG9uZS4gCg== 19 | "#; 20 | 21 | static TEST_EMAIL_MULTIPART: &'static str = r#"Return-Path: 22 | To: Destination 23 | Content-type: multipart/alternative; boundary="XtT01VFrJIenjlg+ZCXSSWq4" 24 | 25 | --XtT01VFrJIenjlg+ZCXSSWq4 26 | Content-Type: text/plain; charset="utf-8" 27 | Content-Transfer-Encoding: base64 28 | 29 | VGhlIGFudGVjaGFwZWwgd2hlcmUgdGhlIHN0YXR1ZSBzdG9vZApPZiBOZXd0b24gd2l0aCBoaXMg 30 | cHJpc20gYW5kIHNpbGVudCBmYWNlLApUaGUgbWFyYmxlIGluZGV4IG9mIGEgbWluZCBmb3IgZXZl 31 | cgpWb3lhZ2luZyB0aHJvdWdoIHN0cmFuZ2Ugc2VhcyBvZiBUaG91Z2h0LCBhbG9uZS4gCg== 32 | --XtT01VFrJIenjlg+ZCXSSWq4 33 | Content-type: multipart/alternative; boundary="2c+OeCbICgJrtINI5EFlsI6G" 34 | 35 | --2c+OeCbICgJrtINI5EFlsI6G 36 | Content-Type: text/plain; charset="utf-8" 37 | Content-Transfer-Encoding: base64 38 | 39 | zprOuSDhvILOvSDPgM+Ez4nPh865zrrhvbQgz4ThvbTOvSDOss+B4b+Hz4IsIOG8oSDhvLjOuM6s 40 | zrrOtyDOtOG9ss69IM+D4b2yIM6zzq3Ou86xz4POtS4K4bycz4TPg865IM+Dzr/PhuG9uM+CIM+A 41 | zr/hvbog4byUzrPOuc69zrXPgiwgzrzhvbIgz4TPjM+Dzrcgz4DOtc6vz4HOsSwK4bykzrTOtyDO 42 | uOG9sCDPhOG9uCDOus6xz4TOrM67zrHOss61z4Ig4b6RIOG8uM64zqzOus61z4Igz4TOryDPg863 43 | zrzOsc6vzr3Ov8+Fzr0uCg== 44 | --2c+OeCbICgJrtINI5EFlsI6G 45 | Content-Type: image/jpeg; 46 | Content-Transfer-Encoding: base64 47 | 48 | SSBhbSBzb3JyeSBEYXZlLCBJbSBhZnJhaWQgSSBjYW50IGRvIHRoYXQK 49 | 50 | --2c+OeCbICgJrtINI5EFlsI6G-- 51 | 52 | --XtT01VFrJIenjlg+ZCXSSWq4 53 | Content-Type: text/plain; charset="utf-8" 54 | Content-Transfer-Encoding: base64 55 | 56 | T3VyIHBvc3R1cmluZ3MsIG91ciBpbWFnaW5lZCBzZWxmLWltcG9ydGFuY2UsIHRoZSBkZWx1c2lv 57 | biB0aGF0IHdlIGhhdmUgc29tZSBwcml2aWxlZ2VkIHBvc2l0aW9uIGluIHRoZSBVbml2ZXJzZSwg 58 | YXJlIGNoYWxsZW5nZWQgYnkgdGhpcyBwb2ludCBvZiBwYWxlIGxpZ2h0LiBPdXIgcGxhbmV0IGlz 59 | IGEgbG9uZWx5IHNwZWNrIGluIHRoZSBncmVhdCBlbnZlbG9waW5nIGNvc21pYyBkYXJrLg== 60 | --XtT01VFrJIenjlg+ZCXSSWq4-- 61 | "#; 62 | 63 | static TEST_EMAIL_INVALID_BASE64: &'static str = r#"Return-Path: 64 | To: Destination 65 | Content-Type: text/plain; charset="utf-8" 66 | Content-Transfer-Encoding: base64 67 | 68 | VGhlIGFudGVjaGFwZWwgd2hlcmUgdGhlIHN0YXR1ZSBzdG9vZApPZiBOZXd0b24gd2l0aCBoaXMg 69 | cHJpc20gYW5kIHNpbGVudCBmYWNlLApUaGUgbWFyYmxlIGluZGV4IG9mIGEgbWluZCBmb3IgZXZl 70 | cgpWb3lhZ2luZyB0aHJvdWdoIHN0cmFuZ2Ugc2VhcyBvZiBUaG91Z2h0LCBhbG9uZS4gCg==== 71 | "#; 72 | 73 | static TEST_EMAIL_QP: &'static str = r#"Return-Path: 74 | To: Destination 75 | Content-Type: text/plain; charset="utf-8" 76 | Content-Transfer-Encoding: quoted-printable 77 | 78 | =54=68=65=20=61=6E=74=65=63=68=61=70=65=6C=20=77=68=65=72=65=20=74=68= 79 | =65=20=73=74=61=74=75=65=20=73=74=6F=6F=64 80 | =4F=66=20=4E=65=77=74=6F=6E=20=77=69=74=68=20=68=69=73=20=70=72=69=73= 81 | =6D=20=61=6E=64=20=73=69=6C=65=6E=74=20=66=61=63=65=2C 82 | =54=68=65=20=6D=61=72=62=6C=65=20=69=6E=64=65=78=20=6F=66=20=61=20=6D= 83 | =69=6E=64=20=66=6F=72=20=65=76=65=72 84 | =56=6F=79=61=67=69=6E=67=20=74=68=72=6F=75=67=68=20=73=74=72=61=6E=67= 85 | =65=20=73=65=61=73=20=6F=66=20=54=68=6F=75=67=68=74=2C=20=61=6C=6F=6E= 86 | =65=2E=20 87 | "#; 88 | 89 | #[test] 90 | fn base64_email_is_decoded() { 91 | let email = Email::from_vec(TEST_EMAIL_BASE64.to_string().into_bytes()).unwrap(); 92 | 93 | assert!(email.body().search(r"a\smind\sfor\sever\svoyaging").unwrap()); 94 | } 95 | 96 | #[test] 97 | fn base64_parts_are_decoded() { 98 | let email = Email::from_vec(TEST_EMAIL_MULTIPART.to_string().into_bytes()).unwrap(); 99 | 100 | // First level part. 101 | assert!(email.body().search(r"a\smind\sfor\sever\svoyaging").unwrap()); 102 | // Second level nested part. 103 | assert!(email.body().search(r"ἤδη θὰ τὸ κατάλαβες ᾑ Ἰθάκες τί σημαίνουν").unwrap()); 104 | // First level part after end of previous nested subparts. 105 | assert!(email.body().search(r"are challenged by this point of pale light").unwrap()); 106 | } 107 | 108 | #[test] 109 | fn base64_boundaries_remain_on_their_own_line() { 110 | let email = Email::from_vec(TEST_EMAIL_MULTIPART.to_string().into_bytes()).unwrap(); 111 | 112 | assert!(!email.data().search(r"[^\n]--XtT01VFrJIenjlg\+ZCXSSWq4").unwrap()); 113 | assert!(!email.data().search(r"[^\n]--2c\+OeCbICgJrtINI5EFlsI6G").unwrap()); 114 | } 115 | 116 | #[test] 117 | fn non_text_base64_is_not_decoded() { 118 | let email = Email::from_vec(TEST_EMAIL_MULTIPART.to_string().into_bytes()).unwrap(); 119 | 120 | assert!(!email.body().search(r"I am sorry Dave").unwrap()); 121 | } 122 | 123 | #[test] 124 | fn invalid_base64_is_not_decoded() { 125 | let email = Email::from_vec(TEST_EMAIL_INVALID_BASE64.to_string().into_bytes()).unwrap(); 126 | 127 | assert!(!email.body().search(r"a\smind\sfor\sever\svoyaging").unwrap()); 128 | assert!(email.body().search(r"4gCg=").unwrap()); 129 | } 130 | 131 | #[test] 132 | fn qp_email_is_decoded() { 133 | let email = Email::from_vec(TEST_EMAIL_QP.to_string().into_bytes()).unwrap(); 134 | 135 | assert!(email.body().search(r"a\smind\sfor\sever\svoyaging").unwrap()); 136 | } 137 | 138 | #[test] 139 | fn raw_data_is_not_decoded() { 140 | let email = Email::from_vec(TEST_EMAIL_MULTIPART.to_string().into_bytes()).unwrap(); 141 | 142 | assert!(email.raw_data().search(r"vZiBUaG91Z2h0LCBhbG9uZS4gCg==").unwrap()); 143 | assert!(!email.raw_data().search(r"ἤδη θὰ τὸ κατάλαβες ᾑ Ἰθάκες τί σημαίνουν").unwrap()); 144 | } 145 | -------------------------------------------------------------------------------- /tests/test_fields.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::Email; 10 | 11 | static TEST_EMAIL: &'static str = "Return-Path: 12 | Multi: multi1 13 | To: Destination 14 | Cc: firstcc , 15 | secondcc , 16 | \tthirsdcc 17 | Multi: multi2 18 | Multi: multi3 19 | multi3.1 20 | 21 | To: Body 22 | Multi: multibody 23 | BodyField: body 24 | Body body body 25 | "; 26 | 27 | static TEST_EMAIL_NO_BODY: &'static str = "Return-Path: 28 | Multi: multi1 29 | To: Destination 30 | Cc: firstcc , 31 | secondcc , 32 | thirsdcc 33 | "; 34 | 35 | static TEST_EMAIL_CRLF: &'static str = "Return-Path: \r 36 | Multi: multi1\r 37 | To: Destination \r 38 | Cc: firstcc ,\r 39 | secondcc ,\r 40 | thirsdcc \r 41 | Multi: multi2\r 42 | Multi: multi3\r 43 | multi3.1 44 | "; 45 | 46 | #[test] 47 | fn parses_single_line_fields() { 48 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 49 | assert_eq!( 50 | email.header_field("To").unwrap().trim(), 51 | "Destination " 52 | ); 53 | assert_eq!( 54 | email.header_field("Return-Path").unwrap().trim(), 55 | "" 56 | ); 57 | } 58 | 59 | #[test] 60 | fn parses_multi_line_fields() { 61 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 62 | assert_eq!( 63 | email.header_field("Cc").unwrap().trim(), 64 | "firstcc , secondcc ,\t\ 65 | thirsdcc " 66 | ); 67 | } 68 | 69 | #[test] 70 | fn field_names_are_case_insensitive() { 71 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 72 | 73 | assert_eq!( 74 | email.header_field("return-path").unwrap().trim(), 75 | "" 76 | ); 77 | assert_eq!( 78 | email.header_field("ReTuRn-PaTh").unwrap().trim(), 79 | "" 80 | ); 81 | } 82 | 83 | #[test] 84 | fn non_existent_field_is_none() { 85 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 86 | assert!(email.header_field("BodyField").is_none()); 87 | } 88 | 89 | #[test] 90 | fn fields_with_multiple_occurrences_return_all() { 91 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 92 | 93 | let multi = email.header_field_all_occurrences("Multi").unwrap(); 94 | 95 | assert_eq!(multi.len(), 3); 96 | assert_eq!(multi.iter().filter(|e| e.trim() == "multi1").count(), 1); 97 | assert_eq!(multi.iter().filter(|e| e.trim() == "multi2").count(), 1); 98 | assert_eq!(multi.iter().filter(|e| e.trim() == "multi3 multi3.1").count(), 1); 99 | } 100 | 101 | #[test] 102 | fn field_with_multiple_occurrences_returns_first() { 103 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 104 | 105 | assert_eq!(email.header_field("Multi").unwrap().trim(), "multi1"); 106 | } 107 | 108 | #[test] 109 | fn all_occurrences_of_non_existent_field_is_none() { 110 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 111 | 112 | assert!(email.header_field_all_occurrences("BodyField").is_none()) 113 | } 114 | 115 | #[test] 116 | fn header_with_no_body_is_parsed_fully() { 117 | let email = Email::from_vec(TEST_EMAIL_NO_BODY.to_string().into_bytes()).unwrap(); 118 | 119 | assert_eq!( 120 | email.header_field("Cc").unwrap().trim(), 121 | "firstcc , secondcc , \ 122 | thirsdcc " 123 | ); 124 | } 125 | 126 | #[test] 127 | fn header_using_crlf() { 128 | let email = Email::from_vec(TEST_EMAIL_CRLF.to_string().into_bytes()).unwrap(); 129 | 130 | assert_eq!( 131 | email.header_field("Cc").unwrap().trim(), 132 | "firstcc , secondcc , \ 133 | thirsdcc " 134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /tests/test_processing.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::Email; 10 | 11 | static TEST_EMAIL: &'static str = "Return-Path: 12 | To: Destination 13 | Cc: firstcc , 14 | secondcc , 15 | thirsdcc 16 | 17 | To: Body 18 | Body body body 19 | "; 20 | 21 | #[test] 22 | fn filtering_creates_new_email() { 23 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 24 | 25 | let email = email.filter(&["sed", "s/destination.com/newdest.com/g"]).unwrap(); 26 | 27 | assert_eq!(email.header_field("To").unwrap().trim(), "Destination "); 28 | } 29 | 30 | #[test] 31 | fn processing_returns_output() { 32 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 33 | 34 | let output_dest = email.process(&["grep", "Destination"]).unwrap(); 35 | let output_some = email.process(&["grep", "SomeInexistentString"]).unwrap(); 36 | 37 | assert_eq!(output_dest.status.code().unwrap(), 0); 38 | assert_eq!(output_some.status.code().unwrap(), 1); 39 | } 40 | -------------------------------------------------------------------------------- /tests/test_regex.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Alexandros Frantzis 2 | // 3 | // This Source Code Form is subject to the terms of the Mozilla Public 4 | // License, v. 2.0. If a copy of the MPL was not distributed with this 5 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 | // 7 | // SPDX-License-Identifier: MPL-2.0 8 | 9 | use mda::{Email, EmailRegex}; 10 | 11 | static TEST_EMAIL: &'static str = "Return-Path: 12 | To: Destination 13 | Cc: firstcc , 14 | secondcc , 15 | thirsdcc 16 | X-Test-Field: name123=value456 17 | Content-Type: text/plain; charset=utf-8 18 | 19 | To: Body 20 | Body body body 21 | Σὰ βγεῖς στὸν πηγαιμὸ γιὰ τὴν Ἰθάκη, 22 | νὰ εὔχεσαι νἆναι μακρὺς ὁ δρόμος, 23 | γεμάτος περιπέτειες, γεμάτος γνώσεις. 24 | "; 25 | 26 | #[test] 27 | fn header_search() { 28 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 29 | 30 | assert!(email.header().search(r"^(Cc|To).*someone\.else@destination\.com").unwrap()); 31 | assert!(!email.header().search(r"^(Cc|To).*body@destination\.com") .unwrap()); 32 | } 33 | 34 | #[test] 35 | fn header_search_multiline() { 36 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 37 | 38 | assert!(email.header().search(r"^Cc.*secondcc@destination\.com").unwrap()); 39 | assert!(email.header().search(r"^Cc.*thirdcc@destination\.com").unwrap()); 40 | } 41 | 42 | #[test] 43 | fn body_search() { 44 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 45 | 46 | assert!(email.body().search(r"^(Cc|To).*body@destination\.com").unwrap()); 47 | assert!(!email.body().search(r"^(Cc|To).*someone\.else@destination\.com").unwrap()); 48 | } 49 | 50 | #[test] 51 | fn data_search() { 52 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 53 | 54 | assert!(email.data().search(r"^(Cc|To).*firstcc@destination\.com").unwrap()); 55 | assert!(email.data().search(r"^(Cc|To).*body@destination\.com").unwrap()); 56 | assert!(!email.data().search(r"^(Cc|To).*unknown@destination\.com").unwrap()); 57 | } 58 | 59 | #[test] 60 | fn invalid_regex() { 61 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 62 | 63 | assert!(email.body().search(r"^(Cc|To).*(body@destination\.com").is_err()); 64 | } 65 | 66 | #[test] 67 | fn header_search_set() { 68 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 69 | 70 | let search = 71 | email.header().search_set( 72 | &[ 73 | r"^(Cc|To).*someone\.else@destination\.com", 74 | r"^(Cc|To).*body@destination\.com", 75 | ] 76 | ).unwrap(); 77 | 78 | let search: Vec<_> = search.into_iter().collect(); 79 | assert_eq!(search, vec![0]); 80 | } 81 | 82 | #[test] 83 | fn body_search_set() { 84 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 85 | 86 | let search = 87 | email.body().search_set( 88 | &[ 89 | r"^(Cc|To).*someone\.else@destination\.com", 90 | r"^(Cc|To).*body@destination\.com", 91 | ] 92 | ).unwrap(); 93 | 94 | let search: Vec<_> = search.into_iter().collect(); 95 | assert_eq!(search, vec![1]); 96 | } 97 | 98 | #[test] 99 | fn data_search_set() { 100 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 101 | 102 | let search = 103 | email.data().search_set( 104 | &[ 105 | r"^(Cc|To).*someone\.else@destination\.com", 106 | r"^(Cc|To).*body@destination\.com", 107 | ] 108 | ).unwrap(); 109 | 110 | let search: Vec<_> = search.into_iter().collect(); 111 | assert_eq!(search, vec![0, 1]); 112 | } 113 | 114 | #[test] 115 | fn search_set_invalid() { 116 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 117 | 118 | let search = 119 | email.data().search_set( 120 | &[ 121 | r"^((Cc|To).*someone\.else@destination\.com", 122 | r"^(Cc|To).*body@destination\.com", 123 | ] 124 | ); 125 | 126 | assert!(search.is_err()); 127 | } 128 | 129 | #[test] 130 | fn unicode_support() { 131 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 132 | 133 | assert!(email.body().search(r"νἆναι μακρὺς ὁ δρόμος").unwrap()); 134 | assert!(email.body().search(r"νἆναι μακρὺς ὁ δρόμος").unwrap()); 135 | 136 | assert_eq!( 137 | email.body().search_set( 138 | &[ 139 | r"Τοὺς Λαιστρυγόνας καὶ τοὺς Κύκλωπας", 140 | r"νἆναι μακρὺς ὁ δρόμος", 141 | ] 142 | ).unwrap().into_iter().collect::>(), 143 | vec![1] 144 | ); 145 | 146 | assert_eq!( 147 | email.data().search_set( 148 | &[ 149 | r"Τοὺς Λαιστρυγόνας καὶ τοὺς Κύκλωπας", 150 | r"νἆναι μακρὺς ὁ δρόμος", 151 | ] 152 | ).unwrap().into_iter().collect::>(), 153 | vec![1] 154 | ); 155 | } 156 | 157 | #[test] 158 | fn captures() { 159 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 160 | 161 | let header = email.header(); 162 | let captures = 163 | header 164 | .search_with_captures(r"^X-Test-Field: *(?P\w+)=(?P\w+)") 165 | .unwrap() 166 | .unwrap(); 167 | 168 | assert_eq!(captures.name("name").map(|m| m.as_bytes()), Some("name123".as_bytes())); 169 | assert_eq!(captures.name("value").map(|m| m.as_bytes()), Some("value456".as_bytes())); 170 | } 171 | 172 | #[test] 173 | fn multiline_headers() { 174 | let email = Email::from_vec(TEST_EMAIL.to_string().into_bytes()).unwrap(); 175 | 176 | let header = email.header(); 177 | let captures = 178 | header 179 | .search_with_captures(r"^X-Test-Field: *(?P\w+)=(?P\w+)") 180 | .unwrap() 181 | .unwrap(); 182 | 183 | assert_eq!(captures.name("name").map(|m| m.as_bytes()), Some("name123".as_bytes())); 184 | assert_eq!(captures.name("value").map(|m| m.as_bytes()), Some("value456".as_bytes())); 185 | } 186 | --------------------------------------------------------------------------------