Skip to content
Merged
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/Sources/IMAP/CapabilityCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import NIOCore
import NIOIMAP

// Fetch advertised server capabilities
// https://www.iana.org/assignments/imap-capabilities/imap-capabilities.xhtml
struct CapabilityCommand: IMAPCommand {

// MARK: IMAPCommand
typealias Result = [Capability]
typealias Handler = CapabilityHandler

static var name: String { "capability" }
var name: String { "capability" }

func tagged(_ tag: String) -> NIOIMAPCore.TaggedCommand {
TaggedCommand(tag: tag, command: .capability)
Expand Down
2 changes: 2 additions & 0 deletions Feature/Sources/IMAP/ConnectionSecurity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ public enum ConnectionSecurity: String, Codable, CaseIterable, CustomStringConve
case tls = "SSL/TLS"
case none

public static var ssl: Self { .tls }

// MARK: CustomStringConvertible
public var description: String { rawValue }

Expand Down
23 changes: 23 additions & 0 deletions Feature/Sources/IMAP/CreateCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import NIOCore
import NIOIMAP

// Create new mailbox
struct CreateCommand: IMAPCommand {
let mailboxName: MailboxName
let parameters: [CreateParameter]

init(_ mailboxName: MailboxName, parameters: [CreateParameter] = []) {
self.mailboxName = mailboxName
self.parameters = parameters
}

// MARK: IMAPCommand
typealias Result = Void
typealias Handler = VoidResultHandler

var name: String { "create \"\(mailboxName)\"" }

func tagged(_ tag: String) -> NIOIMAPCore.TaggedCommand {
TaggedCommand(tag: tag, command: .create(mailboxName, parameters))
}
}
21 changes: 21 additions & 0 deletions Feature/Sources/IMAP/DeleteCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import NIOCore
import NIOIMAP

// Delete an existing mailbox
struct DeleteCommand: IMAPCommand {
let mailboxName: MailboxName

init(_ mailboxName: MailboxName) {
self.mailboxName = mailboxName
}

// MARK: IMAPCommand
typealias Result = Void
typealias Handler = VoidResultHandler

var name: String { "delete \"\(mailboxName)\"" }

func tagged(_ tag: String) -> NIOIMAPCore.TaggedCommand {
TaggedCommand(tag: tag, command: .delete(mailboxName))
}
}
22 changes: 22 additions & 0 deletions Feature/Sources/IMAP/ExamineCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import NIOIMAP

// Select current working mailbox in read-only mode
struct ExamineCommand: IMAPCommand {
let mailboxName: MailboxName

init(_ mailboxName: MailboxName) {
self.mailboxName = mailboxName
}

// MARK: IMAPCommand
typealias Result = MailboxStatus
typealias Handler = ExamineHandler

var name: String { "examine \"\(mailboxName)\"" }

func tagged(_ tag: String) -> NIOIMAPCore.TaggedCommand {
TaggedCommand(tag: tag, command: .examine(mailboxName))
}
}

typealias ExamineHandler = SelectHandler
24 changes: 0 additions & 24 deletions Feature/Sources/IMAP/Folder.swift

This file was deleted.

104 changes: 87 additions & 17 deletions Feature/Sources/IMAP/IMAPClient.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import MIME
import NIO
import NIOIMAP
import NIOSSL
Expand Down Expand Up @@ -57,7 +58,7 @@ public class IMAPClient {
logger?.info("Logging in \(username)…")
let capabilities: [Capability] = try await execute(command: LoginCommand(username: username, password: password))
if !capabilities.isEmpty {
// IMAP servers can return _additional_ capabilities after login
// IMAP servers can return additional capabilities after login
logger?.info("Merging capabilities…")
for capability in capabilities {
self.capabilities.insert(capability)
Expand All @@ -69,7 +70,87 @@ public class IMAPClient {
/// Log out from connected IMAP ``Server``; leave active NIO channel connection intact.
public func logout() async throws {
logger?.info("Logging out…")
try await execute(command: LogoutCommand())
try await execute(command: VoidCommand(.logout))
}

/// Fetch namespaces available to authenticated user.`
public func namespace() async throws -> [Namespace] {
logger?.info("Fetching namespace…")
let namespace: [Namespace] = try await execute(command: NamespaceCommand())
logger?.info("Namespaces: \(namespace)")
return namespace
}

/// List all mailboxes on logged-in IMAP ``Server``.
public func list(wildcard: Character = .wildcard) async throws -> [Mailbox] {
logger?.info("Listing mailboxes…")
return try await execute(command: ListCommand(wildcard: wildcard))
}

/// Select current working mailbox in read/write mode.
public func select(mailbox: Mailbox) async throws -> Mailbox.Status {
logger?.info("Selecting mailbox \(mailbox.path.name)…")
return try await execute(command: SelectCommand(mailbox.path.name))
}

/// Select a working mailbox in read-only mode.
public func examine(mailbox: Mailbox) async throws -> Mailbox.Status {
logger?.info("Examining mailbox \(mailbox.path.name)…")
return try await execute(command: ExamineCommand(mailbox.path.name))
}

/// Fetch the current status for a mailbox.
public func status(mailbox: Mailbox) async throws -> Mailbox.Status {
logger?.info("Refreshing mailbox \(mailbox.path.name) status…")
return try await execute(command: StatusCommand(mailbox.path.name))
}

/// Expunge messages flagged as deleted in current working mailbox.
public func expunge() async throws {
logger?.info("Expunging selected mailbox…")
try await execute(command: VoidCommand(.expunge))
}

/// Unselect current working mailbox; don't expunge messages flagged as deleted.
public func unselect() async throws {
logger?.info("Unselecting mailbox…")
try await execute(command: VoidCommand(.unselect))
}

/// Create a new mailbox.
public func create(mailbox name: MailboxName) async throws {
logger?.info("Creating \"\(name)\" mailbox…")
try await execute(command: CreateCommand(name))
}

/// Rename an existing mailbox.
public func rename(mailbox name: MailboxName, to targetName: MailboxName) async throws {
logger?.info("Renaming \"\(name)\" mailbox to \"\(targetName)\"…")
try await execute(command: RenameCommand(name, to: targetName))
}

/// Delete an existing mailbox.
public func delete(mailbox name: MailboxName) async throws {
logger?.info("Deleting \"\(name)\" mailbox…")
try await execute(command: DeleteCommand(name))
}

/// Subscribe to an existing mailbox.
public func subscribe(mailbox name: MailboxName) async throws {
logger?.info("Subscribing \"\(name)\" mailbox…")
try await execute(command: SubscribeCommand(name))
}

/// Unsubscribe from an existing mailbox.
public func unsubscribe(mailbox name: MailboxName) async throws {
logger?.info("Unsubscribing \"\(name)\" mailbox…")
try await execute(command: UnsubscribeCommand(name))
}

/// Expunge and unselect current working mailbox.
public func close() async throws {
logger?.info("Closing selected mailbox…")
try await execute(command: VoidCommand(.close))
}

public init(
Expand All @@ -81,20 +162,6 @@ public class IMAPClient {
self.logger = logger
}

// Generate IMAP command tag in traditional format "a001"
static func tag(_ count: Int, prefix: Character = .prefix) -> String {
// IMAP uses client-provided unique identifiers, "tags," for matching issued commands to responses
// Tags can be any ASCII string without spaces
// Traditionally, tags are prefixed "a," followed by a 3-digit, auto-incremented number: 001...999
"\(prefix)\(String(format: "%03d", min(max(count, 1), 999)))"
}

// Generate next, auto-incrementing IMAP tag using instance counter
func tag(prefix: Character = .prefix) -> String {
count = count > 998 ? 1 : count + 1 // Auto-increment tag count; roll back to 1 after 999
return Self.tag(count, prefix: prefix)
}

// Run IMAP command through NIO `IMAPClientHandler` in channel and handle results
func execute<T: IMAPCommand>(command: T) async throws -> T.Result {
let logger: Logger? = logger
Expand All @@ -107,7 +174,7 @@ public class IMAPClient {
throw IMAPError.notConnected
}
let promise: EventLoopPromise<T.Result> = channel.eventLoop.makePromise(of: T.Result.self)
let tag: String = tag() // Hold onto specific auto-generated tag
let tag: String = UUID().uuidString(1) // Hold onto specific auto-generated tag
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing against live IMAP servers (AOL, Gmail, Fastmail, iCloud, Outlook/Hotmail), there's no advantage to traditional, custom tags (a001-a999) over shortened UUIDs, which are less work/code that we own, and way less likely to collide.

let handler: T.Handler = T.Handler(tag: tag, promise: promise)
let seconds: Int64 = max(command.timeout, 1)
let task: Scheduled = group.next().scheduleTask(in: .seconds(seconds)) {
Expand Down Expand Up @@ -155,6 +222,9 @@ public class IMAPClient {
}

extension Character {
public static let delimiter: Self = "."
public static let wildcard: Self = "*"

static let prefix: Self = "a"
}

Expand Down
3 changes: 1 addition & 2 deletions Feature/Sources/IMAP/IMAPCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import NIOIMAP
protocol IMAPCommand: CustomStringConvertible, Equatable where Result: Sendable {
associatedtype Result
associatedtype Handler: IMAPCommandHandler where Handler.Result == Result
static var name: String { get }
var name: String { get }
var timeout: Int64 { get } // Seconds
func tagged(_ tag: String) -> TaggedCommand // NIOIMAP command
}

extension IMAPCommand {
var name: String { Self.name }

// MARK: IMAPCommand
var timeout: Int64 { 30 } // Practical default
Expand Down
4 changes: 2 additions & 2 deletions Feature/Sources/IMAP/IMAPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public enum IMAPError: Error, CustomStringConvertible, Equatable {
}

static func commandFailed(_ command: any IMAPCommand) -> Self {
.commandFailed(command.description)
.commandFailed("\(command) failed")
}

static func commandNotSupported(_ command: any IMAPCommand) -> Self {
Expand All @@ -32,7 +32,7 @@ public enum IMAPError: Error, CustomStringConvertible, Equatable {
public var description: String {
switch self {
case .alreadyConnected: "Already connected"
case .commandFailed(let description): "\(description.capitalized(.sentence)) failed"
case .commandFailed(let description): "\(description.capitalized(.sentence))"
case .commandNotSupported(let description): "\(description.capitalized(.sentence)) not supported"
case .notConnected: "Not connected"
case .serverDisconnected: "Server disconnected"
Expand Down
75 changes: 75 additions & 0 deletions Feature/Sources/IMAP/ListCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import NIOCore
import NIOIMAP

// List all mailboxes available for authenticated user
struct ListCommand: IMAPCommand {
let options: [ReturnOption]
let wildcard: Character

init(options: [ReturnOption] = [], wildcard: Character) {
self.options = options
self.wildcard = wildcard
}

// MARK: IMAPCommand
typealias Result = [MailboxInfo]
typealias Handler = ListHandler

var name: String { "list" }

func tagged(_ tag: String) -> NIOIMAPCore.TaggedCommand {
TaggedCommand(tag: tag, command: .list(nil, reference: .reference, .mailbox(wildcard), options))
}
}

class ListHandler: IMAPCommandHandler, @unchecked Sendable {

// MARK: IMAPCommandHandler
typealias InboundIn = Response
typealias InboundOut = Response
typealias Result = [MailboxInfo]

var mailboxes: Result = []
var clientBug: String? = nil
let promise: EventLoopPromise<Result>
let tag: String

required init(tag: String, promise: EventLoopPromise<Result>) {
self.promise = promise
self.tag = tag
}

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let response: Response = unwrapInboundIn(data)
clientBug = response.clientBug
switch response {
case .tagged(let taggedResponse):
switch taggedResponse.state {
case .bad(let text), .no(let text):
promise.fail(IMAPError.commandFailed(text.text))
case .ok:
promise.succeed(mailboxes)
}
case .untagged(let payload):
switch payload {
case .mailboxData(.list(let mailbox)):
mailboxes.append(mailbox)
default:
break
}
default:
break
}
context.fireChannelRead(data)
}
}

extension MailboxName {
static var reference: Self { Self(ByteBuffer(string: "")) }
}

extension MailboxPatterns {
static func mailbox(_ wildcard: Character = .wildcard) -> Self {
.mailbox(ByteBuffer(string: "\(wildcard)"))
}
}
3 changes: 2 additions & 1 deletion Feature/Sources/IMAP/LoginCommand.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import NIOCore
import NIOIMAP

// Log in to IMAP server with user/password combination
struct LoginCommand: IMAPCommand {
let username: String
let password: String
Expand All @@ -9,7 +10,7 @@ struct LoginCommand: IMAPCommand {
typealias Result = [Capability]
typealias Handler = CapabilityHandler

static var name: String { "login" }
var name: String { "login \"\(username)\"" }

func tagged(_ tag: String) -> NIOIMAPCore.TaggedCommand {
TaggedCommand(tag: tag, command: .login(username: username, password: password))
Expand Down
Loading