Skip to content

Commit ac3c246

Browse files
coenttbclaude
andcommitted
Add RFC 5321 implementation and tests
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 62db260 commit ac3c246

File tree

2 files changed

+313
-0
lines changed

2 files changed

+313
-0
lines changed

Sources/RFC_5321/RFC 5321.swift

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import Foundation
2+
import RegexBuilder
3+
4+
@_exported import struct RFC_1123.Domain
5+
6+
/// RFC 5321 compliant email address (basic SMTP format)
7+
public struct EmailAddress: Hashable, Sendable {
8+
/// The display name, if present
9+
public let displayName: String?
10+
11+
/// The local part (before @)
12+
public let localPart: LocalPart
13+
14+
/// The domain part (after @)
15+
public let domain: RFC_1123.Domain
16+
17+
/// Initialize with components
18+
public init(displayName: String? = nil, localPart: LocalPart, domain: RFC_1123.Domain) {
19+
self.displayName = displayName?.trimmingCharacters(in: .whitespaces)
20+
self.localPart = localPart
21+
self.domain = domain
22+
}
23+
24+
/// Initialize from string representation ("Name <local@domain>" or "local@domain")
25+
public init(_ string: String) throws {
26+
27+
let displayNameCapture = /(?:((?:\"(?:[^\"\\]|\\.)*\"|[^<]+?))\s*)/
28+
29+
let emailCapture = /<([^@]+)@([^>]+)>/
30+
31+
let fullRegex = Regex {
32+
Optionally {
33+
displayNameCapture
34+
}
35+
emailCapture
36+
}
37+
38+
// Try matching the full address format first (with angle brackets)
39+
if let match = try? fullRegex.wholeMatch(in: string) {
40+
let captures = match.output
41+
42+
// Extract display name if present and normalize spaces
43+
let displayName = captures.1.map { name in
44+
let trimmedName = name.trimmingCharacters(in: .whitespaces)
45+
if trimmedName.hasPrefix("\"") && trimmedName.hasSuffix("\"") {
46+
let withoutQuotes = String(trimmedName.dropFirst().dropLast())
47+
return withoutQuotes.replacingOccurrences(of: "\\\"", with: "\"")
48+
.replacingOccurrences(of: "\\\\", with: "\\")
49+
}
50+
return trimmedName
51+
}
52+
53+
let localPart = String(captures.2)
54+
let domain = String(captures.3)
55+
56+
// Check total length before creating components
57+
let addressLength = localPart.count + 1 + domain.count // +1 for @
58+
guard addressLength <= Limits.maxTotalLength else {
59+
throw ValidationError.totalLengthExceeded(addressLength)
60+
}
61+
62+
try self.init(
63+
displayName: displayName,
64+
localPart: LocalPart(localPart),
65+
domain: .init(domain)
66+
)
67+
} else {
68+
// Try parsing as bare email address
69+
guard let atIndex = string.firstIndex(of: "@") else {
70+
throw ValidationError.missingAtSign
71+
}
72+
73+
let localString = String(string[..<atIndex])
74+
let domainString = String(string[string.index(after: atIndex)...])
75+
76+
try self.init(
77+
displayName: nil,
78+
localPart: LocalPart(localString),
79+
domain: .init(domainString)
80+
)
81+
}
82+
}
83+
}
84+
85+
// MARK: - Local Part
86+
extension RFC_5321.EmailAddress {
87+
/// RFC 5321 compliant local-part
88+
public struct LocalPart: Hashable, Sendable {
89+
private let storage: Storage
90+
91+
/// Initialize with a string
92+
public init(_ string: String) throws {
93+
// Check overall length first
94+
guard string.count <= Limits.maxLength else {
95+
throw ValidationError.localPartTooLong(string.count)
96+
}
97+
98+
// Handle quoted string format
99+
if string.hasPrefix("\"") && string.hasSuffix("\"") {
100+
let quoted = String(string.dropFirst().dropLast())
101+
guard (try? RFC_5321.EmailAddress.quotedRegex.wholeMatch(in: quoted)) != nil else {
102+
throw ValidationError.invalidQuotedString
103+
}
104+
self.storage = .quoted(string)
105+
}
106+
// Handle dot-atom format
107+
else {
108+
guard (try? RFC_5321.EmailAddress.dotAtomRegex.wholeMatch(in: string)) != nil else {
109+
throw ValidationError.invalidDotAtom
110+
}
111+
self.storage = .dotAtom(string)
112+
}
113+
}
114+
115+
/// The string representation
116+
public var stringValue: String {
117+
switch storage {
118+
case .dotAtom(let string), .quoted(let string):
119+
return string
120+
}
121+
}
122+
123+
private enum Storage: Hashable {
124+
case dotAtom(String) // Regular unquoted format
125+
case quoted(String) // Quoted string format
126+
}
127+
}
128+
}
129+
130+
// MARK: - Constants and Validation
131+
extension RFC_5321.EmailAddress {
132+
private enum Limits {
133+
static let maxLength = 64 // Max length for local-part
134+
static let maxTotalLength = 254
135+
}
136+
137+
// Dot-atom regex: series of atoms separated by dots
138+
nonisolated(unsafe) private static let dotAtomRegex = /[a-zA-Z0-9!#$%&'*+\-\/=?\^_`{|}~]+(?:\.[a-zA-Z0-9!#$%&'*+\-\/=?\^_`{|}~]+)*/
139+
140+
// Quoted string regex: allows any printable character except unescaped quotes
141+
nonisolated(unsafe) private static let quotedRegex = /(?:[^"\\]|\\["\\])+/
142+
}
143+
144+
extension RFC_5321.EmailAddress {
145+
/// The complete email address string, including display name if present
146+
public var stringValue: String {
147+
if let name = displayName {
148+
let needsQuoting = name.contains(where: { !$0.isLetter && !$0.isNumber && !$0.isWhitespace })
149+
let quotedName = needsQuoting ?
150+
"\"\(name.replacingOccurrences(of: "\"", with: "\\\""))\"" :
151+
name
152+
return "\(quotedName) <\(localPart.stringValue)@\(domain.name)>" // Exactly one space before angle bracket
153+
}
154+
return "\(localPart.stringValue)@\(domain.name)"
155+
}
156+
157+
/// Just the email address part without display name
158+
public var addressValue: String {
159+
"\(localPart.stringValue)@\(domain.name)"
160+
}
161+
}
162+
163+
// MARK: - Errors
164+
extension RFC_5321.EmailAddress {
165+
public enum ValidationError: Error, LocalizedError, Equatable {
166+
case missingAtSign
167+
case invalidDotAtom
168+
case invalidQuotedString
169+
case totalLengthExceeded(_ length: Int)
170+
case localPartTooLong(_ length: Int)
171+
172+
public var errorDescription: String? {
173+
switch self {
174+
case .missingAtSign:
175+
return "Email address must contain @"
176+
case .invalidDotAtom:
177+
return "Invalid local-part format (before @)"
178+
case .invalidQuotedString:
179+
return "Invalid quoted string format in local-part"
180+
case .localPartTooLong(let length):
181+
return "Local-part length \(length) exceeds maximum of \(Limits.maxLength)"
182+
case .totalLengthExceeded(let length):
183+
return "Total length \(length) exceeds maximum of \(Limits.maxTotalLength)"
184+
}
185+
}
186+
}
187+
}
188+
189+
// MARK: - Protocol Conformances
190+
extension RFC_5321.EmailAddress: CustomStringConvertible {
191+
public var description: String { stringValue }
192+
}
193+
194+
extension RFC_5321.EmailAddress: Codable {
195+
public func encode(to encoder: Encoder) throws {
196+
var container = encoder.singleValueContainer()
197+
try container.encode(self.rawValue)
198+
}
199+
200+
public init(from decoder: Decoder) throws {
201+
let container = try decoder.singleValueContainer()
202+
let rawValue = try container.decode(String.self)
203+
try self.init(rawValue)
204+
}
205+
}
206+
207+
extension RFC_5321.EmailAddress: RawRepresentable {
208+
public var rawValue: String { stringValue }
209+
public init?(rawValue: String) { try? self.init(rawValue) }
210+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//
2+
// File.swift
3+
// swift-web
4+
//
5+
// Created by Coen ten Thije Boonkkamp on 28/12/2024.
6+
//
7+
8+
import Foundation
9+
import RFC_5321
10+
import Testing
11+
12+
@Suite("RFC 5321 Domain Tests")
13+
struct RFC5321Tests {
14+
@Test("Successfully creates standard domain")
15+
func testStandardDomain() throws {
16+
let domain = try Domain("mail.example.com")
17+
#expect(domain.name == "mail.example.com")
18+
}
19+
20+
@Test("Successfully creates IPv4 literal")
21+
func testIPv4Literal() throws {
22+
let domain = try Domain("[192.168.1.1]")
23+
#expect(domain.name == "[192.168.1.1]")
24+
}
25+
26+
@Test("Successfully creates IPv6 literal")
27+
func testIPv6Literal() throws {
28+
let domain = try Domain("[2001:db8:85a3:8d3:1319:8a2e:370:7348]")
29+
#expect(domain.name == "[2001:db8:85a3:8d3:1319:8a2e:370:7348]")
30+
}
31+
32+
@Test("Fails with empty address literal")
33+
func testEmptyAddressLiteral() throws {
34+
#expect(throws: Error.self) {
35+
_ = try Domain("[]")
36+
}
37+
}
38+
39+
// @Test("Fails with invalid IPv4 format")
40+
// func testInvalidIPv4Format() throws {
41+
// #expect(throws: Domain.ValidationError.invalidIPv4("256.256.256.256")) {
42+
// _ = try Domain("[256.256.256.256]")
43+
// }
44+
// }
45+
//
46+
// @Test("Fails with invalid IPv6 format")
47+
// func testInvalidIPv6Format() throws {
48+
// #expect(throws: Domain.ValidationError.invalidIPv6("not:valid:ipv6")) {
49+
// _ = try Domain("[not:valid:ipv6]")
50+
// }
51+
// }
52+
//
53+
// @Test("Successfully gets standard domain")
54+
// func testGetStandardDomain() throws {
55+
// let domain = try Domain("mail.example.com")
56+
// #expect(domain.standardDomain?.name == "mail.example.com")
57+
// }
58+
//
59+
// @Test("Returns nil standard domain for address literal")
60+
// func testNilStandardDomainForAddressLiteral() throws {
61+
// let domain = try Domain("[192.168.1.1]")
62+
// #expect(domain.standardDomain == nil)
63+
// }
64+
//
65+
// @Test("Successfully creates from RFC1123")
66+
// func testCreateFromRFC1123() throws {
67+
// let rfc1123 = try RFC_1123.Domain("mail.example.com")
68+
// let domain = Domain(domain: rfc1123)
69+
// #expect(domain.name == "mail.example.com")
70+
// #expect(domain.isStandardDomain)
71+
// }
72+
//
73+
// @Test("Successfully creates IPv4 literal directly")
74+
// func testCreateIPv4Literal() throws {
75+
// let domain = try Domain(ipv4Literal: "192.168.1.1")
76+
// #expect(domain.name == "[192.168.1.1]")
77+
// #expect(domain.addressLiteral == "192.168.1.1")
78+
// }
79+
//
80+
// @Test("Successfully creates IPv6 literal directly")
81+
// func testCreateIPv6Literal() throws {
82+
// let ipv6 = "2001:db8:85a3:8d3:1319:8a2e:370:7348"
83+
// let domain = try Domain(ipv6Literal: ipv6)
84+
// #expect(domain.name == "[\(ipv6)]")
85+
// #expect(domain.addressLiteral == ipv6)
86+
// }
87+
88+
@Test("Successfully encodes and decodes standard domain")
89+
func testCodableStandardDomain() throws {
90+
let original = try Domain("mail.example.com")
91+
let encoded = try JSONEncoder().encode(original)
92+
let decoded = try JSONDecoder().decode(Domain.self, from: encoded)
93+
#expect(original == decoded)
94+
}
95+
96+
@Test("Successfully encodes and decodes IPv4 literal")
97+
func testCodableIPv4() throws {
98+
let original = try Domain("[192.168.1.1]")
99+
let encoded = try JSONEncoder().encode(original)
100+
let decoded = try JSONDecoder().decode(Domain.self, from: encoded)
101+
#expect(original == decoded)
102+
}
103+
}

0 commit comments

Comments
 (0)