- move backend/, clients/, scripts/ to DELETE/ (v0.1 era, replaced by on-device arch) - delete feature/v0.1-backend-and-macos branch - add TaskStore dependency to project.yml - fix ComposeViewModel deinit concurrency, make toMessageSummary public - regenerate Xcode project, verify macOS build succeeds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3345 lines
93 KiB
Markdown
3345 lines
93 KiB
Markdown
# Magnum Opus v0.3 — 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:** Turn the read-only v0.2 email client into a fully functional email client. Add SMTP sending (compose, reply, forward), IMAP write-back (flags, move, delete, append), and an offline-safe action queue. Plain text compose only.
|
||
|
||
**Builds on:** v0.2 native Swift email client (IMAP sync, GRDB/SQLite, SwiftUI three-column UI, threaded messages, FTS5 search).
|
||
|
||
**Tech Stack:** Swift 6 (strict concurrency), SwiftUI, GRDB.swift, swift-nio + swift-nio-ssl (SMTP), swift-nio-imap (IMAP), Keychain Services
|
||
|
||
**Design Document:** `docs/plans/2026-03-13-v0.3-compose-triage-design.md`
|
||
|
||
**Branch:** `feature/v0.3-compose-triage` from `main`.
|
||
|
||
---
|
||
|
||
## File Structure (changes from v0.2)
|
||
|
||
```
|
||
MagnumOpus/
|
||
├── Packages/
|
||
│ └── MagnumOpusCore/
|
||
│ ├── Package.swift ← ADD SMTPClient target
|
||
│ ├── Sources/
|
||
│ │ ├── Models/
|
||
│ │ │ ├── AccountConfig.swift ← EDIT: add smtpHost/smtpPort/smtpSecurity
|
||
│ │ │ ├── OutgoingMessage.swift ← NEW
|
||
│ │ │ └── SMTPSecurity.swift ← NEW
|
||
│ │ │
|
||
│ │ ├── SMTPClient/ ← NEW module
|
||
│ │ │ ├── SMTPClient.swift ← actor: public send/testConnection API
|
||
│ │ │ ├── SMTPConnection.swift ← NIO bootstrap + TLS
|
||
│ │ │ ├── SMTPResponseHandler.swift ← ChannelInboundHandler
|
||
│ │ │ ├── SMTPCommandRunner.swift ← sequential command execution
|
||
│ │ │ ├── SMTPError.swift ← error types
|
||
│ │ │ └── MessageFormatter.swift ← RFC 5322 message builder
|
||
│ │ │
|
||
│ │ ├── IMAPClient/
|
||
│ │ │ ├── IMAPClientProtocol.swift ← EDIT: add write methods
|
||
│ │ │ └── IMAPClient.swift ← EDIT: implement write methods
|
||
│ │ │
|
||
│ │ ├── MailStore/
|
||
│ │ │ ├── DatabaseSetup.swift ← EDIT: add v2 migrations
|
||
│ │ │ ├── MailStore.swift ← EDIT: add draft/action/role queries
|
||
│ │ │ ├── Records/
|
||
│ │ │ │ ├── MailboxRecord.swift ← EDIT: add role field
|
||
│ │ │ │ ├── AccountRecord.swift ← EDIT: add SMTP fields
|
||
│ │ │ │ ├── DraftRecord.swift ← NEW
|
||
│ │ │ │ └── PendingActionRecord.swift ← NEW
|
||
│ │ │ └── Queries.swift ← EDIT: add draft/action queries
|
||
│ │ │
|
||
│ │ └── SyncEngine/
|
||
│ │ ├── SyncCoordinator.swift ← EDIT: flush queue before sync
|
||
│ │ └── ActionQueue.swift ← NEW: offline action dispatcher
|
||
│ │
|
||
│ └── Tests/
|
||
│ ├── SMTPClientTests/ ← NEW
|
||
│ │ ├── MockSMTPServer.swift
|
||
│ │ ├── SMTPConnectionTests.swift
|
||
│ │ └── MessageFormatterTests.swift
|
||
│ ├── IMAPClientTests/
|
||
│ │ └── IMAPWriteTests.swift ← NEW
|
||
│ ├── MailStoreTests/
|
||
│ │ └── MigrationTests.swift ← NEW
|
||
│ └── SyncEngineTests/
|
||
│ ├── ActionQueueTests.swift ← NEW
|
||
│ └── MockIMAPClient.swift ← EDIT: add write methods
|
||
│
|
||
├── Apps/
|
||
│ └── MagnumOpus/
|
||
│ ├── Services/
|
||
│ │ └── AutoDiscovery.swift ← EDIT: add SMTP discovery
|
||
│ ├── ViewModels/
|
||
│ │ ├── MailViewModel.swift ← EDIT: add triage actions
|
||
│ │ └── ComposeViewModel.swift ← NEW
|
||
│ └── Views/
|
||
│ ├── ThreadListView.swift ← EDIT: add triage toolbar + swipes
|
||
│ ├── ThreadDetailView.swift ← EDIT: add reply/forward buttons
|
||
│ ├── ComposeView.swift ← NEW
|
||
│ ├── MoveToSheet.swift ← NEW
|
||
│ └── AccountSetupView.swift ← EDIT: add SMTP fields
|
||
```
|
||
|
||
**Dependency graph:** `SyncEngine` → `IMAPClient` + `MailStore` + `SMTPClient` → `Models`. App targets import all five.
|
||
|
||
---
|
||
|
||
## Chunk 1: Schema & Models
|
||
|
||
Extend the data layer for SMTP, drafts, mailbox roles, and the action queue. No networking — pure schema and types.
|
||
|
||
### Task 1: New Model Types
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift`
|
||
|
||
- [ ] **Step 1: Create SMTPSecurity enum**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift`:
|
||
|
||
```swift
|
||
public enum SMTPSecurity: String, Sendable, Codable {
|
||
case ssl // implicit TLS, port 465
|
||
case starttls // upgrade after connect, port 587
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create OutgoingMessage**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift`:
|
||
|
||
```swift
|
||
public struct OutgoingMessage: Sendable, Codable, Equatable {
|
||
public var from: EmailAddress
|
||
public var to: [EmailAddress]
|
||
public var cc: [EmailAddress]
|
||
public var bcc: [EmailAddress]
|
||
public var subject: String
|
||
public var bodyText: String
|
||
public var inReplyTo: String?
|
||
public var references: String?
|
||
public var messageId: String
|
||
|
||
public init(
|
||
from: EmailAddress,
|
||
to: [EmailAddress],
|
||
cc: [EmailAddress] = [],
|
||
bcc: [EmailAddress] = [],
|
||
subject: String,
|
||
bodyText: String,
|
||
inReplyTo: String? = nil,
|
||
references: String? = nil,
|
||
messageId: String
|
||
) {
|
||
self.from = from
|
||
self.to = to
|
||
self.cc = cc
|
||
self.bcc = bcc
|
||
self.subject = subject
|
||
self.bodyText = bodyText
|
||
self.inReplyTo = inReplyTo
|
||
self.references = references
|
||
self.messageId = messageId
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add SMTP fields to AccountConfig**
|
||
|
||
Edit `Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift` — add optional SMTP fields:
|
||
|
||
```swift
|
||
public struct AccountConfig: Sendable, Codable, Equatable {
|
||
public var id: String
|
||
public var name: String
|
||
public var email: String
|
||
public var imapHost: String
|
||
public var imapPort: Int
|
||
public var smtpHost: String?
|
||
public var smtpPort: Int?
|
||
public var smtpSecurity: SMTPSecurity?
|
||
|
||
public init(
|
||
id: String,
|
||
name: String,
|
||
email: String,
|
||
imapHost: String,
|
||
imapPort: Int,
|
||
smtpHost: String? = nil,
|
||
smtpPort: Int? = nil,
|
||
smtpSecurity: SMTPSecurity? = nil
|
||
) {
|
||
self.id = id
|
||
self.name = name
|
||
self.email = email
|
||
self.imapHost = imapHost
|
||
self.imapPort = imapPort
|
||
self.smtpHost = smtpHost
|
||
self.smtpPort = smtpPort
|
||
self.smtpSecurity = smtpSecurity
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify Models compile**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift build --target Models
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add SMTP model types: SMTPSecurity, OutgoingMessage, extend AccountConfig"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Database Migrations
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift`
|
||
|
||
- [ ] **Step 1: Add SMTP fields to AccountRecord**
|
||
|
||
Edit `Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift`:
|
||
|
||
```swift
|
||
import GRDB
|
||
|
||
public struct AccountRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||
public static let databaseTableName = "account"
|
||
|
||
public var id: String
|
||
public var name: String
|
||
public var email: String
|
||
public var imapHost: String
|
||
public var imapPort: Int
|
||
public var smtpHost: String?
|
||
public var smtpPort: Int?
|
||
public var smtpSecurity: String?
|
||
|
||
public init(
|
||
id: String,
|
||
name: String,
|
||
email: String,
|
||
imapHost: String,
|
||
imapPort: Int,
|
||
smtpHost: String? = nil,
|
||
smtpPort: Int? = nil,
|
||
smtpSecurity: String? = nil
|
||
) {
|
||
self.id = id
|
||
self.name = name
|
||
self.email = email
|
||
self.imapHost = imapHost
|
||
self.imapPort = imapPort
|
||
self.smtpHost = smtpHost
|
||
self.smtpPort = smtpPort
|
||
self.smtpSecurity = smtpSecurity
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add role field to MailboxRecord**
|
||
|
||
Edit `Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift`:
|
||
|
||
```swift
|
||
import GRDB
|
||
|
||
public struct MailboxRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||
public static let databaseTableName = "mailbox"
|
||
|
||
public var id: String
|
||
public var accountId: String
|
||
public var name: String
|
||
public var uidValidity: Int
|
||
public var uidNext: Int
|
||
public var role: String? // "trash", "archive", "sent", "drafts", "junk", or nil
|
||
|
||
public init(
|
||
id: String,
|
||
accountId: String,
|
||
name: String,
|
||
uidValidity: Int,
|
||
uidNext: Int,
|
||
role: String? = nil
|
||
) {
|
||
self.id = id
|
||
self.accountId = accountId
|
||
self.name = name
|
||
self.uidValidity = uidValidity
|
||
self.uidNext = uidNext
|
||
self.role = role
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create DraftRecord**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift`:
|
||
|
||
```swift
|
||
import GRDB
|
||
|
||
public struct DraftRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||
public static let databaseTableName = "draft"
|
||
|
||
public var id: String
|
||
public var accountId: String
|
||
public var inReplyTo: String?
|
||
public var forwardOf: String?
|
||
public var toAddresses: String? // JSON array of {"name", "address"}
|
||
public var ccAddresses: String?
|
||
public var bccAddresses: String?
|
||
public var subject: String?
|
||
public var bodyText: String?
|
||
public var createdAt: String
|
||
public var updatedAt: String
|
||
|
||
public init(
|
||
id: String,
|
||
accountId: String,
|
||
inReplyTo: String? = nil,
|
||
forwardOf: String? = nil,
|
||
toAddresses: String? = nil,
|
||
ccAddresses: String? = nil,
|
||
bccAddresses: String? = nil,
|
||
subject: String? = nil,
|
||
bodyText: String? = nil,
|
||
createdAt: String,
|
||
updatedAt: String
|
||
) {
|
||
self.id = id
|
||
self.accountId = accountId
|
||
self.inReplyTo = inReplyTo
|
||
self.forwardOf = forwardOf
|
||
self.toAddresses = toAddresses
|
||
self.ccAddresses = ccAddresses
|
||
self.bccAddresses = bccAddresses
|
||
self.subject = subject
|
||
self.bodyText = bodyText
|
||
self.createdAt = createdAt
|
||
self.updatedAt = updatedAt
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create PendingActionRecord**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift`:
|
||
|
||
```swift
|
||
import GRDB
|
||
|
||
public struct PendingActionRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||
public static let databaseTableName = "pendingAction"
|
||
|
||
public var id: String
|
||
public var accountId: String
|
||
public var actionType: String // "setFlags", "move", "delete", "send", "append"
|
||
public var payload: String // JSON with action-specific data
|
||
public var createdAt: String
|
||
public var retryCount: Int
|
||
public var lastError: String?
|
||
|
||
public init(
|
||
id: String,
|
||
accountId: String,
|
||
actionType: String,
|
||
payload: String,
|
||
createdAt: String,
|
||
retryCount: Int = 0,
|
||
lastError: String? = nil
|
||
) {
|
||
self.id = id
|
||
self.accountId = accountId
|
||
self.actionType = actionType
|
||
self.payload = payload
|
||
self.createdAt = createdAt
|
||
self.retryCount = retryCount
|
||
self.lastError = lastError
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add v2 migrations to DatabaseSetup**
|
||
|
||
Edit `Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift` — add four new migrations after the existing `v1_fts5`:
|
||
|
||
```swift
|
||
migrator.registerMigration("v2_smtp") { db in
|
||
try db.alter(table: "account") { t in
|
||
t.add(column: "smtpHost", .text)
|
||
t.add(column: "smtpPort", .integer)
|
||
t.add(column: "smtpSecurity", .text)
|
||
}
|
||
}
|
||
|
||
migrator.registerMigration("v2_mailboxRole") { db in
|
||
try db.alter(table: "mailbox") { t in
|
||
t.add(column: "role", .text)
|
||
}
|
||
}
|
||
|
||
migrator.registerMigration("v2_draft") { db in
|
||
try db.create(table: "draft") { t in
|
||
t.primaryKey("id", .text)
|
||
t.belongsTo("account", onDelete: .cascade).notNull()
|
||
t.column("inReplyTo", .text)
|
||
t.column("forwardOf", .text)
|
||
t.column("toAddresses", .text)
|
||
t.column("ccAddresses", .text)
|
||
t.column("bccAddresses", .text)
|
||
t.column("subject", .text)
|
||
t.column("bodyText", .text)
|
||
t.column("createdAt", .text).notNull()
|
||
t.column("updatedAt", .text).notNull()
|
||
}
|
||
}
|
||
|
||
migrator.registerMigration("v2_pendingAction") { db in
|
||
try db.create(table: "pendingAction") { t in
|
||
t.primaryKey("id", .text)
|
||
t.belongsTo("account", onDelete: .cascade).notNull()
|
||
t.column("actionType", .text).notNull()
|
||
t.column("payload", .text).notNull()
|
||
t.column("createdAt", .text).notNull()
|
||
t.column("retryCount", .integer).notNull().defaults(to: 0)
|
||
t.column("lastError", .text)
|
||
}
|
||
try db.create(index: "idx_pendingAction_createdAt", on: "pendingAction", columns: ["createdAt"])
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add migration tests**
|
||
|
||
Create `Packages/MagnumOpusCore/Tests/MailStoreTests/MigrationTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
@testable import MailStore
|
||
|
||
@Suite("Database Migrations")
|
||
struct MigrationTests {
|
||
@Test("v2 migrations create expected tables and columns")
|
||
func v2Migrations() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
// Verify account has SMTP columns
|
||
try db.read { db in
|
||
let columns = try db.columns(in: "account").map(\.name)
|
||
#expect(columns.contains("smtpHost"))
|
||
#expect(columns.contains("smtpPort"))
|
||
#expect(columns.contains("smtpSecurity"))
|
||
}
|
||
// Verify mailbox has role column
|
||
try db.read { db in
|
||
let columns = try db.columns(in: "mailbox").map(\.name)
|
||
#expect(columns.contains("role"))
|
||
}
|
||
// Verify draft table exists
|
||
try db.read { db in
|
||
let tables = try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='table'")
|
||
#expect(tables.contains("draft"))
|
||
#expect(tables.contains("pendingAction"))
|
||
}
|
||
}
|
||
|
||
@Test("DraftRecord round-trip")
|
||
func draftRoundTrip() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
let now = "2026-03-14T10:00:00Z"
|
||
let draft = DraftRecord(
|
||
id: "d1",
|
||
accountId: "a1",
|
||
toAddresses: "[{\"address\":\"test@example.com\"}]",
|
||
subject: "Test",
|
||
bodyText: "Hello",
|
||
createdAt: now,
|
||
updatedAt: now
|
||
)
|
||
// Need account first (FK constraint)
|
||
try db.write { db in
|
||
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
|
||
try draft.insert(db)
|
||
}
|
||
let loaded = try db.read { db in
|
||
try DraftRecord.fetchOne(db, key: "d1")
|
||
}
|
||
#expect(loaded?.subject == "Test")
|
||
}
|
||
|
||
@Test("PendingActionRecord round-trip")
|
||
func actionRoundTrip() throws {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
try db.write { db in
|
||
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
|
||
try PendingActionRecord(
|
||
id: "pa1",
|
||
accountId: "a1",
|
||
actionType: "setFlags",
|
||
payload: "{\"setFlags\":{\"uid\":42,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Seen\"],\"remove\":[]}}",
|
||
createdAt: "2026-03-14T10:00:00Z"
|
||
).insert(db)
|
||
}
|
||
let loaded = try db.read { db in
|
||
try PendingActionRecord.fetchOne(db, key: "pa1")
|
||
}
|
||
#expect(loaded?.actionType == "setFlags")
|
||
#expect(loaded?.retryCount == 0)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Verify MailStore compiles and migration tests pass**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift test --filter MigrationTests
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add v2 schema migrations: smtp fields, mailbox role, draft, pendingAction"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: MailStore Query Extensions
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Sources/MailStore/Queries.swift`
|
||
|
||
- [ ] **Step 1: Add draft CRUD to MailStore**
|
||
|
||
Add to `MailStore.swift`:
|
||
|
||
```swift
|
||
// MARK: - Drafts
|
||
|
||
public func insertDraft(_ draft: DraftRecord) throws {
|
||
try dbWriter.write { db in
|
||
try draft.insert(db)
|
||
}
|
||
}
|
||
|
||
public func updateDraft(_ draft: DraftRecord) throws {
|
||
try dbWriter.write { db in
|
||
try draft.update(db)
|
||
}
|
||
}
|
||
|
||
public func deleteDraft(id: String) throws {
|
||
_ = try dbWriter.write { db in
|
||
try DraftRecord.deleteOne(db, key: id)
|
||
}
|
||
}
|
||
|
||
public func draft(id: String) throws -> DraftRecord? {
|
||
try dbWriter.read { db in
|
||
try DraftRecord.fetchOne(db, key: id)
|
||
}
|
||
}
|
||
|
||
public func drafts(accountId: String) throws -> [DraftRecord] {
|
||
try dbWriter.read { db in
|
||
try DraftRecord
|
||
.filter(Column("accountId") == accountId)
|
||
.order(Column("updatedAt").desc)
|
||
.fetchAll(db)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add pending action CRUD to MailStore**
|
||
|
||
Add to `MailStore.swift`:
|
||
|
||
```swift
|
||
// MARK: - Pending Actions
|
||
|
||
public func insertPendingAction(_ action: PendingActionRecord) throws {
|
||
try dbWriter.write { db in
|
||
try action.insert(db)
|
||
}
|
||
}
|
||
|
||
public func insertPendingActions(_ actions: [PendingActionRecord]) throws {
|
||
try dbWriter.write { db in
|
||
for action in actions {
|
||
try action.insert(db)
|
||
}
|
||
}
|
||
}
|
||
|
||
public func pendingActions(accountId: String) throws -> [PendingActionRecord] {
|
||
try dbWriter.read { db in
|
||
try PendingActionRecord
|
||
.filter(Column("accountId") == accountId)
|
||
.order(Column("createdAt").asc)
|
||
.fetchAll(db)
|
||
}
|
||
}
|
||
|
||
public func deletePendingAction(id: String) throws {
|
||
_ = try dbWriter.write { db in
|
||
try PendingActionRecord.deleteOne(db, key: id)
|
||
}
|
||
}
|
||
|
||
public func updatePendingAction(_ action: PendingActionRecord) throws {
|
||
try dbWriter.write { db in
|
||
try action.update(db)
|
||
}
|
||
}
|
||
|
||
public func pendingActionCount(accountId: String) throws -> Int {
|
||
try dbWriter.read { db in
|
||
try PendingActionRecord
|
||
.filter(Column("accountId") == accountId)
|
||
.fetchCount(db)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add mailbox role queries**
|
||
|
||
Add to `MailStore.swift`:
|
||
|
||
```swift
|
||
// MARK: - Mailbox Roles
|
||
|
||
public func mailboxWithRole(_ role: String, accountId: String) throws -> MailboxRecord? {
|
||
try dbWriter.read { db in
|
||
try MailboxRecord
|
||
.filter(Column("accountId") == accountId)
|
||
.filter(Column("role") == role)
|
||
.fetchOne(db)
|
||
}
|
||
}
|
||
|
||
public func updateMailboxRole(id: String, role: String?) throws {
|
||
try dbWriter.write { db in
|
||
try db.execute(
|
||
sql: "UPDATE mailbox SET role = ? WHERE id = ?",
|
||
arguments: [role, id]
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add message flag/mailbox update methods**
|
||
|
||
Add to `MailStore.swift`:
|
||
|
||
```swift
|
||
// MARK: - Message Mutations
|
||
|
||
public func updateMessageMailbox(messageId: String, newMailboxId: String) throws {
|
||
try dbWriter.write { db in
|
||
try db.execute(
|
||
sql: "UPDATE message SET mailboxId = ? WHERE id = ?",
|
||
arguments: [newMailboxId, messageId]
|
||
)
|
||
}
|
||
}
|
||
|
||
public func deleteMessage(id: String) throws {
|
||
_ = try dbWriter.write { db in
|
||
try MessageRecord.deleteOne(db, key: id)
|
||
}
|
||
}
|
||
|
||
public func messagesInThread(threadId: String, mailboxId: String) throws -> [MessageRecord] {
|
||
try dbWriter.read { db in
|
||
try MessageRecord
|
||
.joining(required: MessageRecord.hasOne(ThreadMessageRecord.self, using: ForeignKey(["messageId"])))
|
||
.filter(sql: "threadMessage.threadId = ?", arguments: [threadId])
|
||
.filter(Column("mailboxId") == mailboxId)
|
||
.fetchAll(db)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note:** The exact GRDB join syntax may need adjustment based on existing association definitions. If `MessageRecord` doesn't have an association defined, use raw SQL instead:
|
||
|
||
```swift
|
||
public func messagesInThread(threadId: String, mailboxId: String) throws -> [MessageRecord] {
|
||
try dbWriter.read { db in
|
||
try MessageRecord.fetchAll(db, sql: """
|
||
SELECT message.* FROM message
|
||
JOIN threadMessage ON threadMessage.messageId = message.id
|
||
WHERE threadMessage.threadId = ? AND message.mailboxId = ?
|
||
""",
|
||
arguments: [threadId, mailboxId]
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify MailStore compiles and existing tests pass**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add MailStore queries: drafts, pending actions, mailbox roles, message mutations"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: IMAP Write Operations
|
||
|
||
Extend the existing IMAPClient with write capabilities (flags, move, copy, append, expunge, capabilities).
|
||
|
||
### Task 4: IMAPClient Protocol Extension
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift`
|
||
|
||
- [ ] **Step 1: Add write methods to IMAPClientProtocol**
|
||
|
||
Edit `Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift`:
|
||
|
||
```swift
|
||
public protocol IMAPClientProtocol: Sendable {
|
||
// existing v0.2 read methods
|
||
func connect() async throws
|
||
func disconnect() async throws
|
||
func listMailboxes() async throws -> [IMAPMailboxInfo]
|
||
func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus
|
||
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope]
|
||
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair]
|
||
func fetchBody(uid: Int) async throws -> (text: String?, html: String?)
|
||
|
||
// v0.3 write operations
|
||
func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws
|
||
func moveMessage(uid: Int, from: String, to: String) async throws
|
||
func copyMessage(uid: Int, from: String, to: String) async throws
|
||
func expunge(mailbox: String) async throws
|
||
func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws
|
||
func capabilities() async throws -> Set<String>
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update MockIMAPClient with write methods**
|
||
|
||
Edit `Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import IMAPClient
|
||
import Models
|
||
|
||
final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable {
|
||
var mailboxes: [IMAPMailboxInfo] = []
|
||
var mailboxStatuses: [String: IMAPMailboxStatus] = [:]
|
||
var envelopes: [FetchedEnvelope] = []
|
||
var mailboxEnvelopes: [String: [FetchedEnvelope]] = [:]
|
||
var flagUpdates: [UIDFlagsPair] = []
|
||
var bodies: [Int: (text: String?, html: String?)] = [:]
|
||
|
||
var connectCalled = false
|
||
var disconnectCalled = false
|
||
var selectedMailbox: String?
|
||
|
||
// v0.3 tracking
|
||
var storedFlags: [(uid: Int, mailbox: String, add: [String], remove: [String])] = []
|
||
var movedMessages: [(uid: Int, from: String, to: String)] = []
|
||
var copiedMessages: [(uid: Int, from: String, to: String)] = []
|
||
var expungedMailboxes: [String] = []
|
||
var appendedMessages: [(mailbox: String, message: Data, flags: [String])] = []
|
||
var serverCapabilities: Set<String> = ["IMAP4rev1", "MOVE"]
|
||
|
||
func connect() async throws { connectCalled = true }
|
||
func disconnect() async throws { disconnectCalled = true }
|
||
|
||
func listMailboxes() async throws -> [IMAPMailboxInfo] { mailboxes }
|
||
|
||
func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
|
||
selectedMailbox = name
|
||
guard let status = mailboxStatuses[name] else {
|
||
throw MockIMAPError.mailboxNotFound(name)
|
||
}
|
||
return status
|
||
}
|
||
|
||
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
|
||
let source: [FetchedEnvelope]
|
||
if let mailbox = selectedMailbox, let perMailbox = mailboxEnvelopes[mailbox] {
|
||
source = perMailbox
|
||
} else {
|
||
source = envelopes
|
||
}
|
||
return source.filter { $0.uid > uid }
|
||
}
|
||
|
||
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
|
||
flagUpdates.filter { uids.contains($0.uid) }
|
||
}
|
||
|
||
func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
|
||
bodies[uid] ?? (nil, nil)
|
||
}
|
||
|
||
// v0.3 write operations
|
||
func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
|
||
storedFlags.append((uid: uid, mailbox: mailbox, add: add, remove: remove))
|
||
}
|
||
|
||
func moveMessage(uid: Int, from: String, to: String) async throws {
|
||
movedMessages.append((uid: uid, from: from, to: to))
|
||
}
|
||
|
||
func copyMessage(uid: Int, from: String, to: String) async throws {
|
||
copiedMessages.append((uid: uid, from: from, to: to))
|
||
}
|
||
|
||
func expunge(mailbox: String) async throws {
|
||
expungedMailboxes.append(mailbox)
|
||
}
|
||
|
||
func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
|
||
appendedMessages.append((mailbox: mailbox, message: message, flags: flags))
|
||
}
|
||
|
||
func capabilities() async throws -> Set<String> {
|
||
serverCapabilities
|
||
}
|
||
}
|
||
|
||
enum MockIMAPError: Error {
|
||
case mailboxNotFound(String)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify tests still compile and pass**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "extend IMAPClientProtocol with write operations, update mock"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: IMAPClient Write Implementation
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift`
|
||
- Edit: `Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift` (if needed)
|
||
|
||
- [ ] **Step 1: Implement storeFlags**
|
||
|
||
Add to the IMAPClient actor implementation. Uses IMAP UID STORE command:
|
||
|
||
```swift
|
||
public func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
|
||
try await ensureConnected()
|
||
_ = try await runner.selectMailbox(mailbox)
|
||
if !add.isEmpty {
|
||
let flags = add.joined(separator: " ")
|
||
try await runner.sendCommand("UID STORE \(uid) +FLAGS (\(flags))")
|
||
}
|
||
if !remove.isEmpty {
|
||
let flags = remove.joined(separator: " ")
|
||
try await runner.sendCommand("UID STORE \(uid) -FLAGS (\(flags))")
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note:** The actual implementation depends on how `IMAPCommandRunner` works with the swift-nio-imap library. The existing codebase uses NIOIMAP types, so the implementation must use the library's command types rather than raw strings. Read the existing `IMAPClient.swift` implementation patterns and follow them — this step describes the _logic_ but the exact swift-nio-imap API calls need to match what's already in use.
|
||
|
||
- [ ] **Step 2: Implement moveMessage with MOVE/COPY+DELETE fallback**
|
||
|
||
```swift
|
||
public func moveMessage(uid: Int, from: String, to: String) async throws {
|
||
try await ensureConnected()
|
||
let caps = try await capabilities()
|
||
_ = try await runner.selectMailbox(from)
|
||
if caps.contains("MOVE") {
|
||
try await runner.sendCommand("UID MOVE \(uid) \(to)")
|
||
} else {
|
||
try await copyMessage(uid: uid, from: from, to: to)
|
||
try await storeFlags(uid: uid, mailbox: from, add: ["\\Deleted"], remove: [])
|
||
try await expunge(mailbox: from)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement copyMessage, expunge, appendMessage, capabilities**
|
||
|
||
```swift
|
||
public func copyMessage(uid: Int, from: String, to: String) async throws {
|
||
try await ensureConnected()
|
||
_ = try await runner.selectMailbox(from)
|
||
try await runner.sendCommand("UID COPY \(uid) \(to)")
|
||
}
|
||
|
||
public func expunge(mailbox: String) async throws {
|
||
try await ensureConnected()
|
||
_ = try await runner.selectMailbox(mailbox)
|
||
try await runner.sendCommand("EXPUNGE")
|
||
}
|
||
|
||
public func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
|
||
try await ensureConnected()
|
||
let flagStr = flags.isEmpty ? "" : " (\(flags.joined(separator: " ")))"
|
||
try await runner.sendAppend(mailbox: mailbox, flags: flagStr, message: message)
|
||
}
|
||
|
||
public func capabilities() async throws -> Set<String> {
|
||
try await ensureConnected()
|
||
return try await runner.fetchCapabilities()
|
||
}
|
||
```
|
||
|
||
**Important:** These pseudo-implementations show the logic. The real code must use swift-nio-imap's typed command system (e.g., `Command.uidStore`, `Command.uidMove`, `Command.uidCopy`, `Command.append`). Read the existing `IMAPClient.swift` to match the established patterns for sending commands and handling responses.
|
||
|
||
- [ ] **Step 4: Verify IMAPClient compiles**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift build --target IMAPClient
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "implement IMAP write operations: flags, move, copy, append, expunge"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Special Folder Detection
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift` (or `IMAPTypes.swift`)
|
||
- Edit: `Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift`
|
||
|
||
- [ ] **Step 1: Expose mailbox attributes from listMailboxes**
|
||
|
||
The existing `IMAPMailboxInfo` has a `name` and `attributes` field. Verify that LIST response attributes (like `\Trash`, `\Sent`, `\Archive`, `\Drafts`, `\Junk`) are already captured. If `IMAPMailboxInfo.attributes` doesn't exist yet, add it:
|
||
|
||
```swift
|
||
public struct IMAPMailboxInfo: Sendable {
|
||
public var name: String
|
||
public var attributes: [String] // e.g., ["\\HasNoChildren", "\\Trash"]
|
||
|
||
public init(name: String, attributes: [String] = []) {
|
||
self.name = name
|
||
self.attributes = attributes
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add role detection helper**
|
||
|
||
Add a static helper (could be on MailboxRecord or a free function in SyncEngine):
|
||
|
||
```swift
|
||
/// Detect the well-known role from IMAP LIST attributes, with name-based fallback.
|
||
func detectMailboxRole(name: String, attributes: [String]) -> String? {
|
||
let attrSet = Set(attributes.map { $0.lowercased() })
|
||
if attrSet.contains("\\trash") { return "trash" }
|
||
if attrSet.contains("\\archive") || attrSet.contains("\\all") { return "archive" }
|
||
if attrSet.contains("\\sent") { return "sent" }
|
||
if attrSet.contains("\\drafts") { return "drafts" }
|
||
if attrSet.contains("\\junk") { return "junk" }
|
||
|
||
// Name-based fallback
|
||
switch name.lowercased() {
|
||
case "trash", "deleted messages", "bin": return "trash"
|
||
case "archive", "all mail": return "archive"
|
||
case "sent", "sent messages", "sent mail": return "sent"
|
||
case "drafts": return "drafts"
|
||
case "junk", "spam": return "junk"
|
||
default: return nil
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Store role during sync**
|
||
|
||
Edit `SyncCoordinator.syncMailbox()` to pass attributes through and store the detected role when upserting/updating mailbox records:
|
||
|
||
```swift
|
||
// In syncMailbox, after creating or finding the mailbox:
|
||
let role = detectMailboxRole(name: remoteMailbox.name, attributes: remoteMailbox.attributes)
|
||
try store.updateMailboxRole(id: mailboxId, role: role)
|
||
```
|
||
|
||
- [ ] **Step 4: Verify sync + role detection works**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "detect special folder roles from LIST attributes with name fallback"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 3: SMTPClient Module
|
||
|
||
Build the SMTP sending module from scratch using SwiftNIO + TLS.
|
||
|
||
### Task 7: Package.swift Update
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Package.swift`
|
||
|
||
- [ ] **Step 1: Add SMTPClient target and test target**
|
||
|
||
Add a new library product, target, and test target:
|
||
|
||
```swift
|
||
// In products:
|
||
.library(name: "SMTPClient", targets: ["SMTPClient"]),
|
||
|
||
// In targets:
|
||
.target(
|
||
name: "SMTPClient",
|
||
dependencies: [
|
||
"Models",
|
||
.product(name: "NIO", package: "swift-nio"),
|
||
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||
]
|
||
),
|
||
|
||
// In test targets:
|
||
.testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient"]),
|
||
```
|
||
|
||
Also add `"SMTPClient"` to the SyncEngine target's dependencies:
|
||
|
||
```swift
|
||
.target(
|
||
name: "SyncEngine",
|
||
dependencies: ["Models", "IMAPClient", "MailStore", "SMTPClient"]
|
||
),
|
||
```
|
||
|
||
**Note:** SwiftNIO is already a transitive dependency via swift-nio-imap, but for SMTPClient we need it directly. Add `swift-nio` as an explicit dependency if not already present:
|
||
|
||
```swift
|
||
// In dependencies (check if already present — swift-nio-imap pulls it transitively):
|
||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
|
||
```
|
||
|
||
- [ ] **Step 2: Create placeholder file**
|
||
|
||
```bash
|
||
mkdir -p Packages/MagnumOpusCore/Sources/SMTPClient
|
||
mkdir -p Packages/MagnumOpusCore/Tests/SMTPClientTests
|
||
echo 'enum SMTPClientPlaceholder {}' > Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift
|
||
echo 'import Testing' > Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift
|
||
```
|
||
|
||
- [ ] **Step 3: Verify package resolves**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift package resolve && swift build
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add SMTPClient target to Package.swift"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: SMTP Connection Layer
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift`
|
||
- Delete: `Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift`
|
||
|
||
- [ ] **Step 1: Create SMTPError**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift`:
|
||
|
||
```swift
|
||
public enum SMTPError: Error, Sendable {
|
||
case notConnected
|
||
case connectionFailed(String)
|
||
case authenticationFailed(String)
|
||
case recipientRejected(String)
|
||
case sendFailed(String)
|
||
case unexpectedResponse(code: Int, message: String)
|
||
case tlsUpgradeFailed
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create SMTPResponseHandler**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift`.
|
||
|
||
SMTP responses are line-based: `<3-digit code><separator><text>`. Separator is `-` for continuation, space for final line. The handler buffers lines and delivers the complete response.
|
||
|
||
```swift
|
||
import NIOCore
|
||
|
||
struct SMTPResponse: Sendable {
|
||
var code: Int
|
||
var lines: [String]
|
||
|
||
var isSuccess: Bool { code >= 200 && code < 400 }
|
||
var message: String { lines.joined(separator: "\n") }
|
||
}
|
||
|
||
final class SMTPResponseHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||
typealias InboundIn = ByteBuffer
|
||
typealias InboundOut = SMTPResponse
|
||
|
||
private var buffer = ""
|
||
private var responseLines: [String] = []
|
||
private var continuation: CheckedContinuation<SMTPResponse, Error>?
|
||
|
||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||
var buf = unwrapInboundIn(data)
|
||
guard let str = buf.readString(length: buf.readableBytes) else { return }
|
||
buffer += str
|
||
|
||
while let newlineRange = buffer.range(of: "\r\n") {
|
||
let line = String(buffer[buffer.startIndex..<newlineRange.lowerBound])
|
||
buffer = String(buffer[newlineRange.upperBound...])
|
||
processLine(line)
|
||
}
|
||
}
|
||
|
||
private func processLine(_ line: String) {
|
||
guard line.count >= 3,
|
||
let code = Int(line.prefix(3))
|
||
else { return }
|
||
|
||
let separator = line.count > 3 ? line[line.index(line.startIndex, offsetBy: 3)] : " "
|
||
let text = line.count > 4 ? String(line.dropFirst(4)) : ""
|
||
responseLines.append(text)
|
||
|
||
if separator == " " {
|
||
// Final line — deliver complete response
|
||
let response = SMTPResponse(code: code, lines: responseLines)
|
||
responseLines = []
|
||
continuation?.resume(returning: response)
|
||
continuation = nil
|
||
}
|
||
// separator == "-" means more lines coming
|
||
}
|
||
|
||
func waitForResponse() async throws -> SMTPResponse {
|
||
try await withCheckedThrowingContinuation { cont in
|
||
self.continuation = cont
|
||
}
|
||
}
|
||
|
||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||
continuation?.resume(throwing: error)
|
||
continuation = nil
|
||
context.close(promise: nil)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note:** This is a simplified sketch. The real implementation needs careful NIO lifecycle management — the `waitForResponse()` continuation must be set _before_ channel reads arrive. Use the same patterns as `IMAPResponseHandler` in the existing codebase.
|
||
|
||
- [ ] **Step 3: Create SMTPConnection**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift`.
|
||
|
||
Actor managing the NIO channel. Handles SSL (port 465) and STARTTLS (port 587):
|
||
|
||
```swift
|
||
import NIOCore
|
||
import NIOPosix
|
||
import NIOSSL
|
||
import Models
|
||
|
||
actor SMTPConnection {
|
||
private let host: String
|
||
private let port: Int
|
||
private let security: SMTPSecurity
|
||
private var channel: Channel?
|
||
private var responseHandler: SMTPResponseHandler?
|
||
private let eventLoopGroup: EventLoopGroup
|
||
|
||
init(host: String, port: Int, security: SMTPSecurity) {
|
||
self.host = host
|
||
self.port = port
|
||
self.security = security
|
||
self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||
}
|
||
|
||
func connect() async throws {
|
||
let handler = SMTPResponseHandler()
|
||
self.responseHandler = handler
|
||
|
||
var tlsConfig = TLSConfiguration.makeClientConfiguration()
|
||
tlsConfig.certificateVerification = .fullVerification
|
||
|
||
let bootstrap = ClientBootstrap(group: eventLoopGroup)
|
||
.channelOption(.socketOption(.so_reuseaddr), value: 1)
|
||
.channelInitializer { channel in
|
||
var handlers: [ChannelHandler] = []
|
||
if self.security == .ssl {
|
||
let sslContext = try! NIOSSLContext(configuration: tlsConfig)
|
||
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: self.host)
|
||
handlers.append(sslHandler)
|
||
}
|
||
handlers.append(ByteToMessageHandler(LineBasedFrameDecoder()))
|
||
handlers.append(handler)
|
||
return channel.pipeline.addHandlers(handlers)
|
||
}
|
||
|
||
channel = try await bootstrap.connect(host: host, port: port).get()
|
||
// Read server greeting
|
||
let greeting = try await handler.waitForResponse()
|
||
guard greeting.isSuccess else {
|
||
throw SMTPError.connectionFailed(greeting.message)
|
||
}
|
||
}
|
||
|
||
func sendCommand(_ command: String) async throws -> SMTPResponse {
|
||
guard let channel, let handler = responseHandler else {
|
||
throw SMTPError.notConnected
|
||
}
|
||
var buf = channel.allocator.buffer(capacity: command.utf8.count + 2)
|
||
buf.writeString(command + "\r\n")
|
||
try await channel.writeAndFlush(buf)
|
||
return try await handler.waitForResponse()
|
||
}
|
||
|
||
func upgradeToTLS() async throws {
|
||
guard let channel else { throw SMTPError.notConnected }
|
||
var tlsConfig = TLSConfiguration.makeClientConfiguration()
|
||
let sslContext = try NIOSSLContext(configuration: tlsConfig)
|
||
let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: host)
|
||
try await channel.pipeline.addHandler(sslHandler, position: .first)
|
||
}
|
||
|
||
func sendData(_ data: Data) async throws {
|
||
guard let channel else { throw SMTPError.notConnected }
|
||
var buf = channel.allocator.buffer(capacity: data.count)
|
||
buf.writeBytes(data)
|
||
try await channel.writeAndFlush(buf)
|
||
}
|
||
|
||
func disconnect() async throws {
|
||
try await channel?.close()
|
||
channel = nil
|
||
}
|
||
|
||
deinit {
|
||
try? eventLoopGroup.syncShutdownGracefully()
|
||
}
|
||
}
|
||
```
|
||
|
||
**Note:** This is a structural sketch. The NIO bootstrap patterns should mirror `IMAPConnection.swift` from the existing codebase. Pay attention to how the existing code sets up TLS, event loop groups, and channel pipelines.
|
||
|
||
- [ ] **Step 4: Create SMTPCommandRunner**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift`.
|
||
|
||
Orchestrates the SMTP command sequence:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
|
||
actor SMTPCommandRunner {
|
||
private let connection: SMTPConnection
|
||
private let credentials: Credentials
|
||
|
||
init(connection: SMTPConnection, credentials: Credentials) {
|
||
self.connection = connection
|
||
self.credentials = credentials
|
||
}
|
||
|
||
func ehlo(hostname: String) async throws -> Set<String> {
|
||
let response = try await connection.sendCommand("EHLO \(hostname)")
|
||
guard response.isSuccess else {
|
||
throw SMTPError.unexpectedResponse(code: response.code, message: response.message)
|
||
}
|
||
// Parse capabilities from response lines
|
||
return Set(response.lines.map { $0.split(separator: " ").first.map(String.init) ?? $0 })
|
||
}
|
||
|
||
func startTLS() async throws {
|
||
let response = try await connection.sendCommand("STARTTLS")
|
||
guard response.code == 220 else {
|
||
throw SMTPError.tlsUpgradeFailed
|
||
}
|
||
try await connection.upgradeToTLS()
|
||
}
|
||
|
||
func authenticate() async throws {
|
||
// Try AUTH PLAIN first
|
||
let credentials = "\0\(self.credentials.username)\0\(self.credentials.password)"
|
||
let base64 = Data(credentials.utf8).base64EncodedString()
|
||
let response = try await connection.sendCommand("AUTH PLAIN \(base64)")
|
||
if response.isSuccess { return }
|
||
|
||
// Fallback: AUTH LOGIN
|
||
let loginResponse = try await connection.sendCommand("AUTH LOGIN")
|
||
guard loginResponse.code == 334 else {
|
||
throw SMTPError.authenticationFailed(response.message)
|
||
}
|
||
let userResp = try await connection.sendCommand(Data(self.credentials.username.utf8).base64EncodedString())
|
||
guard userResp.code == 334 else {
|
||
throw SMTPError.authenticationFailed(userResp.message)
|
||
}
|
||
let passResp = try await connection.sendCommand(Data(self.credentials.password.utf8).base64EncodedString())
|
||
guard passResp.isSuccess else {
|
||
throw SMTPError.authenticationFailed(passResp.message)
|
||
}
|
||
}
|
||
|
||
func mailFrom(_ address: String) async throws {
|
||
let response = try await connection.sendCommand("MAIL FROM:<\(address)>")
|
||
guard response.isSuccess else {
|
||
throw SMTPError.sendFailed("MAIL FROM rejected: \(response.message)")
|
||
}
|
||
}
|
||
|
||
func rcptTo(_ address: String) async throws {
|
||
let response = try await connection.sendCommand("RCPT TO:<\(address)>")
|
||
guard response.isSuccess else {
|
||
throw SMTPError.recipientRejected(address)
|
||
}
|
||
}
|
||
|
||
func data(_ messageContent: String) async throws {
|
||
let response = try await connection.sendCommand("DATA")
|
||
guard response.code == 354 else {
|
||
throw SMTPError.sendFailed("DATA rejected: \(response.message)")
|
||
}
|
||
// Send message content, dot-stuffed, ending with \r\n.\r\n
|
||
let dotStuffed = messageContent
|
||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||
.map { line in
|
||
let l = String(line)
|
||
return l.hasPrefix(".") ? "." + l : l
|
||
}
|
||
.joined(separator: "\r\n")
|
||
let endResponse = try await connection.sendCommand(dotStuffed + "\r\n.")
|
||
guard endResponse.isSuccess else {
|
||
throw SMTPError.sendFailed("Message rejected: \(endResponse.message)")
|
||
}
|
||
}
|
||
|
||
func quit() async throws {
|
||
_ = try? await connection.sendCommand("QUIT")
|
||
try await connection.disconnect()
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Delete placeholder and verify build**
|
||
|
||
```bash
|
||
rm Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift
|
||
cd Packages/MagnumOpusCore && swift build --target SMTPClient
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "implement SMTP connection layer: connection, response handler, command runner"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Message Formatter & SMTPClient Public API
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift`
|
||
- Create: `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift`
|
||
|
||
- [ ] **Step 1: Create MessageFormatter**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift`.
|
||
|
||
Builds RFC 5322 formatted messages:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
|
||
public enum MessageFormatter {
|
||
/// Formats an OutgoingMessage into an RFC 5322 message string
|
||
public static func format(_ message: OutgoingMessage) -> String {
|
||
var headers: [(String, String)] = []
|
||
|
||
headers.append(("From", formatAddress(message.from)))
|
||
if !message.to.isEmpty {
|
||
headers.append(("To", message.to.map(formatAddress).joined(separator: ", ")))
|
||
}
|
||
if !message.cc.isEmpty {
|
||
headers.append(("Cc", message.cc.map(formatAddress).joined(separator: ", ")))
|
||
}
|
||
// BCC is intentionally omitted from headers
|
||
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", "text/plain; charset=utf-8"))
|
||
headers.append(("Content-Transfer-Encoding", "quoted-printable"))
|
||
|
||
var result = headers.map { "\($0.0): \($0.1)" }.joined(separator: "\r\n")
|
||
result += "\r\n\r\n"
|
||
result += quotedPrintableEncode(message.bodyText)
|
||
|
||
return result
|
||
}
|
||
|
||
/// Generates a Message-ID: UUID@domain
|
||
public static func generateMessageId(domain: String) -> String {
|
||
"\(UUID().uuidString.lowercased())@\(domain)"
|
||
}
|
||
|
||
/// Extracts domain from email address
|
||
public static func domainFromEmail(_ email: String) -> String {
|
||
email.split(separator: "@").last.map(String.init) ?? "localhost"
|
||
}
|
||
|
||
static func formatAddress(_ addr: EmailAddress) -> String {
|
||
if let name = addr.name, !name.isEmpty {
|
||
return "\"\(name)\" <\(addr.address)>"
|
||
}
|
||
return addr.address
|
||
}
|
||
|
||
static func formatRFC2822Date(_ date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
static func quotedPrintableEncode(_ text: String) -> String {
|
||
var result = ""
|
||
let data = Array(text.utf8)
|
||
var lineLength = 0
|
||
|
||
for byte in data {
|
||
let char: String
|
||
if byte == 0x0A {
|
||
// Newline — emit as-is
|
||
result += "\r\n"
|
||
lineLength = 0
|
||
continue
|
||
} else if byte == 0x0D {
|
||
// CR — skip (we add CRLF for newlines)
|
||
continue
|
||
} else if (byte >= 33 && byte <= 126 && byte != 61) || byte == 9 || byte == 32 {
|
||
// Printable ASCII (except =) or tab/space
|
||
char = String(UnicodeScalar(byte))
|
||
} else {
|
||
char = String(format: "=%02X", byte)
|
||
}
|
||
|
||
if lineLength + char.count > 75 {
|
||
result += "=\r\n"
|
||
lineLength = 0
|
||
}
|
||
result += char
|
||
lineLength += char.count
|
||
}
|
||
|
||
return result
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create SMTPClient**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
|
||
public actor SMTPClient: Sendable {
|
||
private let host: String
|
||
private let port: Int
|
||
private let security: SMTPSecurity
|
||
private let credentials: Credentials
|
||
|
||
public init(host: String, port: Int, security: SMTPSecurity, credentials: Credentials) {
|
||
self.host = host
|
||
self.port = port
|
||
self.security = security
|
||
self.credentials = credentials
|
||
}
|
||
|
||
public func send(message: OutgoingMessage) async throws {
|
||
let connection = SMTPConnection(host: host, port: port, security: security)
|
||
let runner = SMTPCommandRunner(connection: connection, credentials: credentials)
|
||
|
||
try await connection.connect()
|
||
|
||
do {
|
||
let hostname = MessageFormatter.domainFromEmail(message.from.address)
|
||
let caps = try await runner.ehlo(hostname: hostname)
|
||
|
||
if security == .starttls {
|
||
try await runner.startTLS()
|
||
_ = try await runner.ehlo(hostname: hostname)
|
||
}
|
||
|
||
try await runner.authenticate()
|
||
try await runner.mailFrom(message.from.address)
|
||
|
||
let allRecipients = message.to + message.cc + message.bcc
|
||
for recipient in allRecipients {
|
||
try await runner.rcptTo(recipient.address)
|
||
}
|
||
|
||
let formatted = MessageFormatter.format(message)
|
||
try await runner.data(formatted)
|
||
try await runner.quit()
|
||
} catch {
|
||
try? await runner.quit()
|
||
throw error
|
||
}
|
||
}
|
||
|
||
public func testConnection() async throws {
|
||
let connection = SMTPConnection(host: host, port: port, security: security)
|
||
let runner = SMTPCommandRunner(connection: connection, credentials: credentials)
|
||
|
||
try await connection.connect()
|
||
|
||
let hostname = "localhost"
|
||
let caps = try await runner.ehlo(hostname: hostname)
|
||
|
||
if security == .starttls {
|
||
try await runner.startTLS()
|
||
_ = try await runner.ehlo(hostname: hostname)
|
||
}
|
||
|
||
try await runner.authenticate()
|
||
try await runner.quit()
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify SMTPClient compiles**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift build --target SMTPClient
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "implement SMTPClient: message formatter, public send/testConnection API"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: SMTPClient Tests
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift`
|
||
- Delete: `Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift`
|
||
|
||
- [ ] **Step 1: Create MessageFormatterTests**
|
||
|
||
Create `Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
import Models
|
||
@testable import SMTPClient
|
||
|
||
@Suite("MessageFormatter")
|
||
struct MessageFormatterTests {
|
||
@Test("formats basic message with required headers")
|
||
func basicMessage() {
|
||
let message = OutgoingMessage(
|
||
from: EmailAddress(name: "Alice", address: "alice@example.com"),
|
||
to: [EmailAddress(name: "Bob", address: "bob@example.com")],
|
||
subject: "Hello",
|
||
bodyText: "Hi Bob!",
|
||
messageId: "test-123@example.com"
|
||
)
|
||
let result = MessageFormatter.format(message)
|
||
|
||
#expect(result.contains("From: \"Alice\" <alice@example.com>"))
|
||
#expect(result.contains("To: \"Bob\" <bob@example.com>"))
|
||
#expect(result.contains("Subject: Hello"))
|
||
#expect(result.contains("Message-ID: <test-123@example.com>"))
|
||
#expect(result.contains("MIME-Version: 1.0"))
|
||
#expect(result.contains("Content-Type: text/plain; charset=utf-8"))
|
||
#expect(result.contains("Content-Transfer-Encoding: quoted-printable"))
|
||
#expect(result.contains("Hi Bob!"))
|
||
}
|
||
|
||
@Test("includes reply headers when set")
|
||
func replyHeaders() {
|
||
let message = OutgoingMessage(
|
||
from: EmailAddress(address: "a@example.com"),
|
||
to: [EmailAddress(address: "b@example.com")],
|
||
subject: "Re: Hello",
|
||
bodyText: "Reply text",
|
||
inReplyTo: "original-123@example.com",
|
||
references: "<original-123@example.com>",
|
||
messageId: "reply-456@example.com"
|
||
)
|
||
let result = MessageFormatter.format(message)
|
||
|
||
#expect(result.contains("In-Reply-To: <original-123@example.com>"))
|
||
#expect(result.contains("References: <original-123@example.com>"))
|
||
}
|
||
|
||
@Test("omits BCC from formatted headers")
|
||
func bccOmitted() {
|
||
let message = OutgoingMessage(
|
||
from: EmailAddress(address: "a@example.com"),
|
||
to: [EmailAddress(address: "b@example.com")],
|
||
bcc: [EmailAddress(address: "secret@example.com")],
|
||
subject: "Test",
|
||
bodyText: "Body",
|
||
messageId: "test@example.com"
|
||
)
|
||
let result = MessageFormatter.format(message)
|
||
|
||
#expect(!result.contains("secret@example.com"))
|
||
#expect(!result.contains("Bcc"))
|
||
}
|
||
|
||
@Test("formats CC with multiple recipients")
|
||
func multipleCC() {
|
||
let message = OutgoingMessage(
|
||
from: EmailAddress(address: "a@example.com"),
|
||
to: [EmailAddress(address: "b@example.com")],
|
||
cc: [
|
||
EmailAddress(name: "Carol", address: "c@example.com"),
|
||
EmailAddress(address: "d@example.com"),
|
||
],
|
||
subject: "Test",
|
||
bodyText: "Body",
|
||
messageId: "test@example.com"
|
||
)
|
||
let result = MessageFormatter.format(message)
|
||
|
||
#expect(result.contains("Cc: \"Carol\" <c@example.com>, d@example.com"))
|
||
}
|
||
|
||
@Test("quoted-printable encodes non-ASCII")
|
||
func quotedPrintableNonAscii() {
|
||
let encoded = MessageFormatter.quotedPrintableEncode("Grüße")
|
||
#expect(encoded.contains("="))
|
||
// ü is 0xC3 0xBC in UTF-8
|
||
#expect(encoded.contains("=C3=BC"))
|
||
}
|
||
|
||
@Test("domain extraction from email")
|
||
func domainExtraction() {
|
||
#expect(MessageFormatter.domainFromEmail("alice@example.com") == "example.com")
|
||
#expect(MessageFormatter.domainFromEmail("noat") == "localhost")
|
||
}
|
||
|
||
@Test("message-id generation uses domain")
|
||
func messageIdGeneration() {
|
||
let id = MessageFormatter.generateMessageId(domain: "example.com")
|
||
#expect(id.hasSuffix("@example.com"))
|
||
#expect(id.count > 20) // UUID + @ + domain
|
||
}
|
||
|
||
@Test("formats address without name")
|
||
func addressWithoutName() {
|
||
let addr = EmailAddress(address: "plain@example.com")
|
||
#expect(MessageFormatter.formatAddress(addr) == "plain@example.com")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Delete placeholder and run tests**
|
||
|
||
```bash
|
||
rm Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift
|
||
cd Packages/MagnumOpusCore && swift test --filter SMTPClientTests
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add MessageFormatter tests: headers, reply, bcc, quoted-printable, message-id"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 4: ActionQueue
|
||
|
||
Build the offline-safe action queue that dispatches write operations.
|
||
|
||
### Task 11: Action Types
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift`
|
||
|
||
- [ ] **Step 1: Create PendingAction and ActionPayload types**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
|
||
public struct PendingAction: Sendable, Codable {
|
||
public var id: String
|
||
public var accountId: String
|
||
public var actionType: ActionType
|
||
public var payload: ActionPayload
|
||
public var createdAt: Date
|
||
|
||
public init(
|
||
id: String = UUID().uuidString,
|
||
accountId: String,
|
||
actionType: ActionType,
|
||
payload: ActionPayload,
|
||
createdAt: Date = Date()
|
||
) {
|
||
self.id = id
|
||
self.accountId = accountId
|
||
self.actionType = actionType
|
||
self.payload = payload
|
||
self.createdAt = createdAt
|
||
}
|
||
}
|
||
|
||
public enum ActionType: String, Sendable, Codable {
|
||
case setFlags
|
||
case move
|
||
case delete
|
||
case send
|
||
case append
|
||
}
|
||
|
||
public enum ActionPayload: Sendable, Codable {
|
||
case setFlags(uid: Int, mailbox: String, add: [String], remove: [String])
|
||
case move(uid: Int, from: String, to: String)
|
||
case delete(uid: Int, mailbox: String, trashMailbox: String)
|
||
case send(message: OutgoingMessage)
|
||
case append(mailbox: String, messageData: String, flags: [String])
|
||
|
||
public var actionType: ActionType {
|
||
switch self {
|
||
case .setFlags: return .setFlags
|
||
case .move: return .move
|
||
case .delete: return .delete
|
||
case .send: return .send
|
||
case .append: return .append
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift build --target SyncEngine
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add action queue types: PendingAction, ActionType, ActionPayload"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: ActionQueue Implementation
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift`
|
||
|
||
- [ ] **Step 1: Implement ActionQueue**
|
||
|
||
Create `Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
import IMAPClient
|
||
import SMTPClient
|
||
import MailStore
|
||
|
||
public actor ActionQueue {
|
||
private let store: MailStore
|
||
private let imapClientProvider: () -> any IMAPClientProtocol
|
||
private let smtpClientProvider: (() -> SMTPClient)?
|
||
private let accountId: String
|
||
|
||
private static let maxRetries = 5
|
||
|
||
public init(
|
||
store: MailStore,
|
||
accountId: String,
|
||
imapClientProvider: @escaping () -> any IMAPClientProtocol,
|
||
smtpClientProvider: (() -> SMTPClient)? = nil
|
||
) {
|
||
self.store = store
|
||
self.accountId = accountId
|
||
self.imapClientProvider = imapClientProvider
|
||
self.smtpClientProvider = smtpClientProvider
|
||
}
|
||
|
||
// MARK: - Enqueue
|
||
|
||
/// Enqueue a single action. Applies local change first, then attempts remote dispatch.
|
||
public func enqueue(_ action: PendingAction) throws {
|
||
// Phase 1: Apply local change + persist action
|
||
try applyLocally(action)
|
||
try persistAction(action)
|
||
|
||
// Phase 2: Attempt immediate remote dispatch (fire-and-forget)
|
||
Task { [weak self] in
|
||
await self?.dispatchSingle(action)
|
||
}
|
||
}
|
||
|
||
/// Enqueue multiple actions in a single transaction (e.g., send + append).
|
||
public func enqueue(_ actions: [PendingAction]) throws {
|
||
for action in actions {
|
||
try applyLocally(action)
|
||
try persistAction(action)
|
||
}
|
||
|
||
Task { [weak self] in
|
||
for action in actions {
|
||
await self?.dispatchSingle(action)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Flush
|
||
|
||
/// Flush all pending actions. Called by SyncCoordinator before fetch.
|
||
public func flush() async {
|
||
guard let actions = try? store.pendingActions(accountId: accountId),
|
||
!actions.isEmpty
|
||
else { return }
|
||
|
||
for record in actions {
|
||
guard let action = decodeAction(record) else {
|
||
// Corrupt action — remove it
|
||
try? store.deletePendingAction(id: record.id)
|
||
continue
|
||
}
|
||
|
||
do {
|
||
try await dispatch(action)
|
||
try store.deletePendingAction(id: record.id)
|
||
} catch {
|
||
var updated = record
|
||
updated.retryCount += 1
|
||
updated.lastError = error.localizedDescription
|
||
if updated.retryCount >= Self.maxRetries {
|
||
// Exceeded retries — mark failed, remove from queue
|
||
try? store.deletePendingAction(id: record.id)
|
||
// TODO: Surface to user via notification
|
||
} else {
|
||
try? store.updatePendingAction(updated)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
public var pendingCount: Int {
|
||
(try? store.pendingActionCount(accountId: accountId)) ?? 0
|
||
}
|
||
|
||
// MARK: - Local Application
|
||
|
||
private func applyLocally(_ action: PendingAction) throws {
|
||
switch action.payload {
|
||
case .setFlags(let uid, let mailbox, let add, let remove):
|
||
// Find the message by uid + mailbox and update flags
|
||
if let messages = try? store.messages(mailboxId: mailbox) {
|
||
if let msg = messages.first(where: { $0.uid == uid }) {
|
||
var isRead = msg.isRead
|
||
var isFlagged = msg.isFlagged
|
||
if add.contains("\\Seen") { isRead = true }
|
||
if remove.contains("\\Seen") { isRead = false }
|
||
if add.contains("\\Flagged") { isFlagged = true }
|
||
if remove.contains("\\Flagged") { isFlagged = false }
|
||
try store.updateFlags(messageId: msg.id, isRead: isRead, isFlagged: isFlagged)
|
||
}
|
||
}
|
||
|
||
case .move(_, _, let to):
|
||
// Local move: update mailboxId
|
||
// The MailStore query by uid+mailbox is needed to find the message
|
||
break // Handled by caller via MailStore directly
|
||
|
||
case .delete(_, _, _):
|
||
// Local delete: handled by caller
|
||
break
|
||
|
||
case .send(_), .append(_, _, _):
|
||
// No local change for send/append
|
||
break
|
||
}
|
||
}
|
||
|
||
// MARK: - Remote Dispatch
|
||
|
||
private func dispatchSingle(_ action: PendingAction) async {
|
||
do {
|
||
try await dispatch(action)
|
||
try store.deletePendingAction(id: action.id)
|
||
} catch {
|
||
// Failed — leave in queue for flush
|
||
if var record = try? store.pendingActions(accountId: accountId).first(where: { $0.id == action.id }) {
|
||
record.retryCount += 1
|
||
record.lastError = error.localizedDescription
|
||
try? store.updatePendingAction(record)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func dispatch(_ action: PendingAction) async throws {
|
||
switch action.payload {
|
||
case .setFlags(let uid, let mailbox, let add, let remove):
|
||
let imap = imapClientProvider()
|
||
try await imap.connect()
|
||
try await imap.storeFlags(uid: uid, mailbox: mailbox, add: add, remove: remove)
|
||
try await imap.disconnect()
|
||
|
||
case .move(let uid, let from, let to):
|
||
let imap = imapClientProvider()
|
||
try await imap.connect()
|
||
try await imap.moveMessage(uid: uid, from: from, to: to)
|
||
try await imap.disconnect()
|
||
|
||
case .delete(let uid, let mailbox, let trashMailbox):
|
||
let imap = imapClientProvider()
|
||
try await imap.connect()
|
||
if mailbox == trashMailbox {
|
||
// Already in trash — permanent delete
|
||
_ = try await imap.selectMailbox(mailbox)
|
||
try await imap.storeFlags(uid: uid, mailbox: mailbox, add: ["\\Deleted"], remove: [])
|
||
try await imap.expunge(mailbox: mailbox)
|
||
} else {
|
||
try await imap.moveMessage(uid: uid, from: mailbox, to: trashMailbox)
|
||
}
|
||
try await imap.disconnect()
|
||
|
||
case .send(let message):
|
||
guard let smtpProvider = smtpClientProvider else {
|
||
throw SMTPError.notConnected
|
||
}
|
||
let smtp = smtpProvider()
|
||
try await smtp.send(message: message)
|
||
|
||
case .append(let mailbox, let messageData, let flags):
|
||
let imap = imapClientProvider()
|
||
try await imap.connect()
|
||
guard let data = messageData.data(using: .utf8) else {
|
||
throw SMTPError.sendFailed("Could not encode message data")
|
||
}
|
||
try await imap.appendMessage(to: mailbox, message: data, flags: flags)
|
||
try await imap.disconnect()
|
||
}
|
||
}
|
||
|
||
// MARK: - Serialization
|
||
|
||
private func persistAction(_ action: PendingAction) throws {
|
||
let payloadData = try JSONEncoder().encode(action.payload)
|
||
let payloadJson = String(data: payloadData, encoding: .utf8) ?? "{}"
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
|
||
try store.insertPendingAction(PendingActionRecord(
|
||
id: action.id,
|
||
accountId: action.accountId,
|
||
actionType: action.actionType.rawValue,
|
||
payload: payloadJson,
|
||
createdAt: isoFormatter.string(from: action.createdAt)
|
||
))
|
||
}
|
||
|
||
private func decodeAction(_ record: PendingActionRecord) -> PendingAction? {
|
||
guard let data = record.payload.data(using: .utf8),
|
||
let payload = try? JSONDecoder().decode(ActionPayload.self, from: data),
|
||
let actionType = ActionType(rawValue: record.actionType)
|
||
else { return nil }
|
||
|
||
let isoFormatter = ISO8601DateFormatter()
|
||
return PendingAction(
|
||
id: record.id,
|
||
accountId: record.accountId,
|
||
actionType: actionType,
|
||
payload: payload,
|
||
createdAt: isoFormatter.date(from: record.createdAt) ?? Date()
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift build --target SyncEngine
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "implement ActionQueue: two-phase enqueue, flush, retry, dispatch"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: SyncCoordinator Integration
|
||
|
||
**Files:**
|
||
- Edit: `Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift`
|
||
|
||
- [ ] **Step 1: Add ActionQueue to SyncCoordinator**
|
||
|
||
Edit `SyncCoordinator.swift` to:
|
||
1. Accept and store an optional `ActionQueue`
|
||
2. Call `actionQueue.flush()` at the start of `performSync()`, before connecting to IMAP
|
||
|
||
```swift
|
||
// Add property:
|
||
private let actionQueue: ActionQueue?
|
||
|
||
// Extend init:
|
||
public init(
|
||
accountConfig: AccountConfig,
|
||
imapClient: any IMAPClientProtocol,
|
||
store: MailStore,
|
||
actionQueue: ActionQueue? = nil
|
||
) {
|
||
self.accountConfig = accountConfig
|
||
self.imapClient = imapClient
|
||
self.store = store
|
||
self.actionQueue = actionQueue
|
||
}
|
||
|
||
// In performSync(), before connecting to IMAP:
|
||
private func performSync() async throws {
|
||
// Flush pending actions before fetching new state
|
||
if let queue = actionQueue {
|
||
await queue.flush()
|
||
}
|
||
|
||
// ... existing sync code ...
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify existing tests still pass**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "integrate ActionQueue into SyncCoordinator: flush before fetch"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: ActionQueue Tests
|
||
|
||
**Files:**
|
||
- Create: `Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift`
|
||
|
||
- [ ] **Step 1: Create ActionQueueTests**
|
||
|
||
Create `Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift`:
|
||
|
||
```swift
|
||
import Testing
|
||
import Foundation
|
||
@testable import SyncEngine
|
||
@testable import MailStore
|
||
@testable import IMAPClient
|
||
import Models
|
||
|
||
@Suite("ActionQueue")
|
||
struct ActionQueueTests {
|
||
func makeStore() throws -> MailStore {
|
||
let db = try DatabaseSetup.openInMemoryDatabase()
|
||
return MailStore(dbWriter: db)
|
||
}
|
||
|
||
func seedAccount(_ store: MailStore, id: String = "a1") throws {
|
||
try store.insertAccount(AccountRecord(
|
||
id: id, name: "Test", email: "test@example.com",
|
||
imapHost: "imap.example.com", imapPort: 993
|
||
))
|
||
}
|
||
|
||
@Test("enqueue persists action to database")
|
||
func enqueuePersists() async throws {
|
||
let store = try makeStore()
|
||
try seedAccount(store)
|
||
let mock = MockIMAPClient()
|
||
let queue = ActionQueue(
|
||
store: store,
|
||
accountId: "a1",
|
||
imapClientProvider: { mock }
|
||
)
|
||
|
||
let action = PendingAction(
|
||
accountId: "a1",
|
||
actionType: .setFlags,
|
||
payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: [])
|
||
)
|
||
try await queue.enqueue(action)
|
||
|
||
// Give dispatch time to attempt
|
||
try await Task.sleep(for: .milliseconds(100))
|
||
|
||
// If dispatch succeeded, action is removed. If not, it's still there.
|
||
// With mock, dispatch will succeed → action removed
|
||
let remaining = try store.pendingActions(accountId: "a1")
|
||
// MockIMAPClient methods succeed, so action should be dispatched and removed
|
||
// But the mock doesn't actually connect, so it may throw
|
||
// The exact behavior depends on implementation details
|
||
}
|
||
|
||
@Test("flush dispatches actions in order")
|
||
func flushOrder() async throws {
|
||
let store = try makeStore()
|
||
try seedAccount(store)
|
||
let mock = MockIMAPClient()
|
||
mock.mailboxStatuses["INBOX"] = IMAPMailboxStatus(
|
||
name: "INBOX", uidValidity: 1, uidNext: 10, messageCount: 5, recentCount: 0
|
||
)
|
||
let queue = ActionQueue(
|
||
store: store,
|
||
accountId: "a1",
|
||
imapClientProvider: { mock }
|
||
)
|
||
|
||
// Persist actions directly (bypassing immediate dispatch)
|
||
let now = ISO8601DateFormatter().string(from: Date())
|
||
try store.insertPendingActions([
|
||
PendingActionRecord(
|
||
id: "pa1", accountId: "a1", actionType: "setFlags",
|
||
payload: "{\"setFlags\":{\"uid\":1,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Seen\"],\"remove\":[]}}",
|
||
createdAt: now
|
||
),
|
||
PendingActionRecord(
|
||
id: "pa2", accountId: "a1", actionType: "setFlags",
|
||
payload: "{\"setFlags\":{\"uid\":2,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Flagged\"],\"remove\":[]}}",
|
||
createdAt: now
|
||
),
|
||
])
|
||
|
||
await queue.flush()
|
||
|
||
// Verify actions were dispatched
|
||
#expect(mock.storedFlags.count == 2)
|
||
}
|
||
|
||
@Test("pending count reflects queue state")
|
||
func pendingCountReflects() async throws {
|
||
let store = try makeStore()
|
||
try seedAccount(store)
|
||
let mock = MockIMAPClient()
|
||
let queue = ActionQueue(
|
||
store: store,
|
||
accountId: "a1",
|
||
imapClientProvider: { mock }
|
||
)
|
||
|
||
#expect(await queue.pendingCount == 0)
|
||
|
||
let now = ISO8601DateFormatter().string(from: Date())
|
||
try store.insertPendingAction(PendingActionRecord(
|
||
id: "pa1", accountId: "a1", actionType: "send",
|
||
payload: "{\"send\":{\"message\":{}}}",
|
||
createdAt: now
|
||
))
|
||
|
||
#expect(await queue.pendingCount == 1)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift test --filter ActionQueueTests
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add ActionQueue tests: enqueue, flush, pending count"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 5: Account Setup & AutoDiscovery
|
||
|
||
Extend auto-discovery and account setup for SMTP.
|
||
|
||
### Task 15: AutoDiscovery SMTP Extension
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/Services/AutoDiscovery.swift`
|
||
|
||
- [ ] **Step 1: Extend AutoDiscovery for SMTP**
|
||
|
||
Rename `discoverIMAP(for:)` to `discover(for:)` and return both IMAP and SMTP settings:
|
||
|
||
```swift
|
||
struct DiscoveredConfig: Sendable {
|
||
var imap: DiscoveredServer?
|
||
var smtp: DiscoveredServer?
|
||
}
|
||
|
||
enum AutoDiscovery {
|
||
static func discover(for email: String) async -> DiscoveredConfig {
|
||
guard let domain = email.split(separator: "@").last.map(String.init) else {
|
||
return DiscoveredConfig(imap: nil, smtp: nil)
|
||
}
|
||
|
||
if let config = await queryISPDB(domain: domain) {
|
||
return config
|
||
}
|
||
|
||
// Fallback: probe common hostnames
|
||
let imap = await probeIMAP(domain: domain)
|
||
let smtp = await probeSMTP(domain: domain)
|
||
return DiscoveredConfig(imap: imap, smtp: smtp)
|
||
}
|
||
|
||
// Keep the old method as a convenience wrapper for backwards compatibility
|
||
static func discoverIMAP(for email: String) async -> DiscoveredServer? {
|
||
await discover(for: email).imap
|
||
}
|
||
|
||
private static func queryISPDB(domain: String) async -> DiscoveredConfig? {
|
||
let url = URL(string: "https://autoconfig.thunderbird.net/v1.1/\(domain)")!
|
||
guard let (data, response) = try? await URLSession.shared.data(from: url),
|
||
let httpResponse = response as? HTTPURLResponse,
|
||
httpResponse.statusCode == 200,
|
||
let xml = String(data: data, encoding: .utf8)
|
||
else { return nil }
|
||
|
||
let imap = parseISPDBXML(xml, serverType: "imap", tag: "incomingServer")
|
||
let smtp = parseISPDBXML(xml, serverType: "smtp", tag: "outgoingServer")
|
||
guard imap != nil || smtp != nil else { return nil }
|
||
return DiscoveredConfig(imap: imap, smtp: smtp)
|
||
}
|
||
|
||
private static func parseISPDBXML(_ xml: String, serverType: String, tag: String) -> DiscoveredServer? {
|
||
guard let startRange = xml.range(of: "<\(tag) type=\"\(serverType)\">"),
|
||
let endRange = xml.range(of: "</\(tag)>", range: startRange.upperBound..<xml.endIndex)
|
||
else { return nil }
|
||
|
||
let section = String(xml[startRange.upperBound..<endRange.lowerBound])
|
||
|
||
func extractTag(_ name: String) -> String? {
|
||
guard let start = section.range(of: "<\(name)>"),
|
||
let end = section.range(of: "</\(name)>", range: start.upperBound..<section.endIndex)
|
||
else { return nil }
|
||
return String(section[start.upperBound..<end.lowerBound])
|
||
}
|
||
|
||
guard let hostname = extractTag("hostname"),
|
||
let portStr = extractTag("port"),
|
||
let port = Int(portStr)
|
||
else { return nil }
|
||
|
||
let socketType = extractTag("socketType") ?? "SSL"
|
||
return DiscoveredServer(hostname: hostname, port: port, socketType: socketType)
|
||
}
|
||
|
||
private static func probeIMAP(domain: String) async -> DiscoveredServer? {
|
||
for candidate in ["imap.\(domain)", "mail.\(domain)"] {
|
||
if await testConnection(host: candidate, port: 993) {
|
||
return DiscoveredServer(hostname: candidate, port: 993, socketType: "SSL")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func probeSMTP(domain: String) async -> DiscoveredServer? {
|
||
// Try submission port (587 STARTTLS) first, then 465 SSL
|
||
for candidate in ["smtp.\(domain)", "mail.\(domain)"] {
|
||
if await testConnection(host: candidate, port: 587) {
|
||
return DiscoveredServer(hostname: candidate, port: 587, socketType: "STARTTLS")
|
||
}
|
||
if await testConnection(host: candidate, port: 465) {
|
||
return DiscoveredServer(hostname: candidate, port: 465, socketType: "SSL")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func testConnection(host: String, port: Int) async -> Bool {
|
||
// ... existing implementation unchanged ...
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update AccountSetupViewModel for SMTP**
|
||
|
||
Edit `Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift`:
|
||
- Add `smtpHost`, `smtpPort`, `smtpSecurity` properties
|
||
- Update `autoDiscover()` to use `AutoDiscovery.discover(for:)` and fill SMTP fields
|
||
- Update `buildConfig()` to include SMTP fields in `AccountConfig`
|
||
|
||
- [ ] **Step 3: Update AccountSetupView for SMTP**
|
||
|
||
Edit `Apps/MagnumOpus/Views/AccountSetupView.swift`:
|
||
- Add SMTP fields in manual mode (host, port, security picker)
|
||
- Show auto-discovered SMTP settings
|
||
- Test both IMAP and SMTP connections before saving
|
||
|
||
- [ ] **Step 4: Verify app compiles**
|
||
|
||
```bash
|
||
cd Apps && xcodegen generate && xcodebuild -scheme MagnumOpus-macOS build
|
||
```
|
||
|
||
Or open in Xcode and build.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "extend auto-discovery, account setup for SMTP host/port/security"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 6: Compose Flow
|
||
|
||
Build the compose UI and ViewModel for new messages, replies, and forwards.
|
||
|
||
### Task 16: ComposeViewModel
|
||
|
||
**Files:**
|
||
- Create: `Apps/MagnumOpus/ViewModels/ComposeViewModel.swift`
|
||
|
||
- [ ] **Step 1: Create ComposeViewModel**
|
||
|
||
Create `Apps/MagnumOpus/ViewModels/ComposeViewModel.swift`:
|
||
|
||
```swift
|
||
import Foundation
|
||
import Models
|
||
import MailStore
|
||
import SyncEngine
|
||
import SMTPClient
|
||
|
||
enum ComposeMode: Sendable {
|
||
case new
|
||
case reply(to: MessageSummary)
|
||
case replyAll(to: MessageSummary)
|
||
case forward(of: MessageSummary)
|
||
case draft(DraftRecord)
|
||
}
|
||
|
||
@Observable @MainActor
|
||
final class ComposeViewModel {
|
||
var to: String = ""
|
||
var cc: String = ""
|
||
var bcc: String = ""
|
||
var subject: String = ""
|
||
var bodyText: String = ""
|
||
var mode: ComposeMode = .new
|
||
var isSending = false
|
||
var errorMessage: String?
|
||
|
||
private var draftId: String?
|
||
private var savedTo: String = ""
|
||
private var savedCc: String = ""
|
||
private var savedBcc: String = ""
|
||
private var savedSubject: String = ""
|
||
private var savedBody: String = ""
|
||
private var autoSaveTask: Task<Void, Never>?
|
||
|
||
private let accountConfig: AccountConfig
|
||
private let store: MailStore
|
||
private let actionQueue: ActionQueue
|
||
|
||
var isDirty: Bool {
|
||
to != savedTo || cc != savedCc || bcc != savedBcc ||
|
||
subject != savedSubject || bodyText != savedBody
|
||
}
|
||
|
||
init(
|
||
mode: ComposeMode,
|
||
accountConfig: AccountConfig,
|
||
store: MailStore,
|
||
actionQueue: ActionQueue
|
||
) {
|
||
self.mode = mode
|
||
self.accountConfig = accountConfig
|
||
self.store = store
|
||
self.actionQueue = actionQueue
|
||
prefill(mode: mode)
|
||
startAutoSave()
|
||
}
|
||
|
||
deinit {
|
||
autoSaveTask?.cancel()
|
||
}
|
||
|
||
// MARK: - Prefill
|
||
|
||
private func prefill(mode: ComposeMode) {
|
||
switch mode {
|
||
case .new:
|
||
break
|
||
|
||
case .reply(let msg):
|
||
to = msg.from.address
|
||
subject = prefixSubject("Re:", msg.subject ?? "")
|
||
bodyText = quoteBody(msg)
|
||
// Threading handled at send time
|
||
|
||
case .replyAll(let msg):
|
||
to = msg.from.address
|
||
// Add other recipients minus self
|
||
let others = (msg.to + msg.cc)
|
||
.filter { $0.address.lowercased() != accountConfig.email.lowercased() }
|
||
.map { $0.address }
|
||
cc = others.joined(separator: ", ")
|
||
subject = prefixSubject("Re:", msg.subject ?? "")
|
||
bodyText = quoteBody(msg)
|
||
|
||
case .forward(let msg):
|
||
subject = prefixSubject("Fwd:", msg.subject ?? "")
|
||
bodyText = forwardBody(msg)
|
||
|
||
case .draft(let draft):
|
||
draftId = draft.id
|
||
to = decodeAddressField(draft.toAddresses) ?? ""
|
||
cc = decodeAddressField(draft.ccAddresses) ?? ""
|
||
bcc = decodeAddressField(draft.bccAddresses) ?? ""
|
||
subject = draft.subject ?? ""
|
||
bodyText = draft.bodyText ?? ""
|
||
}
|
||
|
||
// Save initial state for dirty tracking
|
||
savedTo = to
|
||
savedCc = cc
|
||
savedBcc = bcc
|
||
savedSubject = subject
|
||
savedBody = bodyText
|
||
}
|
||
|
||
// MARK: - Send
|
||
|
||
func send() async throws {
|
||
isSending = true
|
||
errorMessage = nil
|
||
|
||
do {
|
||
let fromAddr = EmailAddress(name: accountConfig.name, address: accountConfig.email)
|
||
let toAddrs = parseAddressList(to)
|
||
let ccAddrs = parseAddressList(cc)
|
||
let bccAddrs = parseAddressList(bcc)
|
||
|
||
guard !toAddrs.isEmpty else {
|
||
throw ComposeError.noRecipients
|
||
}
|
||
|
||
let domain = MessageFormatter.domainFromEmail(accountConfig.email)
|
||
let messageId = MessageFormatter.generateMessageId(domain: domain)
|
||
|
||
var inReplyTo: String?
|
||
var references: String?
|
||
|
||
switch mode {
|
||
case .reply(let msg), .replyAll(let msg):
|
||
inReplyTo = msg.messageId
|
||
if let existingRefs = msg.references {
|
||
references = existingRefs + " <\(msg.messageId ?? "")>"
|
||
} else if let msgId = msg.messageId {
|
||
references = "<\(msgId)>"
|
||
}
|
||
case .forward(let msg):
|
||
if let msgId = msg.messageId {
|
||
references = "<\(msgId)>"
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
|
||
let outgoing = OutgoingMessage(
|
||
from: fromAddr,
|
||
to: toAddrs,
|
||
cc: ccAddrs,
|
||
bcc: bccAddrs,
|
||
subject: subject,
|
||
bodyText: bodyText,
|
||
inReplyTo: inReplyTo,
|
||
references: references,
|
||
messageId: messageId
|
||
)
|
||
|
||
// Format the message for Sent folder append
|
||
let formatted = MessageFormatter.format(outgoing)
|
||
|
||
// Enqueue send + append atomically
|
||
let sendAction = PendingAction(
|
||
accountId: accountConfig.id,
|
||
actionType: .send,
|
||
payload: .send(message: outgoing)
|
||
)
|
||
let appendAction = PendingAction(
|
||
accountId: accountConfig.id,
|
||
actionType: .append,
|
||
payload: .append(mailbox: "Sent", messageData: formatted, flags: ["\\Seen"])
|
||
)
|
||
|
||
try await actionQueue.enqueue([sendAction, appendAction])
|
||
|
||
// Clean up draft
|
||
if let draftId {
|
||
try store.deleteDraft(id: draftId)
|
||
}
|
||
|
||
isSending = false
|
||
} catch {
|
||
isSending = false
|
||
errorMessage = error.localizedDescription
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// MARK: - Drafts
|
||
|
||
func saveDraft() throws {
|
||
let now = ISO8601DateFormatter().string(from: Date())
|
||
let id = draftId ?? UUID().uuidString
|
||
|
||
let draft = DraftRecord(
|
||
id: id,
|
||
accountId: accountConfig.id,
|
||
inReplyTo: replyMessageId,
|
||
forwardOf: forwardMessageId,
|
||
toAddresses: encodeAddressField(to),
|
||
ccAddresses: encodeAddressField(cc),
|
||
bccAddresses: encodeAddressField(bcc),
|
||
subject: subject,
|
||
bodyText: bodyText,
|
||
createdAt: draftId == nil ? now : now, // Keep original if updating
|
||
updatedAt: now
|
||
)
|
||
|
||
if draftId != nil {
|
||
try store.updateDraft(draft)
|
||
} else {
|
||
try store.insertDraft(draft)
|
||
draftId = id
|
||
}
|
||
|
||
savedTo = to
|
||
savedCc = cc
|
||
savedBcc = bcc
|
||
savedSubject = subject
|
||
savedBody = bodyText
|
||
}
|
||
|
||
func deleteDraft() throws {
|
||
if let draftId {
|
||
try store.deleteDraft(id: draftId)
|
||
}
|
||
}
|
||
|
||
// MARK: - Auto-Save
|
||
|
||
private func startAutoSave() {
|
||
autoSaveTask = Task { [weak self] in
|
||
while !Task.isCancelled {
|
||
try? await Task.sleep(for: .seconds(10))
|
||
guard let self, !Task.isCancelled else { break }
|
||
if self.isDirty {
|
||
try? self.saveDraft()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private var replyMessageId: String? {
|
||
switch mode {
|
||
case .reply(let msg), .replyAll(let msg): return msg.messageId
|
||
default: return nil
|
||
}
|
||
}
|
||
|
||
private var forwardMessageId: String? {
|
||
switch mode {
|
||
case .forward(let msg): return msg.messageId
|
||
default: return nil
|
||
}
|
||
}
|
||
|
||
private func prefixSubject(_ prefix: String, _ original: String) -> String {
|
||
let stripped = original
|
||
.replacingOccurrences(of: "^(Re:|Fwd:|Fw:)\\s*", with: "", options: .regularExpression)
|
||
return "\(prefix) \(stripped)"
|
||
}
|
||
|
||
private func quoteBody(_ msg: MessageSummary) -> String {
|
||
let dateStr = msg.date.map { "\($0)" } ?? "unknown date"
|
||
let sender = msg.from.displayName
|
||
let quoted = (msg.bodyText ?? "")
|
||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||
.map { "> \($0)" }
|
||
.joined(separator: "\n")
|
||
return "\n\nOn \(dateStr), \(sender) wrote:\n\(quoted)"
|
||
}
|
||
|
||
private func forwardBody(_ msg: MessageSummary) -> String {
|
||
let sender = MessageFormatter.formatAddress(msg.from)
|
||
let dateStr = msg.date.map { "\($0)" } ?? ""
|
||
let toStr = msg.to.map(MessageFormatter.formatAddress).joined(separator: ", ")
|
||
return """
|
||
|
||
---------- Forwarded message ----------
|
||
From: \(sender)
|
||
Date: \(dateStr)
|
||
Subject: \(msg.subject ?? "")
|
||
To: \(toStr)
|
||
|
||
\(msg.bodyText ?? "")
|
||
"""
|
||
}
|
||
|
||
private func parseAddressList(_ field: String) -> [EmailAddress] {
|
||
field.split(separator: ",")
|
||
.map { String($0).trimmingCharacters(in: .whitespaces) }
|
||
.filter { !$0.isEmpty }
|
||
.map { EmailAddress.parse($0) }
|
||
}
|
||
|
||
private func encodeAddressField(_ field: String) -> String? {
|
||
let addrs = parseAddressList(field)
|
||
guard !addrs.isEmpty else { return nil }
|
||
guard let data = try? JSONEncoder().encode(addrs) else { return nil }
|
||
return String(data: data, encoding: .utf8)
|
||
}
|
||
|
||
private func decodeAddressField(_ json: String?) -> String? {
|
||
guard let json, let data = json.data(using: .utf8),
|
||
let addrs = try? JSONDecoder().decode([EmailAddress].self, from: data)
|
||
else { return nil }
|
||
return addrs.map(\.address).joined(separator: ", ")
|
||
}
|
||
}
|
||
|
||
enum ComposeError: Error, LocalizedError {
|
||
case noRecipients
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .noRecipients: return "At least one recipient is required"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify app compiles**
|
||
|
||
Build the app target to ensure ComposeViewModel integrates cleanly.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add ComposeViewModel: new, reply, reply-all, forward, draft auto-save"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: ComposeView
|
||
|
||
**Files:**
|
||
- Create: `Apps/MagnumOpus/Views/ComposeView.swift`
|
||
|
||
- [ ] **Step 1: Create ComposeView**
|
||
|
||
Create `Apps/MagnumOpus/Views/ComposeView.swift`:
|
||
|
||
```swift
|
||
import SwiftUI
|
||
import Models
|
||
|
||
struct ComposeView: View {
|
||
@Bindable var viewModel: ComposeViewModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var showBcc = false
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Header fields
|
||
Form {
|
||
TextField("To:", text: $viewModel.to)
|
||
.textContentType(.emailAddress)
|
||
TextField("CC:", text: $viewModel.cc)
|
||
.textContentType(.emailAddress)
|
||
if showBcc {
|
||
TextField("BCC:", text: $viewModel.bcc)
|
||
.textContentType(.emailAddress)
|
||
}
|
||
TextField("Subject:", text: $viewModel.subject)
|
||
}
|
||
.formStyle(.grouped)
|
||
.frame(maxHeight: 200)
|
||
|
||
Divider()
|
||
|
||
// Body
|
||
TextEditor(text: $viewModel.bodyText)
|
||
.font(.body)
|
||
.padding(8)
|
||
|
||
if let error = viewModel.errorMessage {
|
||
Text(error)
|
||
.foregroundStyle(.red)
|
||
.font(.caption)
|
||
.padding(.horizontal)
|
||
}
|
||
}
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Discard") {
|
||
try? viewModel.deleteDraft()
|
||
dismiss()
|
||
}
|
||
}
|
||
ToolbarItem {
|
||
Button(showBcc ? "Hide BCC" : "BCC") {
|
||
showBcc.toggle()
|
||
}
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button {
|
||
Task {
|
||
try? await viewModel.send()
|
||
dismiss()
|
||
}
|
||
} label: {
|
||
if viewModel.isSending {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
} else {
|
||
Label("Send", systemImage: "paperplane")
|
||
}
|
||
}
|
||
.disabled(viewModel.to.isEmpty || viewModel.isSending)
|
||
.keyboardShortcut(.return, modifiers: .command)
|
||
}
|
||
}
|
||
#if os(macOS)
|
||
.frame(minWidth: 500, minHeight: 400)
|
||
#endif
|
||
.onDisappear {
|
||
// Save draft if dirty and not sent
|
||
if viewModel.isDirty && !viewModel.isSending {
|
||
try? viewModel.saveDraft()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify app compiles**
|
||
|
||
Build the app target.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add ComposeView: to/cc/bcc/subject/body, send, discard, draft save"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 7: Triage UI
|
||
|
||
Add triage actions (archive, delete, flag, mark read, move) to the thread list.
|
||
|
||
### Task 18: MailViewModel Triage Actions
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
|
||
|
||
- [ ] **Step 1: Add ActionQueue and triage methods to MailViewModel**
|
||
|
||
Add to `MailViewModel`:
|
||
- An `actionQueue` property, initialized in `setup()`
|
||
- Triage action methods:
|
||
|
||
```swift
|
||
// MARK: - Triage Actions
|
||
|
||
func archiveSelectedThread() async {
|
||
guard let thread = selectedThread else { return }
|
||
guard let archiveMailbox = try? store?.mailboxWithRole("archive", accountId: thread.accountId),
|
||
let selectedMailbox
|
||
else { return }
|
||
|
||
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
|
||
for msg in messages ?? [] {
|
||
let action = PendingAction(
|
||
accountId: thread.accountId,
|
||
actionType: .move,
|
||
payload: .move(uid: msg.uid, from: selectedMailbox.name, to: archiveMailbox.name)
|
||
)
|
||
try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: archiveMailbox.id)
|
||
try? await actionQueue?.enqueue(action)
|
||
}
|
||
autoAdvance()
|
||
}
|
||
|
||
func deleteSelectedThread() async {
|
||
guard let thread = selectedThread else { return }
|
||
guard let trashMailbox = try? store?.mailboxWithRole("trash", accountId: thread.accountId),
|
||
let selectedMailbox
|
||
else { return }
|
||
|
||
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
|
||
for msg in messages ?? [] {
|
||
if selectedMailbox.id == trashMailbox.id {
|
||
// Permanent delete
|
||
let action = PendingAction(
|
||
accountId: thread.accountId,
|
||
actionType: .delete,
|
||
payload: .delete(uid: msg.uid, mailbox: trashMailbox.name, trashMailbox: trashMailbox.name)
|
||
)
|
||
try? store?.deleteMessage(id: msg.id)
|
||
try? await actionQueue?.enqueue(action)
|
||
} else {
|
||
let action = PendingAction(
|
||
accountId: thread.accountId,
|
||
actionType: .move,
|
||
payload: .move(uid: msg.uid, from: selectedMailbox.name, to: trashMailbox.name)
|
||
)
|
||
try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: trashMailbox.id)
|
||
try? await actionQueue?.enqueue(action)
|
||
}
|
||
}
|
||
autoAdvance()
|
||
}
|
||
|
||
func toggleFlagSelectedThread() async {
|
||
guard let thread = selectedThread, let selectedMailbox else { return }
|
||
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
|
||
guard let firstMsg = messages?.first else { return }
|
||
|
||
let newFlagged = !firstMsg.isFlagged
|
||
for msg in messages ?? [] {
|
||
let action = PendingAction(
|
||
accountId: thread.accountId,
|
||
actionType: .setFlags,
|
||
payload: .setFlags(
|
||
uid: msg.uid,
|
||
mailbox: selectedMailbox.name,
|
||
add: newFlagged ? ["\\Flagged"] : [],
|
||
remove: newFlagged ? [] : ["\\Flagged"]
|
||
)
|
||
)
|
||
try? store?.updateFlags(messageId: msg.id, isRead: msg.isRead, isFlagged: newFlagged)
|
||
try? await actionQueue?.enqueue(action)
|
||
}
|
||
}
|
||
|
||
func toggleReadSelectedThread() async {
|
||
guard let thread = selectedThread, let selectedMailbox else { return }
|
||
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
|
||
guard let firstMsg = messages?.first else { return }
|
||
|
||
let newRead = !firstMsg.isRead
|
||
for msg in messages ?? [] {
|
||
let action = PendingAction(
|
||
accountId: thread.accountId,
|
||
actionType: .setFlags,
|
||
payload: .setFlags(
|
||
uid: msg.uid,
|
||
mailbox: selectedMailbox.name,
|
||
add: newRead ? ["\\Seen"] : [],
|
||
remove: newRead ? [] : ["\\Seen"]
|
||
)
|
||
)
|
||
try? store?.updateFlags(messageId: msg.id, isRead: newRead, isFlagged: msg.isFlagged)
|
||
try? await actionQueue?.enqueue(action)
|
||
}
|
||
}
|
||
|
||
func moveSelectedThread(to mailbox: MailboxInfo) async {
|
||
guard let thread = selectedThread, let selectedMailbox else { return }
|
||
guard let targetMailbox = try? store?.mailbox(id: mailbox.id) else { return }
|
||
|
||
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
|
||
for msg in messages ?? [] {
|
||
let action = PendingAction(
|
||
accountId: thread.accountId,
|
||
actionType: .move,
|
||
payload: .move(uid: msg.uid, from: selectedMailbox.name, to: targetMailbox.name)
|
||
)
|
||
try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: targetMailbox.id)
|
||
try? await actionQueue?.enqueue(action)
|
||
}
|
||
autoAdvance()
|
||
}
|
||
|
||
private func autoAdvance() {
|
||
guard let current = selectedThread,
|
||
let idx = threads.firstIndex(where: { $0.id == current.id })
|
||
else {
|
||
selectedThread = nil
|
||
return
|
||
}
|
||
if idx + 1 < threads.count {
|
||
selectedThread = threads[idx + 1]
|
||
} else if idx > 0 {
|
||
selectedThread = threads[idx - 1]
|
||
} else {
|
||
selectedThread = nil
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify app compiles**
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add triage actions to MailViewModel: archive, delete, flag, read, move"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: ThreadListView Triage UI
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/Views/ThreadListView.swift`
|
||
- Create: `Apps/MagnumOpus/Views/MoveToSheet.swift`
|
||
|
||
- [ ] **Step 1: Add toolbar buttons to ThreadListView**
|
||
|
||
Edit `ThreadListView.swift` to add triage toolbar:
|
||
|
||
```swift
|
||
.toolbar {
|
||
ToolbarItemGroup {
|
||
if viewModel.selectedThread != nil {
|
||
Button { Task { await viewModel.archiveSelectedThread() } } label: {
|
||
Label("Archive", systemImage: "archivebox")
|
||
}
|
||
.keyboardShortcut("e", modifiers: [])
|
||
|
||
Button { Task { await viewModel.deleteSelectedThread() } } label: {
|
||
Label("Delete", systemImage: "trash")
|
||
}
|
||
.keyboardShortcut(.delete, modifiers: [])
|
||
|
||
Button { Task { await viewModel.toggleFlagSelectedThread() } } label: {
|
||
Label("Flag", systemImage: "flag")
|
||
}
|
||
.keyboardShortcut("s", modifiers: [])
|
||
|
||
Button { Task { await viewModel.toggleReadSelectedThread() } } label: {
|
||
Label("Read/Unread", systemImage: "envelope.badge")
|
||
}
|
||
.keyboardShortcut("u", modifiers: [.shift, .command])
|
||
|
||
Button { showMoveSheet = true } label: {
|
||
Label("Move", systemImage: "folder")
|
||
}
|
||
.keyboardShortcut("m", modifiers: [.shift, .command])
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add swipe actions to thread rows**
|
||
|
||
```swift
|
||
// On each thread row in the List:
|
||
.swipeActions(edge: .leading) {
|
||
Button {
|
||
Task { await viewModel.archiveSelectedThread() }
|
||
} label: {
|
||
Label("Archive", systemImage: "archivebox")
|
||
}
|
||
.tint(.green)
|
||
}
|
||
.swipeActions(edge: .trailing) {
|
||
Button(role: .destructive) {
|
||
Task { await viewModel.deleteSelectedThread() }
|
||
} label: {
|
||
Label("Delete", systemImage: "trash")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create MoveToSheet**
|
||
|
||
Create `Apps/MagnumOpus/Views/MoveToSheet.swift`:
|
||
|
||
```swift
|
||
import SwiftUI
|
||
import Models
|
||
|
||
struct MoveToSheet: View {
|
||
let mailboxes: [MailboxInfo]
|
||
let onSelect: (MailboxInfo) -> Void
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var searchText = ""
|
||
|
||
var filteredMailboxes: [MailboxInfo] {
|
||
if searchText.isEmpty { return mailboxes }
|
||
return mailboxes.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List(filteredMailboxes, id: \.id) { mailbox in
|
||
Button {
|
||
onSelect(mailbox)
|
||
dismiss()
|
||
} label: {
|
||
Label(mailbox.name, systemImage: mailbox.systemImage)
|
||
}
|
||
}
|
||
.searchable(text: $searchText, prompt: "Search folders")
|
||
.navigationTitle("Move to…")
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
#endif
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Cancel") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
#if os(macOS)
|
||
.frame(minWidth: 300, minHeight: 400)
|
||
#endif
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify app compiles**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add triage ui: toolbar buttons, keyboard shortcuts, swipe actions, move sheet"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Reply/Forward/Compose Buttons
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/Views/ThreadDetailView.swift`
|
||
- Edit: `Apps/MagnumOpus/Views/ContentView.swift` (or wherever compose is triggered)
|
||
|
||
- [ ] **Step 1: Add reply/forward buttons to ThreadDetailView toolbar**
|
||
|
||
Add to the ThreadDetailView toolbar:
|
||
|
||
```swift
|
||
.toolbar {
|
||
ToolbarItemGroup {
|
||
Button { openCompose(.reply(to: lastMessage)) } label: {
|
||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||
}
|
||
.keyboardShortcut("r", modifiers: .command)
|
||
|
||
Button { openCompose(.replyAll(to: lastMessage)) } label: {
|
||
Label("Reply All", systemImage: "arrowshape.turn.up.left.2")
|
||
}
|
||
.keyboardShortcut("r", modifiers: [.command, .shift])
|
||
|
||
Button { openCompose(.forward(of: lastMessage)) } label: {
|
||
Label("Forward", systemImage: "arrowshape.turn.up.right")
|
||
}
|
||
.keyboardShortcut("f", modifiers: .command)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add compose new button to main toolbar**
|
||
|
||
In the sidebar or main toolbar, add a "New Message" button:
|
||
|
||
```swift
|
||
Button { openCompose(.new) } label: {
|
||
Label("New Message", systemImage: "square.and.pencil")
|
||
}
|
||
.keyboardShortcut("n", modifiers: .command)
|
||
```
|
||
|
||
- [ ] **Step 3: Wire compose presentation**
|
||
|
||
Use a `@State var composeMode: ComposeMode?` and present ComposeView as a sheet (iOS) or new window (macOS):
|
||
|
||
```swift
|
||
.sheet(item: $composeMode) { mode in
|
||
NavigationStack {
|
||
ComposeView(viewModel: ComposeViewModel(
|
||
mode: mode,
|
||
accountConfig: accountConfig,
|
||
store: store,
|
||
actionQueue: actionQueue
|
||
))
|
||
}
|
||
}
|
||
```
|
||
|
||
For macOS, consider using `openWindow` if a separate compose window is desired:
|
||
|
||
```swift
|
||
#if os(macOS)
|
||
WindowGroup("Compose", for: ComposeMode.self) { $mode in
|
||
if let mode {
|
||
ComposeView(viewModel: ComposeViewModel(
|
||
mode: mode,
|
||
accountConfig: accountConfig,
|
||
store: store,
|
||
actionQueue: actionQueue
|
||
))
|
||
}
|
||
}
|
||
#endif
|
||
```
|
||
|
||
- [ ] **Step 4: Verify app compiles and runs**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "add reply, reply-all, forward, compose new buttons with keyboard shortcuts"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 8: Integration & Polish
|
||
|
||
### Task 21: End-to-End Wiring
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/ViewModels/MailViewModel.swift`
|
||
- Edit: `Apps/MagnumOpus/ContentView.swift`
|
||
|
||
- [ ] **Step 1: Initialize ActionQueue and SMTPClient in MailViewModel.setup()**
|
||
|
||
When `setup(config:credentials:)` is called, also create:
|
||
- `SMTPClient` (if SMTP fields are configured)
|
||
- `ActionQueue` with the IMAP and SMTP client providers
|
||
- Pass `actionQueue` to `SyncCoordinator`
|
||
|
||
```swift
|
||
func setup(config: AccountConfig, credentials: Credentials) {
|
||
// ... existing store + IMAP client setup ...
|
||
|
||
let smtpClient: SMTPClient? = {
|
||
guard let host = config.smtpHost,
|
||
let port = config.smtpPort,
|
||
let security = config.smtpSecurity
|
||
else { return nil }
|
||
return SMTPClient(host: host, port: port, security: security, credentials: credentials)
|
||
}()
|
||
|
||
let queue = ActionQueue(
|
||
store: store,
|
||
accountId: config.id,
|
||
imapClientProvider: { IMAPClient(host: config.imapHost, port: config.imapPort, credentials: credentials) },
|
||
smtpClientProvider: smtpClient.map { client in { client } }
|
||
)
|
||
self.actionQueue = queue
|
||
|
||
let coordinator = SyncCoordinator(
|
||
accountConfig: config,
|
||
imapClient: imapClient,
|
||
store: store,
|
||
actionQueue: queue
|
||
)
|
||
self.coordinator = coordinator
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Pass dependencies to ComposeViewModel from ContentView**
|
||
|
||
Ensure `ContentView` or whatever view triggers compose has access to `accountConfig`, `store`, and `actionQueue` to create `ComposeViewModel`.
|
||
|
||
- [ ] **Step 3: Verify full app compiles and basic flow works**
|
||
|
||
Open the app, configure an account with SMTP, compose and send a test email. Verify:
|
||
- Compose opens from toolbar button or keyboard shortcut
|
||
- Reply prefills correctly
|
||
- Send enqueues action
|
||
- Sync flushes queue
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "wire end-to-end: ActionQueue, SMTPClient, ComposeViewModel in app"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 22: Body Fetch for Reply/Forward
|
||
|
||
**Files:**
|
||
- Edit: `Apps/MagnumOpus/ViewModels/ComposeViewModel.swift` or `MailViewModel.swift`
|
||
|
||
- [ ] **Step 1: Ensure body is available before opening compose in reply/forward mode**
|
||
|
||
When the user taps reply/forward, check if the message body is populated. If not, fetch it:
|
||
|
||
```swift
|
||
func openCompose(_ mode: ComposeMode) {
|
||
switch mode {
|
||
case .reply(let msg), .replyAll(let msg), .forward(let msg):
|
||
if msg.bodyText == nil {
|
||
Task {
|
||
// Try to fetch the body
|
||
do {
|
||
let (text, html) = try await imapClient.fetchBody(uid: msg.uid)
|
||
if let text {
|
||
try store.storeBody(messageId: msg.id, text: text, html: html)
|
||
}
|
||
// Re-read and open compose with body
|
||
if let updated = try? store.message(id: msg.id) {
|
||
let updatedSummary = /* convert to MessageSummary with body */
|
||
self.composeMode = mode // with updated message
|
||
}
|
||
} catch {
|
||
// Open without body — offline fallback
|
||
self.composeMode = mode
|
||
}
|
||
}
|
||
return
|
||
}
|
||
self.composeMode = mode
|
||
|
||
default:
|
||
self.composeMode = mode
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify reply with body works**
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "fetch message body before compose reply/forward, offline fallback"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 23: Final Integration Tests
|
||
|
||
**Files:**
|
||
- Edit: existing test files as needed
|
||
|
||
- [ ] **Step 1: Run all existing tests**
|
||
|
||
```bash
|
||
cd Packages/MagnumOpusCore && swift test
|
||
```
|
||
|
||
Fix any failures from the v0.3 changes.
|
||
|
||
- [ ] **Step 2: Add SyncCoordinator test for flush-before-fetch**
|
||
|
||
Add a test to `SyncCoordinatorTests.swift` that verifies the ActionQueue is flushed before IMAP fetch during sync.
|
||
|
||
- [ ] **Step 3: Verify app builds for both macOS and iOS**
|
||
|
||
```bash
|
||
cd Apps && xcodegen generate
|
||
xcodebuild -scheme MagnumOpus-macOS build
|
||
xcodebuild -scheme MagnumOpus-iOS -destination 'platform=iOS Simulator,name=iPhone 16' build
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "fix test failures, verify builds for macOS and iOS"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 24: Version Bump & Cleanup
|
||
|
||
- [ ] **Step 1: Bump CalVer**
|
||
|
||
Update version to `2026.03.14` in whatever config tracks it (e.g., `project.yml`, `Info.plist`, or `CLAUDE.md`).
|
||
|
||
- [ ] **Step 2: Remove any leftover placeholder files**
|
||
|
||
```bash
|
||
find Packages -name "Placeholder.swift" -delete
|
||
```
|
||
|
||
- [ ] **Step 3: Final commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "bump calver to 2026.03.14, v0.3: compose, triage, smtp, action queue"
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Chunk | Tasks | Focus |
|
||
|-------|-------|-------|
|
||
| 1: Schema & Models | 1–3 | New types, migrations, MailStore queries |
|
||
| 2: IMAP Write | 4–6 | Protocol extension, write implementation, folder roles |
|
||
| 3: SMTPClient | 7–10 | New module: connection, commands, formatting, tests |
|
||
| 4: ActionQueue | 11–14 | Action types, queue implementation, sync integration, tests |
|
||
| 5: Account Setup | 15 | AutoDiscovery SMTP, account setup UI |
|
||
| 6: Compose | 16–17 | ComposeViewModel, ComposeView |
|
||
| 7: Triage UI | 18–20 | Triage actions, toolbar/swipe/shortcuts, reply/forward buttons |
|
||
| 8: Integration | 21–24 | Wiring, body fetch, tests, version bump |
|
||
|
||
**Parallelizable:** Chunks 2 and 3 (IMAP write + SMTP) are independent and can be done concurrently. Chunk 4 depends on both. Chunks 5–7 depend on chunk 4. Chunk 8 depends on everything.
|