Skip to content

Commit

Permalink
improve email verification
Browse files Browse the repository at this point in the history
  • Loading branch information
odrobnik committed Mar 7, 2025
1 parent bb4fcc5 commit b1312d7
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 4 deletions.
29 changes: 29 additions & 0 deletions Sources/SwiftMailCore/Extensions/String+Email.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// String+Email.swift
// Email validation extensions for String

import Foundation

extension String {
/// Validates if the string is a valid email address format according to RFC 5322
/// - Returns: True if the string matches email format
public func isValidEmail() -> Bool {
let pattern = #"""
^(?:[a-zA-Z0-9](?:[a-zA-Z0-9._%+-]{0,61}[a-zA-Z0-9])?@
[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
(?:\.[a-zA-Z]{2,})+)$
"""#

do {
let regex = try NSRegularExpression(pattern: pattern, options: [.allowCommentsAndWhitespace])
let range = NSRange(location: 0, length: self.utf16.count)
return regex.firstMatch(in: self, options: [], range: range) != nil
} catch {
// If regex creation fails (which shouldn't happen with a valid pattern),
// fall back to a very basic check
return self.contains("@") &&
self.split(separator: "@").count == 2 &&
!self.hasPrefix("@") &&
!self.hasSuffix("@")
}
}
}
23 changes: 23 additions & 0 deletions Sources/SwiftMailCore/Protocols/SMTPTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@

import Foundation

/// SMTP response codes
public enum SMTPResponseCode: Int {
case commandOK = 250
case ready = 220
case readyForContent = 354
case serviceClosing = 221
case authSuccess = 235
case tempError = 421
case mailboxUnavailable = 450
case localError = 451
case insufficientStorage = 452
case syntaxError = 500
case argumentError = 501
case notImplemented = 502
case badSequence = 503
case paramNotImplemented = 504
case mailboxUnavailablePerm = 550
case userNotLocal = 551
case exceededStorage = 552
case nameNotAllowed = 553
case transactionFailed = 554
}

/// A structure representing a response from an SMTP server
public struct SMTPResponse: MailResponse {
/// The numeric response code
Expand Down
13 changes: 12 additions & 1 deletion Sources/SwiftSMTP/Commands/MailFromCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ public struct MailFromCommand: SMTPCommand {
Initialize a new MAIL FROM command
- Parameter senderAddress: The email address of the sender
*/
public init(senderAddress: String) {
public init(senderAddress: String) throws {
// Validate email format
guard senderAddress.isValidEmail() else {
throw SMTPError.invalidEmailAddress("Invalid sender address: \(senderAddress)")
}

self.senderAddress = senderAddress
}

Expand All @@ -48,4 +53,10 @@ public struct MailFromCommand: SMTPCommand {
throw SMTPError.sendFailed("Invalid sender email format: \(senderAddress)")
}
}

func validateResponse(_ response: SMTPResponse) throws {
guard response.code == SMTPResponseCode.commandOK.rawValue else {
throw SMTPError.unexpectedResponse(response)
}
}
}
13 changes: 12 additions & 1 deletion Sources/SwiftSMTP/Commands/RcptToCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ public struct RcptToCommand: SMTPCommand {
Initialize a new RCPT TO command
- Parameter recipientAddress: The email address of the recipient
*/
public init(recipientAddress: String) {
public init(recipientAddress: String) throws {
// Validate email format
guard recipientAddress.isValidEmail() else {
throw SMTPError.invalidEmailAddress("Invalid recipient address: \(recipientAddress)")
}

self.recipientAddress = recipientAddress
}

Expand All @@ -48,4 +53,10 @@ public struct RcptToCommand: SMTPCommand {
throw SMTPError.sendFailed("Invalid recipient email format: \(recipientAddress)")
}
}

func validateResponse(_ response: SMTPResponse) throws {
guard response.code == SMTPResponseCode.commandOK.rawValue else {
throw SMTPError.unexpectedResponse(response)
}
}
}
6 changes: 6 additions & 0 deletions Sources/SwiftSMTP/SMTPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Error types for SMTP operations

import Foundation
import SwiftMailCore

/**
Error types for SMTP operations
Expand All @@ -28,6 +29,9 @@ public enum SMTPError: Error {

/// TLS negotiation failed
case tlsFailed(String)

/// Unexpected response from server
case unexpectedResponse(SMTPResponse)
}

// Add CustomStringConvertible conformance for better error messages
Expand All @@ -48,6 +52,8 @@ extension SMTPError: CustomStringConvertible {
return "SMTP invalid email address: \(reason)"
case .tlsFailed(let reason):
return "SMTP TLS failed: \(reason)"
case .unexpectedResponse(let response):
return "SMTP unexpected response: \(response.code) \(response.message)"
}
}
}
4 changes: 2 additions & 2 deletions Sources/SwiftSMTP/SMTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ public actor SMTPServer {
}

// Send MAIL FROM command using the new command class
let mailFromCommand = MailFromCommand(senderAddress: email.sender.address)
let mailFromCommand = try MailFromCommand(senderAddress: email.sender.address)
let mailFromSuccess = try await executeCommand(mailFromCommand)

// Check if MAIL FROM was accepted
Expand All @@ -342,7 +342,7 @@ public actor SMTPServer {

// Send RCPT TO command for each recipient (To, CC, and BCC) using the new command class
for recipient in email.allRecipients {
let rcptToCommand = RcptToCommand(recipientAddress: recipient.address)
let rcptToCommand = try RcptToCommand(recipientAddress: recipient.address)
let rcptToSuccess = try await executeCommand(rcptToCommand)

// Check if RCPT TO was accepted
Expand Down
51 changes: 51 additions & 0 deletions Tests/SwiftMailCoreTests/String+EmailTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// String+EmailTests.swift
// Tests for email validation String extension

import XCTest
@testable import SwiftMailCore

final class StringEmailTests: XCTestCase {
func testValidEmails() {
// Test basic valid formats
XCTAssertTrue("user@example.com".isValidEmail())
XCTAssertTrue("user.name@example.com".isValidEmail())
XCTAssertTrue("user+tag@example.com".isValidEmail())
XCTAssertTrue("user@subdomain.example.com".isValidEmail())
XCTAssertTrue("123@example.com".isValidEmail())
XCTAssertTrue("user@example.co.uk".isValidEmail())

// Test edge cases that should be valid
XCTAssertTrue("a@b.cc".isValidEmail()) // Minimal length
XCTAssertTrue("disposable.style.email.with+symbol@example.com".isValidEmail())
XCTAssertTrue("other.email-with-hyphen@example.com".isValidEmail())
XCTAssertTrue("fully-qualified-domain@example.com".isValidEmail())
XCTAssertTrue("user.name+tag+sorting@example.com".isValidEmail())
XCTAssertTrue("x@example.com".isValidEmail()) // One-letter local-part
XCTAssertTrue("example-indeed@strange-example.com".isValidEmail())
XCTAssertTrue("example@s.example".isValidEmail()) // Short but valid domain
}

func testInvalidEmails() {
// Test basic invalid formats
XCTAssertFalse("".isValidEmail())
XCTAssertFalse("@example.com".isValidEmail())
XCTAssertFalse("user@".isValidEmail())
XCTAssertFalse("user@.com".isValidEmail())
XCTAssertFalse("user@example".isValidEmail())
XCTAssertFalse("user.example.com".isValidEmail())

// Test invalid characters and formats
XCTAssertFalse("user@exam ple.com".isValidEmail()) // Space in domain
XCTAssertFalse("user@@example.com".isValidEmail()) // Double @
XCTAssertFalse(".user@example.com".isValidEmail()) // Leading dot
XCTAssertFalse("user.@example.com".isValidEmail()) // Trailing dot
XCTAssertFalse("user@example..com".isValidEmail()) // Double dot
XCTAssertFalse("user@-example.com".isValidEmail()) // Leading hyphen in domain
XCTAssertFalse("user@example-.com".isValidEmail()) // Trailing hyphen in domain
XCTAssertFalse("user@.example.com".isValidEmail()) // Leading dot in domain
XCTAssertFalse("user@example.".isValidEmail()) // Trailing dot in domain
XCTAssertFalse("user@ex*ample.com".isValidEmail()) // Invalid character
XCTAssertFalse("user@example.c".isValidEmail()) // TLD too short
XCTAssertFalse("user name@example.com".isValidEmail()) // Space in local part
}
}

0 comments on commit b1312d7

Please sign in to comment.