diff --git a/.gitignore b/.gitignore index 1dcc02e..5f10827 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.claude/ # Environment variables .env .env.* diff --git a/Sources/SwiftMail/IMAP/IMAP/Commands/FetchCommands.swift b/Sources/SwiftMail/IMAP/IMAP/Commands/FetchCommands.swift index a8ff833..94baa6c 100644 --- a/Sources/SwiftMail/IMAP/IMAP/Commands/FetchCommands.swift +++ b/Sources/SwiftMail/IMAP/IMAP/Commands/FetchCommands.swift @@ -4,8 +4,12 @@ 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: IMAPTaggedCommand { typealias ResultType = [MessageInfo] typealias HandlerType = FetchMessageInfoHandler @@ -13,13 +17,30 @@ struct FetchMessageInfoCommand: IMAPTaggedCommand { /// The set of message identifiers to fetch let identifierSet: MessageIdentifierSet + /// 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) { + /// - 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, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) { self.identifierSet = identifierSet + self.options = options + self.headerFields = headerFields } /// Validate the command before execution @@ -33,14 +54,29 @@ struct FetchMessageInfoCommand: 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( diff --git a/Sources/SwiftMail/IMAP/IMAPNamedConnection+Fetch.swift b/Sources/SwiftMail/IMAP/IMAPNamedConnection+Fetch.swift index fbd6d9f..fec9082 100644 --- a/Sources/SwiftMail/IMAP/IMAPNamedConnection+Fetch.swift +++ b/Sources/SwiftMail/IMAP/IMAPNamedConnection+Fetch.swift @@ -38,37 +38,125 @@ extension IMAPNamedConnection { } /// Fetch message metadata for one identifier. - public func fetchMessageInfo(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( + for identifier: T, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) async throws -> MessageInfo? { let set = MessageIdentifierSet(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( - using identifierSet: MessageIdentifierSet + using identifierSet: MessageIdentifierSet, + 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) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: UIDSet(uidRange)) + public func fetchMessageInfos( + uidRange: PartialRangeFrom, + 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) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: UIDSet(uidRange)) + public func fetchMessageInfos( + uidRange: ClosedRange, + 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) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: SequenceNumberSet(sequenceRange)) + public func fetchMessageInfos( + sequenceRange: PartialRangeFrom, + 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) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: SequenceNumberSet(sequenceRange)) + public func fetchMessageInfos( + sequenceRange: ClosedRange, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) async throws -> [MessageInfo] { + try await fetchMessageInfosBulk( + using: SequenceNumberSet(sequenceRange), options: options, headerFields: headerFields + ) + } + + /// 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( + using identifierSet: MessageIdentifierSet, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil, + chunkSize: Int? = nil + ) -> AsyncThrowingStream { + 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() + } + } } } diff --git a/Sources/SwiftMail/IMAP/IMAPServer+BodyStructureHelpers.swift b/Sources/SwiftMail/IMAP/IMAPServer+BodyStructureHelpers.swift new file mode 100644 index 0000000..4f3ae9d --- /dev/null +++ b/Sources/SwiftMail/IMAP/IMAPServer+BodyStructureHelpers.swift @@ -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( + _ 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( + _ 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( + _ 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) + } +} diff --git a/Sources/SwiftMail/IMAP/IMAPServer+Fetch.swift b/Sources/SwiftMail/IMAP/IMAPServer+Fetch.swift index 3dba93d..3cd39a2 100644 --- a/Sources/SwiftMail/IMAP/IMAPServer+Fetch.swift +++ b/Sources/SwiftMail/IMAP/IMAPServer+Fetch.swift @@ -153,56 +153,101 @@ extension IMAPServer { } } - /// Fetch message info for a single identifier - /// - Parameter identifier: The message identifier to fetch + /// Fetch message info for a single identifier. + /// - 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 (...)]`. /// - Returns: The message info if available - public func fetchMessageInfo(for identifier: T) async throws -> MessageInfo? { + public func fetchMessageInfo( + for identifier: T, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) async throws -> MessageInfo? { let singleSet = MessageIdentifierSet(identifier) - let command = FetchMessageInfoCommand(identifierSet: singleSet) + let command = FetchMessageInfoCommand( + identifierSet: singleSet, options: options, headerFields: headerFields + ) return try await executeCommand(command).first } /// Fetch message infos for an identifier set in a **single IMAP FETCH**. /// This is important for UID ranges like `123:*` which must not be expanded into individual UIDs. + /// - 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( - using identifierSet: MessageIdentifierSet + using identifierSet: MessageIdentifierSet, + 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) } // MARK: - Convenience overloads for ranges /// Fetch message infos for a UID range in a **single UID FETCH** (e.g. `11971:*`). - public func fetchMessageInfos(uidRange: PartialRangeFrom) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: UIDSet(uidRange)) + public func fetchMessageInfos( + uidRange: PartialRangeFrom, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) async throws -> [MessageInfo] { + try await fetchMessageInfosBulk(using: UIDSet(uidRange), options: options, headerFields: headerFields) } /// Fetch message infos for a UID range in a **single UID FETCH**. - public func fetchMessageInfos(uidRange: ClosedRange) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: UIDSet(uidRange)) + public func fetchMessageInfos( + uidRange: ClosedRange, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) async throws -> [MessageInfo] { + try await fetchMessageInfosBulk(using: UIDSet(uidRange), options: options, headerFields: headerFields) } /// Fetch message infos for a sequence number range in a single FETCH. - public func fetchMessageInfos(sequenceRange: PartialRangeFrom) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: SequenceNumberSet(sequenceRange)) + public func fetchMessageInfos( + sequenceRange: PartialRangeFrom, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) async throws -> [MessageInfo] { + try await fetchMessageInfosBulk( + using: SequenceNumberSet(sequenceRange), options: options, headerFields: headerFields + ) } /// Fetch message infos for a sequence number range in a single FETCH. - public func fetchMessageInfos(sequenceRange: ClosedRange) async throws -> [MessageInfo] { - try await fetchMessageInfosBulk(using: SequenceNumberSet(sequenceRange)) + public func fetchMessageInfos( + sequenceRange: ClosedRange, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil + ) async throws -> [MessageInfo] { + try await fetchMessageInfosBulk( + using: SequenceNumberSet(sequenceRange), options: options, headerFields: headerFields + ) } - /// Stream message headers for a set of identifiers + /// Stream message metadata for a set of identifiers. /// - /// Large identifier sets are automatically split into chunks of - /// `defaultFetchChunkSize` so that no single IMAP FETCH command is - /// too large. Results are yielded one at a time as they arrive. + /// Large identifier sets are automatically split into chunks so that no single IMAP + /// FETCH command is too large. Chunk size defaults to a value derived from `options` + /// (smaller per-message payload → larger chunks); pass `chunkSize` to override. + /// Results are yielded one at a time as they arrive. /// - /// - Parameter identifierSet: The set of message identifiers to fetch - /// - Returns: An AsyncThrowingStream yielding MessageInfo one at a time + /// - Parameters: + /// - identifierSet: The set of message 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( - using identifierSet: MessageIdentifierSet + using identifierSet: MessageIdentifierSet, + options: FetchMessageInfoOptions = .default, + headerFields: [String]? = nil, + chunkSize: Int? = nil ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in @@ -212,11 +257,13 @@ extension IMAPServer { throw IMAPError.emptyIdentifierSet } - let chunks = identifierSet.chunked(size: defaultFetchChunkSize) + let chunks = identifierSet.chunked(size: chunkSize ?? options.suggestedChunkSize) for chunk in chunks { try Task.checkCancellation() - let command = FetchMessageInfoCommand(identifierSet: chunk) + let command = FetchMessageInfoCommand( + identifierSet: chunk, options: options, headerFields: headerFields + ) let result = try await executeCommand(command) for header in result { continuation.yield(header) @@ -281,112 +328,3 @@ extension IMAPServer { } } } - -// 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( - _ 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( - _ 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( - _ 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) - } -} diff --git a/Sources/SwiftMail/IMAP/Models/FetchMessageInfoOptions.swift b/Sources/SwiftMail/IMAP/Models/FetchMessageInfoOptions.swift new file mode 100644 index 0000000..5f36bd8 --- /dev/null +++ b/Sources/SwiftMail/IMAP/Models/FetchMessageInfoOptions.swift @@ -0,0 +1,83 @@ +// FetchMessageInfoOptions.swift +// Selects which IMAP FETCH attributes to request when populating a `MessageInfo`. + +import Foundation + +/// Per-message attributes to request when fetching `MessageInfo`. UID is always implicit. +/// +/// Use this to trade per-message payload weight against the metadata you actually need. +/// The default `.default` set matches what `MessageInfo` historically populated; `.slim` +/// and `.uidFlagsOnly` strip out the expensive attributes so large mailboxes fit inside +/// the per-command 10s timeout. +public struct FetchMessageInfoOptions: OptionSet, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// Request `ENVELOPE` (subject, from/to/cc/bcc, date, Message-ID, In-Reply-To). + public static let envelope = FetchMessageInfoOptions(rawValue: 1 << 0) + + /// Request `INTERNALDATE` (server-side delivery timestamp). + public static let internalDate = FetchMessageInfoOptions(rawValue: 1 << 1) + + /// Request `FLAGS`. + public static let flags = FetchMessageInfoOptions(rawValue: 1 << 2) + + /// Request `RFC822.SIZE` (total octets of the message). + public static let size = FetchMessageInfoOptions(rawValue: 1 << 3) + + /// Request `BODYSTRUCTURE` (MIME tree). Roughly an order of magnitude larger than the + /// other attributes for non-trivial messages. + public static let bodyStructure = FetchMessageInfoOptions(rawValue: 1 << 4) + + /// Request the full header section via `BODY.PEEK[HEADER]`. Includes everything the + /// envelope already exposes plus all additional headers. + public static let fullHeader = FetchMessageInfoOptions(rawValue: 1 << 5) + + /// Default attribute set used by `fetchMessageInfo(s)` when no options are passed — + /// matches the historical behaviour: envelope, internal date, flags, body structure + /// and the full header section. + public static let `default`: FetchMessageInfoOptions = [ + .envelope, .internalDate, .flags, .bodyStructure, .fullHeader + ] + + /// Slim attribute set for large-mailbox listing / newsletter triage: envelope, + /// internal date, flags and size. No body structure, no header section. Roughly + /// 25× smaller per message than `.default`. + /// + /// Pair with `headerFields: FetchMessageInfoOptions.newsletterHeaderFields` to also + /// surface `List-Unsubscribe` and related auto-mail signals. + public static let slim: FetchMessageInfoOptions = [ + .envelope, .internalDate, .flags, .size + ] + + /// Smallest possible per-message payload — just flags (UID is always included). + /// Designed for incremental-sync diffing where the caller only needs to know which + /// UIDs are still on the server and their flag state. + public static let uidFlagsOnly: FetchMessageInfoOptions = [.flags] + + /// Header fields commonly paired with `.slim` for newsletter / auto-mail detection. + /// Tiny per-message cost (~200 bytes) when requested via `BODY.PEEK[HEADER.FIELDS (...)]`. + public static let newsletterHeaderFields: [String] = [ + "List-Unsubscribe", + "List-Unsubscribe-Post", + "List-ID", + "Auto-Submitted", + "Precedence" + ] + + /// Default streaming chunk size derived from per-message payload weight. Lighter + /// payloads → larger chunks → fewer round-trips for the same total fetch. + /// + /// - `.uidFlagsOnly` → 5000 (~50 bytes per message; tens of thousands fit in one command). + /// - Anything pulling `.bodyStructure` or `.fullHeader` → 50 (current default; stays inside + /// the 10s per-command timeout for typical messages). + /// - Everything else (slim-ish sets) → 500 (~order of magnitude smaller per message). + var suggestedChunkSize: Int { + if self == .uidFlagsOnly { return 5000 } + if contains(.bodyStructure) || contains(.fullHeader) { return 50 } + return 500 + } +} diff --git a/Tests/SwiftIMAPTests/FetchMessageInfoOptionsTests.swift b/Tests/SwiftIMAPTests/FetchMessageInfoOptionsTests.swift new file mode 100644 index 0000000..d0d38cf --- /dev/null +++ b/Tests/SwiftIMAPTests/FetchMessageInfoOptionsTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import SwiftMail + +@Suite +struct FetchMessageInfoOptionsTests { + @Test + func testDefaultIncludesBodyStructureAndFullHeader() { + #expect(FetchMessageInfoOptions.default.contains(.bodyStructure)) + #expect(FetchMessageInfoOptions.default.contains(.fullHeader)) + #expect(FetchMessageInfoOptions.default.contains(.envelope)) + #expect(FetchMessageInfoOptions.default.contains(.internalDate)) + #expect(FetchMessageInfoOptions.default.contains(.flags)) + } + + @Test + func testSlimDropsHeavyAttributes() { + #expect(!FetchMessageInfoOptions.slim.contains(.bodyStructure)) + #expect(!FetchMessageInfoOptions.slim.contains(.fullHeader)) + #expect(FetchMessageInfoOptions.slim.contains(.size)) + } + + @Test + func testSuggestedChunkSizeScalesWithPayload() { + // Heaviest payload → smallest chunk so the response stays inside the 10s timeout. + #expect(FetchMessageInfoOptions.default.suggestedChunkSize == 50) + // Slim drops body structure + header section → ~order-of-magnitude smaller per message. + #expect(FetchMessageInfoOptions.slim.suggestedChunkSize == 500) + // UID+FLAGS only → ~50 bytes per message → tens of thousands fit comfortably. + #expect(FetchMessageInfoOptions.uidFlagsOnly.suggestedChunkSize == 5000) + // Custom sets fall into the same bucketing. + let mid: FetchMessageInfoOptions = [.envelope, .flags] + #expect(mid.suggestedChunkSize == 500) + let heavy: FetchMessageInfoOptions = [.flags, .bodyStructure] + #expect(heavy.suggestedChunkSize == 50) + } +}