24 KiB
Attachment UI 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: Wire attachment UI for compose (send) and thread detail (receive), add list badges.
Architecture: Extends existing ComposeViewModel/ComposeView with attachment state, file picker, drag-drop, paste. MessageView gains lazy-loaded attachment chips with download + Quick Look. ThreadRow/ItemRow show paperclip badges via a new attachmentCount subquery on ThreadSummary.
Tech Stack: SwiftUI (macOS), GRDB, NIO-IMAP, NIO-SSL, WKWebView, Quick Look, UniformTypeIdentifiers
Spec: docs/specs/2026-03-15-attachment-ui-design.md
Chunk 1: Data Model + Send Path
Task 1: Add attachments field to OutgoingMessage
Files:
-
Modify:
Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift -
Test:
Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift -
Step 1: Add optional attachments field to OutgoingMessage
In Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift, add a new field. Since OutgoingMessage is Codable and attachments contain Data, we need a nested Codable struct:
public struct OutgoingAttachment: Sendable, Codable, Equatable {
public var filename: String
public var mimeType: String
public var data: Data
public init(filename: String, mimeType: String, data: Data) {
self.filename = filename
self.mimeType = mimeType
self.data = data
}
}
Add to OutgoingMessage:
public var attachments: [OutgoingAttachment]
Update the init to include attachments: [OutgoingAttachment] = [].
- Step 2: Build and verify no regressions
Run: cd Packages/MagnumOpusCore && swift build
Expected: Build succeeds (default [] means all existing call sites compile)
- Step 3: Commit
git add Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift
git commit -m "add OutgoingAttachment model, attachments field on OutgoingMessage"
Task 2: Update SMTPClient.send to handle attachments
Files:
-
Modify:
Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift:51(thesendmethod) -
Test:
Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift -
Step 1: Write test for multipart send formatting
In MessageFormatterTests.swift, add a test that verifies MessageFormatter.formatMultipart is called when attachments are present:
@Test func formatMultipartWithAttachments() throws {
let msg = OutgoingMessage(
from: EmailAddress(name: "Test", address: "test@example.com"),
to: [EmailAddress(name: "To", address: "to@example.com")],
subject: "With attachment",
bodyText: "Hello",
messageId: "test-123",
attachments: [
OutgoingAttachment(filename: "test.txt", mimeType: "text/plain", data: Data("file content".utf8))
]
)
let formatted = try MessageFormatter.formatMultipart(
msg,
attachments: msg.attachments.map { ($0.filename, $0.mimeType, $0.data) }
)
#expect(formatted.contains("multipart/mixed"))
#expect(formatted.contains("test.txt"))
#expect(formatted.contains("Hello"))
}
- Step 2: Run test to verify it compiles and passes
Run: cd Packages/MagnumOpusCore && swift test --filter MessageFormatterTests/formatMultipartWithAttachments
- Step 3: Update SMTPClient.send to use formatMultipart when attachments present
In Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift, change line 51:
// Before:
let formattedMessage = MessageFormatter.format(message)
// After:
let formattedMessage: String
if message.attachments.isEmpty {
formattedMessage = MessageFormatter.format(message)
} else {
formattedMessage = try MessageFormatter.formatMultipart(
message,
attachments: message.attachments.map { ($0.filename, $0.mimeType, $0.data) }
)
}
- Step 4: Build and run all tests
Run: swift test
Expected: All tests pass
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift Packages/MagnumOpusCore/Tests/SMTPClientTests/
git commit -m "update SMTPClient.send to use formatMultipart when attachments present"
Task 3: Update ComposeViewModel send path for attachments
Files:
-
Modify:
Apps/MagnumOpus/ViewModels/ComposeViewModel.swift -
Step 1: Add ComposeAttachment model and state
Add at the top of ComposeViewModel.swift (below imports):
struct ComposeAttachment: Identifiable {
let id = UUID()
let url: URL
let filename: String
let mimeType: String
let size: Int
let data: Data
}
Add to ComposeViewModel properties:
var attachments: [ComposeAttachment] = []
var sizeWarningMessage: String?
- Step 2: Add addAttachment / removeAttachment methods
import UniformTypeIdentifiers
func addAttachment(url: URL) {
do {
let data = try Data(contentsOf: url)
let size = data.count
let maxSize = 25 * 1024 * 1024
let warnSize = 10 * 1024 * 1024
if size > maxSize {
errorMessage = "\(url.lastPathComponent) is too large (\(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))). Maximum is 25 MB."
return
}
if size > warnSize {
sizeWarningMessage = "\(url.lastPathComponent) is \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)). Large attachments may slow delivery."
}
let ext = url.pathExtension
let mimeType = UTType(filenameExtension: ext)?.preferredMIMEType ?? "application/octet-stream"
attachments.append(ComposeAttachment(
url: url,
filename: url.lastPathComponent,
mimeType: mimeType,
size: size,
data: data
))
} catch {
errorMessage = "Could not read file: \(error.localizedDescription)"
}
}
func removeAttachment(id: UUID) {
attachments.removeAll { $0.id == id }
}
- Step 3: Update send() to pass attachments
In the send() method, after building outgoing, change:
// Before:
let outgoing = OutgoingMessage(
from: from, to: toAddresses, cc: ccAddresses, bcc: bccAddresses,
subject: subject, bodyText: bodyText,
inReplyTo: inReplyTo, references: references, messageId: messageId
)
let formatted = MessageFormatter.format(outgoing)
// After:
let outgoing = OutgoingMessage(
from: from, to: toAddresses, cc: ccAddresses, bcc: bccAddresses,
subject: subject, bodyText: bodyText,
inReplyTo: inReplyTo, references: references, messageId: messageId,
attachments: attachments.map { OutgoingAttachment(filename: $0.filename, mimeType: $0.mimeType, data: $0.data) }
)
let formatted: String
if attachments.isEmpty {
formatted = MessageFormatter.format(outgoing)
} else {
formatted = try MessageFormatter.formatMultipart(
outgoing,
attachments: attachments.map { ($0.filename, $0.mimeType, $0.data) }
)
}
The rest of send() stays the same — both .send(message: outgoing) and .append(mailbox: "Sent", messageData: formatted, ...) use the updated values.
- Step 4: Build
Run: cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"
Expected: No errors
- Step 5: Commit
git add Apps/MagnumOpus/ViewModels/ComposeViewModel.swift
git commit -m "add ComposeAttachment model, wire attachment send path with formatMultipart"
Chunk 2: Compose UI
Task 4: Add attachment chip strip and paperclip button to ComposeView
Files:
-
Modify:
Apps/MagnumOpus/Views/ComposeView.swift -
Step 1: Add file importer state and paperclip toolbar button
Add to ComposeView:
@State private var showFileImporter = false
Add a paperclip toolbar item alongside the BCC toggle:
ToolbarItem(placement: .automatic) {
Button {
showFileImporter = true
} label: {
Label("Attach", systemImage: "paperclip")
}
.keyboardShortcut("a", modifiers: [.shift, .command])
.help("Attach File (⇧⌘A)")
}
Add the .fileImporter modifier to content:
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
for url in urls {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
viewModel.addAttachment(url: url)
}
case .failure(let error):
viewModel.errorMessage = error.localizedDescription
}
}
- Step 2: Add attachment chip strip below the TextEditor section
After the TextEditor section, add:
if !viewModel.attachments.isEmpty {
Section {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(viewModel.attachments) { attachment in
AttachmentChip(attachment: attachment) {
viewModel.removeAttachment(id: attachment.id)
}
}
}
.padding(.vertical, 4)
}
}
}
if let warning = viewModel.sizeWarningMessage {
Section {
Text(warning)
.foregroundStyle(.orange)
.font(.caption)
}
}
- Step 3: Create AttachmentChip view
Add at the bottom of ComposeView.swift:
struct AttachmentChip: View {
let attachment: ComposeAttachment
let onRemove: () -> Void
var body: some View {
HStack(spacing: 4) {
Image(systemName: iconName)
.foregroundStyle(.secondary)
Text(attachment.filename)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150)
Text("·")
.foregroundStyle(.tertiary)
Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.size), countStyle: .file))
.foregroundStyle(.secondary)
Button {
onRemove()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary, in: Capsule())
}
private var iconName: String {
let ext = (attachment.filename as NSString).pathExtension.lowercased()
switch ext {
case "jpg", "jpeg", "png", "gif", "heic", "webp": return "photo"
case "pdf": return "doc.richtext"
case "mp4", "mov", "avi": return "film"
case "mp3", "wav", "aac", "m4a": return "music.note"
case "zip", "gz", "tar", "7z": return "doc.zipper"
default: return "paperclip"
}
}
}
- Step 4: Add drag-and-drop to the TextEditor
Wrap the TextEditor section body with .onDrop:
TextEditor(text: $viewModel.bodyText)
.frame(minHeight: 200)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
for provider in providers {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url {
Task { @MainActor in
viewModel.addAttachment(url: url)
}
}
}
}
return true
}
- Step 5: Build and test manually
Run: cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"
Expected: No errors
- Step 6: Commit
git add Apps/MagnumOpus/Views/ComposeView.swift
git commit -m "add attachment chip strip, paperclip button, file importer, drag-drop to compose view"
Task 5: Add paste support for images
Files:
-
Modify:
Apps/MagnumOpus/Views/ComposeView.swift -
Step 1: Create PastableTextEditor NSViewRepresentable
Add to ComposeView.swift (or a new helper file if preferred):
#if os(macOS)
struct PastableTextEditor: NSViewRepresentable {
@Binding var text: String
var onPasteFile: (URL) -> Void
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
let textView = scrollView.documentView as! NSTextView
textView.delegate = context.coordinator
textView.font = .systemFont(ofSize: 14)
textView.isRichText = false
textView.allowsUndo = true
textView.string = text
context.coordinator.textView = textView
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
let textView = nsView.documentView as! NSTextView
if textView.string != text {
textView.string = text
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, onPasteFile: onPasteFile)
}
class Coordinator: NSObject, NSTextViewDelegate {
@Binding var text: String
var onPasteFile: (URL) -> Void
weak var textView: NSTextView?
init(text: Binding<String>, onPasteFile: @escaping (URL) -> Void) {
_text = text
self.onPasteFile = onPasteFile
}
func textDidChange(_ notification: Notification) {
guard let textView else { return }
text = textView.string
}
// Override paste to detect images
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.paste(_:)) {
let pasteboard = NSPasteboard.general
// Check for file URLs first
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
for url in urls where url.isFileURL {
onPasteFile(url)
}
if !urls.isEmpty { return true }
}
// Check for images
if let images = pasteboard.readObjects(forClasses: [NSImage.self]) as? [NSImage], let image = images.first {
if let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) {
let tempDir = FileManager.default.temporaryDirectory
let tempFile = tempDir.appendingPathComponent("pasted-image-\(UUID().uuidString).png")
try? pngData.write(to: tempFile)
onPasteFile(tempFile)
return true
}
}
}
return false
}
}
}
#endif
- Step 2: Replace TextEditor with PastableTextEditor in ComposeView
Replace:
TextEditor(text: $viewModel.bodyText)
.frame(minHeight: 200)
.onDrop(...)
With:
#if os(macOS)
PastableTextEditor(text: $viewModel.bodyText) { url in
viewModel.addAttachment(url: url)
}
.frame(minHeight: 200)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
// ... same drag-drop handler
}
#else
TextEditor(text: $viewModel.bodyText)
.frame(minHeight: 200)
#endif
- Step 3: Build
Run: cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"
Expected: No errors
- Step 4: Commit
git add Apps/MagnumOpus/Views/ComposeView.swift
git commit -m "add PastableTextEditor with image paste support for macOS compose"
Chunk 3: Received Attachments Display
Task 6: Add attachment chips to MessageView
Files:
-
Modify:
Apps/MagnumOpus/Views/ThreadDetailView.swift -
Modify:
Apps/MagnumOpus/ViewModels/MailViewModel.swift -
Step 1: Add download wrapper to MailViewModel
In MailViewModel.swift, add:
func downloadAttachment(attachmentId: String, messageId: String) async throws -> URL {
guard let store, let coordinator else {
throw NSError(domain: "MagnumOpus", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not configured"])
}
guard let record = try store.message(id: messageId) else {
throw NSError(domain: "MagnumOpus", code: 2, userInfo: [NSLocalizedDescriptionKey: "Message not found"])
}
let mailboxName = mailboxName(for: record.mailboxId) ?? "INBOX"
return try await coordinator.downloadAttachment(
attachmentId: attachmentId,
messageUid: record.uid,
mailboxName: mailboxName
)
}
Also expose attachments(messageId:):
func attachments(messageId: String) -> [AttachmentRecord] {
guard let store else { return [] }
return (try? store.attachments(messageId: messageId)) ?? []
}
- Step 2: Add attachment section to MessageView
In ThreadDetailView.swift, update MessageView to accept viewModel and show attachments:
Update MessageView struct:
struct MessageView: View {
let message: MessageSummary
let viewModel: MailViewModel
@State private var showSource = false
@State private var attachments: [AttachmentRecord] = []
@State private var downloadingIds: Set<String> = []
@State private var previewURL: URL?
Add .onAppear to load attachments:
.onAppear {
if message.hasAttachments {
attachments = viewModel.attachments(messageId: message.id)
}
}
Add attachment section after the body content (before the closing VStack's .padding()):
if !attachments.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Attachments (\(attachments.count))")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(attachments, id: \.id) { attachment in
ReceivedAttachmentChip(
attachment: attachment,
isDownloading: downloadingIds.contains(attachment.id)
) {
downloadAndPreview(attachment)
}
}
}
}
}
}
- Step 3: Add ReceivedAttachmentChip and download helper
struct ReceivedAttachmentChip: View {
let attachment: AttachmentRecord
let isDownloading: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 4) {
if isDownloading {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: iconName)
.foregroundStyle(.blue)
}
Text(attachment.filename ?? "attachment")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150)
if attachment.size > 0 {
Text("·")
.foregroundStyle(.tertiary)
Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.size), countStyle: .file))
.foregroundStyle(.secondary)
}
}
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.plain)
}
private var iconName: String {
let ext = ((attachment.filename ?? "") as NSString).pathExtension.lowercased()
switch ext {
case "jpg", "jpeg", "png", "gif", "heic", "webp": return "photo"
case "pdf": return "doc.richtext"
case "mp4", "mov", "avi": return "film"
case "mp3", "wav", "aac", "m4a": return "music.note"
case "zip", "gz", "tar", "7z": return "doc.zipper"
default: return "paperclip"
}
}
}
Add download helper and error state tracking to MessageView:
@State private var failedIds: Set<String> = []
private func downloadAndPreview(_ attachment: AttachmentRecord) {
// If cached, open directly via Quick Look
if let cachePath = attachment.cachePath,
FileManager.default.fileExists(atPath: cachePath) {
previewURL = URL(fileURLWithPath: cachePath)
return
}
failedIds.remove(attachment.id)
downloadingIds.insert(attachment.id)
Task {
do {
let url = try await viewModel.downloadAttachment(
attachmentId: attachment.id,
messageId: message.id
)
downloadingIds.remove(attachment.id)
previewURL = url
} catch {
downloadingIds.remove(attachment.id)
failedIds.insert(attachment.id)
}
}
}
Add Quick Look sheet to the MessageView body (inside the VStack):
.quickLookPreview($previewURL)
Note: previewURL is already declared as @State private var previewURL: URL? in Step 2.
Update ReceivedAttachmentChip to accept hasFailed parameter and show red tint:
struct ReceivedAttachmentChip: View {
let attachment: AttachmentRecord
let isDownloading: Bool
let hasFailed: Bool
let onTap: () -> Void
// ... existing body, but add:
// .background(hasFailed ? Color.red.opacity(0.15) : Color.quaternary, in: Capsule())
}
Update the chip creation to pass hasFailed:
ReceivedAttachmentChip(
attachment: attachment,
isDownloading: downloadingIds.contains(attachment.id),
hasFailed: failedIds.contains(attachment.id)
) {
downloadAndPreview(attachment)
}
- Step 4: Update MessageView call sites to pass viewModel
In ThreadDetailView, update the ForEach:
case .message(let message):
MessageView(message: message, viewModel: viewModel)
This requires adding viewModel to ThreadDetailView. Update the struct:
struct ThreadDetailView: View {
let thread: ThreadSummary?
let messages: [MessageSummary]
let linkedTasks: [TaskSummary]
let viewModel: MailViewModel
var onComposeRequest: (ComposeMode) -> Void
Update the call site in ContentView.swift:
ThreadDetailView(
thread: viewModel.selectedThread,
messages: viewModel.messages,
linkedTasks: ...,
viewModel: viewModel,
onComposeRequest: ...
)
- Step 5: Build
Run: cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"
Expected: No errors
- Step 6: Commit
git add Apps/MagnumOpus/Views/ThreadDetailView.swift Apps/MagnumOpus/ViewModels/MailViewModel.swift Apps/MagnumOpus/ContentView.swift
git commit -m "add attachment chips to message view with download + open"
Chunk 4: Email List Badges
Task 7: Add attachmentCount to ThreadSummary and thread query
Files:
-
Modify:
Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift -
Modify:
Packages/MagnumOpusCore/Sources/MailStore/Queries.swift -
Step 1: Add attachmentCount to ThreadSummary
In ThreadSummary.swift, add public var attachmentCount: Int and update init:
public struct ThreadSummary: Sendable, Identifiable, Equatable {
public var id: String
public var accountId: String
public var subject: String?
public var lastDate: Date
public var messageCount: Int
public var unreadCount: Int
public var senders: String
public var snippet: String?
public var attachmentCount: Int
public init(
id: String, accountId: String, subject: String?, lastDate: Date,
messageCount: Int, unreadCount: Int, senders: String, snippet: String?,
attachmentCount: Int = 0
) {
// ... existing assignments ...
self.attachmentCount = attachmentCount
}
}
- Step 2: Add attachmentCount subquery to Queries.swift
In threadSummariesFromDB, add to the SQL:
(SELECT COUNT(*) FROM attachment a JOIN threadMessage tm ON tm.messageId = a.messageId WHERE tm.threadId = t.id) as attachmentCount
Update the row mapping:
ThreadSummary(
// ... existing fields ...
snippet: row["snippet"],
attachmentCount: row["attachmentCount"]
)
- Step 3: Build and run tests
Run: cd Packages/MagnumOpusCore && swift test
Expected: All 142+ tests pass
- Step 4: Commit
git add Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift Packages/MagnumOpusCore/Sources/MailStore/Queries.swift
git commit -m "add attachmentCount to ThreadSummary via subquery"
Task 8: Add paperclip badges to ThreadRow and ItemRow
Files:
-
Modify:
Apps/MagnumOpus/Views/ThreadListView.swift -
Step 1: Add paperclip badge to ThreadRow
In ThreadRow, after the message count badge, add:
if thread.attachmentCount > 0 {
HStack(spacing: 2) {
Image(systemName: "paperclip")
Text("\(thread.attachmentCount)")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
- Step 2: Add paperclip icon to ItemRow for emails with attachments
In ItemRow, within the .email(let msg) case, after the snippet, add:
if msg.hasAttachments {
Image(systemName: "paperclip")
.font(.caption2)
.foregroundStyle(.secondary)
}
Place it in the trailing area, next to the relative date.
- Step 3: Build
Run: cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"
Expected: No errors
- Step 4: Commit
git add Apps/MagnumOpus/Views/ThreadListView.swift
git commit -m "add paperclip badges to thread list and item list"
Chunk 5: Final Integration + Verification
Task 9: Full build + test verification
- Step 1: Run all package tests
Run: cd Packages/MagnumOpusCore && swift test
Expected: All tests pass
- Step 2: Build macOS app
Run: cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep -E "error:|warning:" | grep -v "/.build/" | grep -v "appintentsmetadataprocessor"
Expected: No errors, no new warnings
- Step 3: Verify no regressions in existing functionality
Check:
-
Compose without attachments still works (plain text path unchanged)
-
Thread list loads correctly
-
GTD perspectives load correctly
-
Thread detail shows messages
-
Step 4: Final commit if any integration fixes needed
git commit -m "fix attachment UI integration issues"