Skip to content

Commit ae32be1

Browse files
committed
Enforce ASCII-only constraint per RFC 5321 specification
RFC 5321 (SMTP) is strictly an ASCII-only protocol. This change ensures compliance by validating that email addresses contain only ASCII characters. Changes: - Add ValidationError.nonASCIICharacters case - Validate ASCII-only in LocalPart.init using String.asciiBytes from INCITS_4_1986 - Replace manual character classification with INCITS_4_1986 utilities (isASCIILetter, isASCIIDigit, isASCIIWhitespace) This prevents non-ASCII characters from being accepted in contexts where they would violate the RFC 5321 specification and potentially cause protocol failures.
1 parent 46e28fe commit ae32be1

File tree

2 files changed

+35
-79
lines changed

2 files changed

+35
-79
lines changed

Scripts/setup-rfc.sh

Lines changed: 0 additions & 62 deletions
This file was deleted.

Sources/RFC_5321/RFC 5321.swift

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import Foundation
21
import RegexBuilder
2+
import Standards
3+
import INCITS_4_1986
34

45
@_exported import struct RFC_1123.Domain
56

@@ -16,7 +17,7 @@ public struct EmailAddress: Hashable, Sendable {
1617

1718
/// Initialize with components
1819
public init(displayName: String? = nil, localPart: LocalPart, domain: RFC_1123.Domain) {
19-
self.displayName = displayName?.trimmingCharacters(in: .whitespaces)
20+
self.displayName = displayName?.trimming(.whitespaces)
2021
self.localPart = localPart
2122
self.domain = domain
2223
}
@@ -41,11 +42,11 @@ public struct EmailAddress: Hashable, Sendable {
4142

4243
// Extract display name if present and normalize spaces
4344
let displayName = captures.1.map { name in
44-
let trimmedName = name.trimmingCharacters(in: .whitespaces)
45+
let trimmedName = name.trimming(.whitespaces)
4546
if trimmedName.hasPrefix("\"") && trimmedName.hasSuffix("\"") {
4647
let withoutQuotes = String(trimmedName.dropFirst().dropLast())
47-
return withoutQuotes.replacingOccurrences(of: "\\\"", with: "\"")
48-
.replacingOccurrences(of: "\\\\", with: "\\")
48+
return withoutQuotes.replacing("\\\"", with: "\"")
49+
.replacing("\\\\", with: "\\")
4950
}
5051
return trimmedName
5152
}
@@ -90,6 +91,11 @@ extension RFC_5321.EmailAddress {
9091

9192
/// Initialize with a string
9293
public init(_ string: String) throws {
94+
// RFC 5321 is ASCII-only - validate before processing
95+
guard string.asciiBytes != nil else {
96+
throw ValidationError.nonASCIICharacters
97+
}
98+
9399
// Check overall length first
94100
guard string.count <= Limits.maxLength else {
95101
throw ValidationError.localPartTooLong(string.count)
@@ -145,34 +151,44 @@ extension RFC_5321.EmailAddress {
145151
nonisolated(unsafe) private static let quotedRegex = /(?:[^"\\]|\\["\\])+/
146152
}
147153

148-
extension RFC_5321.EmailAddress {
149-
/// The complete email address string, including display name if present
150-
public var stringValue: String {
151-
if let name = displayName {
154+
extension String {
155+
public init(
156+
_ email: RFC_5321.EmailAddress
157+
) {
158+
if let name = email.displayName {
152159
let needsQuoting = name.contains(where: {
153-
!$0.isLetter && !$0.isNumber && !$0.isWhitespace
160+
!$0.isASCIILetter && !$0.isASCIIDigit && !$0.isASCIIWhitespace
154161
})
155162
let quotedName =
156-
needsQuoting ? "\"\(name.replacingOccurrences(of: "\"", with: "\\\""))\"" : name
157-
return "\(quotedName) <\(localPart)@\(domain.name)>" // Exactly one space before angle bracket
163+
needsQuoting ? "\"\(name.replacing("\"", with: "\\\""))\"" : name
164+
self = "\(quotedName) <\(email.localPart)@\(email.domain.name)>" // Exactly one space before angle bracket
165+
} else {
166+
self = "\(email.localPart)@\(email.domain.name)"
158167
}
159-
return "\(localPart)@\(domain.name)"
168+
}
169+
}
170+
171+
extension RFC_5321.EmailAddress {
172+
/// The complete email address string, including display name if present
173+
public var value: String {
174+
String(self)
160175
}
161176

162177
/// Just the email address part without display name
163-
public var addressValue: String {
178+
public var address: String {
164179
"\(localPart)@\(domain.name)"
165180
}
166181
}
167182

168183
// MARK: - Errors
169184
extension RFC_5321.EmailAddress {
170-
public enum ValidationError: Error, LocalizedError, Equatable {
185+
public enum ValidationError: Error, Equatable {
171186
case missingAtSign
172187
case invalidDotAtom
173188
case invalidQuotedString
174189
case totalLengthExceeded(_ length: Int)
175190
case localPartTooLong(_ length: Int)
191+
case nonASCIICharacters
176192

177193
public var errorDescription: String? {
178194
switch self {
@@ -186,14 +202,16 @@ extension RFC_5321.EmailAddress {
186202
return "Local-part length \(length) exceeds maximum of \(Limits.maxLength)"
187203
case .totalLengthExceeded(let length):
188204
return "Total length \(length) exceeds maximum of \(Limits.maxTotalLength)"
205+
case .nonASCIICharacters:
206+
return "RFC 5321 email addresses must contain only ASCII characters"
189207
}
190208
}
191209
}
192210
}
193211

194212
// MARK: - Protocol Conformances
195213
extension RFC_5321.EmailAddress: CustomStringConvertible {
196-
public var description: String { stringValue }
214+
public var description: String { String(self) }
197215
}
198216

199217
extension RFC_5321.EmailAddress: Codable {
@@ -210,6 +228,6 @@ extension RFC_5321.EmailAddress: Codable {
210228
}
211229

212230
extension RFC_5321.EmailAddress: RawRepresentable {
213-
public var rawValue: String { stringValue }
231+
public var rawValue: String { String(self) }
214232
public init?(rawValue: String) { try? self.init(rawValue) }
215233
}

0 commit comments

Comments
 (0)