Skip to content

Commit d0fe24f

Browse files
authored
Add Email and EmailComponents (#51)
1 parent 1da83b4 commit d0fe24f

File tree

4 files changed

+162
-35
lines changed

4 files changed

+162
-35
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright © 2022 SpotHero, Inc. All rights reserved.
2+
3+
import Foundation
4+
5+
/// A validated email address.
6+
public struct Email {
7+
/// A `String` representing the email.
8+
public let string: String
9+
10+
/// The components of an email address.
11+
public let components: EmailComponents
12+
13+
/// Construct an `Email` from a string if it's valid. Returns `nil` otherwise.
14+
///
15+
/// For detailed errors use ``EmailComponents.init(email:)``
16+
/// - Parameter string: The string containing the email.
17+
public init?(string: String) {
18+
guard let components = try? EmailComponents(email: string) else {
19+
return nil
20+
}
21+
22+
self.components = components
23+
self.string = components.string
24+
}
25+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright © 2022 SpotHero, Inc. All rights reserved.
2+
3+
import Foundation
4+
5+
/// A structure that parses emails into and constructs emails from their constituent parts.
6+
public struct EmailComponents {
7+
/// The portion of an email before the @
8+
public let username: String
9+
10+
/// The hostname of the domain
11+
public let hostname: String
12+
13+
/// The top level domain.
14+
public let tld: String
15+
16+
/// An email created from the components
17+
public let string: String
18+
19+
/// Initializes an email using its constituent parts.
20+
/// - Parameters:
21+
/// - username: The portion of an email before the @
22+
/// - hostname: The hostname of the domain
23+
/// - tld: The top level domain.
24+
public init(username: String, hostname: String, tld: String) {
25+
self.username = username
26+
self.hostname = hostname
27+
self.tld = tld
28+
self.string = "\(username)@\(hostname)\(tld)"
29+
}
30+
31+
/// Parses an email and exposes its constituent parts.
32+
public init(email: String) throws {
33+
// Ensure there is exactly one @ symbol.
34+
guard email.filter({ $0 == "@" }).count == 1 else {
35+
throw SpotHeroEmailValidator.Error.invalidSyntax
36+
}
37+
38+
let emailAddressParts = email.split(separator: "@")
39+
40+
// Extract the username from the email address parts
41+
let username = String(emailAddressParts.first ?? "")
42+
// Extract the full domain (including TLD) from the email address parts
43+
let fullDomain = String(emailAddressParts.last ?? "")
44+
// Split the domain parts for evaluation
45+
let domainParts = fullDomain.split(separator: ".")
46+
47+
guard domainParts.count >= 2 else {
48+
// There are no periods found in the domain, throw an error
49+
throw SpotHeroEmailValidator.Error.invalidDomain
50+
}
51+
52+
// TODO: This logic is wrong and doesn't take subdomains into account. We should compare TLDs against the commonTLDs list."
53+
54+
// Extract the domain from the domain parts
55+
let domain = domainParts.first?.lowercased() ?? ""
56+
57+
// Extract the TLD from the domain parts, which are all the remaining parts joined with a period again
58+
let tld = domainParts.dropFirst().joined(separator: ".")
59+
60+
// Complete initialization
61+
self.username = username
62+
self.hostname = domain
63+
self.tld = tld
64+
self.string = email
65+
}
66+
}

Sources/SpotHeroEmailValidator/SpotHeroEmailValidator.swift

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import Foundation
44

55
// TODO: Remove NSObject when entirely converted into Swift
66
public class SpotHeroEmailValidator: NSObject {
7-
private typealias EmailParts = (username: String, hostname: String, tld: String)
8-
97
public static let shared = SpotHeroEmailValidator()
108

119
private let commonTLDs: [String]
@@ -44,7 +42,7 @@ public class SpotHeroEmailValidator: NSObject {
4442
try self.validateSyntax(of: emailAddress)
4543

4644
// Split the email address into its component parts
47-
let emailParts = try self.splitEmailAddress(emailAddress)
45+
let emailParts = try EmailComponents(email: emailAddress)
4846

4947
var suggestedTLD = emailParts.tld
5048

@@ -72,7 +70,7 @@ public class SpotHeroEmailValidator: NSObject {
7270
@discardableResult
7371
public func validateSyntax(of emailAddress: String) throws -> Bool {
7472
// Split the email address into parts
75-
let emailParts = try self.splitEmailAddress(emailAddress)
73+
let emailParts = try EmailComponents(email: emailAddress)
7674

7775
// Ensure the username is valid by itself
7876
guard emailParts.username.isValidEmailUsername() else {
@@ -116,37 +114,6 @@ public class SpotHeroEmailValidator: NSObject {
116114

117115
return closestString
118116
}
119-
120-
private func splitEmailAddress(_ emailAddress: String) throws -> EmailParts {
121-
// Ensure there is exactly one @ symbol.
122-
guard emailAddress.filter({ $0 == "@" }).count == 1 else {
123-
throw Error.invalidSyntax
124-
}
125-
126-
let emailAddressParts = emailAddress.split(separator: "@")
127-
128-
// Extract the username from the email address parts
129-
let username = String(emailAddressParts.first ?? "")
130-
// Extract the full domain (including TLD) from the email address parts
131-
let fullDomain = String(emailAddressParts.last ?? "")
132-
// Split the domain parts for evaluation
133-
let domainParts = fullDomain.split(separator: ".")
134-
135-
guard domainParts.count >= 2 else {
136-
// There are no periods found in the domain, throw an error
137-
throw Error.invalidDomain
138-
}
139-
140-
// TODO: This logic is wrong and doesn't take subdomains into account. We should compare TLDs against the commonTLDs list."
141-
142-
// Extract the domain from the domain parts
143-
let domain = domainParts.first?.lowercased() ?? ""
144-
145-
// Extract the TLD from the domain parts, which are all the remaining parts joined with a period again
146-
let tld = domainParts.dropFirst().joined(separator: ".")
147-
148-
return (username, domain, tld)
149-
}
150117
}
151118

152119
// MARK: - Extensions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright © 2022 SpotHero, Inc. All rights reserved.
2+
3+
@testable import SpotHeroEmailValidator
4+
import XCTest
5+
6+
// swiftlint:disable nesting
7+
class EmailComponentsTests: XCTestCase {
8+
struct TestModel {
9+
enum ExpectedResult {
10+
case `throw`(SpotHeroEmailValidator.Error)
11+
case `return`(username: String, hostname: String, tld: String)
12+
}
13+
14+
/// The email under test.
15+
let email: String
16+
17+
/// The result expected when ran through ``EmailComponents``.
18+
let expectedResult: ExpectedResult
19+
20+
init(email emailUnderTest: String, expectedTo result: ExpectedResult) {
21+
self.email = emailUnderTest
22+
self.expectedResult = result
23+
}
24+
}
25+
26+
func testEmailComponentsInit() {
27+
let tests = [
28+
// Successful Examples
29+
TestModel(email: "test@email.com",
30+
expectedTo: .return(username: "test", hostname: "email", tld: "com")),
31+
32+
TestModel(email: "TEST@EMAIL.COM",
33+
expectedTo: .return(username: "TEST", hostname: "email", tld: "COM")),
34+
35+
TestModel(email: "test+-.test@email.com",
36+
expectedTo: .return(username: "test+-.test", hostname: "email", tld: "com")),
37+
38+
TestModel(email: #""JohnDoe"@email.com"#,
39+
expectedTo: .return(username: #""JohnDoe""#, hostname: "email", tld: "com")),
40+
41+
// Failing Examples
42+
TestModel(email: "t@st@email.com", expectedTo: .throw(.invalidSyntax)),
43+
TestModel(email: "test.com", expectedTo: .throw(.invalidSyntax)),
44+
45+
// Domain Tests
46+
TestModel(email: "test@email", expectedTo: .throw(.invalidDomain)),
47+
]
48+
49+
for test in tests {
50+
switch test.expectedResult {
51+
case .throw:
52+
XCTAssertThrowsError(try EmailComponents(email: test.email)) { error in
53+
XCTAssertEqual(error.localizedDescription,
54+
error.localizedDescription,
55+
"Test failed for email address: \(test.email)")
56+
}
57+
case let .return(username, hostname, tld):
58+
do {
59+
let actualComponents = try EmailComponents(email: test.email)
60+
XCTAssertEqual(actualComponents.username, username)
61+
XCTAssertEqual(actualComponents.hostname, hostname)
62+
XCTAssertEqual(actualComponents.tld, tld)
63+
} catch {
64+
XCTFail("Test failed for email address: \(test.email). \(error.localizedDescription)")
65+
}
66+
}
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)