├── .github ├── FUNDING.yml └── workflows │ ├── devskim-analysis.yml │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── SwiftEmailValidator.xcworkspace │ └── contents.xcworkspacedata │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── SwiftEmailValidator.xcscheme ├── LICENSE ├── Package.swift ├── README.md ├── SECURITY.md ├── Sources └── SwiftEmailValidator │ ├── EmailSyntaxValidator.swift │ ├── IPAddressSyntaxValidator.swift │ └── RFC2047Coder.swift └── Tests └── SwiftEmailValidatorTests ├── EmailSyntaxValidatorTests.swift ├── IPAddressValidatorTests.swift └── RFC2047CoderTests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: #[ekscrypto] 4 | patreon: encodedlife 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/devskim-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: DevSkim 7 | 8 | on: 9 | push: 10 | branches: [ main ] 11 | pull_request: 12 | branches: [ main ] 13 | schedule: 14 | - cron: '41 19 * * 2' 15 | 16 | jobs: 17 | lint: 18 | name: DevSkim 19 | runs-on: ubuntu-20.04 20 | permissions: 21 | actions: read 22 | contents: read 23 | security-events: write 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v2 27 | 28 | - name: Run DevSkim scanner 29 | uses: microsoft/DevSkim-Action@v1 30 | 31 | - name: Upload DevSkim scan results to GitHub Security tab 32 | uses: github/codeql-action/upload-sarif@v1 33 | with: 34 | sarif_file: devskim-results.sarif 35 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | 19 | - name: Run tests 20 | run: swift test -v --enable-code-coverage 21 | 22 | - name: Swift Coverage Conversion 23 | uses: sersoft-gmbh/swift-coverage-action@v2.0.1 24 | id: coverage-files 25 | 26 | - name: Codecov 27 | uses: codecov/codecov-action@v2.1.0 28 | with: 29 | flags: unittests 30 | files: ${{join(fromJSON(steps.coverage-files.outputs.files), ',')}} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | Sources/.DS_Store 93 | 94 | Package.resolved 95 | -------------------------------------------------------------------------------- /.swiftpm/xcode/SwiftEmailValidator.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftEmailValidator.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 59 | 60 | 62 | 68 | 69 | 70 | 71 | 72 | 82 | 83 | 89 | 90 | 96 | 97 | 98 | 99 | 101 | 102 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dave Poirier 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftEmailValidator", 7 | platforms: [ 8 | .macOS(.v10_12), 9 | .iOS(.v11), 10 | .tvOS(.v11) 11 | ], 12 | products: [ 13 | .library( 14 | name: "SwiftEmailValidator", 15 | targets: ["SwiftEmailValidator"]), 16 | ], 17 | dependencies: [ 18 | .package( 19 | url: "https://github.com/ekscrypto/SwiftPublicSuffixList.git", 20 | .upToNextMajor(from: "1.1.5") 21 | ), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "SwiftEmailValidator", 26 | dependencies: ["SwiftPublicSuffixList"], 27 | resources: []), 28 | .testTarget( 29 | name: "SwiftEmailValidatorTests", 30 | dependencies: ["SwiftEmailValidator","SwiftPublicSuffixList"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![swift workflow](https://github.com/ekscrypto/SwiftEmailValidator/actions/workflows/swift.yml/badge.svg) [![codecov](https://codecov.io/gh/ekscrypto/SwiftEmailValidator/branch/main/graph/badge.svg?token=W9KO1BG8S0)](https://codecov.io/gh/ekscrypto/SwiftEmailValidator) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![Issues](https://img.shields.io/github/issues/ekscrypto/SwiftEmailValidator) ![Releases](https://img.shields.io/github/v/release/ekscrypto/SwiftEmailValidator) 2 | 3 | # SwiftEmailValidator 4 | 5 | A Swift implementation of an international email address syntax validator based on RFC822, RFC2047, RFC5321, RFC5322, and RFC6531. 6 | Since email addresses are local @ remote the validator also includes IPAddressSyntaxValidator and the SwiftPublicSuffixList library. 7 | 8 | This Swift Package does not require an Internet connection at runtime and the only dependency is the [SwiftPublicSuffixList](https://github.com/ekscrypto/SwiftPublicSuffixList) library. 9 | 10 | ## Installation 11 | ### Swift Package Manager (SPM) 12 | 13 | You can use The Swift Package Manager to install SwiftEmailValidator by adding it to your Package.swift file: 14 | 15 | import PackageDescription 16 | 17 | let package = Package( 18 | name: "MyApp", 19 | targets: [], 20 | dependencies: [ 21 | .Package(url: "https://github.com/ekscrypto/SwiftEmailValidator.git", .upToNextMajor(from: "1.0.2")) 22 | ] 23 | ) 24 | 25 | ## Public Suffix List 26 | 27 | By default, domains are validated against the [Public Suffix List](https://publicsuffix.org) using the [SwiftPublicSuffixList](https://github.com/ekscrypto/SwiftPublicSuffixList) library. 28 | 29 | ### Notes: 30 | * Due to the high number of entries in the Public Suffix list (>9k), the first validation may add 100ms to 900ms depending on the device you are using. If this is unacceptable you can 31 | pre-load on a background thread PublicSuffixRulesRegistry.rules prior to using EmailSyntaxValidator. 32 | * The [Public Suffix List](https://publicsuffix.org) is updated regularly. If your application is published regularly you may be fine by simply pulling the latest version of the SwiftPublicSuffixList library. However it is recommended to have 33 | your application retrieve the latest copy of the public suffix list on a somewhat regular basis. Details on how to accomplish this are available in the [SwiftPublicSuffixList](https://github.com/ekscrypto/SwiftPublicSuffixList) library page. You can then use the domainValidator parameter to specify the closure to use for the domain validation. See "Using Custom SwiftPublicSuffixList Rules" below. 34 | * You can bypass the Public Suffix List altogether and use your own custom Regex if desired. See "Bypassing SwiftPublicSuffixList" below. 35 | 36 | ## Classes & Usage 37 | 38 | ### EmailSyntaxValidator 39 | 40 | Simple use-cases: 41 | 42 | if EmailSyntaxValidator.correctlyFormatted("email@example.com") { 43 | print("email@example.com respects Email syntax rules") 44 | } 45 | 46 | if let mailboxInfo = EmailSyntaxValidator.mailbox(from: "santa.claus@northpole.com") { 47 | // mailboxInfo.email == "santa.claus@northpole.com" 48 | // mailboxInfo.localPart == .dotAtom("santa.claus") 49 | // mailboxInfo.host == .domain("northpole.com") 50 | } 51 | 52 | if let mailboxInfo = EmailSyntaxValidator.mailbox(from: "\"Santa Claus\"@northpole.com") { 53 | // mailboxInfo.email == "\"Santa Claus\"@northpole.com" 54 | // mailboxInfo.localPart == .quotedString("Santa Claus") 55 | // mailboxInfo.host == .domain("northpole.com"") 56 | } 57 | 58 | Allowing IPv4/IPv6 addresses 59 | 60 | if EmailSyntaxValidator.correctlyFormatted("email@[127.0.0.1]", allowAddressLiteral: true) { 61 | print("email@[127.0.0.1] also respects since address literals are allowed") 62 | } 63 | 64 | if let mailboxInfo = EmailSyntaxValidator.mailbox(from: "email@[IPv6:fe80::1]", allowAddressLiteral: true) { 65 | // mailboxInfo.email == "email@[IPv6:fe80::1]" 66 | // mailboxInfo.localPart == .dotAtom("email") 67 | // mailboxInfo.host == .addressLiteral("IPv6:fe80::1") 68 | } 69 | 70 | Validating Unicode emails encoded into ASCII (RFC2047): 71 | 72 | if let mailboxInfo = EmailSyntaxValidator.mailbox(from: "=?utf-8?B?7ZWcQHgu7ZWc6rWt?=", compatibility: .asciiWithUnicodeExtension) { 73 | // mailboxInfo.email == "=?utf-8?B?7ZWcQHgu7ZWc6rWt?=" 74 | // mailboxInfo.localpart == .dotAtom("한") 75 | // mailboxInfo.host == .domain("x.한국") 76 | } 77 | 78 | Validating Unicode emails with auto-RFC2047 encoding: 79 | 80 | if let mailboxInfo = EmailSyntaxValidator.mailbox(from: "한@x.한국", options: [.autoEncodeToRfc2047], compatibility.asciiWithUnicodeExtension) { 81 | // mailboxInfo.email == "=?utf-8?b?7ZWcQHgu7ZWc6rWt?=" 82 | // mailboxInfo.localpart == .dotAtom("한") 83 | // mailboxInfo.host == .domain("x.한국") 84 | } 85 | 86 | Forcing ASCII-only compatibility: 87 | 88 | if !EmailSyntaxValidator.correctlyFormatted("한@x.한국", compatibility: .ascii) { 89 | // invalid email for ASCII-only support 90 | } 91 | 92 | if EmailSyntaxValidator.correctlyFormatted("hello@world.net", compatibility: .ascii) { 93 | // Email is valid for ASCII-only systems 94 | } 95 | 96 | #### Using Custom SwiftPublicSuffixList Rules 97 | If you implement your own PublicSuffixList rules, or manage your own local copy of the rules as recommended: 98 | 99 | let customRules: [[String]] = [["com"]] 100 | if let mailboxInfo = EmailSyntaxValidator.mailbox(from: "santa.claus@northpole.com", domainValidator: { PublicSuffixList.isUnrestricted($0, rules: customRules)}) { 101 | // mailboxInfo.localPart == .dotAtom("santa.claus") 102 | // mailboxInfo.host == .domain("northpole.com") 103 | } 104 | 105 | #### Bypassing SwiftPublicSuffixList 106 | The EmailSyntaxValidator functions all accept a domainValidator closure, which by default uses the SwiftPublicSuffixList library. This closure should return true if the domain should be considered valid, or false to be rejected. 107 | 108 | if let mailboxInfo = EmailSyntaxValidator.mailbox(from: "santa.claus@Ho Ho Ho North Pole", domainValidator: { _ in true }) { 109 | // mailboxInfo.localPart == .dotAtom("santa.claus") 110 | // mailboxInfo.host == .domain("Ho Ho Ho North Pole") 111 | } 112 | 113 | ### IPAddressSyntaxValidator 114 | 115 | if IPAddressSyntaxValidator.matchIPv6("::1") { 116 | print("::1 is a valid IPv6 address") 117 | } 118 | 119 | if IPAddressSyntaxValidator.matchIPv4("127.0.0.1") { 120 | print("127.0.0.1 is a valid IPv4 address") 121 | } 122 | 123 | if IPAddressSyntaxValidator.match("8.8.8.8") { 124 | print("8.8.8.8 is a valid IP address") 125 | } 126 | 127 | if IPAddressSyntaxValidator.match("fe80::1") { 128 | print("fe80::1 is a valid IP address") 129 | } 130 | 131 | 132 | ### RFC2047Decoder 133 | Allows to decode ASCII-encoded Latin-1/Latin-2/Unicode email addresses from SMTP headers 134 | 135 | print(RFC2047Decoder.decode("=?iso-8859-1?q?h=E9ro\@site.com?=")) 136 | // héro@site.com 137 | 138 | print(RFC2047Decoder.decode("=?utf-8?B?7ZWcQHgu7ZWc6rWt?=")) 139 | // 한@x.한국 140 | 141 | ## Reference Documents 142 | 143 | RFC822 - STANDARD FOR THE FORMAT OF ARPA INTERNET TEXT MESSAGES 144 | https://datatracker.ietf.org/doc/html/rfc822 145 | 146 | RFC2047 - MIME (Multipurpose Internet Mail Extensions) Part Three: Message Header Extensions for Non-ASCII Text 147 | https://datatracker.ietf.org/doc/html/rfc2047 148 | 149 | RFC5321 - Simple Mail Transfer Protocol 150 | https://datatracker.ietf.org/doc/html/rfc5321 151 | 152 | RFC5322 - Internet Message Format 153 | https://datatracker.ietf.org/doc/html/rfc5322 154 | 155 | RFC6531 - SMTP Extension for Internationalized Email 156 | https://datatracker.ietf.org/doc/html/rfc6531 157 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.0.3 | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If security vulnerability is identified, please send an email to dave /@/ encoded.life with the details. 12 | 13 | While this library is not expected to be updated often, if a security vulnerability is identified a priority 14 | update will be implemented. Due to the young age of this library, I thank you for testing this library 15 | extensively before using it in business critical systems. 16 | -------------------------------------------------------------------------------- /Sources/SwiftEmailValidator/EmailSyntaxValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmailSyntaxValidator.swift 3 | // SwiftEmailValidator 4 | // 5 | // Created by Dave Poirier on 2022-01-21. 6 | // Copyrights (C) 2022, Dave Poirier. Distributed under MIT license 7 | // 8 | // References: 9 | // * RFC2047 https://datatracker.ietf.org/doc/html/rfc2047 10 | // * RFC5321 https://datatracker.ietf.org/doc/html/rfc5321 Section 4.1.2 & Section 4.1.3 11 | // * RFC5322 https://datatracker.ietf.org/doc/html/rfc5322 Section 3.2.3 & Section 3.4.1 12 | // * RFC5234 https://datatracker.ietf.org/doc/html/rfc5234 Appendix B.1 13 | // * RFC6531 https://datatracker.ietf.org/doc/html/rfc6531 14 | 15 | import Foundation 16 | import SwiftPublicSuffixList 17 | 18 | public final class EmailSyntaxValidator { 19 | 20 | public struct Mailbox { 21 | public let email: String 22 | public let localPart: LocalPart 23 | public let host: Host 24 | 25 | public enum LocalPart: Equatable { 26 | case dotAtom(String) 27 | case quotedString(String) 28 | } 29 | 30 | public enum Host: Equatable { 31 | case domain(String) 32 | case addressLiteral(String) 33 | } 34 | } 35 | 36 | public enum Options: Equatable { 37 | case autoEncodeToRfc2047 // If using .asciiWithUnicodeExtension and string is in Unicode, will auto encode using RFC2047 38 | } 39 | 40 | public enum Compatibility { 41 | case ascii 42 | case asciiWithUnicodeExtension 43 | case unicode 44 | } 45 | 46 | /// Verify if the email address is correctly formatted 47 | /// - Parameters: 48 | /// - candidate: String to validate 49 | /// - strategy: (Optional) ValidationStrategy to use, use .strict for strict validation or use .autoEncodeToRfc2047 for some auto-formatting flexibility, Uses .strict by default. 50 | /// - compatibility: (Optional) Compatibility required, one of .ascii (RFC822), .asciiWithUnicodeExtension (RFC2047) or .unicode (RFC6531). Uses .unicode by default. 51 | /// - allowAddressLiteral: (Optional) True to allow IPv4 & IPv6 instead of domains in email addresses, false otherwise. False by default. 52 | /// - domainValidator: Non-escaping closure that return true if the domain should be considered valid or false to be rejected 53 | /// - Returns: True if syntax is valid (.smtpHeader validation strategy) or could be adapted to be valid (.userInterface validation strategy) 54 | public static func correctlyFormatted(_ candidate: String, 55 | options: [Options] = [], 56 | compatibility: Compatibility = .unicode, 57 | allowAddressLiteral: Bool = false, 58 | domainValidator: (String) -> Bool = { PublicSuffixList.isUnrestricted($0) }) -> Bool { 59 | 60 | mailbox(from: candidate, 61 | options: options, 62 | compatibility: compatibility, 63 | allowAddressLiteral: allowAddressLiteral, 64 | domainValidator: domainValidator) != nil 65 | } 66 | 67 | /// Attempt to extract the Local and Remote parts of the email address specified 68 | /// - Parameters: 69 | /// - candidate: String to validate 70 | /// - strategy: (Optional) ValidationStrategy to use, use .smtpHeader for strict validation or use UI strategy for some auto-formatting flexibility, Uses .smtpHeader by default. 71 | /// - compatibility: (Optional) Compatibility required, one of .ascii (RFC822), .asciiWithUnicodeExtension (RFC2047) or .unicode (RFC6531). Uses .unicode by default. 72 | /// - allowAddressLiteral: (Optional) True to allow IPv4 & IPv6 instead of domains in email addresses, false otherwise. False by default. 73 | /// - domainValidator: Non-escaping closure that return true if the domain should be considered valid or false to be rejected 74 | /// - Returns: Mailbox struct on success, nil otherwise 75 | public static func mailbox(from candidate: String, 76 | options: [Options] = [], 77 | compatibility: Compatibility = .unicode, 78 | allowAddressLiteral: Bool = false, 79 | domainValidator: (String) -> Bool = { PublicSuffixList.isUnrestricted($0) }) -> Mailbox? { 80 | 81 | var smtpCandidate: String = candidate 82 | var extractionCompatibility: Compatibility = compatibility 83 | if compatibility != .ascii { 84 | if let decodedCandidate = RFC2047Coder.decode(candidate) { 85 | smtpCandidate = decodedCandidate 86 | extractionCompatibility = .unicode 87 | } else { 88 | // Failed RFC2047 SMTP Unicode Extension decoding, fallback to ASCII or full Unicode 89 | extractionCompatibility = (compatibility == .asciiWithUnicodeExtension ? .ascii : .unicode) 90 | } 91 | } 92 | 93 | if let dotAtom = extractDotAtom(smtpCandidate, compatibility: extractionCompatibility) { 94 | return mailbox( 95 | localPart: .dotAtom(dotAtom), 96 | originalCandidate: candidate, 97 | hostCandidate: String(smtpCandidate.dropFirst(dotAtom.count + 1)), 98 | allowAddressLiteral: allowAddressLiteral, 99 | domainValidator: domainValidator) 100 | } 101 | 102 | if let quotedString = extractQuotedString(smtpCandidate, compatibility: extractionCompatibility) { 103 | return mailbox( 104 | localPart: .quotedString(String(quotedString.cleaned)), 105 | originalCandidate: candidate, 106 | hostCandidate: String(smtpCandidate.dropFirst(quotedString.integral.count + 1)), 107 | allowAddressLiteral: allowAddressLiteral, 108 | domainValidator: domainValidator) 109 | } 110 | 111 | if options.contains(.autoEncodeToRfc2047), let rfc2047candidate = candidateForRfc2047(candidate, compatibility: compatibility) { 112 | return mailbox( 113 | from: rfc2047candidate, 114 | options: [], 115 | compatibility: compatibility, 116 | allowAddressLiteral: allowAddressLiteral, 117 | domainValidator: domainValidator) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | /// Attempt to repackage a Unicode email into an RFC2047 encoded email (will return nil if string doesn't contain Unicode characters) 124 | /// - Parameters: 125 | /// - candidate: String that originally failed SMTP validation that should be RFC2047 encoded if possible 126 | /// - compatibility: Required compatibility level 127 | /// - Returns: Repackaged email string (may still fail SMTP validation) or nil if really nothing that could be done 128 | private static func candidateForRfc2047(_ candidate: String, compatibility: Compatibility) -> String? { 129 | 130 | guard compatibility == .asciiWithUnicodeExtension, 131 | !candidate.hasPrefix("=?"), 132 | candidate.rangeOfCharacter(from: qtextUnicodeSMTPCharacterSet.inverted) == nil 133 | else { 134 | // There are some unsupported ASCII characters which are invalid regardless of unicode or ASCII (newline, tabs, etc) 135 | return nil 136 | } 137 | 138 | guard candidate.rangeOfCharacter(from: CharacterSet(charactersIn: asciiRange).inverted) != nil else { 139 | // There are no Unicode characters to encode, so the string was already validated to the maximum extent allowed 140 | return nil 141 | } 142 | 143 | // Some non-ASCII characters are present, and we can RFC2047 encode it 144 | return RFC2047Coder.encode(candidate) 145 | } 146 | 147 | private static func mailbox(localPart: Mailbox.LocalPart, originalCandidate: String, hostCandidate: String, allowAddressLiteral: Bool, domainValidator: (String) -> Bool) -> Mailbox? { 148 | 149 | guard let host = extractHost(from: hostCandidate, allowAddressLiteral: allowAddressLiteral, domainValidator: domainValidator) else { 150 | return nil 151 | } 152 | 153 | return Mailbox( 154 | email: originalCandidate, 155 | localPart: localPart, 156 | host: host) 157 | } 158 | 159 | private static func extractHost(from candidate: String, allowAddressLiteral: Bool, domainValidator: (String) -> Bool) -> Mailbox.Host? { 160 | 161 | if candidate.hasPrefix("[") { 162 | return extractHostLiteral(from: candidate, allowAddressLiteral: allowAddressLiteral) 163 | } 164 | 165 | if domainValidator(candidate) { 166 | return .domain(candidate) 167 | } 168 | 169 | return nil 170 | } 171 | 172 | private static func extractHostLiteral(from candidate: String, allowAddressLiteral: Bool) -> Mailbox.Host? { 173 | guard allowAddressLiteral, candidate.hasSuffix("]") else { 174 | return nil 175 | } 176 | let addressLiteralCandidate = String(candidate.dropFirst().dropLast()) // get rid of [ and ] 177 | let ipv6Tag = "IPv6" // ref: https://www.iana.org/assignments/address-literal-tags/address-literal-tags.xhtml 178 | 179 | if addressLiteralCandidate.hasPrefix("\(ipv6Tag):"), IPAddressSyntaxValidator.matchIPv6(String(addressLiteralCandidate.dropFirst(ipv6Tag.count + 1))) { 180 | return .addressLiteral(addressLiteralCandidate) 181 | } 182 | 183 | guard IPAddressSyntaxValidator.matchIPv4(addressLiteralCandidate) else { 184 | return nil 185 | } 186 | return .addressLiteral(addressLiteralCandidate) 187 | } 188 | 189 | private static let digitRange: ClosedRange = Unicode.Scalar(0x30)!...Unicode.Scalar(0x39)! // 0-9 190 | private static let alphaUpperRange: ClosedRange = Unicode.Scalar(0x41)!...Unicode.Scalar(0x5A)! // A-Z 191 | private static let alphaLowerRange: ClosedRange = Unicode.Scalar(0x61)!...Unicode.Scalar(0x7A)! // a-z 192 | private static let atextCharacterSet: CharacterSet = CharacterSet(charactersIn: alphaLowerRange) 193 | .union(CharacterSet(charactersIn: alphaUpperRange)) 194 | .union(CharacterSet(charactersIn: digitRange)) 195 | .union(CharacterSet(charactersIn: #"!#$%&'*+-/=?^_`{|}~"#)) // Ref RFC5322 section 3.2.3 Atom, definition of atext 196 | private static let asciiRange: ClosedRange = Unicode.Scalar(0x00)!...Unicode.Scalar(0x7F)! 197 | private static let atextUnicodeCharacterSet: CharacterSet = atextCharacterSet 198 | .union(CharacterSet(charactersIn: asciiRange).inverted) 199 | private static let quotedPairSMTP: ClosedRange = Unicode.Scalar(0x20)!...Unicode.Scalar(0x7E)! 200 | private static let qtextSMTP1: ClosedRange = Unicode.Scalar(0x20)!...Unicode.Scalar(0x21)! 201 | private static let qtextSMTP2: ClosedRange = Unicode.Scalar(0x23)!...Unicode.Scalar(0x5B)! 202 | private static let qtextSMTP3: ClosedRange = Unicode.Scalar(0x5D)!...Unicode.Scalar(0x7E)! 203 | private static let qtextSMTPCharacterSet: CharacterSet = CharacterSet(charactersIn: qtextSMTP1) 204 | .union(CharacterSet(charactersIn: qtextSMTP2)) 205 | .union(CharacterSet(charactersIn: qtextSMTP3)) 206 | private static let qtextUnicodeSMTPCharacterSet = qtextSMTPCharacterSet 207 | .union(CharacterSet(charactersIn: asciiRange).inverted) 208 | 209 | private static func extractDotAtom(_ candidate: String, compatibility: Compatibility) -> String? { 210 | guard !candidate.hasPrefix("\""), 211 | let atRange = candidate.range(of: "@") 212 | else { 213 | return nil 214 | } 215 | 216 | let dotAtom = candidate[.. 0, 219 | dotAtom.count <= 64, 220 | !dotAtom.hasPrefix("."), 221 | !dotAtom.hasSuffix("."), 222 | dotAtom.components(separatedBy: ".").allSatisfy({ $0.count > 0 && $0.rangeOfCharacter(from: disallowedCharacterSet) == nil }) 223 | else { 224 | return nil 225 | } 226 | return String(dotAtom) 227 | } 228 | 229 | private struct ExtractedQuotedText { 230 | let integral: String 231 | let cleaned: String 232 | } 233 | 234 | private static func extractQuotedString(_ candidate: String, compatibility: Compatibility) -> ExtractedQuotedText? { 235 | guard candidate.hasPrefix("\"") else { 236 | return nil 237 | } 238 | var cleanedText: String = "" 239 | var integralText: String = "" 240 | var escaped: Bool = false 241 | var dquotes: Int = 0 242 | var expectingAt: Bool = false 243 | 244 | let allowedCharacterSet: CharacterSet = compatibility == .ascii ? qtextSMTPCharacterSet : qtextUnicodeSMTPCharacterSet 245 | let maxScalars: Int = compatibility == .ascii ? 1 : 5 246 | 247 | nextCharacter: 248 | for character in candidate { 249 | precondition(dquotes <= 2) 250 | guard let characterScalar = character.unicodeScalars.first, 251 | character.unicodeScalars.count <= maxScalars 252 | else { 253 | return nil 254 | } 255 | 256 | if expectingAt { 257 | guard character == "@" else { 258 | return nil 259 | } 260 | return ExtractedQuotedText(integral: integralText, cleaned: cleanedText) 261 | } 262 | 263 | integralText.append(character) 264 | 265 | if escaped { 266 | cleanedText.append(character) 267 | guard quotedPairSMTP.contains(characterScalar) else { 268 | return nil 269 | } 270 | escaped = false 271 | continue nextCharacter 272 | } 273 | 274 | if character == "\\" { 275 | escaped = true 276 | continue nextCharacter 277 | } 278 | 279 | if character == "\"" { 280 | dquotes += 1 281 | if dquotes == 2 { 282 | expectingAt = true 283 | } 284 | continue nextCharacter 285 | } 286 | 287 | cleanedText.append(character) 288 | 289 | guard String(character).rangeOfCharacter(from: allowedCharacterSet) != nil else { 290 | return nil 291 | } 292 | } 293 | return nil 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /Sources/SwiftEmailValidator/IPAddressSyntaxValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IPAddressValidator.swift 3 | // SwiftEmailValidator 4 | // 5 | // Created by Dave Poirier on 2022-01-21. 6 | // Copyrights (C) 2022, Dave Poirier. Distributed under MIT license 7 | 8 | import Foundation 9 | 10 | final public class IPAddressSyntaxValidator { 11 | 12 | 13 | /// Validates that the candidate string either respects the IPv4 or IPv6 syntax 14 | /// - Parameter candidate: String to validate 15 | /// - Returns: true if syntax seems valid, false otherwise 16 | static func match(_ candidate: String) -> Bool { 17 | matchIPv4(candidate) || matchIPv6(candidate) 18 | } 19 | 20 | /// Validates that the candidate string respects the IPv4 syntax 21 | /// - Parameter candidate: String to validate 22 | /// - Returns: true if syntax eems valid, false otherwise 23 | static func matchIPv4(_ candidate: String) -> Bool { 24 | let v4regex = #"^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$"# 25 | return candidate.range(of: v4regex, options: .regularExpression) != nil 26 | } 27 | 28 | /// Validates that the candidate string respects the IPv6 syntax 29 | /// - Parameter candidate: String to validate 30 | /// - Returns: true if syntax eems valid, false otherwise 31 | static func matchIPv6(_ candidate: String) -> Bool { 32 | // Source: https://gist.github.com/syzdek/6086792 33 | let v6regex = #"^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$"# 34 | return candidate.range(of: v6regex, options: .regularExpression) != nil 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/SwiftEmailValidator/RFC2047Coder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RFC2047Coder.swift 3 | // SwiftEmailValidator 4 | // 5 | // Created by Dave Poirier on 2022-01-22. 6 | // Copyrights (C) 2022, Dave Poirier. Distributed under MIT license 7 | // 8 | // References: 9 | // * RFC2047 https://datatracker.ietf.org/doc/html/rfc2047 10 | 11 | import Foundation 12 | 13 | public final class RFC2047Coder { 14 | 15 | private static let supportedEncoding: [String: String.Encoding] = [ 16 | "utf-8": .utf8, 17 | "utf-16": .utf16, 18 | "utf-32": .utf32, 19 | "iso-8859-1": .isoLatin1, 20 | "iso-8859-2": .isoLatin2 21 | ] 22 | private static let digitValueTable: [Character: UInt8] = [ 23 | "0": 0, 24 | "1": 1, 25 | "2": 2, 26 | "3": 3, 27 | "4": 4, 28 | "5": 5, 29 | "6": 6, 30 | "7": 7, 31 | "8": 8, 32 | "9": 9, 33 | "a": 10, 34 | "b": 11, 35 | "c": 12, 36 | "d": 13, 37 | "e": 14, 38 | "f": 15, 39 | "A": 10, 40 | "B": 11, 41 | "C": 12, 42 | "D": 13, 43 | "E": 14, 44 | "F": 15 45 | ] 46 | private static let rfc2047regex = #"^=\?([A-Za-z0-9-]+)\?([bBqQ])\?(.*)\?=$"# 47 | 48 | public static func decode(_ encoded: String) -> String? { 49 | 50 | guard encoded.count <= 76 else { 51 | return nil 52 | } 53 | let encodingComponents = match(regex: rfc2047regex, to: encoded) 54 | guard let match = encodingComponents.first, match.count == 4 else { 55 | return nil 56 | } 57 | let charset = match[1].lowercased() 58 | let encoding = match[2].lowercased() 59 | let encodedText = match[3] 60 | guard let stringEncoding = supportedEncoding[charset] else { 61 | return nil 62 | } 63 | 64 | if encoding == "b" { 65 | let padding: [String] = ["", "===", "==", "="] 66 | let paddedEncodedText = "\(encodedText)\(padding[encodedText.count % 4])" 67 | 68 | guard let encodedTextData = Data(base64Encoded: paddedEncodedText), 69 | let decoded = String(data: encodedTextData, encoding: stringEncoding) 70 | else { 71 | return nil 72 | } 73 | return decoded 74 | } 75 | 76 | assert(encoding == "q") 77 | guard [.isoLatin1, .isoLatin2].contains(stringEncoding) else { 78 | // rejects 'q' encoding for utf-8, should be 'b' encoded. 79 | return nil 80 | } 81 | var decoded = "" 82 | var value: UInt8 = 0 83 | var lookingForMarker: Bool = true 84 | var digitsCaptured: Int = 0 85 | 86 | nextCharacter: 87 | for character in encodedText { 88 | if lookingForMarker { 89 | if character == "=" { 90 | lookingForMarker = false 91 | digitsCaptured = 0 92 | value = 0 93 | continue nextCharacter 94 | } 95 | decoded.append(character) 96 | continue nextCharacter 97 | } 98 | 99 | guard let digitValue = digitValueTable[character] else { 100 | return nil 101 | } 102 | value = (value << 4) | digitValue 103 | digitsCaptured += 1 104 | if digitsCaptured == 1 { continue nextCharacter } 105 | 106 | guard value >= 0x20, 107 | value != 0xFF, 108 | let decodedCharacter = String(data: Data([value]), encoding: stringEncoding) 109 | else { 110 | return nil 111 | } 112 | 113 | decoded.append(decodedCharacter) 114 | lookingForMarker = true 115 | } 116 | guard lookingForMarker else { 117 | return nil 118 | } 119 | return decoded 120 | } 121 | 122 | public static func encode(_ candidate: String) -> String? { 123 | guard let utf8data = candidate.data(using: .utf8) else { 124 | return nil 125 | } 126 | let base64 = utf8data.base64EncodedString() 127 | .replacingOccurrences(of: "=", with: "") 128 | return "=?utf-8?b?\(base64)?=" 129 | } 130 | 131 | private static func match(regex: String, to value: String) -> [[String]] { 132 | let nsValue: NSString = value as NSString 133 | return (try? NSRegularExpression(pattern: regex, options: []))?.matches(in: value, options: [], range: NSMakeRange(0, nsValue.length)).map { match in 134 | (0.. EmailSyntaxValidator.Mailbox.LocalPart? { 19 | EmailSyntaxValidator.mailbox( 20 | from: candidate, 21 | allowAddressLiteral: false, 22 | domainValidator: { PublicSuffixList.isUnrestricted($0, rules: [["com"]])})?.localPart 23 | } 24 | 25 | func testDotAtomLocalPart() { 26 | XCTAssertEqual(baseMailboxLocalPartValidation("user@site.com"), .dotAtom("user")) 27 | XCTAssertEqual(baseMailboxLocalPartValidation("first.last@site.com"), .dotAtom("first.last")) 28 | XCTAssertEqual(baseMailboxLocalPartValidation("first-@site.com"), .dotAtom("first-")) 29 | XCTAssertEqual(baseMailboxLocalPartValidation("!@site.com"), .dotAtom("!")) 30 | XCTAssertEqual(baseMailboxLocalPartValidation("#@site.com"), .dotAtom("#")) 31 | XCTAssertEqual(baseMailboxLocalPartValidation("$@site.com"), .dotAtom("$")) 32 | XCTAssertEqual(baseMailboxLocalPartValidation("%@site.com"), .dotAtom("%")) 33 | XCTAssertEqual(baseMailboxLocalPartValidation("&@site.com"), .dotAtom("&")) 34 | XCTAssertEqual(baseMailboxLocalPartValidation("'@site.com"), .dotAtom("'")) 35 | XCTAssertEqual(baseMailboxLocalPartValidation("*@site.com"), .dotAtom("*")) 36 | XCTAssertEqual(baseMailboxLocalPartValidation("+@site.com"), .dotAtom("+")) 37 | XCTAssertEqual(baseMailboxLocalPartValidation("-@site.com"), .dotAtom("-")) 38 | XCTAssertEqual(baseMailboxLocalPartValidation("/@site.com"), .dotAtom("/")) 39 | XCTAssertEqual(baseMailboxLocalPartValidation("=@site.com"), .dotAtom("=")) 40 | XCTAssertEqual(baseMailboxLocalPartValidation("?@site.com"), .dotAtom("?")) 41 | XCTAssertEqual(baseMailboxLocalPartValidation("^@site.com"), .dotAtom("^")) 42 | XCTAssertEqual(baseMailboxLocalPartValidation("_@site.com"), .dotAtom("_")) 43 | XCTAssertEqual(baseMailboxLocalPartValidation("`@site.com"), .dotAtom("`")) 44 | XCTAssertEqual(baseMailboxLocalPartValidation("{@site.com"), .dotAtom("{")) 45 | XCTAssertEqual(baseMailboxLocalPartValidation("|@site.com"), .dotAtom("|")) 46 | XCTAssertEqual(baseMailboxLocalPartValidation("}@site.com"), .dotAtom("}")) 47 | XCTAssertEqual(baseMailboxLocalPartValidation("~@site.com"), .dotAtom("~")) 48 | XCTAssertEqual(baseMailboxLocalPartValidation("~.}.{._.^|.'+'.%!-.#&*.{u/=s3?r}`@site.com"), .dotAtom("~.}.{._.^|.'+'.%!-.#&*.{u/=s3?r}`")) 49 | XCTAssertNil(baseMailboxLocalPartValidation("user.@site.com"), "dot-Atom notation doesn't allow trailing dot") 50 | XCTAssertNil(baseMailboxLocalPartValidation(".user@site.com"), "dot-Atom notation doesn't allow leading dot") 51 | XCTAssertNil(baseMailboxLocalPartValidation("first..last@site.com"), "dot-Atom notation doesn't allow successive dots") 52 | XCTAssertNil(baseMailboxLocalPartValidation("\\user@site.com"), "Backslash not allowed in dot-Atom notation") 53 | XCTAssertNil(baseMailboxLocalPartValidation(":user@site.com"), "Colon not allowed in dot-Atom notation") 54 | XCTAssertNil(baseMailboxLocalPartValidation(":@site.com"), "Colon not allowed in dot-Atom notation") 55 | XCTAssertNil(baseMailboxLocalPartValidation(";@site.com"), "Semi-colon not allowed in dot-Atom notation") 56 | XCTAssertNil(baseMailboxLocalPartValidation("u\"@site.com"), "Double-quote not allowed in dot-Atom notation") 57 | XCTAssertNil(baseMailboxLocalPartValidation("user.\"name\"@site.com"), "Double-quote not allowed in dot-Atom notation") 58 | XCTAssertNotEqual(baseMailboxLocalPartValidation("\"user\"@site.com"), .dotAtom("user")) 59 | } 60 | 61 | func testSimpleQuotedLocalPart() { 62 | XCTAssertEqual(baseMailboxLocalPartValidation(#""email"@site.com"#), .quotedString(#"email"#)) 63 | } 64 | 65 | func testQuotedTextLocalPart() { 66 | XCTAssertEqual(baseMailboxLocalPartValidation(#""Mickey Mouse"@disney.com"#), .quotedString("Mickey Mouse"), "Spaces are allowed in quoted local part") 67 | XCTAssertEqual(baseMailboxLocalPartValidation(#"""@site.com"#), .quotedString(""), "DQUOTE *QcontentSMTP DQUOTE implies empty quoted strings are allowed for local part") 68 | XCTAssertEqual(baseMailboxLocalPartValidation("\" \"@site.com"), .quotedString(" "), "Spaces are allowed in quoted local part") 69 | XCTAssertEqual(baseMailboxLocalPartValidation("\"!\"@site.com"), .quotedString("!"), "! are allowed in quoted local part") 70 | XCTAssertEqual(baseMailboxLocalPartValidation("\"#\"@site.com"), .quotedString("#"), "# are allowed in quoted local part") 71 | XCTAssertEqual(baseMailboxLocalPartValidation("\"$\"@site.com"), .quotedString("$"), "$ are allowed in quoted local part") 72 | XCTAssertEqual(baseMailboxLocalPartValidation("\"%\"@site.com"), .quotedString("%"), "% are allowed in quoted local part") 73 | XCTAssertEqual(baseMailboxLocalPartValidation("\"&\"@site.com"), .quotedString("&"), "& are allowed in quoted local part") 74 | XCTAssertEqual(baseMailboxLocalPartValidation("\"'\"@site.com"), .quotedString("'"), "Single-quote are allowed in quoted local part") 75 | XCTAssertEqual(baseMailboxLocalPartValidation("\"(\"@site.com"), .quotedString("("), "( are allowed in quoted local part") 76 | XCTAssertEqual(baseMailboxLocalPartValidation("\")\"@site.com"), .quotedString(")"), ") are allowed in quoted local part") 77 | XCTAssertEqual(baseMailboxLocalPartValidation("\"*\"@site.com"), .quotedString("*"), "* are allowed in quoted local part") 78 | XCTAssertEqual(baseMailboxLocalPartValidation("\"+\"@site.com"), .quotedString("+"), "+ are allowed in quoted local part") 79 | XCTAssertEqual(baseMailboxLocalPartValidation("\",\"@site.com"), .quotedString(","), ", are allowed in quoted local part") 80 | XCTAssertEqual(baseMailboxLocalPartValidation("\"-\"@site.com"), .quotedString("-"), "- are allowed in quoted local part") 81 | XCTAssertEqual(baseMailboxLocalPartValidation("\".\"@site.com"), .quotedString("."), ". are allowed without restriction in quoted local part") 82 | XCTAssertEqual(baseMailboxLocalPartValidation("\"/\"@site.com"), .quotedString("/"), "/ are allowed in quoted local part") 83 | XCTAssertEqual(baseMailboxLocalPartValidation("\":\"@site.com"), .quotedString(":"), ": are allowed in quoted local part") 84 | XCTAssertEqual(baseMailboxLocalPartValidation("\";\"@site.com"), .quotedString(";"), "; are allowed in quoted local part") 85 | XCTAssertEqual(baseMailboxLocalPartValidation("\"<\"@site.com"), .quotedString("<"), "< are allowed in quoted local part") 86 | XCTAssertEqual(baseMailboxLocalPartValidation("\"=\"@site.com"), .quotedString("="), "= are allowed in quoted local part") 87 | XCTAssertEqual(baseMailboxLocalPartValidation("\">\"@site.com"), .quotedString(">"), "> are allowed in quoted local part") 88 | XCTAssertEqual(baseMailboxLocalPartValidation("\"?\"@site.com"), .quotedString("?"), "? are allowed in quoted local part") 89 | XCTAssertEqual(baseMailboxLocalPartValidation("\"@\"@site.com"), .quotedString("@"), "@ are allowed in quoted local part") 90 | XCTAssertEqual(baseMailboxLocalPartValidation("\"[\"@site.com"), .quotedString("["), "[ are allowed in quoted local part") 91 | XCTAssertEqual(baseMailboxLocalPartValidation("\"]\"@site.com"), .quotedString("]"), "] are allowed in quoted local part") 92 | XCTAssertEqual(baseMailboxLocalPartValidation("\"^\"@site.com"), .quotedString("^"), "^ are allowed in quoted local part") 93 | XCTAssertEqual(baseMailboxLocalPartValidation("\"_\"@site.com"), .quotedString("_"), "_ are allowed in quoted local part") 94 | XCTAssertEqual(baseMailboxLocalPartValidation("\"`\"@site.com"), .quotedString("`"), "` are allowed in quoted local part") 95 | XCTAssertEqual(baseMailboxLocalPartValidation("\"{\"@site.com"), .quotedString("{"), "{ are allowed in quoted local part") 96 | XCTAssertEqual(baseMailboxLocalPartValidation("\"|\"@site.com"), .quotedString("|"), "| are allowed in quoted local part") 97 | XCTAssertEqual(baseMailboxLocalPartValidation("\"}\"@site.com"), .quotedString("}"), "} are allowed in quoted local part") 98 | XCTAssertEqual(baseMailboxLocalPartValidation("\"~\"@site.com"), .quotedString("~"), "~ are allowed in quoted local part") 99 | XCTAssertEqual(baseMailboxLocalPartValidation(#""\\"@site.com"#), .quotedString("\\"), "Backslashes are allowed when escaped in quoted local part") 100 | XCTAssertEqual(baseMailboxLocalPartValidation(#""\t"@site.com"#), .quotedString("t"), "The next ascii (32-126) after a backslash is accepted as is so Blackslash-T isn't TAB but an actual t") 101 | XCTAssertEqual(baseMailboxLocalPartValidation(#""\""@site.com"#), .quotedString(#"""#), "Double-quotes are allowed when escaped in quoted local part") 102 | XCTAssertEqual(baseMailboxLocalPartValidation(#""email@notadomain.com"@site.com"#), .quotedString("email@notadomain.com"), "Since the @ is within the double quotes it is considered as the local part") 103 | XCTAssertNil(baseMailboxLocalPartValidation("\"\t\"@site.com"),"Tab is outside the 32-126 ascii range allowed in quoted text") 104 | XCTAssertNil(baseMailboxLocalPartValidation(#""\"@site.com"#),"The double-quote following the escape would have been escaped so the @site.com would still be part of the local part and no closing double-quotes would be found") 105 | XCTAssertNil(baseMailboxLocalPartValidation(#""email@notadomain.com""#), "Entire email address is within double-quotes so the whole thing would be considered the local part with no @ domain after the quotes this should be rejected") 106 | } 107 | 108 | func testEmailWithIPv4AddressLiteral() { 109 | XCTAssertNil(EmailSyntaxValidator.mailbox(from: "Santa.Claus@[127.0.0.1]", allowAddressLiteral: false)) 110 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "Santa.Claus@[127.0.0.1]", allowAddressLiteral: true)?.localPart, .dotAtom("Santa.Claus"), "When allowing address literals, email addresses should be valid if they specific @[]") 111 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "Santa.Claus@[127.0.0.1]", allowAddressLiteral: true)?.host, .addressLiteral("127.0.0.1"), "When allowing address literals, email addresses should be valid if they specific @[]") 112 | XCTAssertTrue(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@[127.0.0.1]", allowAddressLiteral: true)) 113 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@[127.0.0.1]", allowAddressLiteral: false)) 114 | } 115 | 116 | func testEmailWithIncorrectlyFormattedIPv4Literal() { 117 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@[127.0.0.1", allowAddressLiteral: true)) 118 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@127.0.0.1", allowAddressLiteral: true)) 119 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@[127.0.0.1].com", allowAddressLiteral: true)) 120 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@[127.0.0.1.]", allowAddressLiteral: true)) 121 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@[.127.0.0.1]", allowAddressLiteral: true)) 122 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("Santa.Claus@[127:0:0:1]", allowAddressLiteral: true)) 123 | } 124 | 125 | func testEmailWithIPv6AddressLiteral() { 126 | XCTAssertNil(EmailSyntaxValidator.mailbox(from: "Santa.Claus@[IPv6:fe80::1]", allowAddressLiteral: false)) 127 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "Santa.Claus@[IPv6:fe80::1]", allowAddressLiteral: true)?.localPart, .dotAtom("Santa.Claus"), "When allowing address literals, email addresses should be valid if they specific @[IPv6:]") 128 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "Santa.Claus@[IPv6:fe80::1]", allowAddressLiteral: true)?.host, .addressLiteral("IPv6:fe80::1"), "When allowing address literals, email addresses should be valid if they specific @[IPv6:]") 129 | } 130 | 131 | func testLocalPartMaximumLength() { 132 | let maxlocalPart = String(repeating: "x", count: 64) 133 | let testEmail = "\(maxlocalPart)@site.com" 134 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: testEmail)?.localPart, .dotAtom(maxlocalPart)) 135 | let shouldBeInvalidEmail = "\(maxlocalPart)x@site.com" 136 | XCTAssertNil(EmailSyntaxValidator.mailbox(from: shouldBeInvalidEmail)) 137 | } 138 | 139 | func testAsciiRejectsUnicode() { 140 | XCTAssertNil(EmailSyntaxValidator.mailbox(from: "한@x.한국", compatibility: .ascii), "Unicode in email addresses should not be allowed in ASCII compatibility mode") 141 | XCTAssertNil(EmailSyntaxValidator.mailbox(from: "\"한\"@x.한국", compatibility: .ascii), "Unicode in email addresses should not be allowed in ASCII compatibility mode") 142 | } 143 | 144 | func testUnicodeCompatibility() { 145 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "한@x.한국", compatibility: .unicode)?.localPart, .dotAtom("한"), "Unicode email addresses should be allowed in Unicode compatibility") 146 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "한.భారత్@x.한국", compatibility: .unicode)?.localPart, .dotAtom("한.భారత్"), "Unicode email addresses should be allowed in Unicode compatibility") 147 | } 148 | 149 | func testLocalPartWithQEncoding() { 150 | let testEmail = "=?iso-8859-1?q?\"Santa=20Claus\"@site.com?=" 151 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: testEmail)?.localPart, .quotedString("Santa Claus")) 152 | } 153 | 154 | func testLocalPartWithBEncoding() { 155 | let testEmail = "=?utf-8?B?7ZWcQHgu7ZWc6rWt?=" 156 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: testEmail)?.localPart, .dotAtom("한")) 157 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: testEmail)?.host, .domain("x.한국")) 158 | XCTAssertNil(EmailSyntaxValidator.mailbox(from: testEmail, compatibility: .ascii)) 159 | } 160 | 161 | func testMissingAt() { 162 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("santa.claus")) 163 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"santa.claus\"")) 164 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"santa.claus\"northpole.com")) 165 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"santa.claus@northpole.com")) 166 | } 167 | 168 | func testQuotedLocalPartWithInvalidEscapeSequence() { 169 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"test\\")) 170 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted(#""santa\한"@northpole.com"#, compatibility: .ascii)) 171 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"santa\n\"@northpole.com", compatibility: .ascii)) 172 | } 173 | 174 | func testQuotedLocalPartWithTooManyDquotes() { 175 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"Test\"\"@northpole.com")) 176 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"Test\"@\"northpole.com")) 177 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\"Test\".hello\"@northpole.com")) 178 | } 179 | 180 | func testAsciiWithUnicodeExtension() { 181 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("한@x.한국", options: [], compatibility: .asciiWithUnicodeExtension), "Unicode characters not properly encoded should be rejected") 182 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("한@x.한국", options: [.autoEncodeToRfc2047], compatibility: .ascii), "Option .autoEncodeToRfc2047 should be ignored in pure ASCII compatibility mode") 183 | XCTAssertTrue(EmailSyntaxValidator.correctlyFormatted("한@x.한국", options: [.autoEncodeToRfc2047], compatibility: .asciiWithUnicodeExtension), "Improperly encoded Unicode characters should be automatically RFC2047 encoded when .autoEncodeToRfc2047 option is specified") 184 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "한@x.한국", options: [.autoEncodeToRfc2047], compatibility: .asciiWithUnicodeExtension)?.email, "=?utf-8?b?7ZWcQHgu7ZWc6rWt?=") 185 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "한@x.한국", options: [.autoEncodeToRfc2047], compatibility: .asciiWithUnicodeExtension)?.localPart, .dotAtom("한")) 186 | XCTAssertEqual(EmailSyntaxValidator.mailbox(from: "한@x.한국", options: [.autoEncodeToRfc2047], compatibility: .asciiWithUnicodeExtension)?.host, .domain("x.한국")) 187 | } 188 | 189 | func testAutoEncodeToRfc2047Guards() { 190 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("=?utf-8?b?7ZWcQHgu7ZWc6rWt?=", options: [.autoEncodeToRfc2047], compatibility: .ascii)) 191 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\nHello@this.com", options: [.autoEncodeToRfc2047], compatibility: .ascii)) 192 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("\nHello@this.com", options: [.autoEncodeToRfc2047], compatibility: .unicode)) 193 | XCTAssertFalse(EmailSyntaxValidator.correctlyFormatted("1234567890123456789012345678901234567890123456789012345678901234567890@this.com", options: [.autoEncodeToRfc2047], compatibility: .asciiWithUnicodeExtension)) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Tests/SwiftEmailValidatorTests/IPAddressValidatorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IPAddressValidatorTests.swift 3 | // SwiftEmailValidator 4 | // 5 | // Created by Dave Poirier on 2022-01-21. 6 | // Copyrights (C) 2022, Dave Poirier. Distributed under MIT license 7 | 8 | import XCTest 9 | @testable import SwiftEmailValidator 10 | 11 | final class IPAddressValidatorTests: XCTestCase { 12 | 13 | let validIPv6Addresses: [String] = [ 14 | "1:2:3:4:5:6:7:8", 15 | "::ffff:10.0.0.1", 16 | "::ffff:1.2.3.4", 17 | "::ffff:0.0.0.0", 18 | "1:2:3:4:5:6:77:88", 19 | "::ffff:255.255.255.255", 20 | "fe08::7:8", 21 | "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" 22 | ] 23 | 24 | let invalidIPv6Addresses: [String] = [ 25 | "1:2:3:4:5:6:7:8:9", 26 | "1:2:3:4:5:6::7:8", 27 | ":1:2:3:4:5:6:7:8", 28 | "1:2:3:4:5:6:7:8:", 29 | "::1:2:3:4:5:6:7:8", 30 | "1:2:3:4:5:6:7:8::", 31 | "1:2:3:4:5:6:7:88888", 32 | "2001:db8:3:4:5::192.0.2.33", 33 | "fe08::7:8%", 34 | "fe08::7:8i", 35 | "fe08::7:8interface" 36 | ] 37 | 38 | let validIPv4Addresses: [String] = [ 39 | "0.0.0.0", 40 | "9.9.9.9", 41 | "99.99.99.99", 42 | "199.199.199.199", 43 | "200.200.200.200", 44 | "255.255.255.255", 45 | "192.168.2.1", 46 | "10.0.3.57", 47 | "172.16.9.255" 48 | ] 49 | 50 | let invalidIPv4Addresses: [String] = [ 51 | "0.0.0", 52 | "0.0.0.", 53 | ".0.0.0", 54 | ".0.0.0.0", 55 | "0.0.0.0.", 56 | "256.2.3.4", 57 | "1.256.3.4", 58 | "1.2.256.4", 59 | "1.2.3.256", 60 | "1000.2.3.4", 61 | "300.2.3.4" 62 | ] 63 | 64 | func testValidIPv6Addresses() { 65 | validIPv6Addresses.forEach { XCTAssertTrue(IPAddressSyntaxValidator.matchIPv6($0), "Expected \($0) to be a valid IPv6 address") } 66 | } 67 | 68 | func testInvalidIPv6Addresses() { 69 | invalidIPv6Addresses.forEach { XCTAssertFalse(IPAddressSyntaxValidator.matchIPv6($0), "Expected \($0) to be an invalid IPv6 address") } 70 | validIPv4Addresses.forEach { XCTAssertFalse(IPAddressSyntaxValidator.matchIPv6($0), "Expected \($0) to be a valid IPv4 but not a valid IPv6 address") } 71 | } 72 | 73 | func testValidIPv4Addresses() { 74 | validIPv4Addresses.forEach { XCTAssertTrue(IPAddressSyntaxValidator.matchIPv4($0), "Expected \($0) to be a valid IPv4 address") } 75 | } 76 | 77 | func testInvalidIPv4Addresses() { 78 | invalidIPv4Addresses.forEach { XCTAssertFalse(IPAddressSyntaxValidator.matchIPv4($0), "Expected \($0) to be an invalid IPv4 address") } 79 | validIPv6Addresses.forEach { XCTAssertFalse(IPAddressSyntaxValidator.matchIPv4($0), "Expected \($0) to be a valid IPv6 but not a valid IPv4 address") } 80 | } 81 | 82 | func testValidIPAddresses() { 83 | var allValidAddresses: [String] = [] 84 | allValidAddresses.append(contentsOf: validIPv4Addresses) 85 | allValidAddresses.append(contentsOf: validIPv6Addresses) 86 | 87 | allValidAddresses.forEach { XCTAssertTrue(IPAddressSyntaxValidator.match($0), "Expected \($0) to be a valid IP (v4/v6) address") } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Tests/SwiftEmailValidatorTests/RFC2047CoderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RFC2047CoderTests.swift 3 | // 4 | // 5 | // Created by Dave Poirier on 2022-01-22. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftEmailValidator 10 | 11 | final class RFC2047CoderTests: XCTestCase { 12 | 13 | func testDecodingUTF8B() { 14 | let value = "ந்தி@யா.இந்தியா" 15 | let base64 = value.data(using: .utf8)! 16 | .base64EncodedString() 17 | .replacingOccurrences(of: "=", with: "") 18 | let rfc2047Encoded = "=?utf-8?b?\(base64)?=" 19 | XCTAssertEqual(RFC2047Coder.decode(rfc2047Encoded), value) 20 | } 21 | 22 | func testDecodingLatin1Q() { 23 | XCTAssertEqual(RFC2047Coder.decode("=?iso-8859-1?q?h=E9ro@cinema.ca?="), "héro@cinema.ca") 24 | XCTAssertEqual(RFC2047Coder.decode("=?iso-8859-1?q?Santa=20Claus?="), "Santa Claus") 25 | XCTAssertEqual(RFC2047Coder.decode("=?iso-8859-1?q?\"Santa=20Claus\"@x=20.com?="), #""Santa Claus"@x .com"#) 26 | } 27 | 28 | func testDecodingInvalidCharset() { 29 | XCTAssertNil(RFC2047Coder.decode("=?schtroomf?b?shackalaka?="),"When an unknown charset is provided decoding should fail") 30 | } 31 | 32 | func testDecodingInvalidEncoding() { 33 | XCTAssertNil(RFC2047Coder.decode("=?iso-8859-1?r?h=E9ro@cinema.ca?="),"Per RFC2047 valid values are B / Q, a value or R should therefore fail decoding") 34 | } 35 | 36 | func testDecodingLatin1QWithIncompleteString() { 37 | XCTAssertNil(RFC2047Coder.decode("=?iso-8859-1?q?h=E9ro@cinema.ca?"), "Incorrectly terminated encoded text should not be decodable") 38 | } 39 | 40 | func testDecodingUTF8BWithInvalidBase64Characters() { 41 | let value = "ந்தி@யா.இந்தியா" 42 | let base64 = value.data(using: .utf8)! 43 | .base64EncodedString() 44 | .replacingOccurrences(of: "=", with: "") 45 | let rfc2047Encoded = "=?utf-8?b?\(base64)!@#$%^&*()?=" 46 | XCTAssertNil(RFC2047Coder.decode(rfc2047Encoded), "If invalid characters are present within the expected base64 encoded text, decoding should fail") 47 | } 48 | 49 | func testDecodingValueTooLarge() { 50 | XCTAssertNil(RFC2047Coder.decode("=?iso-8859-1?q?1234567890123456789012345678901234567890123456789012345678901234567890@toolong.net?=")) 51 | } 52 | 53 | func testCurrentlyUnsupportedUTF8Q() { 54 | XCTAssertNil(RFC2047Coder.decode("=?utf8?q?hello=64@site.com?="),"There doesn't seem to be any details in RFC2047 on how to handle this case, skipping for now") 55 | } 56 | 57 | func testDecodingLatin1QInvalidHexDigit() { 58 | XCTAssertNil(RFC2047Coder.decode("=?iso-8859-1?q?h=G9ro@cinema.ca?="), "G is not a valid hex digit and should cause decoding to fail") 59 | } 60 | 61 | func testDecodingLatin1QControlCharacter() { 62 | XCTAssertNil(RFC2047Coder.decode("=?iso-8859-1?q?h=09ro@cinema.ca?="), "Hex value 09 resolves to a control character that should not be used in an email") 63 | } 64 | 65 | func testDecodingLatin1QIncompleteHex() { 66 | XCTAssertNil(RFC2047Coder.decode("=?iso-8859-1?q?hero@cinema.c=3?="), "Failure to find 2 hex digits after = should fail decoding") 67 | } 68 | 69 | func testDecodingUnencoded() { 70 | XCTAssertNil(RFC2047Coder.decode("notEncoded@site.com"), "If the =? ?= signatures are missing, decoding should fail") 71 | } 72 | 73 | func testDecodingUtf8Chinese() { 74 | XCTAssertEqual(RFC2047Coder.decode("=?utf-8?B?7ZWcQHgu7ZWc6rWt?="), "한@x.한국") 75 | } 76 | 77 | func testInvalidBase64String() { 78 | XCTAssertNil(RFC2047Coder.decode("=?utf-8?B?7?="), "Not enough base64 characters to decode a full byte") 79 | XCTAssertNil(RFC2047Coder.decode("=?utf-8?B?7x_?="), "Invalid base64 character _") 80 | } 81 | 82 | func testDecodingUtf8QEncoded() { 83 | XCTAssertNil(RFC2047Coder.decode("=?utf-8?Q?thisShouldNotWork@site.com?="), "Q encoding not currently supported for UTF-8 by this library, not sure it's even supported in any library..") 84 | } 85 | 86 | func testEncoding() { 87 | XCTAssertEqual(RFC2047Coder.encode("한@x.한국"), "=?utf-8?b?7ZWcQHgu7ZWc6rWt?=") 88 | } 89 | } 90 | --------------------------------------------------------------------------------