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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.claude/
# Environment variables
.env
.env.*
Expand Down
58 changes: 47 additions & 11 deletions Sources/SwiftMail/IMAP/IMAP/Commands/FetchCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,43 @@
import Foundation
import NIO
import NIOIMAP
import NIOIMAPCore

/// Command for fetching message headers
/// Command for fetching message metadata. The exact attributes requested are selected by
/// `options`; pass `headerFields` to request a `BODY.PEEK[HEADER.FIELDS (...)]` section
/// instead of (or in addition to) the full header — useful for newsletter / auto-mail
/// detection without the cost of the full header section.
struct FetchMessageInfoCommand<T: MessageIdentifier>: IMAPTaggedCommand {
typealias ResultType = [MessageInfo]
typealias HandlerType = FetchMessageInfoHandler

/// The set of message identifiers to fetch
let identifierSet: MessageIdentifierSet<T>

/// Which attributes to request alongside UID.
let options: FetchMessageInfoOptions

/// Optional named header fields to request via `BODY.PEEK[HEADER.FIELDS (...)]`.
/// Ignored when `options` already contains `.fullHeader` (the full header section
/// includes everything).
let headerFields: [String]?

/// Custom timeout for this operation
let timeoutSeconds = 10

/// Initialize a new fetch headers command
/// - Parameter identifierSet: The set of message identifiers to fetch
init(identifierSet: MessageIdentifierSet<T>) {
/// - Parameters:
/// - identifierSet: The set of message identifiers to fetch
/// - options: Which attributes to request. Defaults to `.default`.
/// - headerFields: Optional named header fields. See `FetchMessageInfoOptions.newsletterHeaderFields`.
init(
identifierSet: MessageIdentifierSet<T>,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil
) {
self.identifierSet = identifierSet
self.options = options
self.headerFields = headerFields
}

/// Validate the command before execution
Expand All @@ -33,14 +54,29 @@ struct FetchMessageInfoCommand<T: MessageIdentifier>: IMAPTaggedCommand {
/// - Parameter tag: The command tag
/// - Returns: A TaggedCommand ready to be sent to the server
func toTaggedCommand(tag: String) -> TaggedCommand {
let attributes: [FetchAttribute] = [
.uid,
.envelope,
.internalDate,
.bodyStructure(extensions: true),
.bodySection(peek: true, .header, nil),
.flags
]
var attributes: [FetchAttribute] = [.uid]
if options.contains(.envelope) {
attributes.append(.envelope)
}
if options.contains(.internalDate) {
attributes.append(.internalDate)
}
if options.contains(.flags) {
attributes.append(.flags)
}
if options.contains(.size) {
attributes.append(.rfc822Size)
}
if options.contains(.bodyStructure) {
attributes.append(.bodyStructure(extensions: true))
}

if options.contains(.fullHeader) {
attributes.append(.bodySection(peek: true, .header, nil))
} else if let fields = headerFields, !fields.isEmpty {
let section = SectionSpecifier(part: .init([]), kind: .headerFields(fields))
attributes.append(.bodySection(peek: true, section, nil))
}

if T.self == UID.self {
return TaggedCommand(tag: tag, command: .uidFetch(
Expand Down
112 changes: 100 additions & 12 deletions Sources/SwiftMail/IMAP/IMAPNamedConnection+Fetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,37 +38,125 @@ extension IMAPNamedConnection {
}

/// Fetch message metadata for one identifier.
public func fetchMessageInfo<T: MessageIdentifier>(for identifier: T) async throws -> MessageInfo? {
/// - Parameters:
/// - identifier: The message identifier to fetch.
/// - options: Which attributes to request. Defaults to `.default`.
/// - headerFields: Optional named header fields to request via `BODY.PEEK[HEADER.FIELDS (...)]`.
public func fetchMessageInfo<T: MessageIdentifier>(
for identifier: T,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil
) async throws -> MessageInfo? {
let set = MessageIdentifierSet<T>(identifier)
let command = FetchMessageInfoCommand(identifierSet: set)
let command = FetchMessageInfoCommand(
identifierSet: set, options: options, headerFields: headerFields
)
return try await executeCommand(command).first
}

/// Fetch message metadata in a single FETCH/UID FETCH command.
/// - Parameters:
/// - identifierSet: The identifiers to fetch.
/// - options: Which attributes to request. Defaults to `.default`.
/// - headerFields: Optional named header fields. See `FetchMessageInfoOptions.newsletterHeaderFields`.
public func fetchMessageInfosBulk<T: MessageIdentifier>(
using identifierSet: MessageIdentifierSet<T>
using identifierSet: MessageIdentifierSet<T>,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil
) async throws -> [MessageInfo] {
let command = FetchMessageInfoCommand(identifierSet: identifierSet)
let command = FetchMessageInfoCommand(
identifierSet: identifierSet, options: options, headerFields: headerFields
)
return try await executeCommand(command)
}

/// Fetch message metadata for a UID range in a single command.
public func fetchMessageInfos(uidRange: PartialRangeFrom<UID>) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(using: UIDSet(uidRange))
public func fetchMessageInfos(
uidRange: PartialRangeFrom<UID>,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil
) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(using: UIDSet(uidRange), options: options, headerFields: headerFields)
}

/// Fetch message metadata for a UID range in a single command.
public func fetchMessageInfos(uidRange: ClosedRange<UID>) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(using: UIDSet(uidRange))
public func fetchMessageInfos(
uidRange: ClosedRange<UID>,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil
) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(using: UIDSet(uidRange), options: options, headerFields: headerFields)
}

/// Fetch message metadata for a sequence-number range in a single command.
public func fetchMessageInfos(sequenceRange: PartialRangeFrom<SequenceNumber>) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(using: SequenceNumberSet(sequenceRange))
public func fetchMessageInfos(
sequenceRange: PartialRangeFrom<SequenceNumber>,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil
) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(
using: SequenceNumberSet(sequenceRange), options: options, headerFields: headerFields
)
}

/// Fetch message metadata for a sequence-number range in a single command.
public func fetchMessageInfos(sequenceRange: ClosedRange<SequenceNumber>) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(using: SequenceNumberSet(sequenceRange))
public func fetchMessageInfos(
sequenceRange: ClosedRange<SequenceNumber>,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil
) async throws -> [MessageInfo] {
try await fetchMessageInfosBulk(
using: SequenceNumberSet(sequenceRange), options: options, headerFields: headerFields
)
Comment thread
odrobnik marked this conversation as resolved.
}

/// Stream message metadata for a set of identifiers, auto-chunking large sets.
///
/// Chunk size defaults to `options.suggestedChunkSize` — lighter per-message payloads
/// (e.g. `.uidFlagsOnly`, `.slim`) take larger chunks so the same total fetch needs
/// fewer round trips. Pass `chunkSize` to override.
///
/// - Parameters:
/// - identifierSet: The identifiers to fetch.
/// - options: Which attributes to request. Defaults to `.default`.
/// - headerFields: Optional named header fields.
/// - chunkSize: Override for the auto-derived chunk size.
/// - Returns: An `AsyncThrowingStream` yielding `MessageInfo` one at a time.
public nonisolated func fetchMessageInfos<T: MessageIdentifier>(
using identifierSet: MessageIdentifierSet<T>,
options: FetchMessageInfoOptions = .default,
headerFields: [String]? = nil,
chunkSize: Int? = nil
) -> AsyncThrowingStream<MessageInfo, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
guard !identifierSet.isEmpty else {
throw IMAPError.emptyIdentifierSet
}

let chunks = identifierSet.chunked(size: chunkSize ?? options.suggestedChunkSize)

for chunk in chunks {
try Task.checkCancellation()
let command = FetchMessageInfoCommand(
identifierSet: chunk, options: options, headerFields: headerFields
)
let result = try await executeCommand(command)
for header in result {
continuation.yield(header)
}
}

continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}

continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
}
112 changes: 112 additions & 0 deletions Sources/SwiftMail/IMAP/IMAPServer+BodyStructureHelpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Foundation
@preconcurrency import NIOIMAP
import NIOIMAPCore

// MARK: - Body Structure Helpers

extension IMAPServer {
/**
Process a body structure recursively to fetch all parts
- Parameters:
- structure: The body structure to process
- section: The section to process
- identifier: The message identifier (SequenceNumber or UID)
- Returns: An array of message parts
- Throws: An error if the fetch operation fails
*/
func recursivelyFetchParts<T: MessageIdentifier>(
_ structure: BodyStructure,
section: Section,
identifier: T
) async throws -> [MessagePart] {
switch structure {
case .singlepart(let part):
return [try await fetchSinglepart(part, section: section, identifier: identifier)]

case .multipart(let multipart):
return try await fetchMultipart(multipart, section: section, identifier: identifier)
}
}

/// Fetch and convert a singlepart body structure.
private func fetchSinglepart<T: MessageIdentifier>(
_ part: BodyStructure.Singlepart,
section: Section,
identifier: T
) async throws -> MessagePart {
// Fetch the part content
let partData = try await fetchPart(section: section, of: identifier)

let contentType = singlepartContentType(part)
let (disposition, filename) = singlepartDispositionAndFilename(part)
let encoding: String? = part.fields.encoding?.debugDescription
let contentId = part.fields.id

return MessagePart(
section: section,
contentType: contentType,
disposition: disposition,
encoding: encoding,
filename: filename,
contentId: contentId,
data: partData
)
}

/// Recursively fetch each part of a multipart body structure.
private func fetchMultipart<T: MessageIdentifier>(
_ multipart: BodyStructure.Multipart,
section: Section,
identifier: T
) async throws -> [MessagePart] {
var allParts: [MessagePart] = []

for (index, childPart) in multipart.parts.enumerated() {
// Create a new section by appending the current index + 1
let childSection = Section(section.components + [index + 1])
let childParts = try await recursivelyFetchParts(
childPart, section: childSection, identifier: identifier
)
allParts.append(contentsOf: childParts)
}

return allParts
}

/// Build the `Content-Type` string for a singlepart structure.
private func singlepartContentType(_ part: BodyStructure.Singlepart) -> String {
var contentType: String
switch part.kind {
case .basic(let mediaType):
contentType = "\(String(mediaType.topLevel))/\(String(mediaType.sub))"
case .text(let text):
contentType = "text/\(String(text.mediaSubtype))"
case .message(let message):
contentType = "message/\(String(message.message))"
}

if let charset = part.fields.parameters.first(where: { $0.key.lowercased() == "charset" })?.value {
contentType += "; charset=\(charset)"
}

return contentType
}

/// Extract disposition + filename from a singlepart structure's extension data.
private func singlepartDispositionAndFilename(
_ part: BodyStructure.Singlepart
) -> (disposition: String?, filename: String?) {
var disposition: String?
var filename: String?

if let ext = part.extension, let dispAndLang = ext.dispositionAndLanguage, let disp = dispAndLang.disposition {
disposition = String(describing: disp)

for (key, value) in disp.parameters where key.lowercased() == "filename" {
filename = value
}
}

return (disposition, filename)
}
}
Loading