diff --git a/Feature/Package.swift b/Feature/Package.swift index 113d394e..92bb1a7a 100644 --- a/Feature/Package.swift +++ b/Feature/Package.swift @@ -105,7 +105,8 @@ let package: Package = Package( .target( name: "JMAP", dependencies: [ - "EmailAddress" + "EmailAddress", + "MIME" ]), .testTarget( name: "JMAPTests", diff --git a/Feature/Sources/IMAP/IMAPClient.swift b/Feature/Sources/IMAP/IMAPClient.swift index 5d28b502..923697b0 100644 --- a/Feature/Sources/IMAP/IMAPClient.swift +++ b/Feature/Sources/IMAP/IMAPClient.swift @@ -8,7 +8,7 @@ import OSLog public class IMAPClient { public let server: Server - public var capabilities: Set = [] + public private(set) var capabilities: Set = [] public var isConnected: Bool { channel != nil && channel!.isActive } public func isSupported(_ capability: Capability) -> Bool { diff --git a/Feature/Sources/JMAP/Account.swift b/Feature/Sources/JMAP/Account.swift index f74c62a0..ef0a8524 100644 --- a/Feature/Sources/JMAP/Account.swift +++ b/Feature/Sources/JMAP/Account.swift @@ -1,5 +1,3 @@ -import Foundation - /// Accounts are a component of the JMAP ``Session`` object, part of [JMAP core.](https://jmap.io/spec-core.html#the-jmap-session-resource) public struct Account: CustomStringConvertible, Decodable, Sendable { public let name: String diff --git a/Feature/Sources/JMAP/Authorization.swift b/Feature/Sources/JMAP/Authorization.swift new file mode 100644 index 00000000..43014c66 --- /dev/null +++ b/Feature/Sources/JMAP/Authorization.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Configure ``Server`` with credentials or token for appropriate HTTP authentication scheme. +public enum Authorization: CustomStringConvertible, Equatable, Sendable { + case basic(_ user: String, _ password: String) + case bearer(_ token: String) + + public static var empty: Self { .bearer("") } + + public var label: String { + switch self { + case .basic: "basic" + case .bearer: "bearer" + } + } + + public var isEmpty: Bool { + switch self { + case .basic: token == "Og==" + case .bearer: token.isEmpty + } + } + + var token: String { + switch self { + case .basic(let user, let password): "\(user):\(password)".base64Encoded() + case .bearer(let token): token + } + } + + // MARK: CustomStringConvertible + public var description: String { "\(label.capitalized) \(token)" } + + // MARK: Equatable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.description == rhs.description + } +} + +extension String { + func base64Encoded() -> Self { + data(using: .utf8)!.base64EncodedString() + } +} diff --git a/Feature/Sources/JMAP/Email.swift b/Feature/Sources/JMAP/Email.swift index ab9e7603..9937ac0b 100644 --- a/Feature/Sources/JMAP/Email.swift +++ b/Feature/Sources/JMAP/Email.swift @@ -1,7 +1,7 @@ import Foundation import UniformTypeIdentifiers -/// Email represents a single message, pre-decoded and modeled, no client-side MIME parsing required; part of [JMAP mail protocol.](https://jmap.io/spec-mail.html#emails) +/// `Email` represents a single message, pre-decoded and modeled, no client-side MIME parsing required; part of [JMAP mail protocol.](https://jmap.io/spec-mail.html#emails) public struct Email: Decodable, Equatable, Hashable, Identifiable, Sendable { public struct Address: CustomStringConvertible, Decodable, Equatable, Sendable { public let email: String diff --git a/Feature/Sources/JMAP/JMAPClient.swift b/Feature/Sources/JMAP/JMAPClient.swift new file mode 100644 index 00000000..fefd6efc --- /dev/null +++ b/Feature/Sources/JMAP/JMAPClient.swift @@ -0,0 +1,136 @@ +import Foundation +import OSLog + +/// Configure `JMAPClient` with a single ``Server``. +public class JMAPClient { + + /// Convenience + /// - Parameter server: ``Server`` configuration for JMAP service provider + /// - Returns: `JMAPClient` with an authenticated ``Session`` already started + public static func session(_ server: Server) async throws -> Self { + let client: Self = Self(server) + try await client.session() // Start session + return client + } + + public let server: Server + + public func thread(for email: Email) async throws -> [Email] { + if let session { + guard let id: String = session.accounts.keys.first else { + throw JMAPError.method(.accountNotFound) + } + guard + let response: MethodGetResponse = try await URLSession.shared.jmapAPI( + [ + Thread.GetMethod( + id, + ids: [ + email.threadID + ]) + ], url: session.apiURL, authorization: server.authorization! + ).first as? MethodGetResponse + else { + throw URLError(.cannotDecodeContentData) + } + let threads: [JMAP.Thread] = try response.decode([JMAP.Thread].self) + guard let ids: [String] = threads.first?.emailIDs, + !ids.isEmpty + else { + throw URLError(.cannotDecodeContentData) + } + return try await emails(ids) + } else { + try await session() + return try await thread(for: email) + } + } + + public func emails(in mailbox: Mailbox) async throws -> [Email] { + if let session { + guard let id: String = session.accounts.keys.first else { + throw JMAPError.method(.accountNotFound) + } + guard + let response: MethodQueryResponse = try await URLSession.shared.jmapAPI( + [ + Email.QueryMethod(id, filter: .inMailbox(mailbox.id)) + ], url: session.apiURL, authorization: server.authorization! + ).first as? MethodQueryResponse + else { + throw JMAPError.underlying(URLError(.cannotDecodeContentData)) + } + return try await emails(response.ids) + } else { + try await session() + return try await emails(in: mailbox) + } + } + + public func emails(_ ids: [String]) async throws -> [Email] { + if let session { + guard let id: String = session.accounts.keys.first else { + throw JMAPError.method(.accountNotFound) + } + guard + let response: MethodGetResponse = try await URLSession.shared.jmapAPI( + [ + Email.GetMethod(id, ids: ids) + ], url: session.apiURL, authorization: server.authorization! + ).first as? MethodGetResponse + else { + throw URLError(.cannotDecodeContentData) + } + return try response.decode([Email].self) + } else { + try await session() + return try await emails(ids) + } + } + + public func mailboxes() async throws -> [Mailbox] { + if let session { + guard let id: String = session.accounts.keys.first else { + throw JMAPError.method(.accountNotFound) + } + guard + let response: MethodGetResponse = try await URLSession.shared.jmapAPI( + [ + Mailbox.GetMethod(id) + ], url: session.apiURL, authorization: server.authorization! + ).first as? MethodGetResponse + else { + throw JMAPError.underlying(URLError(.cannotDecodeContentData)) + } + return try response.decode([Mailbox].self) + } else { + try await session() + return try await mailboxes() + } + } + + @discardableResult public func session() async throws -> Session { + let session: Session = try await URLSession.shared.jmapSession(server: server) + self.session = session + return session + } + + required public init( + _ server: Server, + logger: Logger? = Logger(subsystem: "net.thunderbird", category: "JMAP") + ) { + self.server = server + self.logger = logger + } + + private(set) var session: Session? + private let logger: Logger? +} + +extension Filter { + static func inMailbox(_ id: String) -> Self { + Self([ + Email.Condition.inMailbox(id) + ]) + } +} diff --git a/Feature/Sources/JMAP/JMAPError.swift b/Feature/Sources/JMAP/JMAPError.swift new file mode 100644 index 00000000..401e0d66 --- /dev/null +++ b/Feature/Sources/JMAP/JMAPError.swift @@ -0,0 +1,36 @@ +/// ``JMAPClient`` throws `JMAPError`. +public enum JMAPError: Error, CustomStringConvertible, Equatable { + case method(_ error: MethodError) + case request(_ error: RequestError) + case set(_ error: SetError) + /// Wrap HTTP and other underlying system errors. + case underlying(_ error: Error) + + // Convenience convert any Error to `JMAPError` + init(_ error: Error) { + if let error: MethodError = error as? MethodError { + self = .method(error) + } else if let error: SetError = error as? SetError { + self = .set(error) + } else if let error: Self = error as? Self { + self = error + } else { + self = .underlying(error) + } + } + + // MARK: CustomStringConvertible + public var description: String { + switch self { + case .method(let error): "Method error: \(error)" + case .request(let error): "Request error: \(error)" + case .set(let error): "Set error: \(error)" + case .underlying(let error): "Underlying error: \(error.localizedDescription)" + } + } + + // MARK: Equatable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.description == rhs.description + } +} diff --git a/Feature/Sources/JMAP/Foundation/JSONDecoder.swift b/Feature/Sources/JMAP/JSONDecoder.swift similarity index 72% rename from Feature/Sources/JMAP/Foundation/JSONDecoder.swift rename to Feature/Sources/JMAP/JSONDecoder.swift index 6735d602..cf2644a5 100644 --- a/Feature/Sources/JMAP/Foundation/JSONDecoder.swift +++ b/Feature/Sources/JMAP/JSONDecoder.swift @@ -2,7 +2,7 @@ import Foundation extension JSONDecoder { - /// https://jmap.io/spec-core.html#the-date-and-utcdate-data-types + // https://jmap.io/spec-core.html#the-date-and-utcdate-data-types convenience init(date decodingStrategy: DateDecodingStrategy) { self.init() dateDecodingStrategy = decodingStrategy diff --git a/Feature/Sources/JMAP/Foundation/Locale.swift b/Feature/Sources/JMAP/Locale.swift similarity index 61% rename from Feature/Sources/JMAP/Foundation/Locale.swift rename to Feature/Sources/JMAP/Locale.swift index 2e976f5a..97aad691 100644 --- a/Feature/Sources/JMAP/Foundation/Locale.swift +++ b/Feature/Sources/JMAP/Locale.swift @@ -2,10 +2,10 @@ import Foundation extension Locale { - /// Generate a weighted `Accept-Language` HTTP request header from device user's configured locales. + /// Generate a weighted `Accept-Language` HTTP request header matching the device's configured locales. /// - /// JMAP service should [localize user-facing strings](https://jmap.io/spec-core.html#localisation-of-user-visible-strings) according to header value. - static func acceptedLanguages() -> String { + /// JMAP services _should_ [localize user-facing strings](https://jmap.io/spec-core.html#localisation-of-user-visible-strings) according to header value. + public static func acceptedLanguages() -> String { var languages: [String] = [] for (index, language) in preferredLanguages.prefix(5).enumerated() { languages.append("\(language)\(index > 0 ? ";q=0.\(10 - index)" : "")") diff --git a/Feature/Sources/JMAP/Method.swift b/Feature/Sources/JMAP/Method.swift index b97e6cd5..9cc5abf4 100644 --- a/Feature/Sources/JMAP/Method.swift +++ b/Feature/Sources/JMAP/Method.swift @@ -4,6 +4,7 @@ import Foundation public protocol Method: Identifiable { static var name: String { get } var accountID: String { get } + /// Default implementation: `[.core, .mail]` var using: [Capability.Key] { get } var object: [Any] { get } diff --git a/Feature/Sources/JMAP/MethodError.swift b/Feature/Sources/JMAP/MethodError.swift index a6467122..8c16691c 100644 --- a/Feature/Sources/JMAP/MethodError.swift +++ b/Feature/Sources/JMAP/MethodError.swift @@ -9,7 +9,19 @@ public enum MethodError: String, CaseIterable, CustomStringConvertible, Decodabl case unknownMethod // MARK: CustomStringConvertible - public var description: String { rawValue } + public var description: String { + switch self { + case .accountNotFound: "Account not found" + case .accountNotSupportedByMethod: "Account not supported by method" + case .accountReadOnly: "Account read only" + case .forbidden: "Forbidden" + case .invalidArguments: "Invalid arguments" + case .invalidResultReference: "Invalid result reference" + case .serverFail, .serverPartialFail: "Server fail" + case .serverUnavailable: "Sever unavailable" + case .unknownMethod: "Unknown method" + } + } // MARK: Decodable public init(from decoder: any Decoder) throws { diff --git a/Feature/Sources/JMAP/RequestError.swift b/Feature/Sources/JMAP/RequestError.swift index d79d5a91..8a03e93c 100644 --- a/Feature/Sources/JMAP/RequestError.swift +++ b/Feature/Sources/JMAP/RequestError.swift @@ -1,5 +1,7 @@ /// Request-level configuration errors, part of [JMAP core](https://jmap.io/spec-core.html#errors) public struct RequestError: Error, Decodable, CustomStringConvertible { + + /// Enumerated error code derived from `type` raw value public enum Code: String, CaseIterable, Codable, CustomStringConvertible, Identifiable, Sendable { case limit = "urn:ietf:params:jmap:error:limit" case notJSON = "urn:ietf:params:jmap:error:notJSON" @@ -7,19 +9,32 @@ public struct RequestError: Error, Decodable, CustomStringConvertible { case unknownCapability = "urn:ietf:params:jmap:error:unknownCapability" // MARK: CustomStringConvertible - public var description: String { rawValue.components(separatedBy: ":").last! } + public var description: String { + switch self { + case .limit: "Limit" + case .notJSON: "Not JSON" + case .notRequest: "Not request" + case .unknownCapability: "Unknown capability" + } + } // MARK: Identifiable public var id: String { rawValue } } public let code: Code + public let detail: String + + init(_ code: Code, detail: String = "") { + self.code = code + self.detail = detail + } // MARK: Decodable public init(from decoder: any Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: Key.self) code = try container.decode(Code.self, forKey: .type) - description = try container.decode(String.self, forKey: .detail) + detail = try container.decode(String.self, forKey: .detail) } private enum Key: CodingKey { @@ -27,5 +42,5 @@ public struct RequestError: Error, Decodable, CustomStringConvertible { } // MARK: CustomStringConvertible - public let description: String + public var description: String { "\(code)\(detail.isEmpty ? "" : "; \(detail)")" } } diff --git a/Feature/Sources/JMAP/Server.swift b/Feature/Sources/JMAP/Server.swift new file mode 100644 index 00000000..4c22395b --- /dev/null +++ b/Feature/Sources/JMAP/Server.swift @@ -0,0 +1,19 @@ +/// ``JMAPClient`` requests ``Session`` from `Server`. +public struct Server: CustomStringConvertible, Equatable, Sendable { + public let authorization: Authorization? + public let host: String + public let port: Int + + public init( + authorization: Authorization?, + host: String, + port: Int = 443 + ) { + self.authorization = authorization + self.host = host + self.port = port + } + + // MARK: CustomStringConvertible + public var description: String { "\(host):\(port)" } +} diff --git a/Feature/Sources/JMAP/SetError.swift b/Feature/Sources/JMAP/SetError.swift index 2fa51794..196a826e 100644 --- a/Feature/Sources/JMAP/SetError.swift +++ b/Feature/Sources/JMAP/SetError.swift @@ -1,3 +1,5 @@ +import Foundation + /// Errors encountered during a `set` ``Method`` operation, returned exclusively in ``MethodResponse`` public enum SetError: String, CaseIterable, CustomStringConvertible, Error, Identifiable { case forbidden @@ -13,7 +15,12 @@ public enum SetError: String, CaseIterable, CustomStringConvertible, Error, Iden case willDestroy init?(_ value: Any) { - if let value: [String: Any] = value as? [String: Any], + if let data: Data = value as? Data, + let value: Any = try? JSONSerialization.jsonObject(with: data), + let error: Self = Self(value) + { + self = error + } else if let value: [String: Any] = value as? [String: Any], let error: Self = Self(rawValue: value["type"] as? String ?? "") { self = error @@ -25,7 +32,22 @@ public enum SetError: String, CaseIterable, CustomStringConvertible, Error, Iden } // MARK: CustomStringConvertible - public var description: String { rawValue } + public var description: String { + switch self { + case .forbidden: "Forbidden" + case .invalidPatch: "Invalid patch" + case .invalidProperties: "Invalid properties" + case .mailboxHasEmail: "Mailbox has email" + case .notFound: "Not found" + case .overQuota: "Over quota" + case .rateLimit: "Rate limit" + case .requestTooLarge: "Request too large" + case .singleton: "Singleton" + case .stateMismatch: "State mismatch" + case .tooLarge: "Too large" + case .willDestroy: "Will destroy" + } + } // MARK: Identifiable public var id: String { rawValue } diff --git a/Feature/Sources/JMAP/Foundation/URL.swift b/Feature/Sources/JMAP/URL.swift similarity index 69% rename from Feature/Sources/JMAP/Foundation/URL.swift rename to Feature/Sources/JMAP/URL.swift index 6d930eae..650c8f57 100644 --- a/Feature/Sources/JMAP/Foundation/URL.swift +++ b/Feature/Sources/JMAP/URL.swift @@ -2,14 +2,14 @@ import Foundation extension URL { - /// Generate ``Session`` URL from (autodiscovery) components. + /// Generate ``Session`` URL from host and port number. /// /// All additional URLs and URL templates needed to implement a complete JMAP service are provided in the ``Session``. - static func jmapSession(_ host: String, port: Int? = nil) throws -> Self { - try jmap(host, port: port, path: "jmap/session") + public static func jmapSession(host: String, port: Int? = nil) throws -> Self { + try jmap(host: host, port: port, path: "jmap/session") } - static func jmap(_ host: String, port: Int? = nil, path: String? = nil) throws -> Self { + static func jmap(host: String, port: Int? = nil, path: String? = nil) throws -> Self { var components: URLComponents = URLComponents() components.scheme = "https" guard !host.isEmpty else { diff --git a/Feature/Sources/JMAP/Foundation/URLRequest.swift b/Feature/Sources/JMAP/URLRequest.swift similarity index 52% rename from Feature/Sources/JMAP/Foundation/URLRequest.swift rename to Feature/Sources/JMAP/URLRequest.swift index 4602b61a..0ef87855 100644 --- a/Feature/Sources/JMAP/Foundation/URLRequest.swift +++ b/Feature/Sources/JMAP/URLRequest.swift @@ -5,59 +5,69 @@ extension URLRequest { /// Request one or more operations from a JMAP service. /// - Parameter methods: One or more ``Method``s for JMAP service to perform /// - Parameter url: API endpoint URL from ``Session`` - /// - Parameter token: OAuth bearer token to authenticate with service provider + /// - Parameter authorization: ``Authorization`` credentials or token for request header /// - Returns: `URLRequest` configured to POST to JMAP service provider - public static func jmapAPI(_ methods: [any Method], url: URL, authorization: String) throws -> Self { + public static func jmapAPI(_ methods: [any Method], url: URL, authorization: Authorization) throws -> Self { guard !methods.isEmpty else { throw MethodError.unknownMethod } + guard !authorization.isEmpty else { + throw URLError(.userAuthenticationRequired) + } let object: [String: Any] = [ "using": methods.using.map { $0.id }, "methodCalls": methods.map { $0.object } ] var request: Self = Self(url: url) - try request.setJMAPHeaders(authorization: authorization) + request.setJMAPHeaders(authorization: authorization) request.httpMethod = "POST" request.httpBody = try JSONSerialization.data(withJSONObject: object) return request } + /// Request JMAP session object from a service provider. + /// - Parameter server: ``Server`` configuration for JMAP service provider + /// - Returns: `URLRequest` configured to GET an authenticated session with a JMAP service provider + public static func jmapSession(server: Server) throws -> Self { + try jmapSession(host: server.host, port: server.port, authorization: server.authorization ?? .empty) + } + /// Request JMAP session object from a service provider. /// - Parameter host: Host name of the JMAP service provider; e.g., `api.fastmail.com` - /// - Parameter token: OAuth bearer token to authenticate with service provider + /// - Parameter authorization: ``Authorization`` credentials or token for request header /// - Returns: `URLRequest` configured to GET an authenticated session with a JMAP service provider - public static func jmapSession(_ host: String, port: Int? = nil, authorization: String) throws -> Self { - var request: Self = Self(url: try .jmapSession(host, port: port)) - try request.setJMAPHeaders(authorization: authorization) + public static func jmapSession(host: String, port: Int? = nil, authorization: Authorization) throws -> Self { + guard !authorization.isEmpty else { + throw URLError(.userAuthenticationRequired) + } + var request: Self = Self(url: try .jmapSession(host: host, port: port)) + request.setJMAPHeaders(authorization: authorization) return request } - mutating func setJMAPHeaders(authorization value: String) throws { - try setAuthorization(value) + mutating func setJMAPHeaders(authorization: Authorization) { + setAuthorization(authorization) setAcceptLanguage() setContentType() setAccept() } - /// Authorize HTTP request with session OAuth token. - mutating func setAuthorization(_ value: String) throws { - guard !value.isEmpty else { - throw URLError(.userAuthenticationRequired) - } - setValue(value, forHTTPHeaderField: "Authorization") + // Authorize HTTP request with session OAuth token + mutating func setAuthorization(_ value: Authorization) { + setValue(value.description, forHTTPHeaderField: "Authorization") } - /// Ask JMAP server to [localize user-visible strings](https://jmap.io/spec-core.html#localisation-of-user-visible-strings) according to device configured locales. + // Ask JMAP server to [localize user-visible strings](https://jmap.io/spec-core.html#localisation-of-user-visible-strings) according to device's configured locales. mutating func setAcceptLanguage(_ value: String = Locale.acceptedLanguages()) { setValue(value, forHTTPHeaderField: "Accept-Language") } - /// Declare HTTP request body content as JSON. + // Declare HTTP request body content as JSON mutating func setContentType(_ value: String = "application/json; charset=utf-8") { setValue(value, forHTTPHeaderField: "Content-Type") } - /// Ask JMAP server to serialize HTTP response as JSON. + // Ask JMAP server to serialize HTTP response as JSON mutating func setAccept(_ value: String = "application/json") { setValue(value, forHTTPHeaderField: "Accept") } @@ -65,7 +75,7 @@ extension URLRequest { private extension [any Method] { - /// Compile unique list of all capabilities used by methods in request. + // Compile unique list of all capabilities used by methods in request var using: [Capability.Key] { var using: Set = [] for key in map({ $0.using }).joined() { diff --git a/Feature/Sources/JMAP/Foundation/URLSession.swift b/Feature/Sources/JMAP/URLSession.swift similarity index 83% rename from Feature/Sources/JMAP/Foundation/URLSession.swift rename to Feature/Sources/JMAP/URLSession.swift index 2a569e37..3fbee658 100644 --- a/Feature/Sources/JMAP/Foundation/URLSession.swift +++ b/Feature/Sources/JMAP/URLSession.swift @@ -5,9 +5,9 @@ extension URLSession { /// Post methods to JMAP service. /// - Parameter methods: One or more ``Method``s for JMAP service to perform /// - Parameter url: API endpoint URL from ``Session`` - /// - Parameter token: OAuth bearer token to authenticate with service provider + /// - Parameter authorization: ``Authorization`` credentials or token for request header /// - Returns: ``MethodResponse`` - public func jmapAPI(_ methods: [any Method], url: URL, authorization: String) async throws -> [any MethodResponse] { + public func jmapAPI(_ methods: [any Method], url: URL, authorization: Authorization) async throws -> [any MethodResponse] { let data: Data = try await data(for: try .jmapAPI(methods, url: url, authorization: authorization)).0 let object: Any = try JSONSerialization.jsonObject(with: data) @@ -82,12 +82,19 @@ extension URLSession { } } + /// Get JMAP session object from a service provider. + /// - Parameter server: ``Server`` configuration for JMAP service provider + /// - Returns: ``Session`` object containing available account(s), capabilities and service URLs + public func jmapSession(server: Server) async throws -> Session { + try await jmapSession(host: server.host, port: server.port, authorization: server.authorization ?? .empty) + } + /// Get JMAP session object from a service provider. /// - Parameter host: Host name of the JMAP service provider; e.g., `api.fastmail.com` - /// - Parameter token: OAuth bearer token to authenticate with service provider + /// - Parameter authorization: ``Authorization`` credentials or token for request header /// - Returns: ``Session`` object containing available account(s), capabilities and service URLs - public func jmapSession(_ host: String, port: Int? = nil, authorization: String) async throws -> Session { - let response: (Data, URLResponse) = try await data(for: try .jmapSession(host, port: port, authorization: authorization)) + public func jmapSession(host: String, port: Int? = nil, authorization: Authorization) async throws -> Session { + let response: (Data, URLResponse) = try await data(for: try .jmapSession(host: host, port: port, authorization: authorization)) switch (response.1 as? HTTPURLResponse)?.statusCode { case 401: throw URLError(.userAuthenticationRequired) diff --git a/Feature/Tests/JMAPTests/AccountTests.swift b/Feature/Tests/JMAPTests/AccountTests.swift index 6529b82b..cd904cf0 100644 --- a/Feature/Tests/JMAPTests/AccountTests.swift +++ b/Feature/Tests/JMAPTests/AccountTests.swift @@ -3,9 +3,11 @@ import Foundation import Testing struct AccountTests { + + // MARK: Decodable @Test func decoderInit() throws { let account: Account = try JSONDecoder().decode(Account.self, from: data) - #expect(account.name == "toddheasley@fastmail.com") + #expect(account.name == "user@example.com") #expect(account.capabilities.count == 3) #expect(account.capabilities[.mail]?.maxSizeMailboxName == 490) #expect(account.capabilities[.mail]?.mayCreateTopLevelMailbox == true) @@ -45,9 +47,9 @@ private let data: Data = """ "maxMailboxDepth": null, "maxMailboxesPerEmail": 1000 }, - "https://www.fastmail.com/dev/maskedemail": {} + "https://www.example.com/dev/maskedemail": {} }, - "name": "toddheasley@fastmail.com", + "name": "user@example.com", "isReadOnly": false, "isPersonal": true } diff --git a/Feature/Tests/JMAPTests/AuthorizationTests.swift b/Feature/Tests/JMAPTests/AuthorizationTests.swift new file mode 100644 index 00000000..4022551f --- /dev/null +++ b/Feature/Tests/JMAPTests/AuthorizationTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import JMAP +import Testing + +struct AuthorizationTests { + @Test func token() { + #expect(Authorization.basic("user@example.com", "fAK3-PASs-w0rD").token == "dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA==") + #expect(Authorization.basic("", "").token == "Og==") + #expect(Authorization.bearer("dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA").token == "dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA") + #expect(Authorization.bearer("").token == "") + } + + @Test func isEmpty() { + #expect(Authorization.basic("user@example.com", "fAK3-PASs-w0rD").isEmpty == false) + #expect(Authorization.basic("", "f").isEmpty == false) + #expect(Authorization.basic("u", "").isEmpty == false) + #expect(Authorization.basic("", "").isEmpty == true) + #expect(Authorization.bearer("dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA").isEmpty == false) + #expect(Authorization.bearer("d").isEmpty == false) + #expect(Authorization.bearer("").isEmpty == true) + } + + // MARK: CustomStringConvertible + @Test func description() { + #expect(Authorization.basic("user@example.com", "fAK3-PASs-w0rD").description == "Basic dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA==") + #expect(Authorization.basic("", "").description == "Basic Og==") + #expect(Authorization.bearer("dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA").description == "Bearer dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA") + #expect(Authorization.bearer("").description == "Bearer ") + } +} + +struct StringTests { + @Test func base64Encoded() { + #expect("user@example.com:fAK3-PASs-w0rD".base64Encoded() == "dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA==") + let data: Data = Data(base64Encoded: "dXNlckBleGFtcGxlLmNvbTpmQUszLVBBU3MtdzByRA==")! + #expect(String(data: data, encoding: .utf8) == "user@example.com:fAK3-PASs-w0rD") + #expect(":".base64Encoded() == "Og==") + #expect("".base64Encoded() == "") + } +} diff --git a/Feature/Tests/JMAPTests/EmailTests.swift b/Feature/Tests/JMAPTests/EmailTests.swift index 22f8d63e..10729280 100644 --- a/Feature/Tests/JMAPTests/EmailTests.swift +++ b/Feature/Tests/JMAPTests/EmailTests.swift @@ -32,12 +32,12 @@ struct EmailTests { #expect(emails[0].sender == nil) #expect( emails[0].from == [ - Email.Address("theasley@thunderbird.net", name: "Todd Heasley") + Email.Address("user@example.com", name: "Example User") ]) #expect(emails[0].replyTo == nil) #expect( emails[0].to == [ - Email.Address("toddheasley@fastmail.com") + Email.Address("recipient@example.com") ]) #expect(emails[0].cc == nil) #expect(emails[0].bcc == nil) @@ -99,12 +99,12 @@ struct EmailTests { #expect(emails[1].sender == nil) #expect( emails[1].from == [ - Email.Address("toddheasley@fastmail.com", name: "Todd Heasley") + Email.Address("recipient@example.com", name: "Example User") ]) #expect(emails[1].replyTo == nil) #expect( emails[1].to == [ - Email.Address("theasley@thunderbird.net", name: "Todd Heasley") + Email.Address("user@example.com", name: "Example User") ]) #expect(emails[1].cc == nil) #expect(emails[1].bcc == nil) @@ -127,7 +127,7 @@ struct EmailTests { ]) #expect(emails[1].attachments == []) #expect(emails[1].hasAttachment == false) - #expect(emails[1].preview == "This is a test. On Fri, Jun 27, 2025, at 4:52 PM, Todd Heasley wrote:") + #expect(emails[1].preview == "This is a test. On Fri, Jun 27, 2025, at 4:52 PM, Example User wrote:") } @Test func filterConditionObject() { @@ -219,7 +219,7 @@ private let data: Data = """ "size": 272443, "to": [ { - "email": "toddheasley@fastmail.com", + "email": "recipient@example.com", "name": null } ], @@ -240,8 +240,8 @@ private let data: Data = """ "sender": null, "from": [ { - "email": "theasley@thunderbird.net", - "name": "Todd Heasley" + "email": "user@example.com", + "name": "Example User" } ], "references": null, @@ -255,7 +255,7 @@ private let data: Data = """ "inReplyTo": [ "104F9518-F8FD-4C84-9491-6A887D865DCC@me.com" ], - "preview": "This is a test. On Fri, Jun 27, 2025, at 4:52 PM, Todd Heasley wrote:", + "preview": "This is a test. On Fri, Jun 27, 2025, at 4:52 PM, Example User wrote:", "blobId": "Gd80606e8b5e8b03eba586b5febbdf57b75183d6e", "textBody": [ { @@ -305,8 +305,8 @@ private let data: Data = """ "replyTo": null, "to": [ { - "name": "Todd Heasley", - "email": "theasley@thunderbird.net" + "name": "Example User", + "email": "user@example.com" } ], "attachments": [ @@ -315,8 +315,8 @@ private let data: Data = """ "sender": null, "from": [ { - "name": "Todd Heasley", - "email": "toddheasley@fastmail.com" + "name": "Example User", + "email": "recipient@example.com" } ], "receivedAt": "2025-06-28T15:13:45Z", diff --git a/Feature/Tests/JMAPTests/FoundationTests/URLTests.swift b/Feature/Tests/JMAPTests/FoundationTests/URLTests.swift deleted file mode 100644 index e986a581..00000000 --- a/Feature/Tests/JMAPTests/FoundationTests/URLTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -@testable import JMAP -import Testing - -struct URLTests { - @Test func jmapSession() throws { - #expect(try URL.jmapSession("api.fastmail.com").absoluteString == "https://api.fastmail.com/jmap/session") - #expect(throws: URLError.self) { - try URL.jmapSession("") - } - } - - @Test func jmap() throws { - #expect(try URL.jmap("api.fastmail.com").absoluteString == "https://api.fastmail.com") - #expect(try URL.jmap("api.fastmail.com", path: "/jmap/session").absoluteString == "https://api.fastmail.com/jmap/session") - #expect(try URL.jmap("api.fastmail.com", path: "jmap/session").absoluteString == "https://api.fastmail.com/jmap/session") - #expect(throws: URLError.self) { - try URL.jmap("") - } - } -} diff --git a/Feature/Tests/JMAPTests/JMAPClientTests.swift b/Feature/Tests/JMAPTests/JMAPClientTests.swift new file mode 100644 index 00000000..d21af123 --- /dev/null +++ b/Feature/Tests/JMAPTests/JMAPClientTests.swift @@ -0,0 +1,16 @@ +@testable import JMAP +import Testing + +struct JMAPClientTests { + @Test(arguments: Server.allCases(disabled: false)) func allMethods(_ server: Server) async throws { + let client: JMAPClient = try await .session(server) + #expect(client.session != nil) + let mailboxes: [Mailbox] = try await client.mailboxes() + #expect(mailboxes.count > 0) + #expect(mailboxes.compactMap { $0.role }.contains(.inbox) == true) + let emails: [Email] = try await client.emails(in: mailboxes.first!) + for email in emails { + print(email) + } + } +} diff --git a/Feature/Tests/JMAPTests/JMAPErrorTests.swift b/Feature/Tests/JMAPTests/JMAPErrorTests.swift new file mode 100644 index 00000000..92810ef4 --- /dev/null +++ b/Feature/Tests/JMAPTests/JMAPErrorTests.swift @@ -0,0 +1,15 @@ +import Foundation +@testable import JMAP +import Testing + +struct JMAPErrorTests { + + // MARK: CustomStringConvertible + @Test func description() { + #expect(JMAPError.method(.accountNotFound).description == "Method error: Account not found") + #expect(JMAPError.request(RequestError(.notRequest, detail: "unrecognized value")).description == "Request error: Not request; unrecognized value") + #expect(JMAPError.request(RequestError(.unknownCapability)).description == "Request error: Unknown capability") + #expect(JMAPError.set(.requestTooLarge).description == "Set error: Request too large") + #expect(JMAPError.underlying(URLError(.cannotConnectToHost)).description == "Underlying error: The operation couldn’t be completed. (NSURLErrorDomain error -1004.)") + } +} diff --git a/Feature/Tests/JMAPTests/FoundationTests/JSONDecoderTests.swift b/Feature/Tests/JMAPTests/JSONDecoderTests.swift similarity index 77% rename from Feature/Tests/JMAPTests/FoundationTests/JSONDecoderTests.swift rename to Feature/Tests/JMAPTests/JSONDecoderTests.swift index 4dbce6d3..842b1ad6 100644 --- a/Feature/Tests/JMAPTests/FoundationTests/JSONDecoderTests.swift +++ b/Feature/Tests/JMAPTests/JSONDecoderTests.swift @@ -11,11 +11,11 @@ struct JSONDecoderTests { #expect(dates[2].timeIntervalSince1970 == 1751123625.0) } } - +// swift-format-ignore private let data: Data = """ - [ - "2025-06-27T16:52:45-04:00", - "2025-06-28T11:13:45-04:00", - "2025-06-28T15:13:45Z" - ] - """.data(using: .utf8)! +[ + "2025-06-27T16:52:45-04:00", + "2025-06-28T11:13:45-04:00", + "2025-06-28T15:13:45Z" +] +""".data(using: .utf8)! diff --git a/Feature/Tests/JMAPTests/FoundationTests/LocaleTests.swift b/Feature/Tests/JMAPTests/LocaleTests.swift similarity index 100% rename from Feature/Tests/JMAPTests/FoundationTests/LocaleTests.swift rename to Feature/Tests/JMAPTests/LocaleTests.swift diff --git a/Feature/Tests/JMAPTests/MailboxTests.swift b/Feature/Tests/JMAPTests/MailboxTests.swift index 9cf50f98..58b0ef90 100644 --- a/Feature/Tests/JMAPTests/MailboxTests.swift +++ b/Feature/Tests/JMAPTests/MailboxTests.swift @@ -3,6 +3,8 @@ import Foundation import Testing struct MailboxTests { + + // MARK: Decodable @Test func decoderInit() throws { let mailboxes: [Mailbox] = try JSONDecoder().decode([Mailbox].self, from: data) try #require(mailboxes.count == 6) @@ -11,7 +13,9 @@ struct MailboxTests { #expect(mailboxes[5].name == "Trash") #expect(mailboxes[5].role == .trash) } +} +extension MailboxTests { @Test func getMethod() throws { let ids: [String] = [ "Me542737e24136513aaee4d41", @@ -54,7 +58,9 @@ struct MailboxTests { ] ).actions.count == 1) } +} +extension MailboxTests { @Test func filterConditionObject() { #expect(Mailbox.Condition.parentId("M56e3027f5b7cdfa3c2ce53ff").object["parentId"] as? String == "M56e3027f5b7cdfa3c2ce53ff") #expect(Mailbox.Condition.name("Archived").object["name"] as? String == "Archived") @@ -72,238 +78,238 @@ struct MailboxTests { } } -// swift-format-ignore private let id: UUID = UUID(uuidString: "AFF25F76-8105-413F-9CF7-F66B7703B8BD")! +// swift-format-ignore private let data: Data = """ - [ - { - "totalEmails": 2, - "myRights": { - "maySubmit": true, - "mayDelete": false, - "mayReadItems": true, - "mayAddItems": true, - "mayAdmin": true, - "maySetKeywords": true, - "mayRename": false, - "mayRemoveItems": true, - "mayCreateChild": true, - "maySetSeen": true - }, - "unreadEmails": 0, - "totalThreads": 2, - "hidden": 0, - "isCollapsed": false, - "sort": [ - { - "isAscending": false, - "property": "receivedAt" - } - ], - "sortOrder": 1, - "name": "Inbox", - "autoPurge": false, - "id": "dc6a40aa-8657-4f74-9aaa-7046ca01325b", - "autoLearn": false, - "parentId": null, - "suppressDuplicates": true, - "isSubscribed": true, - "role": "inbox", - "identityRef": null, - "purgeOlderThanDays": 31, - "unreadThreads": 0, - "learnAsSpam": false +[ + { + "totalEmails": 2, + "myRights": { + "maySubmit": true, + "mayDelete": false, + "mayReadItems": true, + "mayAddItems": true, + "mayAdmin": true, + "maySetKeywords": true, + "mayRename": false, + "mayRemoveItems": true, + "mayCreateChild": true, + "maySetSeen": true }, - { - "totalEmails": 0, - "myRights": { - "mayCreateChild": true, - "mayDelete": true, - "mayReadItems": true, - "mayAddItems": true, - "mayAdmin": true, - "maySetKeywords": true, - "mayRename": true, - "mayRemoveItems": true, - "maySubmit": true, - "maySetSeen": true - }, - "unreadEmails": 0, - "isCollapsed": false, - "totalThreads": 0, - "hidden": 0, - "sort": [ - { - "isAscending": false, - "property": "receivedAt" - } - ], - "sortOrder": 3, - "name": "Archive", - "autoPurge": false, - "id": "f68852f4-f531-46f5-b279-1cfb09858476", - "autoLearn": false, - "parentId": null, - "suppressDuplicates": true, - "isSubscribed": true, - "role": "archive", - "identityRef": null, - "unreadThreads": 0, - "purgeOlderThanDays": 31, - "learnAsSpam": false + "unreadEmails": 0, + "totalThreads": 2, + "hidden": 0, + "isCollapsed": false, + "sort": [ + { + "isAscending": false, + "property": "receivedAt" + } + ], + "sortOrder": 1, + "name": "Inbox", + "autoPurge": false, + "id": "dc6a40aa-8657-4f74-9aaa-7046ca01325b", + "autoLearn": false, + "parentId": null, + "suppressDuplicates": true, + "isSubscribed": true, + "role": "inbox", + "identityRef": null, + "purgeOlderThanDays": 31, + "unreadThreads": 0, + "learnAsSpam": false + }, + { + "totalEmails": 0, + "myRights": { + "mayCreateChild": true, + "mayDelete": true, + "mayReadItems": true, + "mayAddItems": true, + "mayAdmin": true, + "maySetKeywords": true, + "mayRename": true, + "mayRemoveItems": true, + "maySubmit": true, + "maySetSeen": true }, - { - "totalEmails": 0, - "myRights": { - "mayRemoveItems": true, - "mayDelete": true, - "maySetSeen": true, - "mayAddItems": true, - "mayAdmin": true, - "maySetKeywords": true, - "mayRename": true, - "mayReadItems": true, - "maySubmit": true, - "mayCreateChild": true - }, - "unreadEmails": 0, - "isCollapsed": false, - "totalThreads": 0, - "hidden": 0, - "sort": [ - { - "isAscending": false, - "property": "receivedAt" - } - ], - "sortOrder": 4, - "name": "Drafts", - "autoPurge": false, - "id": "52fad03c-e7f0-4622-90f4-9d449bffd61c", - "autoLearn": false, - "parentId": null, - "suppressDuplicates": true, - "isSubscribed": true, - "role": "drafts", - "identityRef": null, - "purgeOlderThanDays": 31, - "unreadThreads": 0, - "learnAsSpam": false + "unreadEmails": 0, + "isCollapsed": false, + "totalThreads": 0, + "hidden": 0, + "sort": [ + { + "isAscending": false, + "property": "receivedAt" + } + ], + "sortOrder": 3, + "name": "Archive", + "autoPurge": false, + "id": "f68852f4-f531-46f5-b279-1cfb09858476", + "autoLearn": false, + "parentId": null, + "suppressDuplicates": true, + "isSubscribed": true, + "role": "archive", + "identityRef": null, + "unreadThreads": 0, + "purgeOlderThanDays": 31, + "learnAsSpam": false + }, + { + "totalEmails": 0, + "myRights": { + "mayRemoveItems": true, + "mayDelete": true, + "maySetSeen": true, + "mayAddItems": true, + "mayAdmin": true, + "maySetKeywords": true, + "mayRename": true, + "mayReadItems": true, + "maySubmit": true, + "mayCreateChild": true }, - { - "totalEmails": 0, - "myRights": { - "maySubmit": true, - "mayDelete": true, - "mayReadItems": true, - "mayAddItems": true, - "mayAdmin": true, - "maySetKeywords": true, - "mayRename": true, - "mayRemoveItems": true, - "mayCreateChild": true, - "maySetSeen": true - }, - "unreadEmails": 0, - "totalThreads": 0, - "hidden": 0, - "isCollapsed": false, - "sort": [ - { - "isAscending": false, - "property": "receivedAt" - } - ], - "sortOrder": 5, - "name": "Sent", - "autoPurge": false, - "id": "0faefa8a-8cfa-4562-be2a-a5d25381bbfa", - "autoLearn": false, - "parentId": null, - "suppressDuplicates": true, - "isSubscribed": true, - "role": "sent", - "identityRef": null, - "purgeOlderThanDays": 31, - "unreadThreads": 0, - "learnAsSpam": false + "unreadEmails": 0, + "isCollapsed": false, + "totalThreads": 0, + "hidden": 0, + "sort": [ + { + "isAscending": false, + "property": "receivedAt" + } + ], + "sortOrder": 4, + "name": "Drafts", + "autoPurge": false, + "id": "52fad03c-e7f0-4622-90f4-9d449bffd61c", + "autoLearn": false, + "parentId": null, + "suppressDuplicates": true, + "isSubscribed": true, + "role": "drafts", + "identityRef": null, + "purgeOlderThanDays": 31, + "unreadThreads": 0, + "learnAsSpam": false + }, + { + "totalEmails": 0, + "myRights": { + "maySubmit": true, + "mayDelete": true, + "mayReadItems": true, + "mayAddItems": true, + "mayAdmin": true, + "maySetKeywords": true, + "mayRename": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "maySetSeen": true }, - { - "totalEmails": 0, - "myRights": { - "maySubmit": true, - "mayDelete": true, - "mayCreateChild": true, - "mayAddItems": true, - "mayAdmin": true, - "maySetKeywords": true, - "mayRename": true, - "mayRemoveItems": true, - "mayReadItems": true, - "maySetSeen": true - }, - "unreadEmails": 0, - "isCollapsed": false, - "totalThreads": 0, - "hidden": 0, - "sort": [ - { - "isAscending": false, - "property": "receivedAt" - } - ], - "sortOrder": 6, - "name": "Spam", - "autoPurge": true, - "id": "22633599-d58d-4217-b0fb-28e2dd7cd723", - "autoLearn": false, - "parentId": null, - "suppressDuplicates": true, - "isSubscribed": true, - "role": "junk", - "identityRef": null, - "purgeOlderThanDays": 31, - "unreadThreads": 0, - "learnAsSpam": false + "unreadEmails": 0, + "totalThreads": 0, + "hidden": 0, + "isCollapsed": false, + "sort": [ + { + "isAscending": false, + "property": "receivedAt" + } + ], + "sortOrder": 5, + "name": "Sent", + "autoPurge": false, + "id": "0faefa8a-8cfa-4562-be2a-a5d25381bbfa", + "autoLearn": false, + "parentId": null, + "suppressDuplicates": true, + "isSubscribed": true, + "role": "sent", + "identityRef": null, + "purgeOlderThanDays": 31, + "unreadThreads": 0, + "learnAsSpam": false + }, + { + "totalEmails": 0, + "myRights": { + "maySubmit": true, + "mayDelete": true, + "mayCreateChild": true, + "mayAddItems": true, + "mayAdmin": true, + "maySetKeywords": true, + "mayRename": true, + "mayRemoveItems": true, + "mayReadItems": true, + "maySetSeen": true }, - { - "totalEmails": 0, - "myRights": { - "maySubmit": true, - "mayDelete": true, - "mayReadItems": true, - "mayAddItems": true, - "mayAdmin": true, - "maySetKeywords": true, - "mayRename": true, - "mayRemoveItems": true, - "mayCreateChild": true, - "maySetSeen": true - }, - "unreadEmails": 0, - "totalThreads": 0, - "hidden": 0, - "isCollapsed": false, - "sort": [ - { - "isAscending": false, - "property": "receivedAt" - } - ], - "sortOrder": 7, - "name": "Trash", - "autoPurge": true, - "id": "115bb529-ea77-4af0-b726-c037cdf7cb86", - "autoLearn": false, - "parentId": null, - "suppressDuplicates": true, - "isSubscribed": true, - "role": "trash", - "identityRef": null, - "unreadThreads": 0, - "purgeOlderThanDays": 31, - "learnAsSpam": false - } - ] - """.data(using: .utf8)! + "unreadEmails": 0, + "isCollapsed": false, + "totalThreads": 0, + "hidden": 0, + "sort": [ + { + "isAscending": false, + "property": "receivedAt" + } + ], + "sortOrder": 6, + "name": "Spam", + "autoPurge": true, + "id": "22633599-d58d-4217-b0fb-28e2dd7cd723", + "autoLearn": false, + "parentId": null, + "suppressDuplicates": true, + "isSubscribed": true, + "role": "junk", + "identityRef": null, + "purgeOlderThanDays": 31, + "unreadThreads": 0, + "learnAsSpam": false + }, + { + "totalEmails": 0, + "myRights": { + "maySubmit": true, + "mayDelete": true, + "mayReadItems": true, + "mayAddItems": true, + "mayAdmin": true, + "maySetKeywords": true, + "mayRename": true, + "mayRemoveItems": true, + "mayCreateChild": true, + "maySetSeen": true + }, + "unreadEmails": 0, + "totalThreads": 0, + "hidden": 0, + "isCollapsed": false, + "sort": [ + { + "isAscending": false, + "property": "receivedAt" + } + ], + "sortOrder": 7, + "name": "Trash", + "autoPurge": true, + "id": "115bb529-ea77-4af0-b726-c037cdf7cb86", + "autoLearn": false, + "parentId": null, + "suppressDuplicates": true, + "isSubscribed": true, + "role": "trash", + "identityRef": null, + "unreadThreads": 0, + "purgeOlderThanDays": 31, + "learnAsSpam": false + } +] +""".data(using: .utf8)! diff --git a/Feature/Tests/JMAPTests/MethodErrorTests.swift b/Feature/Tests/JMAPTests/MethodErrorTests.swift index 045398a1..4710d277 100644 --- a/Feature/Tests/JMAPTests/MethodErrorTests.swift +++ b/Feature/Tests/JMAPTests/MethodErrorTests.swift @@ -3,6 +3,8 @@ import Foundation import Testing struct MethodErrorTests { + + // MARK: Decodable @Test func decoderInit() throws { let errors: [MethodError] = try JSONDecoder().decode([MethodError].self, from: data) try #require(errors.count == 2) diff --git a/Feature/Tests/JMAPTests/RequestErrorTests.swift b/Feature/Tests/JMAPTests/RequestErrorTests.swift index 3b3630b8..0ceb3ba7 100644 --- a/Feature/Tests/JMAPTests/RequestErrorTests.swift +++ b/Feature/Tests/JMAPTests/RequestErrorTests.swift @@ -3,13 +3,15 @@ import Foundation import Testing struct RequestErrorTests { + + // MARK: Decodable @Test func decoderInit() throws { let errors: [RequestError] = try JSONDecoder().decode([RequestError].self, from: data) try #require(errors.count == 2) #expect(errors[0].code == .unknownCapability) - #expect(errors[0].description == "The request object used capability which is not supported by this server.") + #expect(errors[0].description == "Unknown capability; The request object used capability which is not supported by this server.") #expect(errors[1].code == .limit) - #expect(errors[1].description == "The request is larger than the server is willing to process.") + #expect(errors[1].description == "Limit; The request is larger than the server is willing to process.") } } diff --git a/Feature/Tests/JMAPTests/Server.swift b/Feature/Tests/JMAPTests/Server.swift new file mode 100644 index 00000000..377275c1 --- /dev/null +++ b/Feature/Tests/JMAPTests/Server.swift @@ -0,0 +1,27 @@ +import JMAP +import Testing + +extension Server: CaseIterable { + + // MARK: Fastmail + static var fastmail: Self { + Server( + authorization: nil, + host: "") + } + + var isDisabled: Bool { (authorization ?? .empty).isEmpty } + + static func allCases(disabled: Bool) -> [Self] { + allCases.filter { $0.isDisabled == disabled } + } + + // MARK: CaseIterable + public static let allCases: [Self] = [.fastmail] +} + +// Catch when test account usernames or passwords are leaking +@Test(arguments: Server.allCases) func isDisabled(_ server: Server) { + #expect((server.authorization?.isEmpty ?? true) == true) + #expect(server.authorization == nil) +} diff --git a/Feature/Tests/JMAPTests/SessionTests.swift b/Feature/Tests/JMAPTests/SessionTests.swift index 167349d1..25aafe52 100644 --- a/Feature/Tests/JMAPTests/SessionTests.swift +++ b/Feature/Tests/JMAPTests/SessionTests.swift @@ -3,29 +3,15 @@ import Foundation import Testing struct SessionTests { - @Test func decoderInit() throws { - let session: Session = try JSONDecoder().decode(Session.self, from: data) - #expect(session.username == "toddheasley@fastmail.com") - #expect(session.accounts.count == 1) - #expect(session.accounts["u7a51e404"]?.name == "toddheasley@fastmail.com") - #expect(session.primaryAccounts.count == 3) - #expect(session.primaryAccounts[.mail] == "u7a51e404") - #expect(session.capabilities[.core]?.maxConcurrentRequests == 10) - #expect(session.downloadURLTemplate == "https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}") - #expect(session.uploadURLTemplate == "https://api.fastmail.com/jmap/upload/{accountId}/") - #expect(session.eventSourceURL.absoluteString == "https://api.fastmail.com/jmap/event/") - #expect(session.apiURL.absoluteString == "https://api.fastmail.com/jmap/api/") - } - @Test func downloadURL() throws { let session: Session = try JSONDecoder().decode(Session.self, from: data) - #expect(session.downloadURLTemplate == "https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}") + #expect(session.downloadURLTemplate == "https://www.example.com/jmap/download/{accountId}/{blobId}/{name}?type={type}") #expect( try session.downloadURL(account: "u7a51e404", blob: "b404e15a7", name: "file_name", type: "pdf").absoluteString - == "https://www.fastmailusercontent.com/jmap/download/u7a51e404/b404e15a7/file_name?type=pdf") + == "https://www.example.com/jmap/download/u7a51e404/b404e15a7/file_name?type=pdf") #expect( try session.downloadURL(account: "u7a51e404", blob: "b404e15a7", name: "file_name", type: "").absoluteString - == "https://www.fastmailusercontent.com/jmap/download/u7a51e404/b404e15a7/file_name?type=") + == "https://www.example.com/jmap/download/u7a51e404/b404e15a7/file_name?type=") #expect(throws: URLError.self) { try session.downloadURL(account: "u7a51e404", blob: "b404e15a7", name: "", type: "") } @@ -39,27 +25,42 @@ struct SessionTests { @Test func uploadURL() throws { let session: Session = try JSONDecoder().decode(Session.self, from: data) - #expect(session.uploadURLTemplate == "https://api.fastmail.com/jmap/upload/{accountId}/") - #expect(try session.uploadURL(account: "u7a51e404").absoluteString == "https://api.fastmail.com/jmap/upload/u7a51e404/") + #expect(session.uploadURLTemplate == "https://api.example.com/jmap/upload/{accountId}/") + #expect(try session.uploadURL(account: "u7a51e404").absoluteString == "https://api.example.com/jmap/upload/u7a51e404/") #expect(throws: URLError.self) { try session.uploadURL(account: "") } } + + // MARK: Decodable + @Test func decoderInit() throws { + let session: Session = try JSONDecoder().decode(Session.self, from: data) + #expect(session.username == "user@example.com") + #expect(session.accounts.count == 1) + #expect(session.accounts["u7a51e404"]?.name == "user@example.com") + #expect(session.primaryAccounts.count == 3) + #expect(session.primaryAccounts[.mail] == "u7a51e404") + #expect(session.capabilities[.core]?.maxConcurrentRequests == 10) + #expect(session.downloadURLTemplate == "https://www.example.com/jmap/download/{accountId}/{blobId}/{name}?type={type}") + #expect(session.uploadURLTemplate == "https://api.example.com/jmap/upload/{accountId}/") + #expect(session.eventSourceURL.absoluteString == "https://api.example.com/jmap/event/") + #expect(session.apiURL.absoluteString == "https://api.example.com/jmap/api/") + } } // swift-format-ignore private let data: Data = """ { "state": "cyrus-0;p-67bb868361;s-68403d307e150478", - "username": "toddheasley@fastmail.com", - "eventSourceUrl": "https://api.fastmail.com/jmap/event/", - "uploadUrl": "https://api.fastmail.com/jmap/upload/{accountId}/", - "downloadUrl": "https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}", + "username": "user@example.com", + "eventSourceUrl": "https://api.example.com/jmap/event/", + "uploadUrl": "https://api.example.com/jmap/upload/{accountId}/", + "downloadUrl": "https://www.example.com/jmap/download/{accountId}/{blobId}/{name}?type={type}", "primaryAccounts": { "urn:ietf:params:jmap:submission": "u7a51e404", "urn:ietf:params:jmap:core": "u7a51e404", "urn:ietf:params:jmap:mail": "u7a51e404", - "https://www.fastmail.com/dev/maskedemail": "u7a51e404" + "https://www.example.com/dev/maskedemail": "u7a51e404" }, "capabilities": { "urn:ietf:params:jmap:submission": {}, @@ -78,7 +79,7 @@ private let data: Data = """ ], "maxSizeUpload": 250000000 }, - "https://www.fastmail.com/dev/maskedemail": {} + "https://www.example.com/dev/maskedemail": {} }, "accounts": { "u7a51e404": { @@ -104,12 +105,12 @@ private let data: Data = """ ], "maxMailboxesPerEmail": 1000 }, - "https://www.fastmail.com/dev/maskedemail": {} + "https://www.example.com/dev/maskedemail": {} }, - "name": "toddheasley@fastmail.com", + "name": "user@example.com", "isPersonal": true } }, - "apiUrl": "https://api.fastmail.com/jmap/api/" + "apiUrl": "https://api.example.com/jmap/api/" } """.data(using: .utf8)! diff --git a/Feature/Tests/JMAPTests/SetErrorTests.swift b/Feature/Tests/JMAPTests/SetErrorTests.swift index e35b2a61..033f5bbc 100644 --- a/Feature/Tests/JMAPTests/SetErrorTests.swift +++ b/Feature/Tests/JMAPTests/SetErrorTests.swift @@ -4,7 +4,7 @@ import Testing struct SetErrorTests { @Test func valueInit() throws { - #expect(SetError(try JSONSerialization.jsonObject(with: data)) == .invalidProperties) + #expect(SetError(data) == .invalidProperties) } } diff --git a/Feature/Tests/JMAPTests/FoundationTests/URLRequestTests.swift b/Feature/Tests/JMAPTests/URLRequestTests.swift similarity index 79% rename from Feature/Tests/JMAPTests/FoundationTests/URLRequestTests.swift rename to Feature/Tests/JMAPTests/URLRequestTests.swift index 5feaf9d2..869a8171 100644 --- a/Feature/Tests/JMAPTests/FoundationTests/URLRequestTests.swift +++ b/Feature/Tests/JMAPTests/URLRequestTests.swift @@ -7,31 +7,31 @@ struct URLRequestTests { let request: URLRequest = try .jmapAPI( [ Mailbox.GetMethod("u7a51e404") - ], url: url, authorization: "Bearer fmu1-1e911257e86b1f194daa-0-a89faae5c11f") + ], url: url, authorization: .bearer("fmu1-1e911257e86b1f194daa-0-a89faae5c11f")) #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fmu1-1e911257e86b1f194daa-0-a89faae5c11f") #expect(request.httpMethod == "POST") #expect(request.httpBody?.count ?? 0 > 100) #expect(throws: Error.self) { - try URLRequest.jmapAPI([], url: url, authorization: "Bearer fmu1-1e911257e86b1f194daa-0-a89faae5c11f") + try URLRequest.jmapAPI([], url: url, authorization: .bearer("fmu1-1e911257e86b1f194daa-0-a89faae5c11f")) } #expect(throws: Error.self) { try URLRequest.jmapAPI( [ Mailbox.GetMethod("u7a51e404") - ], url: url, authorization: "") + ], url: url, authorization: .bearer("")) } } @Test func jmapSession() throws { - let request: URLRequest = try .jmapSession(url.host()!, authorization: "Bearer fmu1-1e911257e86b1f194daa-0-a89faae5c11f") - #expect(request.url?.absoluteString == "https://api.fastmail.com/jmap/session") + let request: URLRequest = try .jmapSession(host: url.host()!, authorization: .bearer("fmu1-1e911257e86b1f194daa-0-a89faae5c11f")) + #expect(request.url?.absoluteString == "https://api.example.com/jmap/session") #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fmu1-1e911257e86b1f194daa-0-a89faae5c11f") #expect(request.httpMethod == "GET") } @Test func setAuthorization() throws { var request: URLRequest = URLRequest(url: url) - try request.setAuthorization("Bearer fmu1-1e911257e86b1f194daa-0-a89faae5c11f") + request.setAuthorization(.bearer("fmu1-1e911257e86b1f194daa-0-a89faae5c11f")) #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fmu1-1e911257e86b1f194daa-0-a89faae5c11f") } @@ -61,4 +61,4 @@ struct URLRequestTests { } } -private let url: URL = URL(string: "https://api.fastmail.com/jmap/api/")! +private let url: URL = URL(string: "https://api.example.com/jmap/api/")! diff --git a/Feature/Tests/JMAPTests/FoundationTests/URLSessionTests.swift b/Feature/Tests/JMAPTests/URLSessionTests.swift similarity index 59% rename from Feature/Tests/JMAPTests/FoundationTests/URLSessionTests.swift rename to Feature/Tests/JMAPTests/URLSessionTests.swift index 1c4e0eaf..d987959d 100644 --- a/Feature/Tests/JMAPTests/FoundationTests/URLSessionTests.swift +++ b/Feature/Tests/JMAPTests/URLSessionTests.swift @@ -3,17 +3,20 @@ import Foundation import Testing struct URLSessionTests { - @Test(.disabled(if: token.isEmpty)) func jmapAPI() async throws { - let session: Session = try await URLSession.shared.jmapSession("api.fastmail.com", port: 443, authorization: "Bearer \(token)") + @Test(arguments: Server.allCases(disabled: false)) func jmapAPI(server: Server) async throws { + let session: Session = try await URLSession.shared.jmapSession(server: server) guard let accountID: String = session.accounts.keys.first else { throw MethodError.accountNotFound } + guard let authorization: Authorization = server.authorization else { + throw URLError(.userAuthenticationRequired) + } let responses: [any MethodResponse] = try await URLSession.shared.jmapAPI( [ - Mailbox.GetMethod(accountID) // Test with ccount-agnostic method + Mailbox.GetMethod(accountID) // Test with account-agnostic method ], url: session.apiURL, - authorization: "Bearer \(token)" + authorization: authorization ) try #require(responses.count == 1) guard let response: MethodGetResponse = responses.first as? MethodGetResponse else { @@ -24,15 +27,8 @@ struct URLSessionTests { #expect(mailboxes.compactMap { $0.role }.contains(.inbox) == true) } - @Test(.disabled(if: token.isEmpty)) func jmapSession() async throws { - let session: Session = try await URLSession.shared.jmapSession("api.fastmail.com", port: 443, authorization: "Bearer \(token)") - #expect(session.username.hasSuffix("@fastmail.com") == true) + @Test(arguments: Server.allCases(disabled: false)) func jmapSession(server: Server) async throws { + let session: Session = try await URLSession.shared.jmapSession(server: server) + print(session) } } - -// Catch when token is being leaked -@Test func emptyToken() { - #expect(token == "") -} - -private let token: String = "" diff --git a/Feature/Tests/JMAPTests/URLTests.swift b/Feature/Tests/JMAPTests/URLTests.swift new file mode 100644 index 00000000..e4843a72 --- /dev/null +++ b/Feature/Tests/JMAPTests/URLTests.swift @@ -0,0 +1,21 @@ +import Foundation +@testable import JMAP +import Testing + +struct URLTests { + @Test func jmapSession() throws { + #expect(try URL.jmapSession(host: "api.example.com").absoluteString == "https://api.example.com/jmap/session") + #expect(throws: URLError.self) { + try URL.jmapSession(host: "") + } + } + + @Test func jmap() throws { + #expect(try URL.jmap(host: "api.example.com").absoluteString == "https://api.example.com") + #expect(try URL.jmap(host: "api.example.com", path: "/jmap/session").absoluteString == "https://api.example.com/jmap/session") + #expect(try URL.jmap(host: "api.example.com", path: "jmap/session").absoluteString == "https://api.example.com/jmap/session") + #expect(throws: URLError.self) { + try URL.jmap(host: "") + } + } +} diff --git a/Thunderbird/Thunderbird/JMAP/JMAPEmailView.swift b/Thunderbird/Thunderbird/JMAP/JMAPEmailView.swift deleted file mode 100644 index c1771941..00000000 --- a/Thunderbird/Thunderbird/JMAP/JMAPEmailView.swift +++ /dev/null @@ -1,36 +0,0 @@ -import JMAP -import SwiftUI - -struct JMAPEmailView: View { - let email: Email - - init(_ email: Email) { - self.email = email - } - - @Environment(JMAPObject.self) private var jmap: JMAPObject - @State private var thread: [Email] = [] - - // MARK: View - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 10.0) { - Text(email.from?.first?.email ?? "
") - Text(email.subject ?? "") - .bold() - Divider() - Text(email.preview ?? "") - } - .padding() - .containerRelativeFrame(.horizontal) - .onAppear { - Task { - thread = await jmap.thread(for: email) - } - } - } - .refreshable { - thread = await jmap.thread(for: email) - } - } -} diff --git a/Thunderbird/Thunderbird/JMAP/JMAPMailboxView.swift b/Thunderbird/Thunderbird/JMAP/JMAPMailboxView.swift deleted file mode 100644 index 682736a1..00000000 --- a/Thunderbird/Thunderbird/JMAP/JMAPMailboxView.swift +++ /dev/null @@ -1,42 +0,0 @@ -import JMAP -import SwiftUI - -struct JMAPMailboxView: View { - let mailbox: Mailbox - - init(_ mailbox: Mailbox) { - self.mailbox = mailbox - } - - @Environment(JMAPObject.self) private var jmap: JMAPObject - @State private var emails: [Email] = [] - - // MARK: View - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 10.0) { - Text(mailbox.name) - .bold() - Divider() - ForEach(emails) { email in - NavigationLink(value: email) { - Text(email.subject ?? "nil") - } - } - } - .navigationDestination(for: Email.self) { email in - JMAPEmailView(email) - } - .padding() - .containerRelativeFrame(.horizontal) - .onAppear { - Task { - emails = await jmap.emails(in: mailbox) - } - } - } - .refreshable { - emails = await jmap.emails(in: mailbox) - } - } -} diff --git a/Thunderbird/Thunderbird/JMAP/JMAPObject.swift b/Thunderbird/Thunderbird/JMAP/JMAPObject.swift deleted file mode 100644 index cfd4bafa..00000000 --- a/Thunderbird/Thunderbird/JMAP/JMAPObject.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Account -import JMAP -import SwiftUI - -@MainActor -@Observable final class JMAPObject: Sendable { - private(set) var session: Session? = nil - var error: Error? = nil - - var token: String = "" { - didSet { - // URLCredentialStorage.shared.set(authorization: .oauth(user: .user, token: token)) - Task { - await session() - } - } - } - - init() { - token = "" // URLCredentialStorage.shared.authorization(for: .user)?.password ?? "" - Task { - await session() - } - } - - func thread(for email: Email) async -> [Email] { - do { - guard let url: URL = session?.apiURL, - let id: String = session?.accounts.first?.key - else { - throw URLError(.cannotConnectToHost) - } - return try await URLSession.shared.thread(token, url: url, for: email, account: id) - } catch { - self.error = error - return [] - } - } - - func emails(in mailbox: Mailbox) async -> [Email] { - do { - guard let url: URL = session?.apiURL, - let id: String = session?.accounts.first?.key - else { - throw URLError(.cannotConnectToHost) - } - return try await URLSession.shared.emails(token, url: url, in: mailbox, account: id) - } catch { - self.error = error - return [] - } - } - - func mailboxes() async -> [Mailbox] { - do { - guard let url: URL = session?.apiURL, - let id: String = session?.accounts.first?.key - else { - throw URLError(.cannotConnectToHost) - } - return try await URLSession.shared.mailboxes(token, url: url, account: id) - } catch { - self.error = error - return [] - } - } - - func session() async { - session = nil - error = nil - guard !token.isEmpty else { return } - do { - session = try await URLSession.shared.session(token) - } catch { - self.error = error - } - } -} - -extension Mailbox { - var systemName: String { - switch role { - case .inbox: "tray.full" - case .drafts: "envelope.open" - case .sent: "paperplane" - case .archive: "archivebox" - case .junk: "xmark.bin" - case .trash: "trash" - default: "folder" - } - } -} - -extension Filter { - static func inMailbox(_ id: String) -> Self { - Self([ - Email.Condition.inMailbox(id) - ]) - } -} - -extension URLSession { - func thread(_ token: String, url: URL, for email: Email, account id: String) async throws -> [Email] { - throw URLError(.resourceUnavailable) - } - - func emails(_ token: String, url: URL, in mailbox: Mailbox, account id: String) async throws -> [Email] { - throw URLError(.resourceUnavailable) - } - - func emails(_ token: String, url: URL, ids: [String], account id: String) async throws -> [Email] { - throw URLError(.resourceUnavailable) - } - - func mailboxes(_ token: String, url: URL, account id: String) async throws -> [Mailbox] { - throw URLError(.resourceUnavailable) - } - - func session(_ token: String) async throws -> Session { - try await jmapSession("api.fastmail.com", port: 443, authorization: "Bearer \(token)") - } -} - -extension URL { - static let help: Self = Self(string: "https://www.fastmail.help/hc/en-us/articles/5254602856719-API-tokens")! -} - -private extension String { - static let user: Self = "fastmail.com" -} diff --git a/Thunderbird/Thunderbird/JMAP/JMAPSessionView.swift b/Thunderbird/Thunderbird/JMAP/JMAPSessionView.swift deleted file mode 100644 index 08098c8b..00000000 --- a/Thunderbird/Thunderbird/JMAP/JMAPSessionView.swift +++ /dev/null @@ -1,68 +0,0 @@ -import JMAP -import SwiftUI - -struct JMAPSessionView: View { - let session: Session - - init(_ session: Session) { - self.session = session - } - - @Environment(JMAPObject.self) private var jmap: JMAPObject - @Environment(\.openURL) private var openURL - - @State private var mailboxes: [Mailbox] = [] - - // MARK: View - var body: some View { - ZStack(alignment: .bottom) { - ScrollView { - VStack(alignment: .leading, spacing: 10.0) { - Text(session.username) - .bold() - Divider() - ForEach(mailboxes) { mailbox in - NavigationLink(value: mailbox) { - HStack { - Image(systemName: mailbox.systemName) - Text(mailbox.name) - } - } - } - } - .navigationDestination(for: Mailbox.self) { mailbox in - JMAPMailboxView(mailbox) - } - .padding(.vertical) - .padding() - .containerRelativeFrame(.horizontal) - .onAppear { - Task { - mailboxes = await jmap.mailboxes() - } - } - } - .refreshable { - mailboxes = await jmap.mailboxes() - } - HStack { - Button(action: { - jmap.token = "" - }) { - Label("Sign out", systemImage: "xmark.circle.fill") - } - .buttonStyle(.bordered) - .padding() - Button(action: { - guard let url = URL(string: "https://www.thunderbird.net/en-US/donate/") else { return } - openURL(url) - }) { - Text("donation_support") - } - .buttonStyle(.borderedProminent) - .padding() - } - - } - } -}