Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Feature/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ let package: Package = Package(
.target(
name: "JMAP",
dependencies: [
"EmailAddress"
"EmailAddress",
"MIME"
]),
.testTarget(
name: "JMAPTests",
Expand Down
2 changes: 1 addition & 1 deletion Feature/Sources/IMAP/IMAPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import OSLog
public class IMAPClient {
public let server: Server

public var capabilities: Set<Capability> = []
public private(set) var capabilities: Set<Capability> = []
public var isConnected: Bool { channel != nil && channel!.isActive }

public func isSupported(_ capability: Capability) -> Bool {
Expand Down
2 changes: 0 additions & 2 deletions Feature/Sources/JMAP/Account.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
44 changes: 44 additions & 0 deletions Feature/Sources/JMAP/Authorization.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
2 changes: 1 addition & 1 deletion Feature/Sources/JMAP/Email.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
136 changes: 136 additions & 0 deletions Feature/Sources/JMAP/JMAPClient.swift
Original file line number Diff line number Diff line change
@@ -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)
])
}
}
36 changes: 36 additions & 0 deletions Feature/Sources/JMAP/JMAPError.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)" : "")")
Expand Down
1 change: 1 addition & 0 deletions Feature/Sources/JMAP/Method.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
14 changes: 13 additions & 1 deletion Feature/Sources/JMAP/MethodError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 18 additions & 3 deletions Feature/Sources/JMAP/RequestError.swift
Original file line number Diff line number Diff line change
@@ -1,31 +1,46 @@
/// 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"
case notRequest = "urn:ietf:params:jmap:error:notRequest"
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<Key> = 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 {
case `type`, detail
}

// MARK: CustomStringConvertible
public let description: String
public var description: String { "\(code)\(detail.isEmpty ? "" : "; \(detail)")" }
}
19 changes: 19 additions & 0 deletions Feature/Sources/JMAP/Server.swift
Original file line number Diff line number Diff line change
@@ -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)" }
}
Loading