A modern, pure-Swift IMAP client library with async/await support. SwiftIMAP provides a clean, type-safe API for interacting with IMAP email servers without any C dependencies.
- Pure Swift: No C or Objective-C dependencies
- Modern Async/Await: Built with Swift Concurrency from the ground up
- Type-Safe: Strongly typed commands and responses
- Secure by Default: TLS 1.2+ with certificate validation
- Memory Efficient: Streaming support for large messages
- Custom Labels: IMAP keywords via raw flag support
- Well-Tested: Comprehensive unit test coverage
- Swift 5.10+
- macOS 13+, iOS 16+, tvOS 16+, watchOS 9+
Add SwiftIMAP to your Package.swift:
dependencies: [
.package(url: "https://github.com/HokuNZ/SwiftIMAP.git", from: "2.0.0")
]import SwiftIMAP
// Configure the client
let config = IMAPConfiguration(
hostname: "imap.example.com",
port: 993,
authMethod: .login(username: "user@example.com", password: "password")
)
// Create client
let client = IMAPClient(configuration: config)
// Connect and authenticate
try await client.connect()
// List mailboxes
let mailboxes = try await client.listMailboxes()
for mailbox in mailboxes {
print("Mailbox: \(mailbox.name)")
}
// Select a mailbox
let status = try await client.selectMailbox("INBOX")
print("Messages in INBOX: \(status.messages)")
// Search for messages
let messageUIDs = try await client.listMessageUIDs(in: "INBOX")
// Fetch a message
if let firstUID = messageUIDs.first {
if let message = try await client.fetchMessage(uid: firstUID, in: "INBOX") {
print("Subject: \(message.envelope?.subject ?? "No subject")")
}
}
// Disconnect
await client.disconnect()An
IMAPClientwraps a single connection with one selected mailbox. Its mailbox-scoped operations are not safe to run concurrently on one instance — issue them serially, or use one client per concurrent context.
A complete runnable version of this flow is in
Examples/BasicUsage/BasicUsage.swift
(swift run BasicUsageExample).
SwiftIMAP includes swift-imap-tester for exercising connections against a real
server. It has two subcommands, connect (one-shot) and interactive.
swift build --product swift-imap-testerconnect runs a single command and exits. --command accepts list (default),
select, search, or fetch:
# List mailboxes
.build/debug/swift-imap-tester connect \
--host imap.gmail.com --username your@email.com --password yourpassword \
--command list
# Fetch a specific message
.build/debug/swift-imap-tester connect \
--host imap.gmail.com --username your@email.com --password yourpassword \
--command fetch --mailbox INBOX --uid 12345Connection flags (both subcommands): --host, --username, --password,
--port (default 993), --starttls, --no-tls, --verbose.
.build/debug/swift-imap-tester interactive \
--host imap.gmail.com --username your@email.com --password yourpasswordInteractive commands (type help for the full list):
list [pattern],select <mailbox>,status [mailbox],closemessages,fetch <uid>,capabilitysearch [from <email> | subject <text> | text <text> | unread | flagged | since <date>]read <uid>,unread <uid>,flag <uid>,unflag <uid>copy <uid> <mailbox>,move <uid> <mailbox>,delete <uid>,expungehelp,quit
let config = IMAPConfiguration(
hostname: "imap.example.com",
port: 993, // Default IMAP SSL/TLS port
tlsMode: .requireTLS, // .requireTLS, .startTLS, or .disabled
authMethod: .login(username: "user", password: "pass"),
connectionTimeout: 30, // seconds
commandTimeout: 60, // seconds
logLevel: .info // .none, .error, .warning, .info, .debug, .trace
)// Username/Password
.login(username: "user", password: "password")
// PLAIN mechanism
.plain(username: "user", password: "password")
// OAuth 2.0
.oauth2(username: "user", accessToken: "token")
// External (client certificate)
.external
// Custom SASL flow
.sasl(
mechanism: "PLAIN",
initialResponse: "base64-encoded",
responseHandler: { challenge in
// Return Base64-encoded response or "" for an empty response
return "next-base64-response"
}
)// List all mailboxes
let mailboxes = try await client.listMailboxes()
// List with pattern
let inboxSubfolders = try await client.listMailboxes(pattern: "INBOX.*")
// List only subscribed mailboxes
let subscribed = try await client.listSubscribedMailboxes()
// Get mailbox status without selecting
let status = try await client.mailboxStatus("Sent")
// Create / rename / delete, and manage subscriptions
try await client.createMailbox("Projects/2026")
try await client.renameMailbox(from: "Projects/2026", to: "Archive/2026")
try await client.subscribeMailbox("Archive/2026")
try await client.unsubscribeMailbox("Archive/2026")
try await client.deleteMailbox("Archive/2026")// Search messages
let allMessageUIDs = try await client.listMessageUIDs(
in: "INBOX",
searchCriteria: .all
)
let unreadUIDs = try await client.listMessageUIDs(
in: "INBOX",
searchCriteria: .unseen
)
let fromAlice = try await client.listMessageUIDs(
in: "INBOX",
searchCriteria: .from("alice@example.com")
)
// Fetch message summary
let summary = try await client.fetchMessage(
uid: 12345,
in: "INBOX",
items: [.uid, .flags, .envelope, .bodyStructure]
)
// Fetch full message body
let bodyData = try await client.fetchMessageBody(
uid: 12345,
in: "INBOX",
peek: true // Don't mark as read
)
// Add/remove custom label (IMAP keyword)
try await client.storeFlags(uid: 12345, in: "INBOX", flags: ["ProjectA"], action: .add)
let labeled = try await client.searchMessages(in: "INBOX", criteria: .keyword("ProjectA"))
// Labels map to IMAP keywords (Gmail's X-GM-LABELS extension is not implemented)
// Mark read / unread
try await client.markAsRead(uid: 12345, in: "INBOX")
// Move, copy, delete
try await client.moveMessage(uid: 12345, from: "INBOX", to: "Archive")
try await client.copyMessages(uids: [1, 2, 3], from: "INBOX", to: "Backup")
try await client.deleteMessages(uids: [12345], in: "INBOX") // STORE \Deleted, then UID EXPUNGE
// Append a message (e.g. save a draft)
try await client.appendMessage(rfc822Data, to: "Drafts", flags: [.draft])Guarding writes against a stale mailbox view. Pass expectedUIDValidity: to
any write (storeFlags, moveMessage(s), copyMessage(s), expunge(uids:),
deleteMessage(s)) and it's refused with IMAPError.uidValidityChanged, before
any command is sent, if the mailbox was recreated since you read those UIDs:
let status = try await client.selectMailbox("INBOX")
let uids = try await client.listMessageUIDs(in: "INBOX", searchCriteria: .unseen)
try await client.moveMessages(uids: uids, from: "INBOX", to: "Archive",
expectedUIDValidity: status.uidValidity)Targeted
deleteMessage(s)/expunge(uids:)requireUIDPLUS(they useUID EXPUNGE) and throwunsupportedCapability("UIDPLUS")otherwise. Without theMOVEextension,moveMessage(s)falls back to copy-then-mark-\Deleted, leaving the source until expunged.
Message-ID, In-Reply-To, and References are exposed as MessageId values
rather than raw strings. MessageId canonicalises to the bare form on parse, so
identifiers compare equal regardless of how a server framed them (with or without
angle brackets) — threading comparisons need no bracket handling:
// Fetch the References header alongside the envelope
let summary = try await client.fetchMessage(
uid: 12345, in: "INBOX",
items: [.envelope, .bodyHeaderFields(fields: ["References"], peek: true)]
)
if let parent = summary?.envelope?.inReplyTo,
summary?.references.contains(parent) == true {
// this message replies to a known ancestor in its own thread
}
let id = summary?.envelope?.messageId
id?.value // "abc@host" — bare canonical identity
id?.bracketed // "<abc@host>" — ready to write into an outgoing headerParse a fetched body into its MIME parts — text, HTML, and attachments:
guard let data = try await client.fetchMessageBody(uid: 12345, in: "INBOX"),
let mime = try MessageSummary.parseMIMEContent(from: data) else { return }
let text = mime.plainTextContent
let html = mime.htmlContent
for attachment in mime.attachments {
print("\(attachment.filename ?? "unnamed"): \(attachment.decodedData?.count ?? 0) bytes")
}ParsedMIMEMessage and MIMEPart are Sendable value types (parts hold decoded
content), so parsed results can cross actor and task boundaries.
For consumers holding raw RFC 822 bytes rather than a live FETCH — .eml
files, Maildir, webhook payloads, test fixtures — build the typed model directly.
Header parsing is independent of the MIME body (so an unparseable body won't lose
the envelope) and tolerates real-world bytes (non-UTF-8 content, a leading mbox
From line):
// Whole message → MessageSummary (envelope + references populated)
let summary = try MessageSummary.parse(rfc822: emlData)
// Just a header dictionary → typed Envelope
let envelope = Envelope(parsingHeaders: ["From": "a@x.com", "Subject": "Hi"])A parsed summary is read-only metadata: its
uidis a placeholder (0), so don't pass it back into UID-based operations.
See Examples/OfflineParsing/OfflineParsing.swift
for a runnable, server-free example (swift run OfflineParsingExample).
All operations throw IMAPError. When the server rejects a command, commandFailed
carries a structured IMAPServerResponse so you can log a faithful server response
line and distinguish causes. SwiftIMAP never puts your command arguments, credentials,
or message bodies into error output. Two caveats on server-supplied text: a
commandFailed/connectionClosed response line includes the server's own text,
which some servers echo user-specific details into (e.g. a mailbox name); and a
parsingError embeds a truncated slice of the offending server input for diagnostics,
which can include message content. Treat both as potentially sensitive before exporting
to third parties.
do {
try await client.moveMessage(uid: 12345, from: "INBOX", to: "Archive")
} catch let error as IMAPError {
switch error {
case .commandFailed(let response):
// response.status -> .no / .bad
// response.code -> e.g. .tryCreate, .other("OVERQUOTA", nil)
// response.line -> "NO [TRYCREATE] Mailbox does not exist"
// (server text — may echo user details like a mailbox name)
log.error("\(response.commandName) failed: \(response.line)")
if response.isMailboxNotFound { /* destination renamed or removed */ }
if response.isOverQuota { /* account over quota */ }
case .connectionClosed(let response):
// A server BYE, e.g. "BYE [ALERT] Too many connections"
log.error("Connection closed: \(response?.line ?? "unexpected")")
case .timeout(let command):
log.error("Timed out: \(command ?? "connection")")
case .connectionFailed(_, let underlying):
// The typed transport cause (NIO/SSL error) is preserved for inspection
log.error("Connect failed: \(underlying.map(String.init(describing:)) ?? "unknown")")
default:
log.error("\(error.localizedDescription)")
}
}The default: arm above is deliberate — see Versions for why library
enums need one.
Run the test suite:
swift testRun tests with verbose output:
swift test --enable-code-coverage --verboseSwiftIMAP is built with a layered architecture:
- Network Layer (SwiftNIO + NIOSSL): Handles TCP connections and TLS
- Protocol Layer (Parser/Encoder): Implements IMAP protocol parsing and encoding
- API Layer (IMAPClient): Provides high-level async/await APIs
- TLS 1.2+ is required by default
- Certificate validation enabled
- Sensitive data (passwords) never logged
- Support for certificate pinning via custom TLSConfiguration
SwiftIMAP follows semantic versioning. Breaking changes to the public API land only in major releases. The CHANGELOG records the per-release detail.
Upgrading across a major? The 1.x → 2.0 migration guide lists every change that needs a code update, with the replacement for each, and the CHANGELOG carries the full per-change history.
Enums may grow in minor releases. Public enums — IMAPError,
IMAPResponse.ResponseCode, IMAPCommand.SearchCriteria, and others — may gain
new cases in a minor release. When you switch over one, always include a
default: arm so a new case doesn't break your build:
switch error {
case .commandFailed(let response): ...
case .connectionClosed(let response): ...
default: log.error("\(error.localizedDescription)") // tolerates future cases
}(Swift source packages can't use @unknown default, so a plain default is the
tool here.)
Contributions are welcome! Please feel free to submit a Pull Request.
This repository is LLM-generated code, and we have done our best to be accurate, but 🤷♂️ it works for us.
SwiftIMAP is released under the MIT license. See LICENSE for details,
and ACKNOWLEDGEMENTS.md for the third-party packages it builds on.
