# 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:

```swift
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`:
```swift
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` (the `send` method)
- 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:

```swift
@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:

```swift
// 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):

```swift
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:

```swift
var attachments: [ComposeAttachment] = []
var sizeWarningMessage: String?
```

- [ ] **Step 2: Add addAttachment / removeAttachment methods**

```swift
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:

```swift
// 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`:
```swift
@State private var showFileImporter = false
```

Add a paperclip toolbar item alongside the BCC toggle:
```swift
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`:
```swift
.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:
```swift
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`:
```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`:
```swift
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):

```swift
#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:
```swift
TextEditor(text: $viewModel.bodyText)
	.frame(minHeight: 200)
	.onDrop(...)
```

With:
```swift
#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:
```swift
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:)`:
```swift
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:
```swift
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:
```swift
.onAppear {
	if message.hasAttachments {
		attachments = viewModel.attachments(messageId: message.id)
	}
}
```

Add attachment section after the body content (before the closing `VStack`'s `.padding()`):
```swift
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**

```swift
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`:
```swift
@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):
```swift
.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:
```swift
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`:
```swift
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`:
```swift
case .message(let message):
	MessageView(message: message, viewModel: viewModel)
```

This requires adding `viewModel` to `ThreadDetailView`. Update the struct:
```swift
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`:
```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`:

```swift
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:
```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:
```swift
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:

```swift
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:

```swift
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"
```
