Files
SwiftMail/Sources/SwiftIMAP/IMAPServer.swift
Oliver Drobnik 510aff7306 Update search function to accept sort criteria and add SortCriteria enum
* Add `sortCriteria` parameter to `search` function in `IMAPServer.swift`
* Create `SortCriteria` enum with options for `DATE`, `ARRIVAL`, `FROM`, `TO`, `SUBJECT`, `SIZE`, and `REVERSE`
* Implement method to convert `SortCriteria` to NIO IMAP types
2025-03-12 15:14:58 +01:00

940 lines
31 KiB
Swift

import Foundation
import Logging
@preconcurrency import NIOIMAP
import NIOIMAPCore
import NIO
import NIOSSL
import NIOConcurrencyHelpers
import SwiftMailCore
/** An actor that represents an IMAP server connection */
public actor IMAPServer {
// MARK: - Properties
/** The hostname of the IMAP server */
private let host: String
/** The port number of the IMAP server */
private let port: Int
/** The event loop group for handling asynchronous operations */
private let group: EventLoopGroup
/** The channel for communication with the server */
private var channel: Channel?
/** Counter for generating unique command tags */
private var commandTagCounter: Int = 0
/** Server capabilities */
private var capabilities: Set<NIOIMAPCore.Capability> = []
/** The list of all mailboxes with their attributes */
public private(set) var mailboxes: [Mailbox.Info] = []
/** Special folders - mailboxes with SPECIAL-USE attributes */
public private(set) var specialMailboxes: [Mailbox.Info] = []
/**
Logger for IMAP operations
To view these logs in Console.app:
1. Open Console.app
2. In the search field, type "process:com.cocoanetics.SwiftIMAP"
3. You may need to adjust the "Action" menu to show "Include Debug Messages" and "Include Info Messages"
*/
private let logger: Logging.Logger
// A logger on the channel that watches both directions
private let duplexLogger: IMAPLogger
/// Error thrown when a standard folder is not defined
public struct UndefinedFolderError: Error, CustomStringConvertible {
public let folderType: String
public var description: String {
return "Standard folder '\(folderType)' is not defined. Call listSpecialUseMailboxes() first to detect special folders."
}
}
// MARK: - Initialization
/**
Initialize a new IMAP server connection
- Parameters:
- host: The hostname of the IMAP server
- port: The port number of the IMAP server
- numberOfThreads: The number of threads to use for the event loop group
*/
public init(host: String, port: Int, numberOfThreads: Int = 1) {
self.host = host
self.port = port
self.group = MultiThreadedEventLoopGroup(numberOfThreads: numberOfThreads)
// Initialize loggers
self.logger = Logging.Logger(label: "com.cocoanetics.SwiftMail.IMAPServer")
let outboundLogger = Logging.Logger(label: "com.cocoanetics.SwiftMail.IMAP_OUT")
let inboundLogger = Logging.Logger(label: "com.cocoanetics.SwiftMail.IMAP_IN")
self.duplexLogger = IMAPLogger(outboundLogger: outboundLogger, inboundLogger: inboundLogger)
}
deinit {
try? group.syncShutdownGracefully()
}
// MARK: - Connection and Login Commands
/**
Connect to the IMAP server
- Returns: A boolean indicating whether the connection was successful
- Throws: An error if the connection fails
*/
public func connect() async throws {
// Create SSL context for secure connection
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
// Capture the host as a local variable to avoid capturing self
let host = self.host
// Create the bootstrap
let bootstrap = ClientBootstrap(group: group)
.channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
.channelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
.channelInitializer { channel in
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: host)
// Create the IMAP client pipeline
return channel.pipeline.addHandlers([
sslHandler,
IMAPClientHandler(),
self.duplexLogger
])
}
// Connect to the server
let channel = try await bootstrap.connect(host: host, port: port).get()
// Store the channel
self.channel = channel
// Wait for the server greeting using our generic handler execution pattern
// The greeting handler now returns capabilities if they were in the greeting
let greetingCapabilities: [Capability] = try await executeHandlerOnly(handlerType: GreetingHandler.self, timeoutSeconds: 5)
// If we got capabilities from the greeting, use them
if !greetingCapabilities.isEmpty {
self.capabilities = Set(greetingCapabilities)
} else {
// Otherwise, fetch capabilities explicitly
try await fetchCapabilities()
}
}
/**
Fetch server capabilities
- Throws: An error if the capability command fails
- Returns: An array of server capabilities
*/
@discardableResult public func fetchCapabilities() async throws -> [Capability] {
let command = CapabilityCommand()
let serverCapabilities = try await executeCommand(command)
self.capabilities = Set(serverCapabilities)
return serverCapabilities
}
/**
Check if the server supports a specific capability
- Parameter capability: The capability to check for
- Returns: True if the server supports the capability
*/
private func supportsCapability(_ check: (Capability) -> Bool) -> Bool {
return capabilities.contains(where: check)
}
/**
Login to the IMAP server
- Parameters:
- username: The username for authentication
- password: The password for authentication
- Throws: An error if the login fails
*/
public func login(username: String, password: String) async throws {
let command = LoginCommand(username: username, password: password)
let loginCapabilities = try await executeCommand(command)
// If we got capabilities from the login response, use them
if !loginCapabilities.isEmpty {
self.capabilities = Set(loginCapabilities)
} else {
// Otherwise, fetch capabilities explicitly
try await fetchCapabilities()
}
}
/**
Disconnect from the server without sending a command
- Throws: An error if the disconnection fails
*/
public func disconnect() async throws
{
guard let channel = self.channel else {
logger.warning("Attempted to disconnect when channel was already nil")
return
}
channel.close(promise: nil)
self.channel = nil
}
// MARK: - Mailbox Commands
/**
Select a mailbox
- Parameter mailboxName: The name of the mailbox to select
- Returns: Status information about the selected mailbox
- Throws: An error if the select operation fails
*/
public func selectMailbox(_ mailboxName: String) async throws -> Mailbox.Status {
let command = SelectMailboxCommand(mailboxName: mailboxName)
return try await executeCommand(command)
}
/**
Close the currently selected mailbox
- Throws: An error if the close operation fails
*/
public func closeMailbox() async throws {
let command = CloseCommand()
try await executeCommand(command)
}
/**
Unselect the currently selected mailbox without expunging deleted messages
This is an IMAP extension command (RFC 3691) that might not be supported by all servers.
If the server does not support UNSELECT, an IMAPError will be thrown.
- Throws: An error if the unselect operation fails or is not supported
*/
public func unselectMailbox() async throws {
// Check if the server supports UNSELECT capability
if !capabilities.contains(.unselect) {
throw IMAPError.commandNotSupported("UNSELECT command not supported by server")
}
let command = UnselectCommand()
try await executeCommand(command)
}
/**
Logout from the IMAP server
- Throws: An error if the logout fails
*/
public func logout() async throws {
let command = LogoutCommand()
try await executeCommand(command)
}
// MARK: - Message Commands
/**
Fetch headers for messages in the selected mailbox
- Parameters:
- identifierSet: The set of message identifiers to fetch
- limit: Optional limit on the number of headers to return
- Returns: An array of email headers
- Throws: An error if the fetch operation fails
*/
public func fetchHeaders<T: MessageIdentifier>(using identifierSet: MessageIdentifierSet<T>, limit: Int? = nil) async throws -> [Header] {
let command = FetchHeadersCommand(identifierSet: identifierSet, limit: limit)
var headers = try await executeCommand(command)
// Apply limit if specified
if let limit = limit, headers.count > limit {
headers = Array(headers.prefix(limit))
}
return headers
}
/**
Fetch a specific part of a message
- Parameters:
- identifier: The message identifier (SequenceNumber or UID)
- sectionPath: The section path to fetch as an array of integers (e.g., [1], [1, 1], [2], etc.)
- Returns: The content of the message part as Data
- Throws: An error if the fetch operation fails
*/
public func fetchMessagePart<T: MessageIdentifier>(identifier: T, sectionPath: [Int]) async throws -> Data {
let command = FetchMessagePartCommand(identifier: identifier, sectionPath: sectionPath)
return try await executeCommand(command)
}
/**
Fetch the structure of a message to determine its parts
- Parameter identifier: The message identifier (SequenceNumber or UID)
- Returns: The body structure of the message
- Throws: An error if the fetch operation fails
*/
public func fetchMessageStructure<T: MessageIdentifier>(identifier: T) async throws -> BodyStructure {
let command = FetchStructureCommand(identifier: identifier)
return try await executeCommand(command)
}
/**
Fetch all parts of a message
- Parameter identifier: The message identifier (SequenceNumber or UID)
- Returns: An array of message parts
- Throws: An error if the fetch operation fails
*/
public func fetchAllMessageParts<T: MessageIdentifier>(identifier: T) async throws -> [MessagePart] {
// First, fetch the message structure to determine the parts
let structure = try await fetchMessageStructure(identifier: identifier)
// Process the structure recursively and return the parts
return try await recursivelyFetchParts(structure, sectionPath: [], identifier: identifier)
}
/**
Fetch a complete email with all parts from an email header
- Parameter header: The email header to fetch the complete email for
- Returns: A complete Email object with all parts
- Throws: An error if the fetch operation fails
*/
public func fetchMessage(from header: Header) async throws -> Message {
// Use the UID from the header if available (non-zero), otherwise fall back to sequence number
if header.uid > 0 {
// Use UID for fetching
let uid = UID(UInt32(header.uid))
let parts = try await fetchAllMessageParts(identifier: uid)
return Message(header: header, parts: parts)
} else {
// Fall back to sequence number
let sequenceNumber = SequenceNumber(UInt32(header.sequenceNumber))
let parts = try await fetchAllMessageParts(identifier: sequenceNumber)
return Message(header: header, parts: parts)
}
}
/**
Fetch complete emails with all parts using a message identifier set
- Parameters:
- identifierSet: The set of message identifiers to fetch
- limit: Optional limit on the number of emails to fetch
- Returns: An array of Email objects with all parts
- Throws: An error if the fetch operation fails
*/
public func fetchMessages<T: MessageIdentifier>(using identifierSet: MessageIdentifierSet<T>, limit: Int? = nil) async throws -> [Message] {
guard !identifierSet.isEmpty else {
throw IMAPError.emptyIdentifierSet
}
// First fetch the headers
let headers = try await fetchHeaders(using: identifierSet, limit: limit)
// Then fetch the complete email for each header
var emails: [Message] = []
for header in headers {
let email = try await fetchMessage(from: header)
emails.append(email)
}
return emails
}
/**
Copy messages from the current mailbox to another mailbox
- Parameters:
- messages: The set of message identifiers to copy
- destinationMailbox: The name of the destination mailbox
- Throws: An error if the copy operation fails
*/
public func copy<T: MessageIdentifier>(messages identifierSet: MessageIdentifierSet<T>, to destinationMailbox: String) async throws {
let command = CopyCommand(identifierSet: identifierSet, destinationMailbox: destinationMailbox)
try await executeCommand(command)
}
/**
Copy a single message from the current mailbox to another mailbox
- Parameters:
- message: The message identifier to copy
- destinationMailbox: The name of the destination mailbox
- Throws: An error if the copy operation fails
*/
public func copy<T: MessageIdentifier>(message identifier: T, to destinationMailbox: String) async throws {
let set = MessageIdentifierSet<T>(identifier)
try await copy(messages: set, to: destinationMailbox)
}
/**
Expunge deleted messages from the selected mailbox
- Throws: An error if the expunge operation fails
*/
public func expunge() async throws {
let command = ExpungeCommand()
try await executeCommand(command)
}
/**
Internal function to execute the MOVE command
- Parameters:
- messages: The set of message identifiers to move
- destinationMailbox: The name of the destination mailbox
- Throws: An error if the move operation fails
*/
private func executeMove<T: MessageIdentifier>(messages identifierSet: MessageIdentifierSet<T>, to destinationMailbox: String) async throws {
let command = MoveCommand(identifierSet: identifierSet, destinationMailbox: destinationMailbox)
try await executeCommand(command)
}
/**
Move messages from the current mailbox to another mailbox
If the server supports the MOVE command and UIDPLUS (when using UIDs), it will use that.
Otherwise, it will fall back to copy + delete + expunge.
- Parameters:
- messages: The set of message identifiers to move
- destinationMailbox: The name of the destination mailbox
- Throws: An error if the move operation fails
*/
public func move<T: MessageIdentifier>(messages identifierSet: MessageIdentifierSet<T>, to destinationMailbox: String) async throws {
if capabilities.contains(.move) && (T.self != UID.self || capabilities.contains(.uidPlus)) {
try await executeMove(messages: identifierSet, to: destinationMailbox)
} else {
// Fall back to COPY + DELETE + EXPUNGE
try await copy(messages: identifierSet, to: destinationMailbox)
try await store(flags: [.deleted], on: identifierSet, operation: .add)
try await expunge()
}
}
/**
Move a single message from the current mailbox to another mailbox
- Parameters:
- message: The message identifier to move
- destinationMailbox: The name of the destination mailbox
- Throws: An error if the move operation fails
*/
public func move<T: MessageIdentifier>(message identifier: T, to destinationMailbox: String) async throws {
let set = MessageIdentifierSet<T>(identifier)
try await move(messages: set, to: destinationMailbox)
}
/**
Move an email identified by its header from the current mailbox to another mailbox
- Parameters:
- header: The email header of the message to move
- destinationMailbox: The name of the destination mailbox
- Throws: An error if the move operation fails
*/
public func move(header: Header, to destinationMailbox: String) async throws {
// Use the UID from the header if available (non-zero), otherwise fall back to sequence number
if header.uid > 0 {
// Use UID for moving
let uid = UID(UInt32(header.uid))
try await move(message: uid, to: destinationMailbox)
} else {
// Fall back to sequence number
let sequenceNumber = SequenceNumber(UInt32(header.sequenceNumber))
try await move(message: sequenceNumber, to: destinationMailbox)
}
}
/**
Store flags on messages
- Parameters:
- flags: The flags to store
- messages: The set of message identifiers to update
- operation: The store operation (.add or .remove)
- Throws: An error if the operation fails
*/
public func store<T: MessageIdentifier>(flags: [Flag], on identifierSet: MessageIdentifierSet<T>, operation: StoreOperation) async throws {
let storeData = StoreData.flags(flags, operation == .add ? .add : .remove)
let command = StoreCommand(identifierSet: identifierSet, data: storeData)
try await executeCommand(command)
}
public enum StoreOperation {
case add
case remove
}
// MARK: - Sub-Commands
/**
Process a body structure recursively to fetch all parts
- Parameters:
- structure: The body structure to process
- sectionPath: Array of integers representing the hierarchical section path
- identifier: The message identifier (SequenceNumber or UID)
- Returns: An array of message parts
- Throws: An error if the fetch operation fails
*/
private func recursivelyFetchParts<T: MessageIdentifier>(_ structure: BodyStructure, sectionPath: [Int], identifier: T) async throws -> [MessagePart] {
switch structure {
case .singlepart(let part):
// Determine the part number string for IMAP (e.g., "1.2.3")
let partNumberString = sectionPath.isEmpty ? "1" : sectionPath.map { String($0) }.joined(separator: ".")
// Fetch the part content
let partData = try await fetchMessagePart(identifier: identifier, sectionPath: sectionPath.isEmpty ? [1] : sectionPath)
// Extract content type and other metadata
var contentType = ""
var contentSubtype = ""
switch part.kind {
case .basic(let mediaType):
contentType = String(mediaType.topLevel)
contentSubtype = String(mediaType.sub)
case .text(let text):
contentType = "text"
contentSubtype = String(text.mediaSubtype)
case .message(let message):
contentType = "message"
contentSubtype = String(message.message)
}
// Extract disposition and filename if available
var disposition: String? = nil
var filename: String? = nil
if let ext = part.extension, let dispAndLang = ext.dispositionAndLanguage {
if let disp = dispAndLang.disposition {
disposition = String(describing: disp)
for (key, value) in disp.parameters {
if key.lowercased() == "filename" {
filename = value
}
}
}
}
// Set content ID if available
let contentId = part.fields.id
// Create a message part
let messagePart = MessagePart(
partNumber: partNumberString,
contentType: contentType,
contentSubtype: contentSubtype,
disposition: disposition,
filename: filename,
contentId: contentId,
data: partData
)
// Return a single-element array with this part
return [messagePart]
case .multipart(let multipart):
// For multipart messages, process each child part and collect results
var allParts: [MessagePart] = []
for (index, childPart) in multipart.parts.enumerated() {
// Create a new section path array by appending the current index + 1
let childSectionPath = sectionPath.isEmpty ? [index + 1] : sectionPath + [index + 1]
let childParts = try await recursivelyFetchParts(childPart, sectionPath: childSectionPath, identifier: identifier)
allParts.append(contentsOf: childParts)
}
return allParts
}
}
// MARK: - Command Helpers
/**
Execute an IMAP command
- Parameter command: The command to execute
- Returns: The result of executing the command
- Throws: An error if the command execution fails
*/
private func executeCommand<CommandType: IMAPCommand>(_ command: CommandType) async throws -> CommandType.ResultType {
// Validate the command before execution
try command.validate()
guard let channel = self.channel else {
throw IMAPError.connectionFailed("Channel not initialized")
}
// Create a promise for the command result
let resultPromise = channel.eventLoop.makePromise(of: CommandType.ResultType.self)
// Generate a unique tag for the command
let tag = generateCommandTag()
// Create the handler for this command
let handler = command.handlerType.init(commandTag: tag, promise: resultPromise)
// Get timeout value for this command
let timeoutSeconds = command.timeoutSeconds
// Create a timeout for the command
let scheduledTask = group.next().scheduleTask(in: .seconds(Int64(timeoutSeconds))) {
self.logger.warning("Command timed out after \(timeoutSeconds) seconds")
resultPromise.fail(IMAPError.timeout)
}
do {
// Add the handler to the channel pipeline
try await channel.pipeline.addHandler(handler).get()
// Write the command to the channel wrapped as CommandStreamPart
try await channel.writeAndFlush(CommandStreamPart.tagged(command.toTaggedCommand(tag: tag))).get()
// Wait for the result
let result = try await resultPromise.futureResult.get()
// Cancel the timeout
scheduledTask.cancel()
// Flush the DuplexLogger's buffer after command execution
duplexLogger.flushInboundBuffer()
return result
} catch {
// Cancel the timeout
scheduledTask.cancel()
// Flush the DuplexLogger's buffer even if there was an error
duplexLogger.flushInboundBuffer()
throw error
}
}
/**
Execute a handler without sending a command (for server-initiated responses like greeting)
- Parameters:
- handlerType: The type of handler to use
- timeoutSeconds: The timeout in seconds
- Returns: The result from the handler
- Throws: An error if the operation fails
*/
private func executeHandlerOnly<T, HandlerType: IMAPCommandHandler>(
handlerType: HandlerType.Type,
timeoutSeconds: Int = 5
) async throws -> T where HandlerType.ResultType == T {
guard let channel = self.channel else {
throw IMAPError.connectionFailed("Channel not initialized")
}
// Create the handler promise
let resultPromise = channel.eventLoop.makePromise(of: T.self)
// Create the handler directly
let handler = HandlerType.init(commandTag: "", promise: resultPromise)
// Create a timeout for the handler
let scheduledTask = group.next().scheduleTask(in: .seconds(Int64(timeoutSeconds))) {
self.logger.warning("Handler execution timed out after \(timeoutSeconds) seconds")
resultPromise.fail(IMAPError.timeout)
}
do {
// Add the handler to the pipeline
try await channel.pipeline.addHandler(handler).get()
// Wait for the result
let result = try await resultPromise.futureResult.get()
// Cancel the timeout
scheduledTask.cancel()
// Flush the DuplexLogger's buffer after command execution
duplexLogger.flushInboundBuffer()
return result
} catch {
// Cancel the timeout
scheduledTask.cancel()
// Flush the DuplexLogger's buffer even if there was an error
duplexLogger.flushInboundBuffer()
throw error
}
}
/**
Generate a unique command tag
- Returns: A unique command tag string
*/
private func generateCommandTag() -> String {
// Simple implementation with a consistent prefix
let tagPrefix = "A"
// Increment the counter directly - actor isolation ensures thread safety
commandTagCounter += 1
return "\(tagPrefix)\(String(format: "%03d", commandTagCounter))"
}
/**
Searches for messages matching the given criteria
- Parameters:
- identifierSet: Optional set of message identifiers to search within
- criteria: The search criteria to apply
- sortCriteria: Optional sort criteria to apply
- Returns: A set of message identifiers matching the search criteria
- Throws: An error if the search operation fails
*/
public func search<T: MessageIdentifier>(identifierSet: MessageIdentifierSet<T>? = nil, criteria: [SearchCriteria], sortCriteria: [SortCriteria]? = nil) async throws -> MessageIdentifierSet<T> {
let command = SearchCommand(identifierSet: identifierSet, criteria: criteria, sortCriteria: sortCriteria)
return try await executeCommand(command)
}
/**
Sorts messages based on the given sort criteria and search criteria
- Parameters:
- sortCriteria: The sort criteria to apply
- charset: The character set for searching (commonly "UTF-8")
- searchCriteria: The search criteria to apply
- Returns: A set of message identifiers matching the search and sort criteria
- Throws: An error if the sort operation fails
*/
public func sort<T: MessageIdentifier>(sortCriteria: [SortCriteria], charset: String = "UTF-8", searchCriteria: [SearchCriteria]) async throws -> MessageIdentifierSet<T> {
// Check if the server supports the SORT capability
if supportsCapability({ $0 == .sort }) {
let command = SearchCommand(identifierSet: nil, criteria: searchCriteria, sortCriteria: sortCriteria)
return try await executeCommand(command)
} else {
// Fallback to SEARCH if SORT is not supported
let command = SearchCommand(identifierSet: nil, criteria: searchCriteria)
return try await executeCommand(command)
}
}
}
// MARK: - Common Mail Operations
extension IMAPServer {
/**
List mailboxes with SPECIAL-USE attributes and update the folder configuration.
This method is the primary way to detect special folders in IMAP:
- If the server supports SPECIAL-USE capability, it will use the LIST command with SPECIAL-USE return option
- If not, it will detect special mailboxes by name using common folder name patterns
- Returns: Array of mailboxes with special-use attributes only
- Throws: An error if the operation fails
*/
public func listSpecialUseMailboxes() async throws -> [Mailbox.Info] {
// Check if the server supports SPECIAL-USE capability
let supportsSpecialUse = capabilities.contains(NIOIMAPCore.Capability("SPECIAL-USE"))
// Get all mailboxes and store them
self.mailboxes = try await listMailboxes()
var specialFolders: [Mailbox.Info] = []
// Flag to track if we've found an explicit inbox
var foundExplicitInbox = false
if supportsSpecialUse {
// Create a ListCommand with SPECIAL-USE return option
let command = ListCommand(returnOptions: [.specialUse])
let mailboxesWithAttributes = try await executeCommand(command)
// Keep only mailboxes with special-use attributes
for mailbox in mailboxesWithAttributes {
let hasSpecialUse = mailbox.attributes.contains(.inbox) ||
mailbox.attributes.contains(.trash) ||
mailbox.attributes.contains(.archive) ||
mailbox.attributes.contains(.sent) ||
mailbox.attributes.contains(.drafts) ||
mailbox.attributes.contains(.junk) ||
mailbox.attributes.contains(.flagged)
if hasSpecialUse {
specialFolders.append(mailbox)
if mailbox.attributes.contains(.inbox) {
foundExplicitInbox = true
}
}
}
} else {
// Detect special folders by name when SPECIAL-USE is not supported
for mailbox in mailboxes {
var attributes = mailbox.attributes
var hasSpecialUse = false
// Check name patterns for common special folders
let nameLower = mailbox.name.lowercased()
if mailbox.attributes.contains(.inbox) {
foundExplicitInbox = true
hasSpecialUse = true
} else if nameLower.contains("trash") || nameLower.contains("deleted") {
attributes.insert(.trash)
hasSpecialUse = true
} else if nameLower.contains("sent") {
attributes.insert(.sent)
hasSpecialUse = true
} else if nameLower.contains("draft") {
attributes.insert(.drafts)
hasSpecialUse = true
} else if nameLower.contains("junk") || nameLower.contains("spam") {
attributes.insert(.junk)
hasSpecialUse = true
} else if nameLower.contains("archive") || (nameLower.contains("all") && nameLower.contains("mail")) {
attributes.insert(.archive)
hasSpecialUse = true
} else if nameLower.contains("starred") || nameLower.contains("flagged") {
attributes.insert(.flagged)
hasSpecialUse = true
}
// Special case for Gmail's folders
if mailbox.name == "[Gmail]/Trash" {
attributes.insert(.trash)
hasSpecialUse = true
} else if mailbox.name == "[Gmail]/Sent Mail" {
attributes.insert(.sent)
hasSpecialUse = true
} else if mailbox.name == "[Gmail]/Drafts" {
attributes.insert(.drafts)
hasSpecialUse = true
} else if mailbox.name == "[Gmail]/Spam" {
attributes.insert(.junk)
hasSpecialUse = true
} else if mailbox.name == "[Gmail]/All Mail" {
attributes.insert(.archive)
hasSpecialUse = true
} else if mailbox.name == "[Gmail]/Starred" {
attributes.insert(.flagged)
hasSpecialUse = true
}
if hasSpecialUse {
// Create a new mailbox info with the enhanced attributes
let specialMailbox = Mailbox.Info(
name: mailbox.name,
attributes: attributes,
hierarchyDelimiter: mailbox.hierarchyDelimiter
)
specialFolders.append(specialMailbox)
}
}
}
// Per IMAP spec, INBOX always exists - if no explicit inbox was found, add it
if !foundExplicitInbox {
// Find the INBOX in the mailboxes list
if let inboxMailbox = mailboxes.first(where: { $0.name.caseInsensitiveCompare("INBOX") == .orderedSame }) {
// Create a copy with the inbox attribute added
var inboxAttributes = inboxMailbox.attributes
inboxAttributes.insert(.inbox)
let inboxWithAttribute = Mailbox.Info(
name: inboxMailbox.name,
attributes: inboxAttributes,
hierarchyDelimiter: inboxMailbox.hierarchyDelimiter
)
specialFolders.append(inboxWithAttribute)
}
}
// Update the specialMailboxes property
self.specialMailboxes = specialFolders
return specialFolders
}
}
// MARK: - Mailbox Listing and Special Folders
extension IMAPServer {
/** Get a list of all available mailboxes
- Returns: Array of mailbox information
- Throws: An error if the operation fails
*/
public func listMailboxes() async throws -> [Mailbox.Info] {
let command = ListCommand()
return try await executeCommand(command)
}
/// Get the inbox folder or throw if not found
public var inboxFolder: Mailbox.Info {
get throws {
guard let inbox = specialMailboxes.inbox ?? mailboxes.inbox else {
throw UndefinedFolderError(folderType: "Inbox")
}
return inbox
}
}
/// Get the trash folder or throw if not found
public var trashFolder: Mailbox.Info {
get throws {
guard let trash = specialMailboxes.trash else {
throw UndefinedFolderError(folderType: "Trash")
}
return trash
}
}
/// Get the archive folder or throw if not found
public var archiveFolder: Mailbox.Info {
get throws {
guard let archive = specialMailboxes.archive else {
throw UndefinedFolderError(folderType: "Archive")
}
return archive
}
}
/// Get the sent folder or throw if not found
public var sentFolder: Mailbox.Info {
get throws {
guard let sent = specialMailboxes.sent else {
throw UndefinedFolderError(folderType: "Sent")
}
return sent
}
}
/// Get the drafts folder or throw if not found
public var draftsFolder: Mailbox.Info {
get throws {
guard let drafts = specialMailboxes.drafts else {
throw UndefinedFolderError(folderType: "Drafts")
}
return drafts
}
}
/// Get the junk folder or throw if not found
public var junkFolder: Mailbox.Info {
get throws {
guard let junk = specialMailboxes.junk else {
throw UndefinedFolderError(folderType: "Junk")
}
return junk
}
}
}
// Update the existing folder operations to use the throwing getters
extension IMAPServer {
public func moveToTrash<T: MessageIdentifier>(messages identifierSet: MessageIdentifierSet<T>) async throws {
try await move(messages: identifierSet, to: try trashFolder.name)
}
public func archive<T: MessageIdentifier>(messages identifierSet: MessageIdentifierSet<T>) async throws {
try await store(flags: [.seen], on: identifierSet, operation: .add)
try await move(messages: identifierSet, to: try archiveFolder.name)
}
public func markAsJunk<T: MessageIdentifier>(messages identifierSet: MessageIdentifierSet<T>) async throws {
try await move(messages: identifierSet, to: try junkFolder.name)
}
public func saveAsDraft<T: MessageIdentifier>(messages identifierSet: MessageIdentifierSet<T>) async throws {
try await store(flags: [.draft], on: identifierSet, operation: .add)
try await move(messages: identifierSet, to: try draftsFolder.name)
}
}