diff --git a/Sources/SuperwallKit/Identity/Email.swift b/Sources/SuperwallKit/Identity/Email.swift new file mode 100644 index 0000000000..2e915fbef6 --- /dev/null +++ b/Sources/SuperwallKit/Identity/Email.swift @@ -0,0 +1,29 @@ +// +// Email.swift +// SuperwallKit +// + +import Foundation + +/// A validated email address. +/// +/// The failable initializer rejects any string that does not match the +/// pattern expected by the checkout API (`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`). +/// Holding an `Email` instance proves the value was validated — downstream +/// code never needs to re-check. +struct Email: Equatable, Sendable { + let rawValue: String + + private static let regex = try! NSRegularExpression( + pattern: #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"# + ) + + /// Returns `nil` when `rawValue` is not a syntactically valid email address. + init?(_ rawValue: String) { + let range = NSRange(rawValue.startIndex..., in: rawValue) + guard Self.regex.firstMatch(in: rawValue, range: range) != nil else { + return nil + } + self.rawValue = rawValue + } +} diff --git a/Sources/SuperwallKit/Identity/UserAttributes.swift b/Sources/SuperwallKit/Identity/UserAttributes.swift index f3e390c4ac..6ff8d8e2a3 100644 --- a/Sources/SuperwallKit/Identity/UserAttributes.swift +++ b/Sources/SuperwallKit/Identity/UserAttributes.swift @@ -94,11 +94,39 @@ extension Superwall { continue } if JSONSerialization.isValidJSONObject([key: value]) { - customAttributes[key] = value + customAttributes[key] = Self.sanitizeAttribute(key: key, value: value) } } } dependencyContainer.identityManager.mergeUserAttributes(customAttributes) } + + /// Validates attribute values that have server-side schema constraints. + /// + /// The checkout API rejects `context.identity.email` unless it is either a + /// valid email address or `null`. Apps that set a placeholder like `"none"` + /// would silently break the Stripe checkout flow, so the SDK parses the + /// value through ``Email`` and drops it when invalid. + static func sanitizeAttribute(key: String, value: Any?) -> Any? { + guard let stringValue = value as? String else { + return value + } + + switch key { + case "email": + guard let email = Email(stringValue) else { + Logger.debug( + logLevel: .warn, + scope: .identityManager, + message: "Invalid email user attribute \"\(stringValue)\" — sending null to server" + ) + return nil + } + return email.rawValue + + default: + return value + } + } } diff --git a/Tests/SuperwallKitTests/Identity/EmailTests.swift b/Tests/SuperwallKitTests/Identity/EmailTests.swift new file mode 100644 index 0000000000..35c9444614 --- /dev/null +++ b/Tests/SuperwallKitTests/Identity/EmailTests.swift @@ -0,0 +1,131 @@ +// +// EmailTests.swift +// SuperwallKit +// + +import Testing +@testable import SuperwallKit + +@Suite("Email") +struct EmailTests { + + // MARK: - Valid emails + + @Test("accepts a simple email address") + func `simple email`() { + #expect(Email("user@example.com") != nil) + } + + @Test("accepts email with dots in local part") + func `dotted local part`() { + #expect(Email("first.last@domain.co") != nil) + } + + @Test("accepts email with plus tag") + func `plus tag`() { + #expect(Email("user+tag@example.com") != nil) + } + + @Test("accepts email with subdomain") + func `subdomain`() { + #expect(Email("user@mail.example.co.uk") != nil) + } + + @Test("accepts email with numbers") + func `numbers`() { + #expect(Email("user123@domain456.com") != nil) + } + + @Test("accepts email with hyphen in domain") + func `hyphenated domain`() { + #expect(Email("user@my-domain.com") != nil) + } + + @Test("accepts email with underscore and percent") + func `underscore and percent`() { + #expect(Email("user_%name@domain.org") != nil) + } + + @Test("preserves the raw value on success") + func `preserves raw value`() { + let address = "hello@world.com" + let email = Email(address) + #expect(email?.rawValue == address) + } + + // MARK: - Invalid emails + + @Test( + "rejects strings that are not valid email addresses", + arguments: [ + "none", + "", + "userexample.com", + "user@", + "@example.com", + "user@domain", + "user@domain.a", + "user @example.com", + "not an email", + "null", + "N/A", + ] + ) + func `rejects invalid value`(value: String) { + #expect(Email(value) == nil) + } + + // MARK: - Equatable + + @Test("two emails with the same address are equal") + func `equal emails`() { + #expect(Email("a@b.com") == Email("a@b.com")) + } + + @Test("two emails with different addresses are not equal") + func `different emails`() { + #expect(Email("a@b.com") != Email("x@y.com")) + } +} + +// MARK: - sanitizeAttribute + +@Suite("Superwall.sanitizeAttribute") +struct SanitizeAttributeTests { + + @Test("passes a valid email through unchanged") + func `valid email passes through`() { + let result = Superwall.sanitizeAttribute(key: "email", value: "user@example.com") + #expect(result as? String == "user@example.com") + } + + @Test("replaces the placeholder 'none' with nil for the email key") + func `none placeholder becomes nil`() { + let result = Superwall.sanitizeAttribute(key: "email", value: "none") + #expect(result == nil) + } + + @Test("replaces an empty string with nil for the email key") + func `empty string becomes nil`() { + let result = Superwall.sanitizeAttribute(key: "email", value: "") + #expect(result == nil) + } + + @Test("does not sanitize non-email keys") + func `non email key untouched`() { + let result = Superwall.sanitizeAttribute(key: "name", value: "none") + #expect(result as? String == "none") + } + + @Test("does not sanitize non-string values on the email key") + func `non string value untouched`() { + let result = Superwall.sanitizeAttribute(key: "email", value: 42) + #expect(result as? Int == 42) + } + + @Test("passes nil through unchanged") + func `nil value passes through`() { + let result = Superwall.sanitizeAttribute(key: "email", value: nil) + #expect(result == nil) + } +}