├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── spec_bug_report.md ├── dependabot.yml └── workflows │ ├── rust.yml │ ├── security-audit.yml │ └── typos.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── pull_request_template.md └── src └── lib.rs /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | A clear and concise description of what you expected to happen. 15 | 16 | ## Minimal Reproducible Examples (MRE) 17 | 18 | Please try to provide information which will help us to fix the issue faster. MREs with few dependencies are especially lovely <3. 19 | 20 | If applicable, add logs/screenshots to help explain your problem. 21 | 22 | **Environment (please complete the following information):** 23 | - Platform: [e.g.`uname -a`] 24 | - Rust [e.g.`rustic -vV`] 25 | - Cargo [e.g.`cargo -vV`] 26 | 27 | ## Additional context 28 | 29 | Add any other context about the problem here. For example, environment variables like `CARGO`, `RUSTUP_HOME` or `CARGO_HOME`. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Problem 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | Include Issue links if they exist. 15 | 16 | Minimal Reproducible Examples (MRE) with few dependencies are useful. 17 | 18 | ## Solution 19 | 20 | A clear and concise description of what you want to happen. 21 | 22 | ## Alternatives 23 | 24 | A clear and concise description of any alternative solutions or features you've considered. 25 | 26 | ## Additional context 27 | 28 | Add any other context or screenshots about the feature request here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/spec_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Specification Non-conformance report 3 | about: Report an error in our implementation 4 | title: '' 5 | labels: specification 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | Please include references to the relevant specification; for example: 15 | 16 | > RFC 2616, section 4.3.2: 17 | > 18 | > > The HEAD method is identical to GET except that the server MUST NOT 19 | > > send a message body in the response 20 | 21 | ## Minimal Reproducible Examples (MRE) 22 | 23 | Please try to provide information which will help us to fix the issue faster. MREs with few dependencies are especially lovely <3. 24 | 25 | ## Additional context 26 | 27 | Add any other context about the problem here. For example, any other specifications that provide additional information, or other implementations that show common behavior. 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - johnstonskj 11 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**' 7 | - '!/*.md' 8 | - '!/*.org' 9 | - "!/LICENSE" 10 | 11 | push: 12 | branches: 13 | - main 14 | paths: 15 | - '**' 16 | - '!/*.md' 17 | - '!/*.org' 18 | - "!/LICENSE" 19 | 20 | schedule: 21 | - cron: '12 12 12 * *' 22 | 23 | jobs: 24 | publish: 25 | name: Publish (dry-run) 26 | needs: [test, docs] 27 | strategy: 28 | matrix: 29 | package: 30 | - email_address 31 | continue-on-error: true 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v4 36 | 37 | - name: Install Rust 38 | uses: actions-rs/toolchain@v1 39 | with: 40 | toolchain: stable 41 | profile: minimal 42 | override: true 43 | 44 | - uses: Swatinem/rust-cache@v2 45 | 46 | - name: Check publish 47 | uses: actions-rs/cargo@v1 48 | with: 49 | command: publish 50 | args: --package ${{ matrix.package}} --dry-run 51 | 52 | 53 | check_tests: 54 | name: Check for test types 55 | runs-on: ubuntu-latest 56 | outputs: 57 | has_benchmarks: ${{ steps.check_benchmarks.outputs.has_benchmarks }} 58 | has_examples: ${{ steps.check_examples.outputs.has_examples }} 59 | steps: 60 | - name: Check for benchmarks 61 | id: check_benchmarks 62 | run: test -d benchmarks && echo "has_benchmarks=1" || echo "has_benchmarks=" >> $GITHUB_OUTPUT 63 | shell: bash 64 | 65 | - name: Check for examples 66 | id: check_examples 67 | run: test -d examples && echo "has_examples=1" || echo "has_examples=" >> $GITHUB_OUTPUT 68 | shell: bash 69 | 70 | 71 | test: 72 | name: Test 73 | needs: [rustfmt, clippy] 74 | strategy: 75 | matrix: 76 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 77 | rust: ["stable", "beta", "nightly"] 78 | test-features: ["", "--all-features", "--no-default-features"] 79 | continue-on-error: ${{ matrix.rust != 'stable' }} 80 | runs-on: ${{ matrix.os }} 81 | steps: 82 | - name: Checkout repository 83 | uses: actions/checkout@v4 84 | 85 | - name: Install Rust 86 | uses: actions-rs/toolchain@v1 87 | with: 88 | toolchain: ${{ matrix.rust }} 89 | profile: minimal 90 | override: true 91 | 92 | - uses: Swatinem/rust-cache@v2 93 | 94 | - name: Build 95 | uses: actions-rs/cargo@v1 96 | with: 97 | command: build 98 | args: --workspace ${{ matrix.test-features }} 99 | 100 | - name: Test 101 | uses: actions-rs/cargo@v1 102 | with: 103 | command: test 104 | args: --workspace ${{ matrix.test-features }} 105 | 106 | 107 | benchmarks: 108 | name: Benchmarks 109 | needs: [rustfmt, clippy, check_tests] 110 | if: needs.check_tests.outputs.has_benchmarks 111 | strategy: 112 | matrix: 113 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 114 | rust: ["stable"] 115 | runs-on: ubuntu-latest 116 | steps: 117 | - name: Checkout repository 118 | uses: actions/checkout@v4 119 | 120 | - name: Install Rust 121 | uses: actions-rs/toolchain@v1 122 | with: 123 | toolchain: ${{ matrix.rust }} 124 | profile: minimal 125 | override: true 126 | 127 | - uses: Swatinem/rust-cache@v2 128 | 129 | - name: Run benchmarks with all features 130 | uses: actions-rs/cargo@v1 131 | with: 132 | command: test 133 | args: --workspace --benches --all-features --no-fail-fast 134 | 135 | 136 | examples: 137 | name: Examples 138 | needs: [rustfmt, clippy, check_tests] 139 | if: needs.check_tests.outputs.has_examples 140 | runs-on: ubuntu-latest 141 | strategy: 142 | matrix: 143 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 144 | rust: ["stable"] 145 | steps: 146 | - name: Checkout repository 147 | uses: actions/checkout@v2 148 | 149 | - name: Install Rust 150 | uses: actions-rs/toolchain@v1 151 | with: 152 | toolchain: ${{ matrix.rust }} 153 | profile: minimal 154 | override: true 155 | 156 | - uses: Swatinem/rust-cache@v2 157 | 158 | - name: Run examples with all features 159 | uses: actions-rs/cargo@v1 160 | with: 161 | command: test 162 | args: --workspace --examples --all-features --no-fail-fast 163 | 164 | 165 | coverage: 166 | name: Code Coverage 167 | needs: test 168 | runs-on: ubuntu-latest 169 | strategy: 170 | matrix: 171 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 172 | rust: ["stable"] 173 | steps: 174 | - name: Checkout repository 175 | uses: actions/checkout@v4 176 | 177 | - name: Install Rust 178 | uses: actions-rs/toolchain@v1 179 | with: 180 | toolchain: ${{ matrix.rust }} 181 | override: true 182 | 183 | - name: Run cargo-tarpaulin 184 | uses: actions-rs/tarpaulin@v0.1 185 | with: 186 | version: 0.22.0 187 | args: --all-features -- --test-threads 1 188 | 189 | - name: Upload to codecov.io 190 | uses: codecov/codecov-action@v1.0.2 191 | with: 192 | token: ${{secrets.CODECOV_TOKEN}} 193 | 194 | - name: Archive code coverage results 195 | uses: actions/upload-artifact@v1 196 | with: 197 | name: code-coverage-report 198 | path: cobertura.xml 199 | 200 | 201 | docs: 202 | name: Document generation 203 | needs: [rustfmt, clippy] 204 | runs-on: ubuntu-latest 205 | steps: 206 | - name: Checkout repository 207 | uses: actions/checkout@v4 208 | 209 | - name: Install Rust 210 | uses: actions-rs/toolchain@v1 211 | with: 212 | toolchain: stable 213 | profile: minimal 214 | override: true 215 | 216 | - uses: Swatinem/rust-cache@v2 217 | 218 | - name: Generate documentation 219 | uses: actions-rs/cargo@v1 220 | env: 221 | RUSTDOCFLAGS: -D warnings 222 | with: 223 | command: doc 224 | args: --workspace --all-features --no-deps 225 | 226 | 227 | rustfmt: 228 | name: rustfmt 229 | runs-on: ubuntu-latest 230 | steps: 231 | - name: Checkout repository 232 | uses: actions/checkout@v4 233 | 234 | - name: Install Rust 235 | uses: actions-rs/toolchain@v1 236 | with: 237 | toolchain: stable 238 | profile: minimal 239 | override: true 240 | components: rustfmt 241 | 242 | - uses: Swatinem/rust-cache@v2 243 | 244 | - name: Check formatting 245 | uses: actions-rs/cargo@v1 246 | with: 247 | command: fmt 248 | args: --all -- --check 249 | 250 | 251 | clippy: 252 | name: clippy 253 | runs-on: ubuntu-latest 254 | permissions: 255 | checks: write 256 | steps: 257 | - name: Checkout repository 258 | uses: actions/checkout@v4 259 | 260 | - name: Install Rust 261 | uses: actions-rs/toolchain@v1 262 | with: 263 | toolchain: stable 264 | profile: minimal 265 | override: true 266 | components: clippy 267 | 268 | - uses: Swatinem/rust-cache@v2 269 | 270 | - uses: actions-rs/clippy-check@v1 271 | with: 272 | token: ${{ secrets.GITHUB_TOKEN }} 273 | args: --workspace --no-deps --all-features --all-targets -- -D warnings 274 | -------------------------------------------------------------------------------- /.github/workflows/security-audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**/Cargo.toml' 7 | - '**/Cargo.lock' 8 | 9 | pull_request: 10 | paths: 11 | - '**/Cargo.toml' 12 | - '**/Cargo.lock' 13 | 14 | schedule: 15 | - cron: '12 12 12 * *' 16 | 17 | jobs: 18 | security_audit: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | checks: write 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v3 25 | 26 | - name: Install Rust 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: stable 30 | profile: minimal 31 | override: true 32 | 33 | - uses: actions-rs/audit-check@v1 34 | with: 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | name: Spelling 2 | on: [pull_request] 3 | 4 | jobs: 5 | spelling: 6 | name: Spell Check with Typos 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v2 11 | 12 | - name: Spell check repository 13 | uses: crate-ci/typos@master 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -*- mode: gitignore; -*- 2 | 3 | ### Emacs ### 4 | 5 | *~ 6 | \#*\# 7 | /.emacs.desktop 8 | /.emacs.desktop.lock 9 | *.elc 10 | auto-save-list 11 | tramp 12 | .\#* 13 | 14 | # Org-mode 15 | .org-id-locations 16 | *_archive 17 | 18 | # flymake-mode 19 | *_flymake.* 20 | 21 | # eshell files 22 | /eshell/history 23 | /eshell/lastdir 24 | 25 | # elpa packages 26 | /elpa/ 27 | 28 | # reftex files 29 | *.rel 30 | 31 | # AUCTeX auto folder 32 | /auto/ 33 | 34 | # cask packages 35 | .cask/ 36 | dist/ 37 | 38 | # Flycheck 39 | flycheck_*.el 40 | 41 | # server auth directory 42 | /server/ 43 | 44 | # projectiles files 45 | .projectile 46 | 47 | # directory configuration 48 | .dir-locals.el 49 | 50 | # network security 51 | /network-security.data 52 | 53 | ### Rust ### 54 | 55 | # Generated by Cargo 56 | # will have compiled files and executables 57 | /target/ 58 | 59 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 60 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 61 | Cargo.lock 62 | 63 | # Generated by `cargo mutants` 64 | /mutants.out/ 65 | 66 | # Generated by `unused-features analyze` 67 | /report.json 68 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | johnstonskj@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | I'm really glad you're reading this, because we need volunteer developers to 4 | help this project continue to grow and improve. 5 | 6 | 1. file [bugs](../../issues/new?assignees=&labels=bug&template=bug_report.md) and [enhancement requests](../../issues/new?assignees=&labels=enhancement&template=feature_request.md) 7 | 2. review the project documentation know if you find are issues, or missing 8 | content, there 9 | 3. Fix or Add something and send us a pull request; you may like to pick up one 10 | of the issues marked [help wanted](../../labels/help%20wanted) or [good first issue](../../labels/good%20first%20issue) as an introduction. 11 | Alternatively, [documentation](../../labels/documentation) issues can be a great way to understand the 12 | project and help improve the developer experience. 13 | 14 | ## Submitting changes 15 | 16 | 17 | We love pull requests from everyone. By participating in this project, you agree 18 | to abide by our [code of conduct](./CODE_OF_CONDUCT.md), and [License](./LICENSE). 19 | 20 | Fork, then clone the repo: 21 | 22 | ``` 23 | git clone git@github.com:johnstonskj/{{repository-name}}.git 24 | ``` 25 | 26 | Ensure you have a good Rust install, usually managed by [Rustup](https://rustup.rs/). 27 | You can ensure the latest tools with the following: 28 | 29 | ``` 30 | rustup update 31 | ``` 32 | 33 | Make sure the tests pass: 34 | 35 | ``` 36 | cargo test --package {{package-name}} --no-fail-fast -- --exact 37 | cargo test --package {{package-name}} --no-fail-fast --all-features -- --exact 38 | cargo test --package {{package-name}} --no-fail-fast --no-default-features -- --exact 39 | ``` 40 | 41 | Make your change. Add tests, and documentation, for your change. For tests 42 | please add a comment of the form: 43 | 44 | ```rust 45 | #[test] 46 | // Regression test: GitHub issue #11 47 | // or 48 | // Feature test: GitHub PR: #15 49 | fn test_something() { } 50 | ``` 51 | 52 | Ensure not only that tests pass, but the following all run successfully. 53 | 54 | ``` 55 | cargo doc --all-features --no-deps 56 | cargo fmt 57 | cargo clippy 58 | ``` 59 | 60 | If you have made any changes to `Cargo.toml`, also check: 61 | 62 | ``` 63 | cargo outdated --depth 1 64 | cargo audit 65 | ``` 66 | 67 | Push to your fork and [submit a pull request](../../compare/) using our [template](./pull_request_template.md). 68 | 69 | At this point you're waiting on us. We like to at least comment on pull requests 70 | within three business days (and, typically, one business day). We may suggest 71 | some changes or improvements or alternatives. 72 | 73 | Some things that will increase the chance that your pull request is accepted: 74 | 75 | * Write unit tests. 76 | * Write API documentation. 77 | * Write a [good commit message](https://cbea.ms/git-commit/https://cbea.ms/git-commit/). 78 | 79 | ## Coding conventions 80 | 81 | The primary tool for coding conventions is rustfmt, and specifically `cargo fmt` 82 | is a part of the build process and will cause Actions to fail. 83 | 84 | DO NOT create or update any existing `rustfmt.toml` file to change the default 85 | formatting rules. 86 | 87 | DO NOT alter any `warn` or `deny` library attributes. 88 | 89 | DO NOT add any `feature` attributes that would prohibit building on the stable 90 | channel. In some cases new crate-level features can be used to introduce an 91 | unstable feature dependency but these MUST be clearly documented and optional. 92 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "email_address" 3 | version = "0.2.9" 4 | authors = ["Simon Johnston "] 5 | description = "A Rust crate providing an implementation of an RFC-compliant `EmailAddress` newtype. " 6 | documentation = "https://docs.rs/email_address/" 7 | repository = "https://github.com/johnstonskj/rust-email_address.git" 8 | edition = "2018" 9 | license = "MIT" 10 | readme = "README.md" 11 | publish = true 12 | 13 | [package.metadata.docs.rs] 14 | # This only builds a single target for documentation. 15 | targets = ["x86_64-unknown-linux-gnu"] 16 | 17 | [features] 18 | default = ["serde_support"] 19 | serde_support = ["serde"] 20 | 21 | [dependencies] 22 | serde = { optional = true, version = "1.0" } 23 | 24 | [dev-dependencies] 25 | claims = "0.7.1" 26 | serde_assert = "0.8.0" 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Simon Johnston 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crate email_address 2 | 3 | A Rust crate providing an implementation of an RFC-compliant `EmailAddress` newtype. 4 | 5 | ![MIT License](https://img.shields.io/badge/license-mit-118811.svg) 6 | ![Minimum Rust Version](https://img.shields.io/badge/Min%20Rust-1.40-green.svg) 7 | [![crates.io](https://img.shields.io/crates/v/email_address.svg)](https://crates.io/crates/email_address) 8 | [![docs.rs](https://docs.rs/email_address/badge.svg)](https://docs.rs/email_address) 9 | ![Build](https://github.com/johnstonskj/rust-email_address/workflows/Rust/badge.svg) 10 | ![Audit](https://github.com/johnstonskj/rust-email_address/workflows/Security%20audit/badge.svg) 11 | [![GitHub stars](https://img.shields.io/github/stars/johnstonskj/rust-email_address.svg)](https://github.com/johnstonskj/rust-email_address/stargazers) 12 | 13 | Primarily for validation, the `EmailAddress` type is constructed with 14 | `FromStr::from_str` which will raise any parsing errors. Prior to constructions 15 | the functions `is_valid`, `is_valid_local_part`, and `is_valid_domain` may also be 16 | used to test for validity without constructing an instance. 17 | 18 | ## Status 19 | 20 | Currently, it supports all the RFC ASCII and UTF-8 character set rules as well 21 | as quoted and unquoted local parts but does not yet support all the productions 22 | required for SMTP headers; folding whitespace, comments, etc. 23 | 24 | ## Example 25 | 26 | ```rust 27 | use email_address::*; 28 | 29 | assert!(EmailAddress::is_valid("user.name+tag+sorting@example.com")); 30 | 31 | assert_eq!( 32 | EmailAddress::from_str("Abc.example.com"), 33 | Error::MissingSeparator.into() 34 | ); 35 | ``` 36 | 37 | ## Specifications 38 | 39 | 1. RFC 1123: [_Requirements for Internet Hosts -- Application and Support_](https://tools.ietf.org/html/rfc1123), 40 | IETF,Oct 1989. 41 | 1. RFC 3629: [_UTF-8, a transformation format of ISO 10646_](https://tools.ietf.org/html/rfc3629), 42 | IETF, Nov 2003. 43 | 1. RFC 3696: [_Application Techniques for Checking and Transformation of 44 | Names_](https://tools.ietf.org/html/rfc3696), IETF, Feb 2004. 45 | 1. RFC 4291 [_IP Version 6 Addressing Architecture_](https://tools.ietf.org/html/rfc4291), 46 | IETF, Feb 2006. 47 | 1. RFC 5234: [_Augmented BNF for Syntax Specifications: ABNF_](https://tools.ietf.org/html/rfc5234), 48 | IETF, Jan 2008. 49 | 1. RFC 5321: [_Simple Mail Transfer Protocol_](https://tools.ietf.org/html/rfc5321), 50 | IETF, Oct 2008. 51 | 1. RFC 5322: [_Internet Message Format_](https://tools.ietf.org/html/rfc5322), I 52 | ETF, Oct 2008. 53 | 1. RFC 5890: [_Internationalized Domain Names for Applications (IDNA): Definitions 54 | and Document Framework_](https://tools.ietf.org/html/rfc5890), IETF, Aug 2010. 55 | 1. RFC 6531: [_SMTP Extension for Internationalized Email_](https://tools.ietf.org/html/rfc6531), 56 | IETF, Feb 2012 57 | 1. RFC 6532: [_Internationalized Email Headers_](https://tools.ietf.org/html/rfc6532), 58 | IETF, Feb 2012. 59 | 60 | ## Changes 61 | 62 | ### Version 0.2.9 63 | 64 | * Fixed bug [#21](https://github.com/johnstonskj/rust-email_address/issues/21): Invalid Unicode characters accepted. 65 | 66 | ### Version 0.2.8 67 | 68 | * Fixed bug [#29](https://github.com/johnstonskj/rust-email_address/issues/29): Put back implementation of `Eq`. 69 | 70 | ### Version 0.2.7 71 | 72 | * Feature: added builder functions to the `Option` type. 73 | * Documentation: added examples to the `Option` type documentation. 74 | 75 | ### Version 0.2.6 76 | 77 | * Fix: merge issues. 78 | 79 | ### Version 0.2.5 80 | 81 | * Feature: Pull Request #15 -- Potential enhancement to add any free-text as 82 | display name. 83 | * Feature: Pull Request #17 -- Check for non-alphanumeric character starting or 84 | ending domain parts. 85 | * Feature: Pull Request #18 -- Error with `SubDomainEmpty` when empty instead of 86 | `InvalidCharacter`. 87 | * Feature: Pull Request #19 -- Allow configuring minimum number of subdomains. 88 | * Feature: Pull Request #20 -- Add option to disallow domain literals. 89 | * Feature: Pull Request #22 -- Handle a single qoute in local part of email 90 | 91 | Thanks to [ghandic](https://github.com/ghandic), [blaine-arcjet](https://github.com/blaine-arcjet), 92 | [Thomasdezeeuw](https://github.com/Thomasdezeeuw). 93 | 94 | ### Version 0.2.4 95 | 96 | * Fixed bug [#11](https://github.com/johnstonskj/rust-email_address/issues/11): 97 | 1. Add manual implementation of `PartialEq` with case insensitive comparison for 98 | domain part. 99 | 2. Add manual implementation of `Hash`, because above. 100 | * Change signature for `new_unchecked` to be more flexible. 101 | * Add `as_str` helper method. 102 | 103 | ### Version 0.2.3 104 | 105 | * Added new `EmailAddress::new_unchecked` function ([Sören Meier](https://github.com/soerenmeier)). 106 | 107 | ### Version 0.2.2 108 | 109 | * Removed manual `Send` and `Sync` implementation, and fixed documentation bug 110 | ([Sören Meier](https://github.com/soerenmeier)). 111 | 112 | ### Version 0.2.1 113 | 114 | * Added `From` for `String`. 115 | * Added `AsRef" 12 | ^------------------^ email() 13 | ^-------^ domain() 14 | ^--------^ local_part() 15 | ^------------^ display_part() 16 | ``` 17 | 18 | # Example 19 | 20 | The following shoes the basic `is_valid` and `from_str` functions. 21 | 22 | ```rust 23 | use email_address::*; 24 | use std::str::FromStr; 25 | 26 | assert!(EmailAddress::is_valid("user.name+tag+sorting@example.com")); 27 | 28 | assert_eq!( 29 | EmailAddress::from_str("Abc.example.com"), 30 | Error::MissingSeparator.into() 31 | ); 32 | ``` 33 | 34 | The following shows the three format functions used to output an email address. 35 | 36 | ```rust 37 | use email_address::*; 38 | use std::str::FromStr; 39 | 40 | let email = EmailAddress::from_str("johnstonsk@gmail.com").unwrap(); 41 | 42 | assert_eq!( 43 | email.to_string(), 44 | "johnstonsk@gmail.com".to_string() 45 | ); 46 | 47 | assert_eq!( 48 | String::from(email.clone()), 49 | "johnstonsk@gmail.com".to_string() 50 | ); 51 | 52 | assert_eq!( 53 | email.as_ref(), 54 | "johnstonsk@gmail.com" 55 | ); 56 | 57 | assert_eq!( 58 | email.to_uri(), 59 | "mailto:johnstonsk@gmail.com".to_string() 60 | ); 61 | 62 | assert_eq!( 63 | email.to_display("Simon Johnston"), 64 | "Simon Johnston ".to_string() 65 | ); 66 | ``` 67 | 68 | 69 | # Specifications 70 | 71 | 1. RFC 1123: [_Requirements for Internet Hosts -- Application and Support_](https://tools.ietf.org/html/rfc1123), 72 | IETF,Oct 1989. 73 | 1. RFC 3629: [_UTF-8, a transformation format of ISO 10646_](https://tools.ietf.org/html/rfc3629), 74 | IETF, Nov 2003. 75 | 1. RFC 3696: [_Application Techniques for Checking and Transformation of 76 | Names_](https://tools.ietf.org/html/rfc3696), IETF, Feb 2004. 77 | 1. RFC 4291 [_IP Version 6 Addressing Architecture_](https://tools.ietf.org/html/rfc4291), 78 | IETF, Feb 2006. 79 | 1. RFC 5234: [_Augmented BNF for Syntax Specifications: ABNF_](https://tools.ietf.org/html/rfc5234), 80 | IETF, Jan 2008. 81 | 1. RFC 5321: [_Simple Mail Transfer Protocol_](https://tools.ietf.org/html/rfc5321), 82 | IETF, Oct 2008. 83 | 1. RFC 5322: [_Internet Message Format_](https://tools.ietf.org/html/rfc5322), I 84 | ETF, Oct 2008. 85 | 1. RFC 5890: [_Internationalized Domain Names for Applications (IDNA): Definitions and Document 86 | Framework_](https://tools.ietf.org/html/rfc5890), IETF, Aug 2010. 87 | 1. RFC 6531: [_SMTP Extension for Internationalized Email_](https://tools.ietf.org/html/rfc6531), 88 | IETF, Feb 2012 89 | 1. RFC 6532: [_Internationalized Email Headers_](https://tools.ietf.org/html/rfc6532), 90 | IETF, Feb 2012. 91 | 92 | From RFC 5322: §3.2.1. [Quoted characters](https://tools.ietf.org/html/rfc5322#section-3.2.1): 93 | 94 | ```ebnf 95 | quoted-pair = ("\" (VCHAR / WSP)) / obs-qp 96 | ``` 97 | 98 | From RFC 5322: §3.2.2. [Folding White Space and Comments](https://tools.ietf.org/html/rfc5322#section-3.2.2): 99 | 100 | ```ebnf 101 | FWS = ([*WSP CRLF] 1*WSP) / obs-FWS 102 | ; Folding white space 103 | 104 | ctext = %d33-39 / ; Printable US-ASCII 105 | %d42-91 / ; characters not including 106 | %d93-126 / ; "(", ")", or "\" 107 | obs-ctext 108 | 109 | ccontent = ctext / quoted-pair / comment 110 | 111 | comment = "(" *([FWS] ccontent) [FWS] ")" 112 | 113 | CFWS = (1*([FWS] comment) [FWS]) / FWS 114 | ``` 115 | 116 | From RFC 5322: §3.2.3. [Atom](https://tools.ietf.org/html/rfc5322#section-3.2.3): 117 | 118 | ```ebnf 119 | atext = ALPHA / DIGIT / ; Printable US-ASCII 120 | "!" / "#" / ; characters not including 121 | "$" / "%" / ; specials. Used for atoms. 122 | "&" / "'" / 123 | "*" / "+" / 124 | "-" / "/" / 125 | "=" / "?" / 126 | "^" / "_" / 127 | "`" / "{" / 128 | "|" / "}" / 129 | "~" 130 | 131 | atom = [CFWS] 1*atext [CFWS] 132 | 133 | dot-atom-text = 1*atext *("." 1*atext) 134 | 135 | dot-atom = [CFWS] dot-atom-text [CFWS] 136 | 137 | specials = "(" / ")" / ; Special characters that do 138 | "<" / ">" / ; not appear in atext 139 | "[" / "]" / 140 | ":" / ";" / 141 | "@" / "\" / 142 | "," / "." / 143 | DQUOTE 144 | ``` 145 | 146 | From RFC 5322: §3.2.4. [Quoted Strings](https://tools.ietf.org/html/rfc5322#section-3.2.4): 147 | 148 | ```ebnf 149 | qtext = %d33 / ; Printable US-ASCII 150 | %d35-91 / ; characters not including 151 | %d93-126 / ; "\" or the quote character 152 | obs-qtext 153 | 154 | qcontent = qtext / quoted-pair 155 | 156 | quoted-string = [CFWS] 157 | DQUOTE *([FWS] qcontent) [FWS] DQUOTE 158 | [CFWS] 159 | ``` 160 | 161 | From RFC 5322, §3.4.1. [Addr-Spec Specification](https://tools.ietf.org/html/rfc5322#section-3.4.1): 162 | 163 | ```ebnf 164 | addr-spec = local-part "@" domain 165 | 166 | local-part = dot-atom / quoted-string / obs-local-part 167 | 168 | domain = dot-atom / domain-literal / obs-domain 169 | 170 | domain-literal = [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS] 171 | 172 | dtext = %d33-90 / ; Printable US-ASCII 173 | %d94-126 / ; characters not including 174 | obs-dtext ; "[", "]", or "\" 175 | ``` 176 | 177 | RFC 3696, §3. [Restrictions on email addresses](https://tools.ietf.org/html/rfc3696#section-3) 178 | describes in detail the quoting of characters in an address. 179 | 180 | ## Unicode 181 | 182 | RFC 6531, §3.3. [Extended Mailbox Address Syntax](https://tools.ietf.org/html/rfc6531#section-3.3) 183 | extends the rules above for non-ASCII character sets. 184 | 185 | ```ebnf 186 | sub-domain =/ U-label 187 | ; extend the definition of sub-domain in RFC 5321, Section 4.1.2 188 | 189 | atext =/ UTF8-non-ascii 190 | ; extend the implicit definition of atext in 191 | ; RFC 5321, Section 4.1.2, which ultimately points to 192 | ; the actual definition in RFC 5322, Section 3.2.3 193 | 194 | qtextSMTP =/ UTF8-non-ascii 195 | ; extend the definition of qtextSMTP in RFC 5321, Section 4.1.2 196 | 197 | esmtp-value =/ UTF8-non-ascii 198 | ; extend the definition of esmtp-value in RFC 5321, Section 4.1.2 199 | ``` 200 | 201 | A "U-label" is an IDNA-valid string of Unicode characters, in Normalization Form C (NFC) and 202 | including at least one non-ASCII character, expressed in a standard Unicode Encoding Form (such as 203 | UTF-8). It is also subject to the constraints about permitted characters that are specified in 204 | Section 4.2 of the Protocol document and the rules in the Sections 2 and 3 of the Tables document, 205 | the Bidi constraints in that document if it contains any character from scripts that are written 206 | right to left, and the symmetry constraint described immediately below. Conversions between U-labels 207 | and A-labels are performed according to the "Punycode" specification RFC3492, adding or removing 208 | the ACE prefix as needed. 209 | 210 | RFC 6532: §3.1 [UTF-8 Syntax and Normalization](https://tools.ietf.org/html/rfc6532#section-3.1), 211 | and §3.2 [Syntax Extensions to RFC 5322](https://tools.ietf.org/html/rfc6532#section-3.2) extend 212 | the syntax above with: 213 | 214 | ```ebnf 215 | UTF8-non-ascii = UTF8-2 / UTF8-3 / UTF8-4 216 | 217 | ... 218 | 219 | VCHAR =/ UTF8-non-ascii 220 | 221 | ctext =/ UTF8-non-ascii 222 | 223 | atext =/ UTF8-non-ascii 224 | 225 | qtext =/ UTF8-non-ascii 226 | 227 | text =/ UTF8-non-ascii 228 | ; note that this upgrades the body to UTF-8 229 | 230 | dtext =/ UTF8-non-ascii 231 | ``` 232 | 233 | These in turn refer to RFC 6529 §4. [Syntax of UTF-8 Byte Sequences](https://tools.ietf.org/html/rfc3629#section-4): 234 | 235 | > A UTF-8 string is a sequence of octets representing a sequence of UCS 236 | > characters. An octet sequence is valid UTF-8 only if it matches the 237 | > following syntax, which is derived from the rules for encoding UTF-8 238 | > and is expressed in the ABNF of \[RFC2234\]. 239 | 240 | ```ebnf 241 | UTF8-octets = *( UTF8-char ) 242 | UTF8-char = UTF8-1 / UTF8-2 / UTF8-3 / UTF8-4 243 | UTF8-1 = %x00-7F 244 | UTF8-2 = %xC2-DF UTF8-tail 245 | UTF8-3 = %xE0 %xA0-BF UTF8-tail / %xE1-EC 2( UTF8-tail ) / 246 | %xED %x80-9F UTF8-tail / %xEE-EF 2( UTF8-tail ) 247 | UTF8-4 = %xF0 %x90-BF 2( UTF8-tail ) / %xF1-F3 3( UTF8-tail ) / 248 | %xF4 %x80-8F 2( UTF8-tail ) 249 | UTF8-tail = %x80-BF 250 | ``` 251 | 252 | Comments in addresses are discussed in RFC 5322 Appendix A.5. [White Space, Comments, and Other 253 | Oddities](https://tools.ietf.org/html/rfc5322#appendix-A.5). 254 | 255 | An informal description can be found on [Wikipedia](https://en.wikipedia.org/wiki/Email_address). 256 | 257 | */ 258 | 259 | #![warn( 260 | unknown_lints, 261 | // ---------- Stylistic 262 | absolute_paths_not_starting_with_crate, 263 | elided_lifetimes_in_paths, 264 | explicit_outlives_requirements, 265 | macro_use_extern_crate, 266 | nonstandard_style, /* group */ 267 | noop_method_call, 268 | rust_2018_idioms, 269 | single_use_lifetimes, 270 | trivial_casts, 271 | trivial_numeric_casts, 272 | // ---------- Future 273 | future_incompatible, /* group */ 274 | rust_2021_compatibility, /* group */ 275 | // ---------- Public 276 | missing_debug_implementations, 277 | missing_docs, 278 | unreachable_pub, 279 | // ---------- Unsafe 280 | unsafe_code, 281 | unsafe_op_in_unsafe_fn, 282 | // ---------- Unused 283 | unused, /* group */ 284 | )] 285 | #![deny( 286 | // ---------- Public 287 | exported_private_dependencies, 288 | // ---------- Deprecated 289 | anonymous_parameters, 290 | bare_trait_objects, 291 | ellipsis_inclusive_range_patterns, 292 | // ---------- Unsafe 293 | deref_nullptr, 294 | drop_bounds, 295 | dyn_drop, 296 | )] 297 | 298 | #[cfg(feature = "serde_support")] 299 | use serde::{Deserialize, Serialize, Serializer}; 300 | use std::fmt::{Debug, Display, Formatter}; 301 | use std::hash::Hash; 302 | use std::str::FromStr; 303 | 304 | // ------------------------------------------------------------------------------------------------ 305 | // Public Types 306 | // ------------------------------------------------------------------------------------------------ 307 | 308 | /// 309 | /// Error type used when parsing an address. 310 | /// 311 | #[derive(Debug, Clone, PartialEq)] 312 | pub enum Error { 313 | /// An invalid character was found in some component of the address. 314 | InvalidCharacter, 315 | /// The separator character between `local-part` and `domain` (character: '@') was missing. 316 | MissingSeparator, 317 | /// The `local-part` is an empty string. 318 | LocalPartEmpty, 319 | /// The `local-part` is is too long. 320 | LocalPartTooLong, 321 | /// The `domain` is an empty string. 322 | DomainEmpty, 323 | /// The `domain` is is too long. 324 | DomainTooLong, 325 | /// The `sub-domain` within the `domain` is empty. 326 | SubDomainEmpty, 327 | /// A `sub-domain` within the `domain` is is too long. 328 | SubDomainTooLong, 329 | /// Too few `sub-domain`s in `domain`. 330 | DomainTooFew, 331 | /// Invalid placement of the domain separator (character: '.'). 332 | DomainInvalidSeparator, 333 | /// The quotes (character: '"') around `local-part` are unbalanced. 334 | UnbalancedQuotes, 335 | /// A Comment within the either the `local-part`, or `domain`, was malformed. 336 | InvalidComment, 337 | /// An IP address in a `domain-literal` was malformed. 338 | InvalidIPAddress, 339 | /// A `domain-literal` was supplied, but is unsupported by parser configuration. 340 | UnsupportedDomainLiteral, 341 | /// Display name was supplied, but is unsupported by parser configuration. 342 | UnsupportedDisplayName, 343 | /// Display name was not supplied, but email starts with '<'. 344 | MissingDisplayName, 345 | /// An email enclosed within <...> is missing the final '>'. 346 | MissingEndBracket, 347 | } 348 | 349 | /// 350 | /// Struct of options that can be configured when parsing with `parse_with_options`. 351 | /// 352 | #[derive(Debug, Copy, Clone)] 353 | pub struct Options { 354 | /// 355 | /// Sets the minimum number of domain segments that must exist to parse successfully. 356 | /// 357 | /// ```rust 358 | /// use email_address::*; 359 | /// 360 | /// assert!( 361 | /// EmailAddress::parse_with_options( 362 | /// "simon@localhost", 363 | /// Options::default().with_no_minimum_sub_domains(), 364 | /// ).is_ok() 365 | /// ); 366 | /// assert_eq!( 367 | /// EmailAddress::parse_with_options( 368 | /// "simon@localhost", 369 | /// Options::default().with_required_tld() 370 | /// ), 371 | /// Err(Error::DomainTooFew) 372 | /// ); 373 | /// ``` 374 | /// 375 | pub minimum_sub_domains: usize, 376 | 377 | /// 378 | /// Specifies if domain literals are allowed. Defaults to `true`. 379 | /// 380 | /// ```rust 381 | /// use email_address::*; 382 | /// 383 | /// assert!( 384 | /// EmailAddress::parse_with_options( 385 | /// "email@[127.0.0.256]", 386 | /// Options::default().with_domain_literal() 387 | /// ).is_ok() 388 | /// ); 389 | /// 390 | /// assert_eq!( 391 | /// EmailAddress::parse_with_options( 392 | /// "email@[127.0.0.256]", 393 | /// Options::default().without_domain_literal() 394 | /// ), 395 | /// Err(Error::UnsupportedDomainLiteral), 396 | /// ); 397 | /// ``` 398 | /// 399 | pub allow_domain_literal: bool, 400 | 401 | /// 402 | /// Specified whether display text is allowed. Defaults to `true`. If you want strict 403 | /// email-only checking setting this to `false` will remove support for the prefix string 404 | /// and therefore the '<' and '>' brackets around the email part. 405 | /// 406 | /// ```rust 407 | /// use email_address::*; 408 | /// 409 | /// assert_eq!( 410 | /// EmailAddress::parse_with_options( 411 | /// "Simon ", 412 | /// Options::default().without_display_text() 413 | /// ), 414 | /// Err(Error::UnsupportedDisplayName), 415 | /// ); 416 | /// 417 | /// assert_eq!( 418 | /// EmailAddress::parse_with_options( 419 | /// "", 420 | /// Options::default().without_display_text() 421 | /// ), 422 | /// Err(Error::InvalidCharacter), 423 | /// ); 424 | /// ``` 425 | /// 426 | pub allow_display_text: bool, 427 | } 428 | 429 | /// 430 | /// Type representing a single email address. This is basically a wrapper around a String, the 431 | /// email address is parsed for correctness with `FromStr::from_str`, which is the only want to 432 | /// create an instance. The various components of the email _are not_ parsed out to be accessible 433 | /// independently. 434 | /// 435 | #[derive(Debug, Clone)] 436 | pub struct EmailAddress(String); 437 | 438 | // ------------------------------------------------------------------------------------------------ 439 | // Implementations 440 | // ------------------------------------------------------------------------------------------------ 441 | 442 | const LOCAL_PART_MAX_LENGTH: usize = 64; 443 | // see: https://www.rfc-editor.org/errata_search.php?rfc=3696&eid=1690 444 | const DOMAIN_MAX_LENGTH: usize = 254; 445 | const SUB_DOMAIN_MAX_LENGTH: usize = 63; 446 | 447 | #[allow(dead_code)] 448 | const CR: char = '\r'; 449 | #[allow(dead_code)] 450 | const LF: char = '\n'; 451 | const SP: char = ' '; 452 | const HTAB: char = '\t'; 453 | const ESC: char = '\\'; 454 | 455 | const AT: char = '@'; 456 | const DOT: char = '.'; 457 | const DQUOTE: char = '"'; 458 | const LBRACKET: char = '['; 459 | const RBRACKET: char = ']'; 460 | #[allow(dead_code)] 461 | const LPAREN: char = '('; 462 | #[allow(dead_code)] 463 | const RPAREN: char = ')'; 464 | 465 | const DISPLAY_SEP: &str = " <"; 466 | const DISPLAY_START: char = '<'; 467 | const DISPLAY_END: char = '>'; 468 | 469 | const MAILTO_URI_PREFIX: &str = "mailto:"; 470 | 471 | // ------------------------------------------------------------------------------------------------ 472 | 473 | impl Display for Error { 474 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 475 | match self { 476 | Error::InvalidCharacter => write!(f, "Invalid character."), 477 | Error::LocalPartEmpty => write!(f, "Local part is empty."), 478 | Error::LocalPartTooLong => write!( 479 | f, 480 | "Local part is too long. Length limit: {}", 481 | LOCAL_PART_MAX_LENGTH 482 | ), 483 | Error::DomainEmpty => write!(f, "Domain is empty."), 484 | Error::DomainTooLong => { 485 | write!(f, "Domain is too long. Length limit: {}", DOMAIN_MAX_LENGTH) 486 | } 487 | Error::SubDomainEmpty => write!(f, "A sub-domain is empty."), 488 | Error::SubDomainTooLong => write!( 489 | f, 490 | "A sub-domain is too long. Length limit: {}", 491 | SUB_DOMAIN_MAX_LENGTH 492 | ), 493 | Error::MissingSeparator => write!(f, "Missing separator character '{}'.", AT), 494 | Error::DomainTooFew => write!(f, "Too few parts in the domain"), 495 | Error::DomainInvalidSeparator => { 496 | write!(f, "Invalid placement of the domain separator '{:?}", DOT) 497 | } 498 | Error::InvalidIPAddress => write!(f, "Invalid IP Address specified for domain."), 499 | Error::UnbalancedQuotes => write!(f, "Quotes around the local-part are unbalanced."), 500 | Error::InvalidComment => write!(f, "A comment was badly formed."), 501 | Error::UnsupportedDomainLiteral => write!(f, "Domain literals are not supported."), 502 | Error::UnsupportedDisplayName => write!(f, "Display names are not supported."), 503 | Error::MissingDisplayName => write!( 504 | f, 505 | "Display name was not supplied, but email starts with '<'." 506 | ), 507 | Error::MissingEndBracket => write!(f, "Terminating '>' is missing."), 508 | } 509 | } 510 | } 511 | 512 | impl std::error::Error for Error {} 513 | 514 | impl From for std::result::Result { 515 | fn from(err: Error) -> Self { 516 | Err(err) 517 | } 518 | } 519 | 520 | // ------------------------------------------------------------------------------------------------ 521 | 522 | impl Default for Options { 523 | fn default() -> Self { 524 | Self { 525 | minimum_sub_domains: Default::default(), 526 | allow_domain_literal: true, 527 | allow_display_text: true, 528 | } 529 | } 530 | } 531 | 532 | impl Options { 533 | /// Set the value of `minimum_sub_domains`. 534 | #[inline(always)] 535 | pub const fn with_minimum_sub_domains(self, min: usize) -> Self { 536 | Self { 537 | minimum_sub_domains: min, 538 | ..self 539 | } 540 | } 541 | #[inline(always)] 542 | /// Set the value of `minimum_sub_domains` to zero. 543 | pub const fn with_no_minimum_sub_domains(self) -> Self { 544 | Self { 545 | minimum_sub_domains: 0, 546 | ..self 547 | } 548 | } 549 | #[inline(always)] 550 | /// Set the value of `minimum_sub_domains` to two, this has the effect of requiring a 551 | /// domain name with a top-level domain (TLD). 552 | pub const fn with_required_tld(self) -> Self { 553 | Self { 554 | minimum_sub_domains: 2, 555 | ..self 556 | } 557 | } 558 | /// Set the value of `allow_domain_literal` to `true`. 559 | #[inline(always)] 560 | pub const fn with_domain_literal(self) -> Self { 561 | Self { 562 | allow_domain_literal: true, 563 | ..self 564 | } 565 | } 566 | /// Set the value of `allow_domain_literal` to `false`. 567 | #[inline(always)] 568 | pub const fn without_domain_literal(self) -> Self { 569 | Self { 570 | allow_domain_literal: false, 571 | ..self 572 | } 573 | } 574 | /// Set the value of `allow_display_text` to `true`. 575 | #[inline(always)] 576 | pub const fn with_display_text(self) -> Self { 577 | Self { 578 | allow_display_text: true, 579 | ..self 580 | } 581 | } 582 | /// Set the value of `allow_display_text` to `false`. 583 | #[inline(always)] 584 | pub const fn without_display_text(self) -> Self { 585 | Self { 586 | allow_display_text: false, 587 | ..self 588 | } 589 | } 590 | } 591 | 592 | // ------------------------------------------------------------------------------------------------ 593 | 594 | impl Display for EmailAddress { 595 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 596 | write!(f, "{}", self.0) 597 | } 598 | } 599 | 600 | // From RFC 5321, section 2.4: 601 | // 602 | // The local-part of a mailbox MUST BE treated as case sensitive. Therefore, 603 | // SMTP implementations MUST take care to preserve the case of mailbox 604 | // local-parts. In particular, for some hosts, the user "smith" is different 605 | // from the user "Smith". However, exploiting the case sensitivity of mailbox 606 | // local-parts impedes interoperability and is discouraged. Mailbox domains 607 | // follow normal DNS rules and are hence not case sensitive. 608 | // 609 | 610 | impl PartialEq for EmailAddress { 611 | fn eq(&self, other: &Self) -> bool { 612 | let (left, right) = split_at(&self.0).unwrap(); 613 | let (other_left, other_right) = split_at(&other.0).unwrap(); 614 | left.eq(other_left) && right.eq_ignore_ascii_case(other_right) 615 | } 616 | } 617 | 618 | impl Eq for EmailAddress {} 619 | 620 | impl Hash for EmailAddress { 621 | fn hash(&self, state: &mut H) { 622 | self.0.hash(state); 623 | } 624 | } 625 | 626 | impl FromStr for EmailAddress { 627 | type Err = Error; 628 | 629 | fn from_str(s: &str) -> Result { 630 | parse_address(s, Default::default()) 631 | } 632 | } 633 | 634 | impl From for String { 635 | fn from(email: EmailAddress) -> Self { 636 | email.0 637 | } 638 | } 639 | 640 | impl AsRef for EmailAddress { 641 | fn as_ref(&self) -> &str { 642 | &self.0 643 | } 644 | } 645 | 646 | #[cfg(feature = "serde_support")] 647 | impl Serialize for EmailAddress { 648 | fn serialize(&self, serializer: S) -> Result 649 | where 650 | S: Serializer, 651 | { 652 | serializer.serialize_str(&self.0) 653 | } 654 | } 655 | 656 | #[cfg(feature = "serde_support")] 657 | impl<'de> Deserialize<'de> for EmailAddress { 658 | fn deserialize(deserializer: D) -> Result 659 | where 660 | D: serde::Deserializer<'de>, 661 | { 662 | use serde::de::{Error, Unexpected, Visitor}; 663 | 664 | struct EmailAddressVisitor; 665 | 666 | impl Visitor<'_> for EmailAddressVisitor { 667 | type Value = EmailAddress; 668 | 669 | fn expecting(&self, fmt: &mut Formatter<'_>) -> std::fmt::Result { 670 | fmt.write_str("string containing a valid email address") 671 | } 672 | 673 | fn visit_str(self, s: &str) -> Result 674 | where 675 | E: Error, 676 | { 677 | EmailAddress::from_str(s).map_err(|err| { 678 | let exp = format!("{}", err); 679 | Error::invalid_value(Unexpected::Str(s), &exp.as_ref()) 680 | }) 681 | } 682 | } 683 | 684 | deserializer.deserialize_str(EmailAddressVisitor) 685 | } 686 | } 687 | 688 | impl EmailAddress { 689 | /// 690 | /// Creates an `EmailAddress` without checking if the email is valid. Only 691 | /// call this method if the address is known to be valid. 692 | /// 693 | /// ``` 694 | /// use std::str::FromStr; 695 | /// use email_address::EmailAddress; 696 | /// 697 | /// let unchecked = "john.doe@example.com"; 698 | /// let email = EmailAddress::from_str(unchecked).expect("email is not valid"); 699 | /// let valid_email = String::from(email); 700 | /// let email = EmailAddress::new_unchecked(valid_email); 701 | /// 702 | /// assert_eq!("John Doe ", email.to_display("John Doe")); 703 | /// ``` 704 | pub fn new_unchecked(address: S) -> Self 705 | where 706 | S: Into, 707 | { 708 | Self(address.into()) 709 | } 710 | 711 | /// 712 | /// Parses an [EmailAddress] with custom [Options]. Useful for configuring validations 713 | /// that aren't mandatory by the specification. 714 | /// 715 | /// ``` 716 | /// use email_address::{EmailAddress, Options}; 717 | /// 718 | /// let options = Options { minimum_sub_domains: 2, ..Options::default() }; 719 | /// let result = EmailAddress::parse_with_options("john.doe@localhost", options).is_ok(); 720 | /// 721 | /// assert_eq!(result, false); 722 | /// ``` 723 | pub fn parse_with_options(address: &str, options: Options) -> Result { 724 | parse_address(address, options) 725 | } 726 | 727 | /// 728 | /// Determine whether the `address` string is a valid email address. Note this is equivalent to 729 | /// the following: 730 | /// 731 | /// ```rust 732 | /// use email_address::*; 733 | /// use std::str::FromStr; 734 | /// 735 | /// let is_valid = EmailAddress::from_str("johnstonskj@gmail.com").is_ok(); 736 | /// ``` 737 | /// 738 | pub fn is_valid(address: &str) -> bool { 739 | Self::from_str(address).is_ok() 740 | } 741 | 742 | /// 743 | /// Determine whether the `part` string would be a valid `local-part` if it were in an 744 | /// email address. 745 | /// 746 | pub fn is_valid_local_part(part: &str) -> bool { 747 | parse_local_part(part, Default::default()).is_ok() 748 | } 749 | 750 | /// 751 | /// Determine whether the `part` string would be a valid `domain` if it were in an 752 | /// email address. 753 | /// 754 | pub fn is_valid_domain(part: &str) -> bool { 755 | parse_domain(part, Default::default()).is_ok() 756 | } 757 | 758 | /// 759 | /// Return this email address formatted as a URI. This will also URI-encode the email 760 | /// address itself. So, `name@example.org` becomes `mailto:name@example.org`. 761 | /// 762 | /// ```rust 763 | /// use email_address::*; 764 | /// use std::str::FromStr; 765 | /// 766 | /// assert_eq!( 767 | /// EmailAddress::from_str("name@example.org").unwrap().to_uri(), 768 | /// String::from("mailto:name@example.org") 769 | /// ); 770 | /// ``` 771 | /// 772 | pub fn to_uri(&self) -> String { 773 | let encoded = encode(&self.0); 774 | format!("{}{}", MAILTO_URI_PREFIX, encoded) 775 | } 776 | 777 | /// 778 | /// Return a string formatted as a display email with the user name. This is commonly used 779 | /// in email headers and other locations where a display name is associated with the 780 | /// address. 781 | /// 782 | /// ```rust 783 | /// use email_address::*; 784 | /// use std::str::FromStr; 785 | /// 786 | /// assert_eq!( 787 | /// EmailAddress::from_str("name@example.org").unwrap().to_display("My Name"), 788 | /// String::from("My Name ") 789 | /// ); 790 | /// ``` 791 | /// 792 | pub fn to_display(&self, display_name: &str) -> String { 793 | format!("{} <{}>", display_name, self) 794 | } 795 | 796 | /// 797 | /// Returns the local part of the email address. This is borrowed so that no additional 798 | /// allocation is required. 799 | /// 800 | /// ```rust 801 | /// use email_address::*; 802 | /// use std::str::FromStr; 803 | /// 804 | /// assert_eq!( 805 | /// EmailAddress::from_str("name@example.org").unwrap().local_part(), 806 | /// String::from("name") 807 | /// ); 808 | /// ``` 809 | /// 810 | pub fn local_part(&self) -> &str { 811 | let (local, _, _) = split_parts(&self.0).unwrap(); 812 | local 813 | } 814 | 815 | /// 816 | /// Returns the display part of the email address. This is borrowed so that no additional 817 | /// allocation is required. 818 | /// 819 | /// ```rust 820 | /// use email_address::*; 821 | /// use std::str::FromStr; 822 | /// 823 | /// assert_eq!( 824 | /// EmailAddress::from_str("Name ").unwrap().display_part(), 825 | /// String::from("Name") 826 | /// ); 827 | /// ``` 828 | /// 829 | pub fn display_part(&self) -> &str { 830 | let (_, _, display) = split_parts(&self.0).unwrap(); 831 | display 832 | } 833 | 834 | /// 835 | /// Returns the email part of the email address. This is borrowed so that no additional 836 | /// allocation is required. 837 | /// 838 | /// ```rust 839 | /// use email_address::*; 840 | /// use std::str::FromStr; 841 | /// 842 | /// assert_eq!( 843 | /// EmailAddress::from_str("Name ").unwrap().email(), 844 | /// String::from("name@example.org") 845 | /// ); 846 | /// ``` 847 | /// 848 | pub fn email(&self) -> String { 849 | let (local, domain, _) = split_parts(&self.0).unwrap(); 850 | format!("{}{AT}{}", local, domain) 851 | } 852 | 853 | /// 854 | /// Returns the domain of the email address. This is borrowed so that no additional 855 | /// allocation is required. 856 | /// 857 | /// ```rust 858 | /// use email_address::*; 859 | /// use std::str::FromStr; 860 | /// 861 | /// assert_eq!( 862 | /// EmailAddress::from_str("name@example.org").unwrap().domain(), 863 | /// String::from("example.org") 864 | /// ); 865 | /// ``` 866 | /// 867 | pub fn domain(&self) -> &str { 868 | let (_, domain, _) = split_parts(&self.0).unwrap(); 869 | domain 870 | } 871 | 872 | /// 873 | /// Returns the entire email address as a string reference. 874 | /// 875 | pub fn as_str(&self) -> &str { 876 | self.as_ref() 877 | } 878 | } 879 | 880 | // ------------------------------------------------------------------------------------------------ 881 | // Private Functions 882 | // ------------------------------------------------------------------------------------------------ 883 | 884 | fn encode(address: &str) -> String { 885 | let mut result = String::new(); 886 | for c in address.chars() { 887 | if is_uri_reserved(c) { 888 | result.push_str(&format!("%{:02X}", c as u8)) 889 | } else { 890 | result.push(c); 891 | } 892 | } 893 | result 894 | } 895 | 896 | fn is_uri_reserved(c: char) -> bool { 897 | // No need to encode '@' as this is allowed in the email scheme. 898 | c == '!' 899 | || c == '#' 900 | || c == '$' 901 | || c == '%' 902 | || c == '&' 903 | || c == '\'' 904 | || c == '(' 905 | || c == ')' 906 | || c == '*' 907 | || c == '+' 908 | || c == ',' 909 | || c == '/' 910 | || c == ':' 911 | || c == ';' 912 | || c == '=' 913 | || c == '?' 914 | || c == '[' 915 | || c == ']' 916 | } 917 | 918 | fn parse_address(address: &str, options: Options) -> Result { 919 | // 920 | // Deals with cases of '@' in `local-part`, if it is quoted they are legal, if 921 | // not then they'll return an `InvalidCharacter` error later. 922 | // 923 | let (local_part, domain, display) = split_parts(address)?; 924 | match ( 925 | display.is_empty(), 926 | local_part.starts_with(DISPLAY_START), 927 | options.allow_display_text, 928 | ) { 929 | (false, _, false) => Err(Error::UnsupportedDisplayName), 930 | (true, true, true) => Err(Error::MissingDisplayName), 931 | (true, true, false) => Err(Error::InvalidCharacter), 932 | _ => { 933 | parse_local_part(local_part, options)?; 934 | parse_domain(domain, options)?; 935 | Ok(EmailAddress(address.to_owned())) 936 | } 937 | } 938 | } 939 | 940 | fn split_parts(address: &str) -> Result<(&str, &str, &str), Error> { 941 | let (display, email) = split_display_email(address)?; 942 | let (local_part, domain) = split_at(email)?; 943 | Ok((local_part, domain, display)) 944 | } 945 | 946 | fn split_display_email(text: &str) -> Result<(&str, &str), Error> { 947 | match text.rsplit_once(DISPLAY_SEP) { 948 | None => Ok(("", text)), 949 | Some((left, right)) => { 950 | let right = right.trim(); 951 | if !right.ends_with(DISPLAY_END) { 952 | Err(Error::MissingEndBracket) 953 | } else { 954 | let email = &right[0..right.len() - 1]; 955 | let display_name = left.trim(); 956 | 957 | Ok((display_name, email)) 958 | } 959 | } 960 | } 961 | } 962 | 963 | fn split_at(address: &str) -> Result<(&str, &str), Error> { 964 | match address.rsplit_once(AT) { 965 | None => Error::MissingSeparator.into(), 966 | Some(left_right) => Ok(left_right), 967 | } 968 | } 969 | 970 | fn parse_local_part(part: &str, _: Options) -> Result<(), Error> { 971 | if part.is_empty() { 972 | Error::LocalPartEmpty.into() 973 | } else if part.len() > LOCAL_PART_MAX_LENGTH { 974 | Error::LocalPartTooLong.into() 975 | } else if part.starts_with(DQUOTE) && part.ends_with(DQUOTE) { 976 | // <= to handle `part` = `"` (single quote). 977 | if part.len() <= 2 { 978 | Error::LocalPartEmpty.into() 979 | } else { 980 | parse_quoted_local_part(&part[1..part.len() - 1]) 981 | } 982 | } else { 983 | parse_unquoted_local_part(part) 984 | } 985 | } 986 | 987 | fn parse_quoted_local_part(part: &str) -> Result<(), Error> { 988 | if is_qcontent(part) { 989 | Ok(()) 990 | } else { 991 | Error::InvalidCharacter.into() 992 | } 993 | } 994 | 995 | fn parse_unquoted_local_part(part: &str) -> Result<(), Error> { 996 | if is_dot_atom_text(part) { 997 | Ok(()) 998 | } else { 999 | Error::InvalidCharacter.into() 1000 | } 1001 | } 1002 | 1003 | fn parse_domain(part: &str, options: Options) -> Result<(), Error> { 1004 | if part.is_empty() { 1005 | Error::DomainEmpty.into() 1006 | } else if part.len() > DOMAIN_MAX_LENGTH { 1007 | Error::DomainTooLong.into() 1008 | } else if part.starts_with(LBRACKET) && part.ends_with(RBRACKET) { 1009 | if options.allow_domain_literal { 1010 | parse_literal_domain(&part[1..part.len() - 1]) 1011 | } else { 1012 | Error::UnsupportedDomainLiteral.into() 1013 | } 1014 | } else { 1015 | parse_text_domain(part, options) 1016 | } 1017 | } 1018 | 1019 | fn parse_text_domain(part: &str, options: Options) -> Result<(), Error> { 1020 | let mut sub_domains = 0; 1021 | 1022 | for sub_part in part.split(DOT) { 1023 | // As per https://www.rfc-editor.org/rfc/rfc1034#section-3.5 1024 | // and https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address, 1025 | // at least one character must exist in a `subdomain`/`label` part of the domain 1026 | if sub_part.is_empty() { 1027 | return Error::SubDomainEmpty.into(); 1028 | } 1029 | 1030 | // As per https://www.rfc-editor.org/rfc/rfc1034#section-3.5, 1031 | // the domain label needs to start with a `letter`; 1032 | // however, https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address 1033 | // specifies a label can start 1034 | // with a `let-dig` (letter or digit), so we allow the wider range 1035 | 1036 | if !sub_part.starts_with(char::is_alphanumeric) { 1037 | return Error::InvalidCharacter.into(); 1038 | } 1039 | // Both specifications mentioned above require the last character to be a 1040 | // `let-dig` (letter or digit) 1041 | if !sub_part.ends_with(char::is_alphanumeric) { 1042 | return Error::InvalidCharacter.into(); 1043 | } 1044 | 1045 | if sub_part.len() > SUB_DOMAIN_MAX_LENGTH { 1046 | return Error::SubDomainTooLong.into(); 1047 | } 1048 | 1049 | if !is_atom(sub_part) { 1050 | return Error::InvalidCharacter.into(); 1051 | } 1052 | 1053 | sub_domains += 1; 1054 | } 1055 | 1056 | if sub_domains < options.minimum_sub_domains { 1057 | Error::DomainTooFew.into() 1058 | } else { 1059 | Ok(()) 1060 | } 1061 | } 1062 | 1063 | fn parse_literal_domain(part: &str) -> Result<(), Error> { 1064 | if part.chars().all(is_dtext_char) { 1065 | return Ok(()); 1066 | } 1067 | Error::InvalidCharacter.into() 1068 | } 1069 | 1070 | // ------------------------------------------------------------------------------------------------ 1071 | 1072 | fn is_atext(c: char) -> bool { 1073 | c.is_alphanumeric() 1074 | || c == '!' 1075 | || c == '#' 1076 | || c == '$' 1077 | || c == '%' 1078 | || c == '&' 1079 | || c == '\'' 1080 | || c == '*' 1081 | || c == '+' 1082 | || c == '-' 1083 | || c == '/' 1084 | || c == '=' 1085 | || c == '?' 1086 | || c == '^' 1087 | || c == '_' 1088 | || c == '`' 1089 | || c == '{' 1090 | || c == '|' 1091 | || c == '}' 1092 | || c == '~' 1093 | || is_utf8_non_ascii(c) 1094 | } 1095 | 1096 | //fn is_special(c: char) -> bool { 1097 | // c == '(' 1098 | // || c == ')' 1099 | // || c == '<' 1100 | // || c == '>' 1101 | // || c == '[' 1102 | // || c == ']' 1103 | // || c == ':' 1104 | // || c == ';' 1105 | // || c == '@' 1106 | // || c == '\\' 1107 | // || c == ',' 1108 | // || c == '.' 1109 | // || c == DQUOTE 1110 | //} 1111 | 1112 | fn is_utf8_non_ascii(c: char) -> bool { 1113 | let bytes = (c as u32).to_be_bytes(); 1114 | // UTF8-non-ascii = UTF8-2 / UTF8-3 / UTF8-4 1115 | match (bytes[0], bytes[1], bytes[2], bytes[3]) { 1116 | // UTF8-2 = %xC2-DF UTF8-tail 1117 | (0x00, 0x00, 0xC2..=0xDF, 0x80..=0xBF) => true, 1118 | // UTF8-3 = %xE0 %xA0-BF UTF8-tail / 1119 | // %xE1-EC 2( UTF8-tail ) / 1120 | // %xED %x80-9F UTF8-tail / 1121 | // %xEE-EF 2( UTF8-tail ) 1122 | (0x00, 0xE0, 0xA0..=0xBF, 0x80..=0xBF) => true, 1123 | (0x00, 0xE1..=0xEC, 0x80..=0xBF, 0x80..=0xBF) => true, 1124 | (0x00, 0xED, 0x80..=0x9F, 0x80..=0xBF) => true, 1125 | (0x00, 0xEE..=0xEF, 0x80..=0xBF, 0x80..=0xBF) => true, 1126 | // UTF8-4 = %xF0 %x90-BF 2( UTF8-tail ) / 1127 | // %xF1-F3 3( UTF8-tail ) / 1128 | // %xF4 %x80-8F 2( UTF8-tail ) 1129 | (0xF0, 0x90..=0xBF, 0x80..=0xBF, 0x80..=0xBF) => true, 1130 | (0xF1..=0xF3, 0x80..=0xBF, 0x80..=0xBF, 0x80..=0xBF) => true, 1131 | (0xF4, 0x80..=0x8F, 0x80..=0xBF, 0x80..=0xBF) => true, 1132 | // UTF8-tail = %x80-BF 1133 | _ => false, 1134 | } 1135 | } 1136 | 1137 | fn is_atom(s: &str) -> bool { 1138 | !s.is_empty() && s.chars().all(is_atext) 1139 | } 1140 | 1141 | fn is_dot_atom_text(s: &str) -> bool { 1142 | s.split(DOT).all(is_atom) 1143 | } 1144 | 1145 | fn is_vchar(c: char) -> bool { 1146 | ('\x21'..='\x7E').contains(&c) 1147 | } 1148 | 1149 | fn is_wsp(c: char) -> bool { 1150 | c == SP || c == HTAB 1151 | } 1152 | 1153 | fn is_qtext_char(c: char) -> bool { 1154 | c == '\x21' 1155 | || ('\x23'..='\x5B').contains(&c) 1156 | || ('\x5D'..='\x7E').contains(&c) 1157 | || is_utf8_non_ascii(c) 1158 | } 1159 | 1160 | fn is_qcontent(s: &str) -> bool { 1161 | let mut char_iter = s.chars(); 1162 | while let Some(c) = &char_iter.next() { 1163 | if c == &ESC { 1164 | // quoted-pair 1165 | match char_iter.next() { 1166 | Some(c2) if is_vchar(c2) => (), 1167 | _ => return false, 1168 | } 1169 | } else if !(is_wsp(*c) || is_qtext_char(*c)) { 1170 | // qtext 1171 | return false; 1172 | } 1173 | } 1174 | true 1175 | } 1176 | 1177 | fn is_dtext_char(c: char) -> bool { 1178 | ('\x21'..='\x5A').contains(&c) || ('\x5E'..='\x7E').contains(&c) || is_utf8_non_ascii(c) 1179 | } 1180 | 1181 | //fn is_ctext_char(c: char) -> bool { 1182 | // (c >= '\x21' && c == '\x27') 1183 | // || ('\x2A'..='\x5B').contains(&c) 1184 | // || ('\x5D'..='\x7E').contains(&c) 1185 | // || is_utf8_non_ascii(c) 1186 | //} 1187 | // 1188 | //fn is_ctext(s: &str) -> bool { 1189 | // s.chars().all(is_ctext_char) 1190 | //} 1191 | 1192 | // ------------------------------------------------------------------------------------------------ 1193 | // Unit Tests 1194 | // ------------------------------------------------------------------------------------------------ 1195 | 1196 | #[cfg(feature = "serde_support")] 1197 | #[cfg(test)] 1198 | mod serde_tests { 1199 | use super::*; 1200 | use claims::{assert_err_eq, assert_ok, assert_ok_eq}; 1201 | use serde::de::{Error as _, Unexpected}; 1202 | use serde_assert::{Deserializer, Serializer, Token}; 1203 | 1204 | #[test] 1205 | fn test_serialize() { 1206 | let email = assert_ok!(EmailAddress::from_str("simple@example.com")); 1207 | 1208 | let serializer = Serializer::builder().build(); 1209 | 1210 | assert_ok_eq!( 1211 | email.serialize(&serializer), 1212 | [Token::Str("simple@example.com".to_owned())] 1213 | ); 1214 | } 1215 | 1216 | #[test] 1217 | fn test_deserialize() { 1218 | let mut deserializer = 1219 | Deserializer::builder([Token::Str("simple@example.com".to_owned())]).build(); 1220 | 1221 | let email = assert_ok!(EmailAddress::from_str("simple@example.com")); 1222 | assert_ok_eq!(EmailAddress::deserialize(&mut deserializer), email); 1223 | } 1224 | 1225 | #[test] 1226 | fn test_deserialize_invalid_value() { 1227 | let mut deserializer = 1228 | Deserializer::builder([Token::Str("Abc.example.com".to_owned())]).build(); 1229 | 1230 | assert_err_eq!( 1231 | EmailAddress::deserialize(&mut deserializer), 1232 | serde_assert::de::Error::invalid_value( 1233 | Unexpected::Str("Abc.example.com"), 1234 | &"Missing separator character '@'." 1235 | ) 1236 | ); 1237 | } 1238 | 1239 | #[test] 1240 | fn test_deserialize_invalid_type() { 1241 | let mut deserializer = Deserializer::builder([Token::U64(42)]).build(); 1242 | 1243 | assert_err_eq!( 1244 | EmailAddress::deserialize(&mut deserializer), 1245 | serde_assert::de::Error::invalid_type( 1246 | Unexpected::Unsigned(42), 1247 | &"string containing a valid email address" 1248 | ) 1249 | ); 1250 | } 1251 | 1252 | // Regression test: GitHub issue #26 1253 | #[test] 1254 | fn test_serde_roundtrip() { 1255 | let email = assert_ok!(EmailAddress::from_str("simple@example.com")); 1256 | 1257 | let serializer = Serializer::builder().build(); 1258 | let mut deserializer = 1259 | Deserializer::builder(assert_ok!(email.serialize(&serializer))).build(); 1260 | 1261 | assert_ok_eq!(EmailAddress::deserialize(&mut deserializer), email); 1262 | } 1263 | } 1264 | 1265 | #[cfg(test)] 1266 | mod tests { 1267 | use super::*; 1268 | 1269 | fn is_valid(address: &str, test_case: Option<&str>) { 1270 | if let Some(test_case) = test_case { 1271 | println!(">> test case: {}", test_case); 1272 | println!(" <{}>", address); 1273 | } else { 1274 | println!(">> <{}>", address); 1275 | } 1276 | assert!(EmailAddress::is_valid(address)); 1277 | } 1278 | 1279 | fn valid_with_options(address: &str, options: Options, test_case: Option<&str>) { 1280 | if let Some(test_case) = test_case { 1281 | println!(">> test case: {}", test_case); 1282 | println!(" <{}>", address); 1283 | } else { 1284 | println!(">> <{}>", address); 1285 | } 1286 | assert!(EmailAddress::parse_with_options(address, options).is_ok()); 1287 | } 1288 | 1289 | #[test] 1290 | fn test_good_examples_from_wikipedia_01() { 1291 | is_valid("simple@example.com", None); 1292 | } 1293 | 1294 | #[test] 1295 | fn test_good_examples_from_wikipedia_02() { 1296 | is_valid("very.common@example.com", None); 1297 | } 1298 | 1299 | #[test] 1300 | fn test_good_examples_from_wikipedia_03() { 1301 | is_valid("disposable.style.email.with+symbol@example.com", None); 1302 | } 1303 | 1304 | #[test] 1305 | fn test_good_examples_from_wikipedia_04() { 1306 | is_valid("other.email-with-hyphen@example.com", None); 1307 | } 1308 | 1309 | #[test] 1310 | fn test_good_examples_from_wikipedia_05() { 1311 | is_valid("fully-qualified-domain@example.com", None); 1312 | } 1313 | 1314 | #[test] 1315 | fn test_good_examples_from_wikipedia_06() { 1316 | is_valid( 1317 | "user.name+tag+sorting@example.com", 1318 | Some(" may go to user.name@example.com inbox depending on mail server"), 1319 | ); 1320 | } 1321 | 1322 | #[test] 1323 | fn test_good_examples_from_wikipedia_07() { 1324 | is_valid("x@example.com", Some("one-letter local-part")); 1325 | } 1326 | 1327 | #[test] 1328 | fn test_good_examples_from_wikipedia_08() { 1329 | is_valid("example-indeed@strange-example.com", None); 1330 | } 1331 | 1332 | #[test] 1333 | fn test_good_examples_from_wikipedia_09() { 1334 | is_valid( 1335 | "admin@mailserver1", 1336 | Some("local domain name with no TLD, although ICANN highly discourages dotless email addresses") 1337 | ); 1338 | } 1339 | 1340 | #[test] 1341 | fn test_good_examples_from_wikipedia_10() { 1342 | is_valid( 1343 | "example@s.example", 1344 | Some("see the List of Internet top-level domains"), 1345 | ); 1346 | } 1347 | 1348 | #[test] 1349 | fn test_good_examples_from_wikipedia_11() { 1350 | is_valid("\" \"@example.org", Some("space between the quotes")); 1351 | } 1352 | 1353 | #[test] 1354 | fn test_good_examples_from_wikipedia_12() { 1355 | is_valid("\"john..doe\"@example.org", Some("quoted double dot")); 1356 | } 1357 | 1358 | #[test] 1359 | fn test_good_examples_from_wikipedia_13() { 1360 | is_valid( 1361 | "mailhost!username@example.org", 1362 | Some("bangified host route used for uucp mailers"), 1363 | ); 1364 | } 1365 | 1366 | #[test] 1367 | fn test_good_examples_from_wikipedia_14() { 1368 | is_valid( 1369 | "user%example.com@example.org", 1370 | Some("% escaped mail route to user@example.com via example.org"), 1371 | ); 1372 | } 1373 | 1374 | #[test] 1375 | fn test_good_examples_from_wikipedia_15() { 1376 | is_valid("jsmith@[192.168.2.1]", None); 1377 | } 1378 | 1379 | #[test] 1380 | fn test_good_examples_from_wikipedia_16() { 1381 | is_valid("jsmith@[IPv6:2001:db8::1]", None); 1382 | } 1383 | 1384 | #[test] 1385 | fn test_good_examples_from_wikipedia_17() { 1386 | is_valid("user+mailbox/department=shipping@example.com", None); 1387 | } 1388 | 1389 | #[test] 1390 | fn test_good_examples_from_wikipedia_18() { 1391 | is_valid("!#$%&'*+-/=?^_`.{|}~@example.com", None); 1392 | } 1393 | 1394 | #[test] 1395 | fn test_good_examples_from_wikipedia_19() { 1396 | // '@' is allowed in a quoted local part. Sorry. 1397 | is_valid("\"Abc@def\"@example.com", None); 1398 | } 1399 | 1400 | #[test] 1401 | fn test_good_examples_from_wikipedia_20() { 1402 | is_valid("\"Joe.\\\\Blow\"@example.com", None); 1403 | } 1404 | 1405 | #[test] 1406 | fn test_good_examples_from_wikipedia_21() { 1407 | is_valid("用户@例子.广告", Some("Chinese")); 1408 | } 1409 | 1410 | #[test] 1411 | fn test_good_examples_from_wikipedia_22() { 1412 | is_valid("अजय@डाटा.भारत", Some("Hindi")); 1413 | } 1414 | 1415 | #[test] 1416 | fn test_good_examples_from_wikipedia_23() { 1417 | is_valid("квіточка@пошта.укр", Some("Ukranian")); 1418 | } 1419 | 1420 | #[test] 1421 | fn test_good_examples_from_wikipedia_24() { 1422 | is_valid("θσερ@εχαμπλε.ψομ", Some("Greek")); 1423 | } 1424 | 1425 | #[test] 1426 | fn test_good_examples_from_wikipedia_25() { 1427 | is_valid("Dörte@Sörensen.example.com", Some("German")); 1428 | } 1429 | 1430 | #[test] 1431 | fn test_good_examples_from_wikipedia_26() { 1432 | is_valid("коля@пример.рф", Some("Russian")); 1433 | } 1434 | 1435 | #[test] 1436 | fn test_good_examples_01() { 1437 | valid_with_options( 1438 | "foo@example.com", 1439 | Options { 1440 | minimum_sub_domains: 2, 1441 | ..Default::default() 1442 | }, 1443 | Some("minimum sub domains"), 1444 | ); 1445 | } 1446 | 1447 | #[test] 1448 | fn test_good_examples_02() { 1449 | valid_with_options( 1450 | "email@[127.0.0.256]", 1451 | Options { 1452 | allow_domain_literal: true, 1453 | ..Default::default() 1454 | }, 1455 | Some("minimum sub domains"), 1456 | ); 1457 | } 1458 | 1459 | #[test] 1460 | fn test_good_examples_03() { 1461 | valid_with_options( 1462 | "email@[2001:db8::12345]", 1463 | Options { 1464 | allow_domain_literal: true, 1465 | ..Default::default() 1466 | }, 1467 | Some("minimum sub domains"), 1468 | ); 1469 | } 1470 | 1471 | #[test] 1472 | fn test_good_examples_04() { 1473 | valid_with_options( 1474 | "email@[2001:db8:0:0:0:0:1]", 1475 | Options { 1476 | allow_domain_literal: true, 1477 | ..Default::default() 1478 | }, 1479 | Some("minimum sub domains"), 1480 | ); 1481 | } 1482 | 1483 | #[test] 1484 | fn test_good_examples_05() { 1485 | valid_with_options( 1486 | "email@[::ffff:127.0.0.256]", 1487 | Options { 1488 | allow_domain_literal: true, 1489 | ..Default::default() 1490 | }, 1491 | Some("minimum sub domains"), 1492 | ); 1493 | } 1494 | 1495 | #[test] 1496 | fn test_good_examples_06() { 1497 | valid_with_options( 1498 | "email@[2001:dg8::1]", 1499 | Options { 1500 | allow_domain_literal: true, 1501 | ..Default::default() 1502 | }, 1503 | Some("minimum sub domains"), 1504 | ); 1505 | } 1506 | 1507 | #[test] 1508 | fn test_good_examples_07() { 1509 | valid_with_options( 1510 | "email@[2001:dG8:0:0:0:0:0:1]", 1511 | Options { 1512 | allow_domain_literal: true, 1513 | ..Default::default() 1514 | }, 1515 | Some("minimum sub domains"), 1516 | ); 1517 | } 1518 | 1519 | #[test] 1520 | fn test_good_examples_08() { 1521 | valid_with_options( 1522 | "email@[::fTzF:127.0.0.1]", 1523 | Options { 1524 | allow_domain_literal: true, 1525 | ..Default::default() 1526 | }, 1527 | Some("minimum sub domains"), 1528 | ); 1529 | } 1530 | 1531 | // ------------------------------------------------------------------------------------------------ 1532 | 1533 | #[test] 1534 | fn test_to_strings() { 1535 | let email = EmailAddress::from_str("коля@пример.рф").unwrap(); 1536 | 1537 | assert_eq!(String::from(email.clone()), String::from("коля@пример.рф")); 1538 | 1539 | assert_eq!(email.to_string(), String::from("коля@пример.рф")); 1540 | 1541 | assert_eq!(email.as_ref(), "коля@пример.рф"); 1542 | } 1543 | 1544 | #[test] 1545 | fn test_to_display() { 1546 | let email = EmailAddress::from_str("коля@пример.рф").unwrap(); 1547 | 1548 | assert_eq!( 1549 | email.to_display("коля"), 1550 | String::from("коля <коля@пример.рф>") 1551 | ); 1552 | } 1553 | 1554 | #[test] 1555 | fn test_touri() { 1556 | let email = EmailAddress::from_str("коля@пример.рф").unwrap(); 1557 | 1558 | assert_eq!(email.to_uri(), String::from("mailto:коля@пример.рф")); 1559 | } 1560 | 1561 | // ------------------------------------------------------------------------------------------------ 1562 | 1563 | fn expect(address: &str, error: Error, test_case: Option<&str>) { 1564 | if let Some(test_case) = test_case { 1565 | println!(">> test case: {}", test_case); 1566 | println!(" <{}>, expecting {:?}", address, error); 1567 | } else { 1568 | println!(">> <{}>, expecting {:?}", address, error); 1569 | } 1570 | assert_eq!(EmailAddress::from_str(address), error.into()); 1571 | } 1572 | 1573 | fn expect_with_options(address: &str, options: Options, error: Error, test_case: Option<&str>) { 1574 | if let Some(test_case) = test_case { 1575 | println!(">> test case: {}", test_case); 1576 | println!(" <{}>, expecting {:?}", address, error); 1577 | } else { 1578 | println!(">> <{}>, expecting {:?}", address, error); 1579 | } 1580 | assert_eq!( 1581 | EmailAddress::parse_with_options(address, options), 1582 | error.into() 1583 | ); 1584 | } 1585 | 1586 | #[test] 1587 | fn test_bad_examples_from_wikipedia_00() { 1588 | expect( 1589 | "Abc.example.com", 1590 | Error::MissingSeparator, 1591 | Some("no @ character"), 1592 | ); 1593 | } 1594 | 1595 | #[test] 1596 | fn test_bad_examples_from_wikipedia_01() { 1597 | expect( 1598 | "A@b@c@example.com", 1599 | Error::InvalidCharacter, 1600 | Some("only one @ is allowed outside quotation marks"), 1601 | ); 1602 | } 1603 | 1604 | #[test] 1605 | fn test_bad_examples_from_wikipedia_02() { 1606 | expect( 1607 | "a\"b(c)d,e:f;gi[j\\k]l@example.com", 1608 | Error::InvalidCharacter, 1609 | Some("none of the special characters in this local-part are allowed outside quotation marks") 1610 | ); 1611 | } 1612 | 1613 | #[test] 1614 | fn test_bad_examples_from_wikipedia_03() { 1615 | expect( 1616 | "just\"not\"right@example.com", 1617 | Error::InvalidCharacter, 1618 | Some( 1619 | "quoted strings must be dot separated or the only element making up the local-part", 1620 | ), 1621 | ); 1622 | } 1623 | 1624 | #[test] 1625 | fn test_bad_examples_from_wikipedia_04() { 1626 | expect( 1627 | "this is\"not\\allowed@example.com", 1628 | Error::InvalidCharacter, 1629 | Some("spaces, quotes, and backslashes may only exist when within quoted strings and preceded by a backslash") 1630 | ); 1631 | } 1632 | 1633 | #[test] 1634 | fn test_bad_examples_from_wikipedia_05() { 1635 | expect( 1636 | "this\\ still\"not\\allowed@example.com", 1637 | Error::InvalidCharacter, 1638 | Some("even if escaped (preceded by a backslash), spaces, quotes, and backslashes must still be contained by quotes") 1639 | ); 1640 | } 1641 | 1642 | #[test] 1643 | fn test_bad_examples_from_wikipedia_06() { 1644 | expect( 1645 | "1234567890123456789012345678901234567890123456789012345678901234+x@example.com", 1646 | Error::LocalPartTooLong, 1647 | Some("local part is longer than 64 characters"), 1648 | ); 1649 | } 1650 | 1651 | #[test] 1652 | fn test_bad_example_01() { 1653 | expect( 1654 | "foo@example.v1234567890123456789012345678901234567890123456789012345678901234v.com", 1655 | Error::SubDomainTooLong, 1656 | Some("domain part is longer than 64 characters"), 1657 | ); 1658 | } 1659 | 1660 | #[test] 1661 | fn test_bad_example_02() { 1662 | expect( 1663 | "@example.com", 1664 | Error::LocalPartEmpty, 1665 | Some("local-part is empty"), 1666 | ); 1667 | } 1668 | 1669 | #[test] 1670 | fn test_bad_example_03() { 1671 | expect( 1672 | "\"\"@example.com", 1673 | Error::LocalPartEmpty, 1674 | Some("local-part is empty"), 1675 | ); 1676 | expect( 1677 | "\"@example.com", 1678 | Error::LocalPartEmpty, 1679 | Some("local-part is empty"), 1680 | ); 1681 | } 1682 | 1683 | #[test] 1684 | fn test_bad_example_04() { 1685 | expect("simon@", Error::DomainEmpty, Some("domain is empty")); 1686 | } 1687 | 1688 | #[test] 1689 | fn test_bad_example_05() { 1690 | expect( 1691 | "example@invalid-.com", 1692 | Error::InvalidCharacter, 1693 | Some("domain label ends with hyphen"), 1694 | ); 1695 | } 1696 | 1697 | #[test] 1698 | fn test_bad_example_06() { 1699 | expect( 1700 | "example@-invalid.com", 1701 | Error::InvalidCharacter, 1702 | Some("domain label starts with hyphen"), 1703 | ); 1704 | } 1705 | 1706 | #[test] 1707 | fn test_bad_example_07() { 1708 | expect( 1709 | "example@invalid.com-", 1710 | Error::InvalidCharacter, 1711 | Some("domain label starts ends hyphen"), 1712 | ); 1713 | } 1714 | 1715 | #[test] 1716 | fn test_bad_example_08() { 1717 | expect( 1718 | "example@inv-.alid-.com", 1719 | Error::InvalidCharacter, 1720 | Some("subdomain label ends hyphen"), 1721 | ); 1722 | } 1723 | 1724 | #[test] 1725 | fn test_bad_example_09() { 1726 | expect( 1727 | "example@-inv.alid-.com", 1728 | Error::InvalidCharacter, 1729 | Some("subdomain label starts hyphen"), 1730 | ); 1731 | } 1732 | 1733 | #[test] 1734 | fn test_bad_example_10() { 1735 | expect( 1736 | "example@-.com", 1737 | Error::InvalidCharacter, 1738 | Some("domain label is hyphen"), 1739 | ); 1740 | } 1741 | 1742 | #[test] 1743 | fn test_bad_example_11() { 1744 | expect( 1745 | "example@-", 1746 | Error::InvalidCharacter, 1747 | Some("domain label is hyphen"), 1748 | ); 1749 | } 1750 | 1751 | #[test] 1752 | fn test_bad_example_12() { 1753 | expect( 1754 | "example@-abc", 1755 | Error::InvalidCharacter, 1756 | Some("domain label starts with hyphen"), 1757 | ); 1758 | } 1759 | 1760 | #[test] 1761 | fn test_bad_example_13() { 1762 | expect( 1763 | "example@abc-", 1764 | Error::InvalidCharacter, 1765 | Some("domain label ends with hyphen"), 1766 | ); 1767 | } 1768 | 1769 | #[test] 1770 | fn test_bad_example_14() { 1771 | expect( 1772 | "example@.com", 1773 | Error::SubDomainEmpty, 1774 | Some("subdomain label is empty"), 1775 | ); 1776 | } 1777 | 1778 | #[test] 1779 | fn test_bad_example_15() { 1780 | expect_with_options( 1781 | "foo@localhost", 1782 | Options::default().with_minimum_sub_domains(2), 1783 | Error::DomainTooFew, 1784 | Some("too few domains"), 1785 | ); 1786 | } 1787 | 1788 | #[test] 1789 | fn test_bad_example_16() { 1790 | expect_with_options( 1791 | "foo@a.b.c.d.e.f.g.h.i", 1792 | Options::default().with_minimum_sub_domains(10), 1793 | Error::DomainTooFew, 1794 | Some("too few domains"), 1795 | ); 1796 | } 1797 | 1798 | #[test] 1799 | fn test_bad_example_17() { 1800 | expect_with_options( 1801 | "email@[127.0.0.256]", 1802 | Options::default().without_domain_literal(), 1803 | Error::UnsupportedDomainLiteral, 1804 | Some("unsupported domain literal (1)"), 1805 | ); 1806 | } 1807 | 1808 | #[test] 1809 | fn test_bad_example_18() { 1810 | expect_with_options( 1811 | "email@[2001:db8::12345]", 1812 | Options::default().without_domain_literal(), 1813 | Error::UnsupportedDomainLiteral, 1814 | Some("unsupported domain literal (2)"), 1815 | ); 1816 | } 1817 | 1818 | #[test] 1819 | fn test_bad_example_19() { 1820 | expect_with_options( 1821 | "email@[2001:db8:0:0:0:0:1]", 1822 | Options::default().without_domain_literal(), 1823 | Error::UnsupportedDomainLiteral, 1824 | Some("unsupported domain literal (3)"), 1825 | ); 1826 | } 1827 | 1828 | #[test] 1829 | fn test_bad_example_20() { 1830 | expect_with_options( 1831 | "email@[::ffff:127.0.0.256]", 1832 | Options::default().without_domain_literal(), 1833 | Error::UnsupportedDomainLiteral, 1834 | Some("unsupported domain literal (4)"), 1835 | ); 1836 | } 1837 | 1838 | // make sure Error impl Send + Sync 1839 | fn is_send() {} 1840 | fn is_sync() {} 1841 | 1842 | #[test] 1843 | fn test_error_traits() { 1844 | is_send::(); 1845 | is_sync::(); 1846 | } 1847 | 1848 | #[test] 1849 | fn test_parse_trimmed() { 1850 | let email = EmailAddress::parse_with_options( 1851 | " Simons Email ", 1852 | Options::default(), 1853 | ) 1854 | .unwrap(); 1855 | 1856 | assert_eq!(email.display_part(), "Simons Email"); 1857 | assert_eq!(email.email(), "simon@example.com"); 1858 | } 1859 | 1860 | #[test] 1861 | // Feature test: GitHub PR: #15 1862 | fn test_parse_display_name() { 1863 | let email = EmailAddress::parse_with_options( 1864 | "Simons Email ", 1865 | Options::default(), 1866 | ) 1867 | .unwrap(); 1868 | 1869 | assert_eq!(email.display_part(), "Simons Email"); 1870 | assert_eq!(email.email(), "simon@example.com"); 1871 | assert_eq!(email.local_part(), "simon"); 1872 | assert_eq!(email.domain(), "example.com"); 1873 | } 1874 | 1875 | #[test] 1876 | // Feature test: GitHub PR: #15 1877 | fn test_parse_display_empty_name() { 1878 | expect( 1879 | "", 1880 | Error::MissingDisplayName, 1881 | Some("missing display name"), 1882 | ); 1883 | } 1884 | 1885 | #[test] 1886 | // Feature test: GitHub PR: #15 1887 | // Reference: GitHub issue #14 1888 | fn test_parse_display_empty_name_2() { 1889 | expect_with_options( 1890 | "", 1891 | Options::default().without_display_text(), 1892 | Error::InvalidCharacter, 1893 | Some("without display text '<' is invalid"), 1894 | ); 1895 | } 1896 | 1897 | #[test] 1898 | // Feature test: GitHub PR: #15 1899 | // Reference: GitHub issue #14 1900 | fn test_parse_display_name_unsupported() { 1901 | expect_with_options( 1902 | "Simons Email ", 1903 | Options::default().without_display_text(), 1904 | Error::UnsupportedDisplayName, 1905 | Some("unsupported display name (1)"), 1906 | ); 1907 | } 1908 | 1909 | #[test] 1910 | // Regression test: GitHub issue #23 1911 | fn test_missing_tld() { 1912 | EmailAddress::parse_with_options("simon@localhost", Options::default()).unwrap(); 1913 | EmailAddress::parse_with_options( 1914 | "simon@localhost", 1915 | Options::default().with_no_minimum_sub_domains(), 1916 | ) 1917 | .unwrap(); 1918 | 1919 | expect_with_options( 1920 | "simon@localhost", 1921 | Options::default().with_required_tld(), 1922 | Error::DomainTooFew, 1923 | Some("too few domain segments"), 1924 | ); 1925 | } 1926 | 1927 | #[test] 1928 | // Regression test: GitHub issue #11 1929 | fn test_eq_name_case_sensitive_local() { 1930 | let email = EmailAddress::new_unchecked("simon@example.com"); 1931 | 1932 | assert_eq!(email, EmailAddress::new_unchecked("simon@example.com")); 1933 | assert_ne!(email, EmailAddress::new_unchecked("Simon@example.com")); 1934 | assert_ne!(email, EmailAddress::new_unchecked("simoN@example.com")); 1935 | } 1936 | 1937 | #[test] 1938 | // Regression test: GitHub issue #11 1939 | fn test_eq_name_case_insensitive_domain() { 1940 | let email = EmailAddress::new_unchecked("simon@example.com"); 1941 | 1942 | assert_eq!(email, EmailAddress::new_unchecked("simon@Example.com")); 1943 | assert_eq!(email, EmailAddress::new_unchecked("simon@example.COM")); 1944 | } 1945 | 1946 | #[test] 1947 | // Regression test: GitHub issue #21 1948 | fn test_utf8_non_ascii() { 1949 | assert!(!is_utf8_non_ascii('A')); 1950 | assert!(!is_utf8_non_ascii('§')); 1951 | assert!(!is_utf8_non_ascii('�')); 1952 | assert!(!is_utf8_non_ascii('\u{0F40}')); 1953 | assert!(is_utf8_non_ascii('\u{C2B0}')); 1954 | } 1955 | } 1956 | --------------------------------------------------------------------------------