Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
62 changes: 50 additions & 12 deletions Sources/SwiftMail/IMAP/IMAPNamedConnection+Fetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,37 +38,75 @@ 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.
}
}
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