81 KiB
v0.5 Attachments & IMAP IDLE — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Complete the email client with full attachment support (send and receive) and real-time email arrival via IMAP IDLE.
Architecture: Three subsystems: (1) MIMEParser module for parsing multipart MIME messages, (2) attachment sending via multipart/mixed MessageFormatter extension, (3) IMAPIdleClient actor for persistent INBOX monitoring. MIMEParser is a new module with no external dependencies. IDLE uses a separate NIO connection from the sync connection.
Tech Stack: Swift 6, swift-nio, swift-nio-imap, swift-nio-ssl, GRDB.swift, Swift Testing
File Structure
New Files
| File | Responsibility |
|---|---|
Sources/MIMEParser/MIMETypes.swift |
Public types: MIMEMessage, MIMEPart, MIMEAttachment, TransferEncoding, ContentDisposition |
Sources/MIMEParser/MIMEParser.swift |
Static parse/decode/boundary functions |
Sources/MIMEParser/RFC2047Decoder.swift |
Decode =?charset?encoding?text?= encoded words in filenames |
Sources/IMAPClient/IMAPIdleClient.swift |
Actor managing dedicated IDLE connection |
Sources/IMAPClient/IMAPIdleHandler.swift |
NIO ChannelInboundHandler for IDLE responses, delivers AsyncStream |
Tests/MIMEParserTests/MIMEParserTests.swift |
MIME parsing tests |
Tests/MIMEParserTests/RFC2047DecoderTests.swift |
RFC 2047 filename decoding tests |
Tests/IMAPClientTests/IMAPIdleClientTests.swift |
IDLE client tests (using mock handler) |
Modified Files
| File | Changes |
|---|---|
Package.swift |
Add MIMEParser target + test target |
Sources/MailStore/DatabaseSetup.swift |
Add v4_attachment migration |
Sources/MailStore/Records/AttachmentRecord.swift |
Add sectionPath column |
Sources/MailStore/Records/MessageRecord.swift |
Add hasAttachments column |
Sources/MailStore/MailStore.swift |
Add attachment CRUD, storeBodyWithAttachments, attachment download query |
Sources/MailStore/Queries.swift |
Read hasAttachments in toMessageSummary |
Sources/IMAPClient/IMAPClientProtocol.swift |
Add fetchFullMessage, fetchSection |
Sources/IMAPClient/IMAPClient.swift |
Implement fetchFullMessage, fetchSection; remove body from fetchEnvelopes |
Sources/SMTPClient/MessageFormatter.swift |
Add formatMultipart, base64Encode, 25MB guard |
Sources/SyncEngine/SyncCoordinator.swift |
MIME-aware prefetchBodies, IDLE start/stop, attachment download, credentials |
Tests/SyncEngineTests/MockIMAPClient.swift |
Add fetchFullMessage, fetchSection mocks |
Tests/SMTPClientTests/MessageFormatterTests.swift |
Add multipart formatting + size guard tests |
Tests/SyncEngineTests/SyncCoordinatorTests.swift |
Add MIME sync + IDLE integration tests |
Tests/MailStoreTests/MigrationTests.swift |
Add v4_attachment migration test |
Deferred to App Layer
The spec's UI changes (ComposeViewModel attachments, attachment strip in ThreadDetail, file picker, paperclip icon in message list, inline image cid: replacement) are app-layer SwiftUI code outside MagnumOpusCore. They depend on the core infrastructure built in this plan. They will be wired up when integrating v0.5 into the app targets — same pattern as v0.3/v0.4 where core logic ships first, then UI wraps it.
Chunk 1: MIMEParser Module — Types & Basic Parsing
Task 1: Add MIMEParser target to Package.swift
Files:
-
Modify:
Package.swift -
Step 1: Add MIMEParser target and test target
In Package.swift, add:
- A product:
.library(name: "MIMEParser", targets: ["MIMEParser"]) - A target:
.target(name: "MIMEParser")(no dependencies — pure Swift) - A test target:
.testTarget(name: "MIMEParserTests", dependencies: ["MIMEParser"])
Add after the existing SMTPClient product/target entries.
- Step 2: Create source directory
mkdir -p Packages/MagnumOpusCore/Sources/MIMEParser
mkdir -p Packages/MagnumOpusCore/Tests/MIMEParserTests
- Step 3: Verify build
cd Packages/MagnumOpusCore
swift build
Expected: Build succeeds (empty target is fine with directory existing).
- Step 4: Commit
git add Package.swift Sources/MIMEParser Tests/MIMEParserTests
git commit -m "add MIMEParser module target to Package.swift"
Task 2: MIME types
Files:
-
Create:
Sources/MIMEParser/MIMETypes.swift -
Step 1: Write MIMETypes.swift
import Foundation
public enum TransferEncoding: String, Sendable {
case base64
case quotedPrintable = "quoted-printable"
case sevenBit = "7bit"
case eightBit = "8bit"
case binary
}
public enum ContentDisposition: Sendable {
case inline
case attachment
}
public struct MIMEPart: Sendable {
public var headers: [String: String]
public var contentType: String
public var charset: String?
public var transferEncoding: TransferEncoding
public var disposition: ContentDisposition?
public var filename: String?
public var contentId: String?
public var body: Data
public var subparts: [MIMEPart]
public init(
headers: [String: String] = [:],
contentType: String = "text/plain",
charset: String? = "utf-8",
transferEncoding: TransferEncoding = .sevenBit,
disposition: ContentDisposition? = nil,
filename: String? = nil,
contentId: String? = nil,
body: Data = Data(),
subparts: [MIMEPart] = []
) {
self.headers = headers
self.contentType = contentType
self.charset = charset
self.transferEncoding = transferEncoding
self.disposition = disposition
self.filename = filename
self.contentId = contentId
self.body = body
self.subparts = subparts
}
}
public struct MIMEAttachment: Sendable {
public var filename: String
public var mimeType: String
public var size: Int
public var contentId: String?
public var sectionPath: String
public var isInline: Bool
public init(
filename: String,
mimeType: String,
size: Int,
contentId: String? = nil,
sectionPath: String,
isInline: Bool = false
) {
self.filename = filename
self.mimeType = mimeType
self.size = size
self.contentId = contentId
self.sectionPath = sectionPath
self.isInline = isInline
}
}
public struct MIMEMessage: Sendable {
public var headers: [String: String]
public var parts: [MIMEPart]
public var textBody: String?
public var htmlBody: String?
public var attachments: [MIMEAttachment]
public var inlineImages: [MIMEAttachment]
public init(
headers: [String: String] = [:],
parts: [MIMEPart] = [],
textBody: String? = nil,
htmlBody: String? = nil,
attachments: [MIMEAttachment] = [],
inlineImages: [MIMEAttachment] = []
) {
self.headers = headers
self.parts = parts
self.textBody = textBody
self.htmlBody = htmlBody
self.attachments = attachments
self.inlineImages = inlineImages
}
}
- Step 2: Verify build
cd Packages/MagnumOpusCore
swift build
- Step 3: Commit
git add Sources/MIMEParser/MIMETypes.swift
git commit -m "add MIME types: MIMEMessage, MIMEPart, MIMEAttachment, TransferEncoding"
Task 3: RFC 2047 decoder
Files:
-
Create:
Sources/MIMEParser/RFC2047Decoder.swift -
Create:
Tests/MIMEParserTests/RFC2047DecoderTests.swift -
Step 1: Write failing tests for RFC 2047 decoding
import Testing
import Foundation
@testable import MIMEParser
@Suite("RFC2047Decoder")
struct RFC2047DecoderTests {
@Test("plain ASCII filename passes through unchanged")
func plainAscii() {
let result = RFC2047Decoder.decode("report.pdf")
#expect(result == "report.pdf")
}
@Test("base64 encoded UTF-8 filename decoded correctly")
func base64Utf8() {
// "Bericht.pdf" in base64
let encoded = "=?utf-8?B?QmVyaWNodC5wZGY=?="
let result = RFC2047Decoder.decode(encoded)
#expect(result == "Bericht.pdf")
}
@Test("quoted-printable encoded UTF-8 filename decoded correctly")
func quotedPrintableUtf8() {
// "Grüße.txt" — ü = =C3=BC, ß = =C3=9F
let encoded = "=?utf-8?Q?Gr=C3=BC=C3=9Fe.txt?="
let result = RFC2047Decoder.decode(encoded)
#expect(result == "Grüße.txt")
}
@Test("multiple encoded words concatenated")
func multipleEncodedWords() {
let encoded = "=?utf-8?B?SGVsbG8=?= =?utf-8?B?V29ybGQ=?="
let result = RFC2047Decoder.decode(encoded)
#expect(result == "HelloWorld")
}
@Test("ISO-8859-1 encoded filename decoded correctly")
func iso88591() {
// "café" — é = 0xE9 in ISO-8859-1, base64 of "café" in ISO-8859-1 is "Y2Fm6Q=="
let encoded = "=?iso-8859-1?B?Y2Fm6Q==?="
let result = RFC2047Decoder.decode(encoded)
#expect(result == "café")
}
@Test("underscores in Q-encoding replaced with spaces")
func qEncodingUnderscores() {
let encoded = "=?utf-8?Q?my_file_name.pdf?="
let result = RFC2047Decoder.decode(encoded)
#expect(result == "my file name.pdf")
}
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore
swift test --filter RFC2047DecoderTests 2>&1 | tail -5
Expected: Compilation error — RFC2047Decoder not defined.
- Step 3: Implement RFC2047Decoder
import Foundation
public enum RFC2047Decoder {
/// Decode RFC 2047 encoded words in a string.
/// Pattern: =?charset?encoding?encoded_text?=
public static func decode(_ input: String) -> String {
let pattern = #"=\?([^?]+)\?([BbQq])\?([^?]*)\?="#
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return input
}
let nsInput = input as NSString
let matches = regex.matches(in: input, range: NSRange(location: 0, length: nsInput.length))
guard !matches.isEmpty else { return input }
var result = ""
var lastEnd = 0
for match in matches {
let matchRange = match.range
// Add any non-encoded text between matches (skip whitespace between adjacent encoded words)
let gap = nsInput.substring(with: NSRange(location: lastEnd, length: matchRange.location - lastEnd))
let trimmedGap = gap.trimmingCharacters(in: .whitespaces)
if !trimmedGap.isEmpty || lastEnd == 0 {
// Only add gap if it's not just whitespace between encoded words
if lastEnd == 0 && matchRange.location > 0 {
result += gap
} else if !trimmedGap.isEmpty {
result += gap
}
}
let charset = nsInput.substring(with: match.range(at: 1))
let encoding = nsInput.substring(with: match.range(at: 2)).uppercased()
let encodedText = nsInput.substring(with: match.range(at: 3))
let cfEncoding = CFStringConvertIANACharSetNameToEncoding(charset as CFString)
let nsEncoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding)
let decoded: String?
if encoding == "B" {
guard let data = Data(base64Encoded: encodedText) else {
result += nsInput.substring(with: matchRange)
lastEnd = matchRange.location + matchRange.length
continue
}
decoded = String(data: data, encoding: String.Encoding(rawValue: nsEncoding))
} else {
// Q encoding: like quoted-printable but underscores represent spaces
let withSpaces = encodedText.replacingOccurrences(of: "_", with: " ")
let data = decodeQuotedPrintableBytes(withSpaces)
decoded = String(data: data, encoding: String.Encoding(rawValue: nsEncoding))
}
result += decoded ?? nsInput.substring(with: matchRange)
lastEnd = matchRange.location + matchRange.length
}
// Append any trailing non-encoded text
if lastEnd < nsInput.length {
result += nsInput.substring(from: lastEnd)
}
return result
}
private static func decodeQuotedPrintableBytes(_ input: String) -> Data {
var data = Data()
var i = input.startIndex
while i < input.endIndex {
if input[i] == "=" {
let hexStart = input.index(after: i)
guard hexStart < input.endIndex else {
data.append(contentsOf: "=".utf8)
break
}
let hexEnd = input.index(hexStart, offsetBy: 1, limitedBy: input.endIndex) ?? input.endIndex
guard hexEnd < input.endIndex else {
data.append(contentsOf: String(input[i...]).utf8)
break
}
let nextAfterHex = input.index(after: hexEnd)
let hex = String(input[hexStart...hexEnd])
if let byte = UInt8(hex, radix: 16) {
data.append(byte)
i = nextAfterHex
} else {
data.append(contentsOf: "=".utf8)
i = hexStart
}
} else {
data.append(contentsOf: String(input[i]).utf8)
i = input.index(after: i)
}
}
return data
}
}
- Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore
swift test --filter RFC2047DecoderTests 2>&1 | tail -5
Expected: All 6 tests pass.
- Step 5: Commit
git add Sources/MIMEParser/RFC2047Decoder.swift Tests/MIMEParserTests/RFC2047DecoderTests.swift
git commit -m "add RFC 2047 encoded word decoder for MIME filenames"
Task 4: MIMEParser core — header parsing, content decoding, boundary generation
Files:
-
Create:
Sources/MIMEParser/MIMEParser.swift -
Create:
Tests/MIMEParserTests/MIMEParserTests.swift -
Step 1: Write failing tests for content decoding and boundary generation
import Testing
import Foundation
@testable import MIMEParser
@Suite("MIMEParser")
struct MIMEParserTests {
// MARK: - Content Decoding
@Test("decode base64 content")
func decodeBase64() {
let encoded = "SGVsbG8gV29ybGQ="
let data = MIMEParser.decodeContent(encoded, encoding: .base64)
#expect(String(data: data, encoding: .utf8) == "Hello World")
}
@Test("decode quoted-printable content")
func decodeQuotedPrintable() {
let encoded = "Gr=C3=BC=C3=9Fe"
let data = MIMEParser.decodeContent(encoded, encoding: .quotedPrintable)
#expect(String(data: data, encoding: .utf8) == "Grüße")
}
@Test("decode 7bit content passes through")
func decode7bit() {
let text = "Hello World"
let data = MIMEParser.decodeContent(text, encoding: .sevenBit)
#expect(String(data: data, encoding: .utf8) == "Hello World")
}
@Test("boundary generation produces unique strings with =_ prefix")
func boundaryGeneration() {
let b1 = MIMEParser.generateBoundary()
let b2 = MIMEParser.generateBoundary()
#expect(b1 != b2)
#expect(b1.hasPrefix("=_MagnumOpus_"))
#expect(b2.hasPrefix("=_MagnumOpus_"))
}
// MARK: - Single-part Parsing
@Test("parse single-part text/plain message")
func parseSinglePartText() {
let raw = """
Content-Type: text/plain; charset=utf-8\r
Content-Transfer-Encoding: 7bit\r
\r
Hello, this is the body.
"""
let message = MIMEParser.parse(raw)
#expect(message.textBody == "Hello, this is the body.")
#expect(message.htmlBody == nil)
#expect(message.attachments.isEmpty)
}
// MARK: - Multipart Parsing
@Test("parse multipart/mixed with text and one attachment")
func parseMultipartMixed() {
let raw = """
Content-Type: multipart/mixed; boundary="----boundary123"\r
\r
------boundary123\r
Content-Type: text/plain; charset=utf-8\r
Content-Transfer-Encoding: 7bit\r
\r
Hello from the body.\r
------boundary123\r
Content-Type: application/pdf; name="report.pdf"\r
Content-Disposition: attachment; filename="report.pdf"\r
Content-Transfer-Encoding: base64\r
\r
SGVsbG8=\r
------boundary123--
"""
let message = MIMEParser.parse(raw)
#expect(message.textBody == "Hello from the body.")
#expect(message.attachments.count == 1)
#expect(message.attachments.first?.filename == "report.pdf")
#expect(message.attachments.first?.mimeType == "application/pdf")
#expect(message.attachments.first?.sectionPath == "2")
#expect(message.attachments.first?.isInline == false)
}
@Test("parse multipart/alternative extracts text and html bodies")
func parseMultipartAlternative() {
let raw = """
Content-Type: multipart/alternative; boundary="alt-boundary"\r
\r
--alt-boundary\r
Content-Type: text/plain; charset=utf-8\r
\r
Plain text body\r
--alt-boundary\r
Content-Type: text/html; charset=utf-8\r
\r
<p>HTML body</p>\r
--alt-boundary--
"""
let message = MIMEParser.parse(raw)
#expect(message.textBody == "Plain text body")
#expect(message.htmlBody == "<p>HTML body</p>")
#expect(message.attachments.isEmpty)
}
@Test("parse multipart/related with inline image")
func parseMultipartRelated() {
let raw = """
Content-Type: multipart/related; boundary="rel-boundary"\r
\r
--rel-boundary\r
Content-Type: text/html; charset=utf-8\r
\r
<p>Image: <img src="cid:img001"></p>\r
--rel-boundary\r
Content-Type: image/png\r
Content-ID: <img001>\r
Content-Disposition: inline\r
Content-Transfer-Encoding: base64\r
\r
iVBORw0KGgo=\r
--rel-boundary--
"""
let message = MIMEParser.parse(raw)
#expect(message.htmlBody == "<p>Image: <img src=\"cid:img001\"></p>")
#expect(message.inlineImages.count == 1)
#expect(message.inlineImages.first?.contentId == "img001")
#expect(message.inlineImages.first?.isInline == true)
}
@Test("parse nested multipart/mixed containing multipart/alternative")
func parseNestedMultipart() {
let raw = """
Content-Type: multipart/mixed; boundary="outer"\r
\r
--outer\r
Content-Type: multipart/alternative; boundary="inner"\r
\r
--inner\r
Content-Type: text/plain\r
\r
Plain text\r
--inner\r
Content-Type: text/html\r
\r
<p>HTML</p>\r
--inner--\r
--outer\r
Content-Type: application/pdf; name="doc.pdf"\r
Content-Disposition: attachment; filename="doc.pdf"\r
Content-Transfer-Encoding: base64\r
\r
AAAA\r
--outer--
"""
let message = MIMEParser.parse(raw)
#expect(message.textBody == "Plain text")
#expect(message.htmlBody == "<p>HTML</p>")
#expect(message.attachments.count == 1)
#expect(message.attachments.first?.filename == "doc.pdf")
}
@Test("section paths assigned correctly for nested parts")
func sectionPaths() {
let raw = """
Content-Type: multipart/mixed; boundary="outer"\r
\r
--outer\r
Content-Type: text/plain\r
\r
Body text\r
--outer\r
Content-Type: application/pdf; name="a.pdf"\r
Content-Disposition: attachment; filename="a.pdf"\r
Content-Transfer-Encoding: base64\r
\r
AAAA\r
--outer\r
Content-Type: image/jpeg; name="b.jpg"\r
Content-Disposition: attachment; filename="b.jpg"\r
Content-Transfer-Encoding: base64\r
\r
BBBB\r
--outer--
"""
let message = MIMEParser.parse(raw)
#expect(message.attachments.count == 2)
#expect(message.attachments[0].sectionPath == "2")
#expect(message.attachments[1].sectionPath == "3")
}
@Test("extract filename from Content-Type name parameter when no Content-Disposition")
func filenameFromContentType() {
let raw = """
Content-Type: multipart/mixed; boundary="bound"\r
\r
--bound\r
Content-Type: text/plain\r
\r
Body\r
--bound\r
Content-Type: application/octet-stream; name="data.bin"\r
Content-Transfer-Encoding: base64\r
\r
AAAA\r
--bound--
"""
let message = MIMEParser.parse(raw)
#expect(message.attachments.count == 1)
#expect(message.attachments.first?.filename == "data.bin")
}
@Test("estimate decoded size from base64 content")
func base64SizeEstimate() {
// 8 base64 chars = 6 decoded bytes
let raw = """
Content-Type: multipart/mixed; boundary="bound"\r
\r
--bound\r
Content-Type: text/plain\r
\r
Body\r
--bound\r
Content-Type: application/pdf; name="f.pdf"\r
Content-Disposition: attachment; filename="f.pdf"\r
Content-Transfer-Encoding: base64\r
\r
AAAAAAAA\r
--bound--
"""
let message = MIMEParser.parse(raw)
#expect(message.attachments.first?.size == 6)
}
@Test("handle malformed MIME gracefully — missing boundary")
func malformedMissingBoundary() {
let raw = """
Content-Type: multipart/mixed\r
\r
Some text without proper boundary markers.
"""
let message = MIMEParser.parse(raw)
// Should not crash; treat as single-part
#expect(message.attachments.isEmpty)
}
@Test("RFC 2047 encoded filename decoded")
func rfc2047Filename() {
let raw = """
Content-Type: multipart/mixed; boundary="bound"\r
\r
--bound\r
Content-Type: text/plain\r
\r
Body\r
--bound\r
Content-Type: application/pdf; name="=?utf-8?B?QmVyaWNodC5wZGY=?="\r
Content-Disposition: attachment; filename="=?utf-8?B?QmVyaWNodC5wZGY=?="\r
Content-Transfer-Encoding: base64\r
\r
AAAA\r
--bound--
"""
let message = MIMEParser.parse(raw)
#expect(message.attachments.first?.filename == "Bericht.pdf")
}
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore
swift test --filter MIMEParserTests 2>&1 | tail -5
Expected: Compilation error — MIMEParser not defined.
- Step 3: Implement MIMEParser
import Foundation
public enum MIMEParser {
// MARK: - Public API
/// Parse a raw MIME message into a structured tree of parts
public static func parse(_ rawMessage: String) -> MIMEMessage {
let (headers, body) = splitHeadersAndBody(rawMessage)
let contentType = headers["content-type"] ?? "text/plain"
if contentType.lowercased().contains("multipart/") {
guard let boundary = extractBoundary(contentType) else {
// Malformed: multipart without boundary — treat as plain text
return MIMEMessage(
headers: headers,
textBody: body.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
let parts = splitOnBoundary(body, boundary: boundary)
let parsedParts = parts.enumerated().map { (index, partString) in
parsePart(partString, sectionPrefix: "", index: index + 1)
}
var message = MIMEMessage(headers: headers, parts: parsedParts)
extractBodiesAndAttachments(from: parsedParts, contentType: contentType, into: &message, sectionPrefix: "")
return message
} else {
// Single-part message
let transferEncoding = parseTransferEncoding(headers["content-transfer-encoding"])
let decoded = decodeContent(body, encoding: transferEncoding)
if contentType.lowercased().contains("text/html") {
return MIMEMessage(headers: headers, htmlBody: String(data: decoded, encoding: .utf8))
} else {
return MIMEMessage(
headers: headers,
textBody: String(data: decoded, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
}
}
/// Decode content based on Content-Transfer-Encoding
public static func decodeContent(_ content: String, encoding: TransferEncoding) -> Data {
switch encoding {
case .base64:
let cleaned = content.filter { !$0.isWhitespace }
return Data(base64Encoded: cleaned) ?? Data(content.utf8)
case .quotedPrintable:
return decodeQuotedPrintable(content)
case .sevenBit, .eightBit, .binary:
return Data(content.utf8)
}
}
/// Generate a unique MIME boundary string
public static func generateBoundary() -> String {
"=_MagnumOpus_\(UUID().uuidString)"
}
// MARK: - Header Parsing
private static func splitHeadersAndBody(_ raw: String) -> ([String: String], String) {
// Split on first blank line (CRLF CRLF or LF LF)
let separator: String
if raw.contains("\r\n\r\n") {
separator = "\r\n\r\n"
} else if raw.contains("\n\n") {
separator = "\n\n"
} else {
return ([:], raw)
}
guard let range = raw.range(of: separator) else {
return ([:], raw)
}
let headerSection = String(raw[raw.startIndex..<range.lowerBound])
let bodySection = String(raw[range.upperBound...])
return (parseHeaders(headerSection), bodySection)
}
private static func parseHeaders(_ section: String) -> [String: String] {
var headers: [String: String] = [:]
let lineBreak = section.contains("\r\n") ? "\r\n" : "\n"
let lines = section.components(separatedBy: lineBreak)
var currentKey: String?
var currentValue: String = ""
for line in lines {
if line.isEmpty { continue }
if line.first == " " || line.first == "\t" {
// Continuation of previous header (folded)
currentValue += " " + line.trimmingCharacters(in: .whitespaces)
} else if let colonIndex = line.firstIndex(of: ":") {
// Save previous header
if let key = currentKey {
headers[key.lowercased()] = currentValue
}
currentKey = String(line[..<colonIndex]).trimmingCharacters(in: .whitespaces)
currentValue = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
}
}
// Save last header
if let key = currentKey {
headers[key.lowercased()] = currentValue
}
return headers
}
// MARK: - Boundary / Part Splitting
private static func extractBoundary(_ contentType: String) -> String? {
// Look for boundary="value" or boundary=value
let lower = contentType.lowercased()
guard let boundaryRange = lower.range(of: "boundary=") else { return nil }
var value = String(contentType[boundaryRange.upperBound...])
// Strip leading quote
if value.hasPrefix("\"") {
value = String(value.dropFirst())
if let endQuote = value.firstIndex(of: "\"") {
value = String(value[..<endQuote])
}
} else {
// Unquoted — stop at semicolon or whitespace
if let end = value.firstIndex(where: { $0 == ";" || $0.isWhitespace }) {
value = String(value[..<end])
}
}
return value
}
private static func splitOnBoundary(_ body: String, boundary: String) -> [String] {
let delimiter = "--\(boundary)"
let terminator = "--\(boundary)--"
let lineBreak = body.contains("\r\n") ? "\r\n" : "\n"
var parts: [String] = []
let lines = body.components(separatedBy: lineBreak)
var currentPart: [String]? = nil
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed == terminator || trimmed.hasPrefix(terminator) {
if let part = currentPart {
parts.append(part.joined(separator: lineBreak))
}
break
} else if trimmed == delimiter || trimmed.hasPrefix(delimiter) {
if let part = currentPart {
parts.append(part.joined(separator: lineBreak))
}
currentPart = []
} else if currentPart != nil {
currentPart!.append(line)
}
}
return parts
}
// MARK: - Part Parsing
private static func parsePart(_ partString: String, sectionPrefix: String, index: Int) -> MIMEPart {
let (headers, body) = splitHeadersAndBody(partString)
let contentType = headers["content-type"] ?? "text/plain"
let transferEncoding = parseTransferEncoding(headers["content-transfer-encoding"])
let charset = extractParameter(contentType, name: "charset")
let disposition = parseDisposition(headers["content-disposition"])
let contentId = extractContentId(headers["content-id"])
var filename = extractParameter(headers["content-disposition"] ?? "", name: "filename")
if filename == nil {
filename = extractParameter(contentType, name: "name")
}
// Decode RFC 2047 encoded filenames
if let encoded = filename {
filename = RFC2047Decoder.decode(encoded)
}
let section = sectionPrefix.isEmpty ? "\(index)" : "\(sectionPrefix).\(index)"
// Check for nested multipart
if contentType.lowercased().contains("multipart/") {
if let boundary = extractBoundary(contentType) {
let subparts = splitOnBoundary(body, boundary: boundary)
let parsedSubparts = subparts.enumerated().map { (i, s) in
parsePart(s, sectionPrefix: section, index: i + 1)
}
return MIMEPart(
headers: headers,
contentType: contentType.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() ?? contentType,
charset: charset,
transferEncoding: transferEncoding,
disposition: disposition,
filename: filename,
contentId: contentId,
body: Data(),
subparts: parsedSubparts
)
}
}
let decodedBody = decodeContent(body, encoding: transferEncoding)
let baseContentType = contentType.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() ?? contentType
return MIMEPart(
headers: headers,
contentType: baseContentType,
charset: charset,
transferEncoding: transferEncoding,
disposition: disposition,
filename: filename,
contentId: contentId,
body: decodedBody,
subparts: []
)
}
// MARK: - Body & Attachment Extraction
private static func extractBodiesAndAttachments(
from parts: [MIMEPart],
contentType: String,
into message: inout MIMEMessage,
sectionPrefix: String
) {
let lowerType = contentType.lowercased()
if lowerType.contains("multipart/alternative") {
for part in parts {
if !part.subparts.isEmpty {
extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "")
} else if part.contentType == "text/plain" && message.textBody == nil {
message.textBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} else if part.contentType == "text/html" && message.htmlBody == nil {
message.htmlBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
} else if lowerType.contains("multipart/related") {
// First part is the HTML body, rest are inline resources
for (index, part) in parts.enumerated() {
if index == 0 {
if !part.subparts.isEmpty {
extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "")
} else if part.contentType == "text/html" {
message.htmlBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} else if part.contentType == "text/plain" {
message.textBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
} else {
let sectionIndex = sectionPrefix.isEmpty ? "\(index + 1)" : "\(sectionPrefix).\(index + 1)"
let attachment = MIMEAttachment(
filename: part.filename ?? "inline-\(index)",
mimeType: part.contentType,
size: estimateDecodedSize(part),
contentId: part.contentId,
sectionPath: sectionIndex,
isInline: true
)
message.inlineImages.append(attachment)
}
}
} else {
// multipart/mixed or unknown multipart
var bodyFound = false
for (index, part) in parts.enumerated() {
let sectionIndex = sectionPrefix.isEmpty ? "\(index + 1)" : "\(sectionPrefix).\(index + 1)"
if !part.subparts.isEmpty {
// Nested multipart — recurse
extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "")
bodyFound = true
} else if !bodyFound && part.disposition != .attachment && part.contentType.hasPrefix("text/") {
if part.contentType == "text/html" {
message.htmlBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
message.textBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
bodyFound = true
} else if part.disposition == .attachment || part.filename != nil || !part.contentType.hasPrefix("text/") {
let attachment = MIMEAttachment(
filename: part.filename ?? "attachment-\(index + 1)",
mimeType: part.contentType,
size: estimateDecodedSize(part),
contentId: part.contentId,
sectionPath: sectionIndex,
isInline: part.disposition == .inline
)
if part.disposition == .inline {
message.inlineImages.append(attachment)
} else {
message.attachments.append(attachment)
}
}
}
}
}
// MARK: - Helper Functions
private static func parseTransferEncoding(_ value: String?) -> TransferEncoding {
guard let value = value?.trimmingCharacters(in: .whitespaces).lowercased() else { return .sevenBit }
return TransferEncoding(rawValue: value) ?? .sevenBit
}
private static func parseDisposition(_ value: String?) -> ContentDisposition? {
guard let value = value?.lowercased() else { return nil }
if value.hasPrefix("inline") { return .inline }
if value.hasPrefix("attachment") { return .attachment }
return nil
}
private static func extractParameter(_ headerValue: String, name: String) -> String? {
let lower = headerValue.lowercased()
let search = "\(name.lowercased())="
guard let range = lower.range(of: search) else { return nil }
var value = String(headerValue[range.upperBound...])
if value.hasPrefix("\"") {
value = String(value.dropFirst())
if let endQuote = value.firstIndex(of: "\"") {
value = String(value[..<endQuote])
}
} else {
if let end = value.firstIndex(where: { $0 == ";" || $0.isWhitespace }) {
value = String(value[..<end])
}
}
return value.isEmpty ? nil : value
}
private static func extractContentId(_ value: String?) -> String? {
guard var cid = value?.trimmingCharacters(in: .whitespaces) else { return nil }
if cid.hasPrefix("<") { cid = String(cid.dropFirst()) }
if cid.hasSuffix(">") { cid = String(cid.dropLast()) }
return cid.isEmpty ? nil : cid
}
private static func estimateDecodedSize(_ part: MIMEPart) -> Int {
if part.transferEncoding == .base64 {
// base64 inflates ~33%: decoded = encoded * 3 / 4
let base64Length = part.body.count
// But body is already decoded at this point, so use body.count directly
return part.body.count
}
return part.body.count
}
private static func decodeQuotedPrintable(_ input: String) -> Data {
var data = Data()
let lines = input.components(separatedBy: "\n")
for (lineIndex, line) in lines.enumerated() {
var processedLine = line
if processedLine.hasSuffix("\r") {
processedLine = String(processedLine.dropLast())
}
// Check for soft line break
if processedLine.hasSuffix("=") {
processedLine = String(processedLine.dropLast())
data.append(contentsOf: decodeQPLine(processedLine))
} else {
data.append(contentsOf: decodeQPLine(processedLine))
if lineIndex < lines.count - 1 {
data.append(contentsOf: "\r\n".utf8)
}
}
}
return data
}
private static func decodeQPLine(_ line: String) -> Data {
var data = Data()
var i = line.startIndex
while i < line.endIndex {
if line[i] == "=" {
let next1 = line.index(after: i)
guard next1 < line.endIndex else {
data.append(contentsOf: "=".utf8)
break
}
let next2 = line.index(after: next1)
guard next2 < line.endIndex else {
data.append(contentsOf: String(line[i...]).utf8)
break
}
let hex = String(line[next1...next2])
if let byte = UInt8(hex, radix: 16) {
data.append(byte)
i = line.index(after: next2)
} else {
data.append(contentsOf: "=".utf8)
i = next1
}
} else {
data.append(contentsOf: String(line[i]).utf8)
i = line.index(after: i)
}
}
return data
}
}
- Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore
swift test --filter MIMEParserTests 2>&1 | tail -10
Expected: All 11 tests pass.
- Step 5: Run full test suite
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
Expected: 111 + 6 + 11 = 128 tests pass (existing + RFC2047 + MIMEParser).
- Step 6: Commit
git add Sources/MIMEParser/MIMEParser.swift Tests/MIMEParserTests/MIMEParserTests.swift
git commit -m "add MIMEParser: multipart parsing, content decoding, boundary generation"
Chunk 2: Schema Migration, Store Updates & IMAPClient Extensions
Task 5: v4_attachment migration
Files:
-
Modify:
Sources/MailStore/DatabaseSetup.swift -
Modify:
Sources/MailStore/Records/AttachmentRecord.swift -
Modify:
Sources/MailStore/Records/MessageRecord.swift -
Modify:
Tests/MailStoreTests/MigrationTests.swift -
Step 1: Write failing migration test
Open Tests/MailStoreTests/MigrationTests.swift and add a test to the existing suite:
@Test("v4_attachment migration adds sectionPath to attachment and hasAttachments to message")
func v4AttachmentMigration() throws {
let db = try DatabaseSetup.openInMemoryDatabase()
try db.read { db in
let attachmentColumns = try db.columns(in: "attachment")
let attachmentColumnNames = attachmentColumns.map(\.name)
#expect(attachmentColumnNames.contains("sectionPath"))
let messageColumns = try db.columns(in: "message")
let messageColumnNames = messageColumns.map(\.name)
#expect(messageColumnNames.contains("hasAttachments"))
}
}
- Step 2: Run test to verify it fails
cd Packages/MagnumOpusCore
swift test --filter MigrationTests/v4AttachmentMigration 2>&1 | tail -5
Expected: FAIL — columns don't exist yet.
- Step 3: Add migration to DatabaseSetup.swift
Add before the return migrator line:
migrator.registerMigration("v4_attachment") { db in
try db.alter(table: "attachment") { t in
t.add(column: "sectionPath", .text)
}
try db.alter(table: "message") { t in
t.add(column: "hasAttachments", .boolean).notNull().defaults(to: false)
}
}
- Step 4: Update AttachmentRecord to include sectionPath
Add sectionPath property to AttachmentRecord:
public struct AttachmentRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "attachment"
public var id: String
public var messageId: String
public var filename: String?
public var mimeType: String
public var size: Int
public var contentId: String?
public var cachePath: String?
public var sectionPath: String?
public init(
id: String, messageId: String, filename: String?, mimeType: String,
size: Int, contentId: String?, cachePath: String?, sectionPath: String? = nil
) {
self.id = id
self.messageId = messageId
self.filename = filename
self.mimeType = mimeType
self.size = size
self.contentId = contentId
self.cachePath = cachePath
self.sectionPath = sectionPath
}
}
- Step 5: Update MessageRecord to include hasAttachments
Add hasAttachments property to MessageRecord. Add it after size:
public var hasAttachments: Bool
Update the init to include the new parameter with a default value:
public init(
id: String, accountId: String, mailboxId: String, uid: Int,
messageId: String?, inReplyTo: String?, refs: String?,
subject: String?, fromAddress: String?, fromName: String?,
toAddresses: String?, ccAddresses: String?,
date: String, snippet: String?, bodyText: String?, bodyHtml: String?,
isRead: Bool, isFlagged: Bool, size: Int, hasAttachments: Bool = false
) {
// ... existing assignments ...
self.hasAttachments = hasAttachments
}
- Step 6: Run test to verify it passes
cd Packages/MagnumOpusCore
swift test --filter MigrationTests 2>&1 | tail -5
Expected: All migration tests pass.
- Step 7: Run full test suite to check nothing broke
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
Expected: All tests pass. The new hasAttachments field has a default value of false, so existing code continues to work.
- Step 8: Commit
git add Sources/MailStore/DatabaseSetup.swift Sources/MailStore/Records/AttachmentRecord.swift Sources/MailStore/Records/MessageRecord.swift Tests/MailStoreTests/MigrationTests.swift
git commit -m "add v4_attachment migration: sectionPath on attachment, hasAttachments on message"
Task 6: MailStore attachment methods & update Queries.swift
Files:
-
Modify:
Sources/MailStore/MailStore.swift -
Modify:
Sources/MailStore/Queries.swift -
Step 1: Add attachment CRUD methods to MailStore
Add to MailStore.swift in a new // MARK: - Attachments section:
// MARK: - Attachments
public func insertAttachment(_ attachment: AttachmentRecord) throws {
try dbWriter.write { db in
try attachment.insert(db)
}
}
public func insertAttachments(_ attachments: [AttachmentRecord]) throws {
try dbWriter.write { db in
for attachment in attachments {
try attachment.insert(db)
}
}
}
public func attachments(messageId: String) throws -> [AttachmentRecord] {
try dbWriter.read { db in
try AttachmentRecord
.filter(Column("messageId") == messageId)
.order(Column("filename"))
.fetchAll(db)
}
}
public func updateAttachmentCachePath(id: String, cachePath: String) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE attachment SET cachePath = ? WHERE id = ?",
arguments: [cachePath, id]
)
}
}
public func updateHasAttachments(messageId: String, hasAttachments: Bool) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE message SET hasAttachments = ? WHERE id = ?",
arguments: [hasAttachments, messageId]
)
}
}
public func storeBodyWithAttachments(
messageId: String,
text: String?,
html: String?,
attachments: [AttachmentRecord]
) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE message SET bodyText = ?, bodyHtml = ?, hasAttachments = ? WHERE id = ?",
arguments: [text, html, !attachments.isEmpty, messageId]
)
for attachment in attachments {
try attachment.insert(db)
}
}
}
- Step 2: Update Queries.swift to read hasAttachments
In Queries.swift, update toMessageSummary to read from the record:
Change:
hasAttachments: false
To:
hasAttachments: record.hasAttachments
- Step 3: Run full test suite
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
Expected: All tests pass.
- Step 4: Commit
git add Sources/MailStore/MailStore.swift Sources/MailStore/Queries.swift
git commit -m "add attachment CRUD to MailStore, wire hasAttachments in Queries"
Task 7: IMAPClient protocol & implementation — fetchFullMessage, fetchSection
Files:
-
Modify:
Sources/IMAPClient/IMAPClientProtocol.swift -
Modify:
Sources/IMAPClient/IMAPClient.swift -
Modify:
Tests/SyncEngineTests/MockIMAPClient.swift -
Step 1: Add new methods to IMAPClientProtocol
Add to IMAPClientProtocol.swift:
// v0.5 attachment/MIME operations
func fetchFullMessage(uid: Int) async throws -> String
func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data
- Step 2: Implement fetchFullMessage in IMAPClient
Add to IMAPClient.swift in the // MARK: - Fetch operations section:
public func fetchFullMessage(uid: Int) async throws -> String {
guard var runner else { throw IMAPError.notConnected }
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
let responses = try await runner.run(.uidFetch(
.set(set),
[.bodySection(peek: true, SectionSpecifier(kind: .complete), nil)],
[]
))
self.runner = runner
return parseFullMessageResponse(responses)
}
Add the parsing helper:
private func parseFullMessageResponse(_ responses: [Response]) -> String {
var bodyBuffer = ByteBuffer()
for response in responses {
if case .fetch(let fetchResponse) = response {
switch fetchResponse {
case .streamingBytes(let bytes):
var mutableBytes = bytes
bodyBuffer.writeBuffer(&mutableBytes)
default:
break
}
}
}
return String(buffer: bodyBuffer)
}
- Step 3: Implement fetchSection in IMAPClient
Add to IMAPClient.swift:
public func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data {
guard var runner else { throw IMAPError.notConnected }
// Select the mailbox first
let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: mailbox))))
self.runner = runner
guard selectResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("SELECT \(mailbox) failed")
}
let uidValue = UID(rawValue: UInt32(uid))
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
// Parse section path into SectionSpecifier.Part
let sectionParts = section.split(separator: ".").compactMap { Int($0) }
let part = SectionSpecifier.Part(sectionParts)
let spec = SectionSpecifier(part: part)
let responses = try await runner.run(.uidFetch(
.set(set),
[.bodySection(peek: true, spec, nil)],
[]
))
self.runner = runner
var bodyBuffer = ByteBuffer()
for response in responses {
if case .fetch(let fetchResponse) = response {
if case .streamingBytes(let bytes) = fetchResponse {
var mutableBytes = bytes
bodyBuffer.writeBuffer(&mutableBytes)
}
}
}
// The section content may be base64 encoded — decode it
let raw = String(buffer: bodyBuffer)
let cleaned = raw.filter { !$0.isWhitespace }
if let decoded = Data(base64Encoded: cleaned) {
return decoded
}
return Data(bodyBuffer.readableBytesView)
}
- Step 4: Update MockIMAPClient
Add to MockIMAPClient.swift:
Properties:
var fullMessages: [Int: String] = [:]
var sections: [String: Data] = [:] // key: "uid-section"
var fetchFullMessageCalls: [Int] = []
var fetchSectionCalls: [(uid: Int, mailbox: String, section: String)] = []
Methods:
func fetchFullMessage(uid: Int) async throws -> String {
fetchFullMessageCalls.append(uid)
return fullMessages[uid] ?? ""
}
func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data {
fetchSectionCalls.append((uid: uid, mailbox: mailbox, section: section))
return sections["\(uid)-\(section)"] ?? Data()
}
- Step 5: Build to verify compilation
cd Packages/MagnumOpusCore
swift build
Expected: Build succeeds.
- Step 6: Run full test suite
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
Expected: All tests pass.
- Step 7: Commit
git add Sources/IMAPClient/IMAPClientProtocol.swift Sources/IMAPClient/IMAPClient.swift Tests/SyncEngineTests/MockIMAPClient.swift
git commit -m "add fetchFullMessage, fetchSection to IMAPClient for MIME attachment retrieval"
Chunk 3: MessageFormatter Multipart & SyncCoordinator MIME Integration
Task 8: MessageFormatter — multipart formatting & base64 encoding
Files:
-
Modify:
Sources/SMTPClient/MessageFormatter.swift -
Modify:
Tests/SMTPClientTests/MessageFormatterTests.swift -
Step 1: Write failing tests for multipart formatting
Add to MessageFormatterTests.swift:
// MARK: - Multipart Formatting
@Test("format with attachments produces multipart/mixed")
func multipartWithAttachment() {
let message = OutgoingMessage(
from: EmailAddress(address: "alice@example.com"),
to: [EmailAddress(address: "bob@example.com")],
subject: "With attachment",
bodyText: "See attached.",
messageId: "test@example.com"
)
let attachments: [(filename: String, mimeType: String, data: Data)] = [
(filename: "test.pdf", mimeType: "application/pdf", data: Data("PDF content".utf8)),
]
let formatted = MessageFormatter.formatMultipart(message, attachments: attachments)
#expect(formatted.contains("Content-Type: multipart/mixed; boundary="))
#expect(formatted.contains("Content-Type: text/plain; charset=utf-8"))
#expect(formatted.contains("Content-Type: application/pdf; name=\"test.pdf\""))
#expect(formatted.contains("Content-Disposition: attachment; filename=\"test.pdf\""))
#expect(formatted.contains("Content-Transfer-Encoding: base64"))
#expect(formatted.contains("See attached."))
}
@Test("format without attachments produces single-part")
func singlePartNoAttachments() {
let message = OutgoingMessage(
from: EmailAddress(address: "alice@example.com"),
to: [EmailAddress(address: "bob@example.com")],
subject: "Plain",
bodyText: "Just text.",
messageId: "test@example.com"
)
let formatted = MessageFormatter.format(message)
#expect(formatted.contains("Content-Type: text/plain; charset=utf-8"))
#expect(!formatted.contains("multipart"))
}
@Test("base64 encoding wraps at 76 characters")
func base64LineWrapping() {
let data = Data(repeating: 0xFF, count: 100)
let encoded = MessageFormatter.base64Encode(data)
let lines = encoded.components(separatedBy: "\r\n")
for line in lines where !line.isEmpty {
#expect(line.count <= 76)
}
}
@Test("multiple attachments produce correct number of boundary sections")
func multipleAttachments() {
let message = OutgoingMessage(
from: EmailAddress(address: "alice@example.com"),
to: [EmailAddress(address: "bob@example.com")],
subject: "Multi",
bodyText: "Files.",
messageId: "test@example.com"
)
let attachments: [(filename: String, mimeType: String, data: Data)] = [
(filename: "a.pdf", mimeType: "application/pdf", data: Data("A".utf8)),
(filename: "b.jpg", mimeType: "image/jpeg", data: Data("B".utf8)),
]
let formatted = MessageFormatter.formatMultipart(message, attachments: attachments)
#expect(formatted.contains("a.pdf"))
#expect(formatted.contains("b.jpg"))
#expect(formatted.contains("application/pdf"))
#expect(formatted.contains("image/jpeg"))
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore
swift test --filter MessageFormatterTests 2>&1 | tail -5
Expected: Compilation error — formatMultipart and base64Encode not defined.
- Step 3: Implement formatMultipart and base64Encode
Add to MessageFormatter.swift:
/// Format a multipart/mixed message with attachments.
public static func formatMultipart(
_ message: OutgoingMessage,
attachments: [(filename: String, mimeType: String, data: Data)]
) -> String {
let boundary = "=_MagnumOpus_\(UUID().uuidString)"
var headers: [(String, String)] = []
headers.append(("From", formatAddress(message.from)))
headers.append(("To", message.to.map(formatAddress).joined(separator: ", ")))
if !message.cc.isEmpty {
headers.append(("Cc", message.cc.map(formatAddress).joined(separator: ", ")))
}
headers.append(("Subject", message.subject))
headers.append(("Date", formatRFC2822Date(Date())))
headers.append(("Message-ID", "<\(message.messageId)>"))
if let inReplyTo = message.inReplyTo {
headers.append(("In-Reply-To", "<\(inReplyTo)>"))
}
if let references = message.references {
headers.append(("References", references))
}
headers.append(("MIME-Version", "1.0"))
headers.append(("Content-Type", "multipart/mixed; boundary=\"\(boundary)\""))
var result = ""
for (name, value) in headers {
result += "\(name): \(value)\r\n"
}
result += "\r\n"
// Text body part
result += "--\(boundary)\r\n"
result += "Content-Type: text/plain; charset=utf-8\r\n"
result += "Content-Transfer-Encoding: quoted-printable\r\n"
result += "\r\n"
result += quotedPrintableEncode(message.bodyText)
result += "\r\n"
// Attachment parts
for attachment in attachments {
result += "--\(boundary)\r\n"
result += "Content-Type: \(attachment.mimeType); name=\"\(attachment.filename)\"\r\n"
result += "Content-Disposition: attachment; filename=\"\(attachment.filename)\"\r\n"
result += "Content-Transfer-Encoding: base64\r\n"
result += "\r\n"
result += base64Encode(attachment.data)
result += "\r\n"
}
// Closing boundary
result += "--\(boundary)--\r\n"
return result
}
/// Base64 encode data with line wrapping at 76 characters per RFC 2045.
public static func base64Encode(_ data: Data, lineLength: Int = 76) -> String {
let encoded = data.base64EncodedString()
var result = ""
var index = encoded.startIndex
while index < encoded.endIndex {
let end = encoded.index(index, offsetBy: lineLength, limitedBy: encoded.endIndex) ?? encoded.endIndex
result += String(encoded[index..<end])
if end < encoded.endIndex {
result += "\r\n"
}
index = end
}
return result
}
- Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore
swift test --filter MessageFormatterTests 2>&1 | tail -5
Expected: All tests pass.
- Step 5: Commit
git add Sources/SMTPClient/MessageFormatter.swift Tests/SMTPClientTests/MessageFormatterTests.swift
git commit -m "add multipart/mixed formatting, base64 line-wrapped encoding to MessageFormatter"
Task 9: SyncCoordinator MIME-aware body prefetch
Files:
-
Modify:
Sources/SyncEngine/SyncCoordinator.swift -
Modify:
Package.swift(add MIMEParser dependency to SyncEngine) -
Modify:
Tests/SyncEngineTests/SyncCoordinatorTests.swift -
Step 1: Add MIMEParser dependency to SyncEngine target
In Package.swift, update the SyncEngine target dependencies:
.target(
name: "SyncEngine",
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore", "MIMEParser"]
),
- Step 2: Write failing test for MIME-aware sync
Add to SyncCoordinatorTests.swift:
@Test("sync with full message parses MIME and stores attachments")
func mimeAwareSync() async throws {
let store = try makeStore()
let mock = makeMock()
// Provide a multipart MIME message for body prefetch
let mimeMessage = """
Content-Type: multipart/mixed; boundary="bound"\r
\r
--bound\r
Content-Type: text/plain; charset=utf-8\r
\r
Hello from MIME.\r
--bound\r
Content-Type: application/pdf; name="report.pdf"\r
Content-Disposition: attachment; filename="report.pdf"\r
Content-Transfer-Encoding: base64\r
\r
SGVsbG8=\r
--bound--
"""
mock.fullMessages[1] = mimeMessage
let coordinator = SyncCoordinator(
accountConfig: AccountConfig(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
),
imapClient: mock,
store: store
)
try await coordinator.syncNow()
// Verify MIME body was stored
let inboxMb = try store.mailboxes(accountId: "acc1").first { $0.name == "INBOX" }!
let messages = try store.messages(mailboxId: inboxMb.id)
let msg = messages.first { $0.uid == 1 }!
#expect(msg.bodyText == "Hello from MIME.")
#expect(msg.hasAttachments == true)
// Verify attachment was stored
let attachments = try store.attachments(messageId: msg.id)
#expect(attachments.count == 1)
#expect(attachments.first?.filename == "report.pdf")
#expect(attachments.first?.mimeType == "application/pdf")
#expect(attachments.first?.sectionPath == "2")
}
- Step 3: Run test to verify it fails
cd Packages/MagnumOpusCore
swift test --filter SyncCoordinatorTests/mimeAwareSync 2>&1 | tail -5
Expected: FAIL — fullMessages not in mock or body not parsed as MIME.
- Step 4: Update SyncCoordinator.prefetchBodies to use MIME parsing
Replace the prefetchBodies method in SyncCoordinator.swift:
import MIMEParser
(Add at the top of the file with other imports.)
/// Fetch full RFC822 messages and parse MIME for body + attachments
private func prefetchBodies(mailboxId: String) async {
let thirtyDaysAgo = ISO8601DateFormatter().string(
from: Calendar.current.date(byAdding: .day, value: -30, to: Date())!
)
do {
let messages = try store.messages(mailboxId: mailboxId)
let recent = messages.filter { $0.bodyText == nil && $0.bodyHtml == nil && $0.date >= thirtyDaysAgo }
for message in recent.prefix(50) {
guard !Task.isCancelled else { break }
let rawMessage = try await imapClient.fetchFullMessage(uid: message.uid)
guard !rawMessage.isEmpty else { continue }
let parsed = MIMEParser.parse(rawMessage)
// Build attachment records
let attachmentRecords = (parsed.attachments + parsed.inlineImages).map { att in
AttachmentRecord(
id: UUID().uuidString,
messageId: message.id,
filename: att.filename,
mimeType: att.mimeType,
size: att.size,
contentId: att.contentId,
cachePath: nil,
sectionPath: att.sectionPath
)
}
try store.storeBodyWithAttachments(
messageId: message.id,
text: parsed.textBody,
html: parsed.htmlBody,
attachments: attachmentRecords
)
}
} catch {
// Background prefetch failure is non-fatal
print("[SyncCoordinator] prefetchBodies error: \(error)")
}
}
- Step 5: Run test to verify it passes
cd Packages/MagnumOpusCore
swift test --filter SyncCoordinatorTests/mimeAwareSync 2>&1 | tail -5
Expected: PASS.
- Step 6: Run full test suite
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
Expected: All tests pass.
- Step 7: Commit
git add Package.swift Sources/SyncEngine/SyncCoordinator.swift Tests/SyncEngineTests/SyncCoordinatorTests.swift
git commit -m "replace body text prefetch with MIME-aware parsing, store attachments from parsed messages"
Chunk 4: IMAP IDLE
Task 10: IMAPIdleHandler — NIO channel handler for IDLE responses
Files:
-
Create:
Sources/IMAPClient/IMAPIdleHandler.swift -
Step 1: Write IMAPIdleHandler
import NIO
import NIOIMAPCore
import NIOIMAP
/// Events emitted by the IDLE handler
public enum IMAPIdleEvent: Sendable {
case exists(Int)
case expunge(Int)
case idleTerminated
}
/// NIO ChannelInboundHandler that processes untagged responses during IMAP IDLE.
/// Unlike the standard IMAPResponseHandler (which uses CheckedContinuation for tagged responses),
/// this handler uses AsyncStream to deliver a continuous stream of events.
final class IMAPIdleHandler: ChannelInboundHandler, @unchecked Sendable {
typealias InboundIn = Response
private let continuation: AsyncStream<IMAPIdleEvent>.Continuation
private var idleTag: String?
init(continuation: AsyncStream<IMAPIdleEvent>.Continuation) {
self.continuation = continuation
}
func setIdleTag(_ tag: String) {
idleTag = tag
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let response = unwrapInboundIn(data)
switch response {
case .untagged(let payload):
switch payload {
case .mailboxData(let data):
switch data {
case .exists(let count):
continuation.yield(.exists(count))
default:
break
}
case .messageData(let data):
switch data {
case .expunge(let seqNum):
continuation.yield(.expunge(seqNum))
default:
break
}
default:
break
}
case .tagged(let tagged):
if tagged.tag == idleTag {
continuation.yield(.idleTerminated)
}
case .idleStarted:
// Server acknowledged IDLE — we're now idling
break
case .fetch, .authenticationChallenge, .fatal:
break
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
continuation.finish()
context.close(promise: nil)
}
func channelInactive(context: ChannelHandlerContext) {
continuation.finish()
}
}
- Step 2: Verify build
cd Packages/MagnumOpusCore
swift build
Expected: Build succeeds.
- Step 3: Commit
git add Sources/IMAPClient/IMAPIdleHandler.swift
git commit -m "add IMAPIdleHandler: NIO channel handler for IDLE event streaming"
Task 11: IMAPIdleClient actor
Files:
-
Create:
Sources/IMAPClient/IMAPIdleClient.swift -
Create:
Tests/IMAPClientTests/IMAPIdleClientTests.swift -
Step 1: Write IMAPIdleClient
import Foundation
import NIO
import NIOIMAPCore
@preconcurrency import NIOIMAP
@preconcurrency import NIOSSL
import Models
public actor IMAPIdleClient {
private let host: String
private let port: Int
private let credentials: Credentials
private var channel: Channel?
private var group: EventLoopGroup?
private var isMonitoring = false
private var monitorTask: Task<Void, Never>?
private let reIdleInterval: Duration = .seconds(29 * 60) // 29 minutes per RFC 2177
public init(host: String, port: Int, credentials: Credentials) {
self.host = host
self.port = port
self.credentials = credentials
}
/// Start monitoring INBOX via IMAP IDLE. Calls onNewMail when server sends EXISTS.
public func startMonitoring(onNewMail: @escaping @Sendable () -> Void) async throws {
guard !isMonitoring else { return }
isMonitoring = true
try await connectAndLogin()
try await selectInbox()
monitorTask = Task { [weak self] in
var backoffSeconds: UInt64 = 5
while !Task.isCancelled {
guard let self else { break }
do {
try await self.idleLoop(onNewMail: onNewMail)
} catch {
if Task.isCancelled { break }
// Reconnect with exponential backoff
try? await Task.sleep(for: .seconds(Int(backoffSeconds)))
backoffSeconds = min(backoffSeconds * 2, 300) // cap at 5 min
do {
try await self.connectAndLogin()
try await self.selectInbox()
backoffSeconds = 5 // reset on success
} catch {
if Task.isCancelled { break }
continue
}
}
}
}
}
/// Stop monitoring and disconnect.
public func stopMonitoring() async {
isMonitoring = false
monitorTask?.cancel()
monitorTask = nil
try? await channel?.close()
channel = nil
try? await group?.shutdownGracefully()
group = nil
}
// MARK: - Connection
private func connectAndLogin() async throws {
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
let hostname = host
let responseHandler = IMAPResponseHandler()
let bootstrap = ClientBootstrap(group: eventLoopGroup)
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
return channel.pipeline.addHandlers([
sslHandler,
IMAPClientHandler(),
responseHandler,
])
}
let chan = try await bootstrap.connect(host: host, port: port).get()
try await responseHandler.waitForGreeting()
// Login
var tagCounter = 0
func nextTag() -> String {
tagCounter += 1
return "IDLE\(tagCounter)"
}
let loginTag = nextTag()
let loginCommand = TaggedCommand(
tag: loginTag,
command: .login(username: credentials.username, password: credentials.password)
)
let loginResponses = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[Response], Error>) in
responseHandler.sendCommand(tag: loginTag, continuation: cont)
chan.writeAndFlush(IMAPClientHandler.Message.part(.tagged(loginCommand)), promise: nil)
}
guard loginResponses.contains(where: { isOK($0) }) else {
throw IMAPError.authenticationFailed
}
self.channel = chan
self.group = eventLoopGroup
}
private func selectInbox() async throws {
guard let channel else { throw IMAPError.notConnected }
let responseHandler = try await getResponseHandler()
let tag = "IDLESEL1"
let selectCommand = TaggedCommand(
tag: tag,
command: .select(MailboxName(ByteBuffer(string: "INBOX")))
)
let responses = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[Response], Error>) in
responseHandler.sendCommand(tag: tag, continuation: cont)
channel.writeAndFlush(IMAPClientHandler.Message.part(.tagged(selectCommand)), promise: nil)
}
guard responses.contains(where: { isOK($0) }) else {
throw IMAPError.unexpectedResponse("SELECT INBOX failed")
}
}
// MARK: - IDLE Loop
private func idleLoop(onNewMail: @escaping @Sendable () -> Void) async throws {
guard let channel else { throw IMAPError.notConnected }
let pipeline = channel.pipeline
// Iterative IDLE loop — avoids unbounded stack growth from recursion
while !Task.isCancelled {
// Swap response handler for IDLE handler
let (stream, streamContinuation) = AsyncStream<IMAPIdleEvent>.makeStream()
let idleHandler = IMAPIdleHandler(continuation: streamContinuation)
let oldHandler = try await getResponseHandler()
try await pipeline.removeHandler(oldHandler).get()
try await pipeline.addHandler(idleHandler).get()
let idleTag = "IDLE1"
idleHandler.setIdleTag(idleTag)
// Send IDLE command
let idleCommand = TaggedCommand(tag: idleTag, command: .idleStart)
channel.writeAndFlush(IMAPClientHandler.Message.part(.tagged(idleCommand)), promise: nil)
// Set up re-IDLE timer — after 29 min, send DONE to break IDLE (RFC 2177)
let reIdleChannel = channel
let reIdleContinuation = streamContinuation
let reIdleTask = Task {
try await Task.sleep(for: reIdleInterval)
// Timer fired — break IDLE so the outer loop re-enters
reIdleChannel.writeAndFlush(IMAPClientHandler.Message.part(.idleDone), promise: nil)
}
var shouldReIdle = false
// Consume events
for await event in stream {
switch event {
case .exists:
// New mail — break IDLE, trigger sync
channel.writeAndFlush(IMAPClientHandler.Message.part(.idleDone), promise: nil)
for await innerEvent in stream {
if case .idleTerminated = innerEvent { break }
}
reIdleTask.cancel()
onNewMail()
shouldReIdle = true
case .expunge:
break
case .idleTerminated:
reIdleTask.cancel()
shouldReIdle = true
}
if shouldReIdle { break }
}
// Restore response handler before re-entering IDLE
try await pipeline.removeHandler(idleHandler).get()
let newResponseHandler = IMAPResponseHandler()
try await pipeline.addHandler(newResponseHandler).get()
if !shouldReIdle {
// Stream ended — connection dropped
throw IMAPError.serverError("IDLE connection dropped")
}
// Otherwise: loop back to re-enter IDLE
}
}
// MARK: - Helpers
private func getResponseHandler() async throws -> IMAPResponseHandler {
guard let channel else { throw IMAPError.notConnected }
return try await channel.pipeline.handler(type: IMAPResponseHandler.self).get()
}
private func isOK(_ response: Response) -> Bool {
if case .tagged(let tagged) = response {
if case .ok = tagged.state { return true }
}
return false
}
}
- Step 2: Write basic IDLE client tests
import Testing
import Foundation
@testable import IMAPClient
import Models
@Suite("IMAPIdleClient")
struct IMAPIdleClientTests {
@Test("IMAPIdleClient can be initialized")
func initialization() {
let client = IMAPIdleClient(
host: "imap.example.com",
port: 993,
credentials: Credentials(username: "user", password: "pass")
)
// Just verify it compiles and initializes — actual IDLE testing
// requires a real server or more sophisticated mocking
#expect(true)
}
@Test("IMAPIdleEvent cases exist")
func eventCases() {
let exists = IMAPIdleEvent.exists(42)
let expunge = IMAPIdleEvent.expunge(1)
let terminated = IMAPIdleEvent.idleTerminated
if case .exists(let count) = exists {
#expect(count == 42)
}
if case .expunge(let num) = expunge {
#expect(num == 1)
}
if case .idleTerminated = terminated {
#expect(true)
}
}
}
- Step 3: Run tests
cd Packages/MagnumOpusCore
swift test --filter IMAPIdleClientTests 2>&1 | tail -5
Expected: Tests pass.
- Step 4: Commit
git add Sources/IMAPClient/IMAPIdleClient.swift Tests/IMAPClientTests/IMAPIdleClientTests.swift
git commit -m "add IMAPIdleClient actor with IDLE loop, reconnect backoff, re-IDLE timer"
Task 12: SyncCoordinator IDLE integration
Files:
-
Modify:
Sources/SyncEngine/SyncCoordinator.swift -
Modify:
Tests/SyncEngineTests/SyncCoordinatorTests.swift -
Step 1: Write failing test for IDLE integration
Add to SyncCoordinatorTests.swift:
@Test("startIdleMonitoring does nothing when server lacks IDLE capability")
func idleNotSupported() async throws {
let store = try makeStore()
let mock = makeMock()
mock.serverCapabilities = ["IMAP4rev1"] // No IDLE
let coordinator = SyncCoordinator(
accountConfig: AccountConfig(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
),
imapClient: mock,
store: store
)
// First sync to establish account
try await coordinator.syncNow()
// Should not crash, just return without starting IDLE
await coordinator.startIdleMonitoring()
// Verify no IDLE client was created (by checking that sync still works normally)
try await coordinator.syncNow()
#expect(coordinator.syncState == .idle)
}
@Test("stopIdleMonitoring is safe to call even when not monitoring")
func stopIdleWhenNotMonitoring() async throws {
let store = try makeStore()
let mock = makeMock()
let coordinator = SyncCoordinator(
accountConfig: AccountConfig(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
),
imapClient: mock,
store: store
)
// Should not crash
await coordinator.stopIdleMonitoring()
#expect(coordinator.syncState == .idle)
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore
swift test --filter "SyncCoordinatorTests/idleNotSupported|SyncCoordinatorTests/stopIdleWhenNotMonitoring" 2>&1 | tail -5
Expected: Compilation error — methods don't exist.
- Step 3: Add IDLE integration to SyncCoordinator
First, update the SyncCoordinator init to accept Credentials for IDLE use. Add a new stored property:
private let credentials: Credentials?
Update the init signature (add credentials parameter with default nil):
public init(
accountConfig: AccountConfig,
imapClient: any IMAPClientProtocol,
store: MailStore,
actionQueue: ActionQueue? = nil,
taskStore: TaskStore? = nil,
credentials: Credentials? = nil
) {
self.accountConfig = accountConfig
self.imapClient = imapClient
self.store = store
self.actionQueue = actionQueue
self.taskStore = taskStore
self.credentials = credentials
}
Add IDLE properties after existing ones:
private var idleClient: IMAPIdleClient?
private var idleActive = false
Add IDLE methods:
// MARK: - IMAP IDLE
/// Check server capabilities and start IDLE monitoring if supported.
/// Must be called after at least one successful sync (so capabilities are cached).
public func startIdleMonitoring() async {
guard let credentials else {
print("[SyncCoordinator] No credentials provided, cannot start IDLE")
return
}
do {
let caps = try await imapClient.capabilities()
guard caps.contains("IDLE") else {
print("[SyncCoordinator] Server does not support IDLE, using periodic sync only")
return
}
let client = IMAPIdleClient(
host: accountConfig.imapHost,
port: accountConfig.imapPort,
credentials: credentials
)
idleClient = client
idleActive = true
try await client.startMonitoring { [weak self] in
Task { @MainActor [weak self] in
try? await self?.syncNow()
}
}
} catch {
print("[SyncCoordinator] Failed to start IDLE monitoring: \(error)")
}
}
/// Stop IDLE monitoring and clean up.
public func stopIdleMonitoring() async {
idleActive = false
await idleClient?.stopMonitoring()
idleClient = nil
}
Note: Existing callers that don't pass credentials continue to work — credentials defaults to nil and IDLE simply won't start. The app layer passes credentials when constructing SyncCoordinator.
- Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore
swift test --filter SyncCoordinatorTests 2>&1 | tail -5
Expected: All SyncCoordinator tests pass.
- Step 5: Run full test suite
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
Expected: All tests pass.
- Step 6: Commit
git add Sources/SyncEngine/SyncCoordinator.swift Tests/SyncEngineTests/SyncCoordinatorTests.swift
git commit -m "add IDLE monitoring to SyncCoordinator: start/stop with capability check"
Chunk 5: SyncEngine Update — Stop Fetching Body in Envelopes
Task 13: Remove body fetch from fetchEnvelopes
The existing fetchEnvelopes fetches body text inline during initial sync via BODY[TEXT]. Now that prefetchBodies uses fetchFullMessage with MIME parsing, we should stop fetching bodies in fetchEnvelopes to avoid doubling bandwidth. The body data from envelopes was being stored as raw text without MIME parsing anyway, so it's better to let the MIME-aware prefetchBodies handle it.
Files:
-
Modify:
Sources/IMAPClient/IMAPClient.swift -
Modify:
Sources/IMAPClient/FetchedEnvelope.swift -
Modify:
Sources/SyncEngine/SyncCoordinator.swift -
Step 1: Remove BODY[TEXT] from fetchEnvelopes fetch attributes
In IMAPClient.swift, update fetchEnvelopes — remove the .bodySection attribute:
Change:
let responses = try await runner.run(.uidFetch(
.set(set),
[
.envelope,
.flags,
.uid,
.rfc822Size,
.bodySection(peek: true, SectionSpecifier(kind: .text), nil),
],
[]
))
To:
let responses = try await runner.run(.uidFetch(
.set(set),
[
.envelope,
.flags,
.uid,
.rfc822Size,
],
[]
))
- Step 2: Simplify parseFetchResponses — remove bodyBuffer
In IMAPClient.swift, update parseFetchResponses to remove bodyBuffer. The body now comes from MIME-aware prefetchBodies, not from envelope fetch.
Replace the method:
private func parseFetchResponses(_ responses: [Response]) -> [FetchedEnvelope] {
var envelopes: [FetchedEnvelope] = []
var currentUID: Int?
var currentEnvelope: Envelope?
var currentFlags: [Flag] = []
var currentSize: Int = 0
for response in responses {
switch response {
case .fetch(let fetchResponse):
switch fetchResponse {
case .start, .startUID:
currentUID = nil
currentEnvelope = nil
currentFlags = []
currentSize = 0
case .simpleAttribute(let attr):
switch attr {
case .uid(let uid):
currentUID = Int(uid.rawValue)
case .envelope(let env):
currentEnvelope = env
case .flags(let flags):
currentFlags = flags
case .rfc822Size(let size):
currentSize = size
default:
break
}
case .streamingBegin, .streamingBytes, .streamingEnd:
break
case .finish:
if let uid = currentUID {
let envelope = buildFetchedEnvelope(
uid: uid,
envelope: currentEnvelope,
flags: currentFlags,
size: currentSize
)
envelopes.append(envelope)
}
}
default:
break
}
}
return envelopes
}
- Step 3: Simplify buildFetchedEnvelope — remove bodyBuffer parameter
Replace the method (bodies come from prefetchBodies now):
private func buildFetchedEnvelope(
uid: Int,
envelope: Envelope?,
flags: [Flag],
size: Int
) -> FetchedEnvelope {
let subject = envelope?.subject.flatMap { String(buffer: $0) }
let date = envelope?.date.map { String($0) } ?? ""
let messageId = envelope?.messageID.map { String($0) }
let inReplyTo = envelope?.inReplyTo.map { String($0) }
let from = envelope?.from.compactMap { extractEmailAddress($0) }.first
let to = envelope?.to.compactMap { extractEmailAddress($0) } ?? []
let cc = envelope?.cc.compactMap { extractEmailAddress($0) } ?? []
return FetchedEnvelope(
uid: uid,
messageId: messageId,
inReplyTo: inReplyTo,
references: nil,
subject: subject,
from: from,
to: to,
cc: cc,
date: date,
snippet: nil,
bodyText: nil,
bodyHtml: nil,
isRead: flags.contains(.seen),
isFlagged: flags.contains(.flagged),
size: size
)
}
- Step 4: Run full test suite
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
Expected: All tests pass. Existing sync tests use MockIMAPClient which doesn't change behavior. The fullMessages mock provides MIME bodies during prefetch.
- Step 5: Commit
git add Sources/IMAPClient/IMAPClient.swift
git commit -m "remove inline body fetch from fetchEnvelopes, bodies now come from MIME-aware prefetchBodies"
Task 14: Attachment download service
Files:
- Modify:
Sources/SyncEngine/SyncCoordinator.swift
The spec defines an attachment download flow: check cachePath → if cached open QuickLook → if not fetch via fetchSection → save to disk → update cachePath. This orchestration lives in SyncCoordinator since it coordinates IMAP + MailStore.
- Step 1: Add downloadAttachment method
Add to SyncCoordinator.swift:
// MARK: - Attachment Download
/// Download an attachment on demand, cache it to disk, update the DB record.
/// Returns the local file URL for preview.
public func downloadAttachment(
attachmentId: String,
messageUid: Int,
mailboxName: String
) async throws -> URL {
// Look up the attachment record
guard let record = try store.attachment(id: attachmentId) else {
throw AttachmentError.notFound
}
// Check if already cached
if let cachePath = record.cachePath {
let url = URL(fileURLWithPath: cachePath)
if FileManager.default.fileExists(atPath: cachePath) {
return url
}
}
guard let sectionPath = record.sectionPath else {
throw AttachmentError.noSectionPath
}
// Fetch the section data from IMAP
try await imapClient.connect()
let data = try await imapClient.fetchSection(uid: messageUid, mailbox: mailboxName, section: sectionPath)
try? await imapClient.disconnect()
// Build cache directory
let cacheDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
.appendingPathComponent("MagnumOpus")
.appendingPathComponent(accountConfig.id)
.appendingPathComponent("attachments")
try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
// Determine file extension from filename
let ext = record.filename.flatMap { URL(fileURLWithPath: $0).pathExtension } ?? "bin"
let fileName = "\(attachmentId).\(ext)"
let fileURL = cacheDir.appendingPathComponent(fileName)
// Write data to disk
try data.write(to: fileURL)
// Update cache path in DB
try store.updateAttachmentCachePath(id: attachmentId, cachePath: fileURL.path)
return fileURL
}
public enum AttachmentError: Error {
case notFound
case noSectionPath
}
- Step 2: Add
attachment(id:)query to MailStore
Add to MailStore.swift in the // MARK: - Attachments section:
public func attachment(id: String) throws -> AttachmentRecord? {
try dbWriter.read { db in
try AttachmentRecord.fetchOne(db, key: id)
}
}
- Step 3: Build and run tests
cd Packages/MagnumOpusCore
swift build && swift test 2>&1 | tail -3
Expected: Build and tests pass.
- Step 4: Commit
git add Sources/SyncEngine/SyncCoordinator.swift Sources/MailStore/MailStore.swift
git commit -m "add attachment download service: fetch section from IMAP, cache to disk"
Task 15: 25 MB per-file attachment size guard
Files:
-
Modify:
Sources/SMTPClient/MessageFormatter.swift -
Modify:
Tests/SMTPClientTests/MessageFormatterTests.swift -
Step 1: Write failing test
Add to MessageFormatterTests.swift:
@Test("formatMultipart throws for file exceeding 25 MB")
func attachmentSizeGuard() {
let message = OutgoingMessage(
from: EmailAddress(address: "alice@example.com"),
to: [EmailAddress(address: "bob@example.com")],
subject: "Big file",
bodyText: "See attached.",
messageId: "test@example.com"
)
// 26 MB attachment
let bigData = Data(repeating: 0xFF, count: 26 * 1024 * 1024)
let attachments: [(filename: String, mimeType: String, data: Data)] = [
(filename: "huge.bin", mimeType: "application/octet-stream", data: bigData),
]
#expect(throws: MessageFormatterError.self) {
try MessageFormatter.formatMultipart(message, attachments: attachments)
}
}
- Step 2: Add size guard and error type
In MessageFormatter.swift, add at the bottom of the file:
public enum MessageFormatterError: Error {
case attachmentTooLarge(filename: String, size: Int)
}
Update formatMultipart signature to throws and add a size check at the start:
public static func formatMultipart(
_ message: OutgoingMessage,
attachments: [(filename: String, mimeType: String, data: Data)]
) throws -> String {
let maxSize = 25 * 1024 * 1024 // 25 MB
for attachment in attachments {
if attachment.data.count > maxSize {
throw MessageFormatterError.attachmentTooLarge(
filename: attachment.filename,
size: attachment.data.count
)
}
}
// ... rest of existing implementation unchanged
- Step 3: Update non-throwing test calls to use
try
The existing multipart tests now need try since formatMultipart throws. Update test calls from:
let formatted = MessageFormatter.formatMultipart(message, attachments: attachments)
To:
let formatted = try MessageFormatter.formatMultipart(message, attachments: attachments)
- Step 4: Run tests
cd Packages/MagnumOpusCore
swift test --filter MessageFormatterTests 2>&1 | tail -5
Expected: All tests pass.
- Step 5: Commit
git add Sources/SMTPClient/MessageFormatter.swift Tests/SMTPClientTests/MessageFormatterTests.swift
git commit -m "add 25 MB per-file attachment size guard to MessageFormatter"
Task 16: Update periodic sync interval & add SyncEngine dependency on MIMEParser to Package.swift project.yml
Files:
-
Modify:
Sources/SyncEngine/SyncCoordinator.swift -
Modify:
Apps/project.yml -
Step 1: Update default periodic sync interval
In SyncCoordinator.swift, change the startPeriodicSync default interval:
public func startPeriodicSync(interval: Duration = .seconds(900)) {
(Changed from 300s/5min to 900s/15min — IDLE is the fast path now.)
- Step 2: Add MIMEParser to project.yml
In Apps/project.yml, add MIMEParser to the dependencies for both macOS and iOS targets. Look at how other products are listed and add:
- package: MagnumOpusCore
product: MIMEParser
- Step 3: Build and verify
cd Packages/MagnumOpusCore
swift build
- Step 4: Commit
git add Sources/SyncEngine/SyncCoordinator.swift Apps/project.yml
git commit -m "bump periodic sync to 15min (IDLE is fast path), add MIMEParser to app targets"
Task 17: Final verification
- Step 1: Run full test suite
cd Packages/MagnumOpusCore
swift test 2>&1
Expected: All tests pass (original 111 + new tests).
- Step 2: Build Xcode project
cd /Users/felixfoertsch/Developer/MagnumOpus
xcodegen generate --spec Apps/project.yml --project Apps/
Then:
cd Apps
xcodebuild build -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -10
Expected: Build succeeds.
- Step 3: Commit any remaining changes
cd /Users/felixfoertsch/Developer/MagnumOpus
git status
If clean, proceed. Otherwise commit remaining changes.
- Step 4: Bump CalVer version
Update the CalVer version in Apps/project.yml to today's date if not already done:
MARKETING_VERSION: "2026.03.14"
(Already set from v0.4 — if this runs on a different day, update accordingly.)
- Step 5: Final commit
git add -A
git commit -m "v0.5: attachments (MIME parse, multipart send) + IMAP IDLE real-time mail"