Files
MagnumOpus/docs/plans/2026-03-14-v0.5-implementation-plan.md
T

2903 lines
81 KiB
Markdown

# 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<IMAPIdleEvent> |
| `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**
```bash
mkdir -p Packages/MagnumOpusCore/Sources/MIMEParser
mkdir -p Packages/MagnumOpusCore/Tests/MIMEParserTests
```
- [ ] **Step 3: Verify build**
```bash
cd Packages/MagnumOpusCore
swift build
```
Expected: Build succeeds (empty target is fine with directory existing).
- [ ] **Step 4: Commit**
```bash
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**
```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**
```bash
cd Packages/MagnumOpusCore
swift build
```
- [ ] **Step 3: Commit**
```bash
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**
```swift
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**
```bash
cd Packages/MagnumOpusCore
swift test --filter RFC2047DecoderTests 2>&1 | tail -5
```
Expected: Compilation error — `RFC2047Decoder` not defined.
- [ ] **Step 3: Implement RFC2047Decoder**
```swift
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**
```bash
cd Packages/MagnumOpusCore
swift test --filter RFC2047DecoderTests 2>&1 | tail -5
```
Expected: All 6 tests pass.
- [ ] **Step 5: Commit**
```bash
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**
```swift
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**
```bash
cd Packages/MagnumOpusCore
swift test --filter MIMEParserTests 2>&1 | tail -5
```
Expected: Compilation error — `MIMEParser` not defined.
- [ ] **Step 3: Implement MIMEParser**
```swift
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**
```bash
cd Packages/MagnumOpusCore
swift test --filter MIMEParserTests 2>&1 | tail -10
```
Expected: All 11 tests pass.
- [ ] **Step 5: Run full test suite**
```bash
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
```
Expected: 111 + 6 + 11 = 128 tests pass (existing + RFC2047 + MIMEParser).
- [ ] **Step 6: Commit**
```bash
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:
```swift
@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**
```bash
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:
```swift
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`:
```swift
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`:
```swift
public var hasAttachments: Bool
```
Update the init to include the new parameter with a default value:
```swift
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**
```bash
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**
```bash
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**
```bash
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:
```swift
// 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:
```swift
hasAttachments: false
```
To:
```swift
hasAttachments: record.hasAttachments
```
- [ ] **Step 3: Run full test suite**
```bash
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
```
Expected: All tests pass.
- [ ] **Step 4: Commit**
```bash
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`:
```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:
```swift
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:
```swift
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`:
```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:
```swift
var fullMessages: [Int: String] = [:]
var sections: [String: Data] = [:] // key: "uid-section"
var fetchFullMessageCalls: [Int] = []
var fetchSectionCalls: [(uid: Int, mailbox: String, section: String)] = []
```
Methods:
```swift
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**
```bash
cd Packages/MagnumOpusCore
swift build
```
Expected: Build succeeds.
- [ ] **Step 6: Run full test suite**
```bash
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
```
Expected: All tests pass.
- [ ] **Step 7: Commit**
```bash
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`:
```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**
```bash
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`:
```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**
```bash
cd Packages/MagnumOpusCore
swift test --filter MessageFormatterTests 2>&1 | tail -5
```
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
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:
```swift
.target(
name: "SyncEngine",
dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore", "TaskStore", "MIMEParser"]
),
```
- [ ] **Step 2: Write failing test for MIME-aware sync**
Add to `SyncCoordinatorTests.swift`:
```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**
```bash
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`:
```swift
import MIMEParser
```
(Add at the top of the file with other imports.)
```swift
/// 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**
```bash
cd Packages/MagnumOpusCore
swift test --filter SyncCoordinatorTests/mimeAwareSync 2>&1 | tail -5
```
Expected: PASS.
- [ ] **Step 6: Run full test suite**
```bash
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
```
Expected: All tests pass.
- [ ] **Step 7: Commit**
```bash
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**
```swift
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**
```bash
cd Packages/MagnumOpusCore
swift build
```
Expected: Build succeeds.
- [ ] **Step 3: Commit**
```bash
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**
```swift
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**
```swift
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**
```bash
cd Packages/MagnumOpusCore
swift test --filter IMAPIdleClientTests 2>&1 | tail -5
```
Expected: Tests pass.
- [ ] **Step 4: Commit**
```bash
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`:
```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**
```bash
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:
```swift
private let credentials: Credentials?
```
Update the init signature (add `credentials` parameter with default `nil`):
```swift
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:
```swift
private var idleClient: IMAPIdleClient?
private var idleActive = false
```
Add IDLE methods:
```swift
// 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**
```bash
cd Packages/MagnumOpusCore
swift test --filter SyncCoordinatorTests 2>&1 | tail -5
```
Expected: All SyncCoordinator tests pass.
- [ ] **Step 5: Run full test suite**
```bash
cd Packages/MagnumOpusCore
swift test 2>&1 | tail -3
```
Expected: All tests pass.
- [ ] **Step 6: Commit**
```bash
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:
```swift
let responses = try await runner.run(.uidFetch(
.set(set),
[
.envelope,
.flags,
.uid,
.rfc822Size,
.bodySection(peek: true, SectionSpecifier(kind: .text), nil),
],
[]
))
```
To:
```swift
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:
```swift
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):
```swift
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**
```bash
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**
```bash
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`:
```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:
```swift
public func attachment(id: String) throws -> AttachmentRecord? {
try dbWriter.read { db in
try AttachmentRecord.fetchOne(db, key: id)
}
}
```
- [ ] **Step 3: Build and run tests**
```bash
cd Packages/MagnumOpusCore
swift build && swift test 2>&1 | tail -3
```
Expected: Build and tests pass.
- [ ] **Step 4: Commit**
```bash
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`:
```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:
```swift
public enum MessageFormatterError: Error {
case attachmentTooLarge(filename: String, size: Int)
}
```
Update `formatMultipart` signature to `throws` and add a size check at the start:
```swift
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:
```swift
let formatted = MessageFormatter.formatMultipart(message, attachments: attachments)
```
To:
```swift
let formatted = try MessageFormatter.formatMultipart(message, attachments: attachments)
```
- [ ] **Step 4: Run tests**
```bash
cd Packages/MagnumOpusCore
swift test --filter MessageFormatterTests 2>&1 | tail -5
```
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
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:
```swift
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:
```yaml
- package: MagnumOpusCore
product: MIMEParser
```
- [ ] **Step 3: Build and verify**
```bash
cd Packages/MagnumOpusCore
swift build
```
- [ ] **Step 4: Commit**
```bash
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**
```bash
cd Packages/MagnumOpusCore
swift test 2>&1
```
Expected: All tests pass (original 111 + new tests).
- [ ] **Step 2: Build Xcode project**
```bash
cd /Users/felixfoertsch/Developer/MagnumOpus
xcodegen generate --spec Apps/project.yml --project Apps/
```
Then:
```bash
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**
```bash
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:
```yaml
MARKETING_VERSION: "2026.03.14"
```
(Already set from v0.4 — if this runs on a different day, update accordingly.)
- [ ] **Step 5: Final commit**
```bash
git add -A
git commit -m "v0.5: attachments (MIME parse, multipart send) + IMAP IDLE real-time mail"
```