Skip to content

Commit a230305

Browse files
committed
Update RFC 5321 email address APIs, remove deprecated extension
1 parent a3a77c1 commit a230305

File tree

7 files changed

+472
-294
lines changed

7 files changed

+472
-294
lines changed

Sources/RFC 5321/RFC_5321.EmailAddress.Error.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ extension RFC_5321.EmailAddress {
2323
///
2424
/// These represent compositional constraint violations at the email address level,
2525
/// as defined by RFC 5321.
26-
public enum Error: Swift.Error, Equatable {
26+
public enum Error: Swift.Error, Sendable, Equatable {
2727
/// Email address is missing @ sign
2828
case missingAtSign
2929

Sources/RFC 5321/RFC_5321.EmailAddress.LocalPart.Error.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ extension RFC_5321.EmailAddress.LocalPart {
2222
///
2323
/// These represent atomic constraint violations at the local-part level,
2424
/// as defined by RFC 5321 Section 4.5.3.1.1.
25-
public enum Error: Swift.Error, Equatable {
25+
public enum Error: Swift.Error, Sendable, Equatable {
2626
/// Local-part is empty
2727
case empty
2828

@@ -32,6 +32,9 @@ extension RFC_5321.EmailAddress.LocalPart {
3232
/// Local-part contains non-ASCII characters (RFC 5321 is ASCII-only)
3333
case nonASCII
3434

35+
/// Invalid character in local-part
36+
case invalidCharacter(_ value: String, byte: UInt8)
37+
3538
/// Dot-atom format is invalid
3639
case invalidDotAtom(_ localPart: String)
3740

@@ -51,6 +54,8 @@ extension RFC_5321.EmailAddress.LocalPart.Error: CustomStringConvertible {
5154
return "Local-part is too long (\(length) bytes, maximum 64)"
5255
case .nonASCII:
5356
return "Local-part must contain only ASCII characters (RFC 5321)"
57+
case .invalidCharacter(let value, let byte):
58+
return "Invalid byte 0x\(String(byte, radix: 16)) in local-part '\(value)'"
5459
case .invalidDotAtom(let localPart):
5560
return "Invalid dot-atom format in local-part '\(localPart)'"
5661
case .invalidQuotedString(let localPart):

Sources/RFC 5321/RFC_5321.EmailAddress.LocalPart.swift

Lines changed: 199 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,91 +13,245 @@ extension RFC_5321.EmailAddress {
1313
///
1414
/// The local-part appears before the @ sign in an email address.
1515
/// RFC 5321 supports two formats: dot-atom and quoted-string.
16-
public struct LocalPart: Hashable, Sendable {
16+
///
17+
/// ## Constraints
18+
///
19+
/// Per RFC 5321 Section 4.5.3.1.1:
20+
/// - Maximum length: 64 octets
21+
/// - Must be ASCII-only
22+
/// - Supports dot-atom or quoted-string format
23+
///
24+
/// ## Example
25+
///
26+
/// ```swift
27+
/// let localPart = try RFC_5321.EmailAddress.LocalPart(ascii: "user".utf8)
28+
/// ```
29+
public struct LocalPart: Hashable, Sendable, Codable {
1730
/// Canonical byte storage (ASCII-only per RFC 5321)
1831
let _value: [UInt8]
1932

2033
/// The storage format (dot-atom or quoted)
2134
private let format: Format
2235

36+
/// Raw string value
37+
public var rawValue: String {
38+
String(decoding: _value, as: UTF8.self)
39+
}
40+
2341
/// String representation derived from canonical bytes
2442
public var value: String {
25-
String(self)
43+
String(ascii: self)
44+
}
45+
46+
/// Creates local-part WITHOUT validation
47+
///
48+
/// **Warning**: Bypasses RFC validation. Only use for:
49+
/// - Static constants
50+
/// - Pre-validated values
51+
/// - Internal construction after validation
52+
init(__unchecked: Void, rawValue: String) {
53+
self._value = [UInt8](utf8: rawValue)
54+
// Infer format from presence of quotes
55+
if rawValue.hasPrefix("\"") && rawValue.hasSuffix("\"") {
56+
self.format = .quoted
57+
} else {
58+
self.format = .dotAtom
59+
}
2660
}
2761

2862
/// Initialize a local-part from a string, validating RFC 5321 rules
2963
///
30-
/// This is the canonical initializer that performs validation.
64+
/// This is a convenience initializer that converts String to bytes.
3165
public init(_ string: some StringProtocol) throws(Error) {
32-
let stringValue = String(string)
66+
try self.init(ascii: Array(string.utf8))
67+
}
3368

34-
// Check emptiness
35-
guard !stringValue.isEmpty else {
36-
throw Error.empty
37-
}
69+
// MARK: - Format
3870

39-
// RFC 5321 is ASCII-only - validate before processing
40-
guard stringValue.allSatisfy({ $0.isASCII }) else {
71+
private enum Format: Hashable, Codable {
72+
case dotAtom // Regular unquoted format
73+
case quoted // Quoted string format
74+
}
75+
}
76+
}
77+
78+
// MARK: - Byte-Level Parsing (UInt8.ASCII.Serializable)
79+
80+
extension RFC_5321.EmailAddress.LocalPart: UInt8.ASCII.Serializable {
81+
/// Initialize from ASCII bytes, validating RFC 5321 rules
82+
///
83+
/// ## Category Theory
84+
///
85+
/// Parsing transformation:
86+
/// - **Domain**: [UInt8] (ASCII bytes)
87+
/// - **Codomain**: RFC_5321.EmailAddress.LocalPart (structured data)
88+
///
89+
/// String parsing is derived composition:
90+
/// ```
91+
/// String → [UInt8] (UTF-8) → LocalPart
92+
/// ```
93+
///
94+
/// ## Constraints
95+
///
96+
/// Per RFC 5321 Section 4.5.3.1.1:
97+
/// - Must be ASCII-only
98+
/// - Maximum 64 octets
99+
/// - Supports dot-atom or quoted-string format
100+
///
101+
/// ## Example
102+
///
103+
/// ```swift
104+
/// let localPart = try RFC_5321.EmailAddress.LocalPart(ascii: "user".utf8)
105+
/// ```
106+
public init<Bytes: Collection>(ascii bytes: Bytes, in _: Void = ()) throws(Error)
107+
where Bytes.Element == UInt8 {
108+
guard let firstByte = bytes.first else { throw Error.empty }
109+
110+
var count = 0
111+
var lastByte = firstByte
112+
for byte in bytes {
113+
count += 1
114+
lastByte = byte
115+
// Validate ASCII-only (high bit must be 0)
116+
guard byte < 0x80 else {
41117
throw Error.nonASCII
42118
}
119+
}
120+
121+
guard count <= Limits.maxLength else {
122+
throw Error.tooLong(count)
123+
}
43124

44-
// Check overall length
45-
guard stringValue.count <= Limits.maxLength else {
46-
throw Error.tooLong(stringValue.count)
125+
let rawValue = String(decoding: bytes, as: UTF8.self)
126+
127+
// Handle quoted string format
128+
if firstByte == .ascii.quotationMark {
129+
guard lastByte == .ascii.quotationMark else {
130+
throw Error.invalidQuotedString(rawValue)
47131
}
48132

49-
// Handle quoted string format
50-
if stringValue.hasPrefix("\"") && stringValue.hasSuffix("\"") {
51-
let quoted = String(stringValue.dropFirst().dropLast())
52-
guard (try? RFC_5321.EmailAddress.quotedRegex.wholeMatch(in: quoted)) != nil else {
53-
throw Error.invalidQuotedString(stringValue)
133+
// Validate quoted string content
134+
var insideQuotes = false
135+
var escaped = false
136+
for byte in bytes {
137+
if !insideQuotes {
138+
if byte == .ascii.quotationMark {
139+
insideQuotes = true
140+
}
141+
} else {
142+
if escaped {
143+
escaped = false
144+
// After backslash, allow quote or backslash
145+
guard byte == .ascii.quotationMark || byte == .ascii.reverseSolidus else {
146+
throw Error.invalidQuotedString(rawValue)
147+
}
148+
} else if byte == .ascii.reverseSolidus {
149+
escaped = true
150+
} else if byte == .ascii.quotationMark {
151+
// End of quoted string
152+
break
153+
} else {
154+
// Inside quotes: allow printable ASCII except unescaped quote
155+
guard byte >= 0x20 && byte < 0x7F else {
156+
throw Error.invalidCharacter(rawValue, byte: byte)
157+
}
158+
}
54159
}
55-
self.format = .quoted
56-
self._value = [UInt8](utf8: stringValue)
57160
}
58-
// Handle dot-atom format
59-
else {
60-
guard (try? RFC_5321.EmailAddress.dotAtomRegex.wholeMatch(in: stringValue)) != nil else {
61-
throw Error.invalidDotAtom(stringValue)
161+
162+
self._value = Array(bytes)
163+
self.format = .quoted
164+
}
165+
// Handle dot-atom format
166+
else {
167+
// atext = ALPHA / DIGIT / "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "/" / "=" / "?" / "^" / "_" / "`" / "{" / "|" / "}" / "~"
168+
var lastWasDot = false
169+
var index = bytes.startIndex
170+
171+
for byte in bytes {
172+
let isAtext = byte.ascii.isLetter || byte.ascii.isDigit ||
173+
byte == 0x21 || // !
174+
byte == 0x23 || // #
175+
byte == 0x24 || // $
176+
byte == 0x25 || // %
177+
byte == 0x26 || // &
178+
byte == 0x27 || // '
179+
byte == 0x2A || // *
180+
byte == 0x2B || // +
181+
byte == 0x2D || // -
182+
byte == 0x2F || // /
183+
byte == 0x3D || // =
184+
byte == 0x3F || // ?
185+
byte == 0x5E || // ^
186+
byte == 0x5F || // _
187+
byte == 0x60 || // `
188+
byte == 0x7B || // {
189+
byte == 0x7C || // |
190+
byte == 0x7D || // }
191+
byte == 0x7E // ~
192+
193+
let isDot = byte == .ascii.period
194+
195+
guard isAtext || isDot else {
196+
throw Error.invalidCharacter(rawValue, byte: byte)
62197
}
63-
self.format = .dotAtom
64-
self._value = [UInt8](utf8: stringValue)
198+
199+
// Can't start or end with dot, can't have consecutive dots
200+
if isDot {
201+
guard index != bytes.startIndex else {
202+
throw Error.invalidDotAtom(rawValue)
203+
}
204+
guard !lastWasDot else {
205+
throw Error.invalidDotAtom(rawValue)
206+
}
207+
}
208+
209+
lastWasDot = isDot
210+
index = bytes.index(after: index)
65211
}
66-
}
67212

68-
// MARK: - Format
213+
// Can't end with dot
214+
guard !lastWasDot else {
215+
throw Error.invalidDotAtom(rawValue)
216+
}
69217

70-
private enum Format: Hashable {
71-
case dotAtom // Regular unquoted format
72-
case quoted // Quoted string format
218+
self._value = Array(bytes)
219+
self.format = .dotAtom
73220
}
74221
}
75222
}
76223

77-
// MARK: - Convenience Initializers
224+
// MARK: - Protocol Conformances
78225

79-
extension RFC_5321.EmailAddress.LocalPart {
80-
/// Initialize a local-part from bytes, validating RFC 5321 rules
81-
///
82-
/// Convenience initializer that decodes bytes as UTF-8 and validates.
83-
public init(_ bytes: [UInt8]) throws(Error) {
84-
let string = String(decoding: bytes, as: UTF8.self)
85-
try self.init(string)
86-
}
226+
extension RFC_5321.EmailAddress.LocalPart: UInt8.ASCII.RawRepresentable {
227+
public typealias RawValue = String
87228
}
88229

89-
// MARK: - Constants
230+
// MARK: - ASCII Serialization
90231

91232
extension RFC_5321.EmailAddress.LocalPart {
92-
private enum Limits {
93-
static let maxLength = 64 // Max length for local-part per RFC 5321
233+
/// Serialize local-part to ASCII bytes
234+
///
235+
/// Required implementation for `UInt8.ASCII.RawRepresentable` to avoid
236+
/// infinite recursion (since `rawValue` is synthesized from serialization).
237+
public static func serialize<Buffer: RangeReplaceableCollection>(
238+
ascii localPart: Self,
239+
into buffer: inout Buffer
240+
) where Buffer.Element == UInt8 {
241+
buffer.append(contentsOf: localPart._value)
94242
}
95243
}
96244

97-
// MARK: - Protocol Conformances
98-
99245
extension RFC_5321.EmailAddress.LocalPart: CustomStringConvertible {
100246
public var description: String {
101247
String(decoding: _value, as: UTF8.self)
102248
}
103249
}
250+
251+
// MARK: - Constants
252+
253+
extension RFC_5321.EmailAddress.LocalPart {
254+
package enum Limits {
255+
static let maxLength = 64 // Max length for local-part per RFC 5321
256+
}
257+
}

0 commit comments

Comments
 (0)