├── .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 |  [](https://codecov.io/gh/ekscrypto/SwiftEmailValidator) [](https://opensource.org/licenses/MIT)  
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 |
--------------------------------------------------------------------------------