Files
MagnumOpus/docs/specs/2026-03-15-attachment-ui-design.md
Felix Förtsch a3e3618d34 add attachment UI design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:58:07 +01:00

7.7 KiB
Raw Blame History

Attachment UI — Design Spec

Date: 2026-03-15 Scope: Wire attachment UI for compose (send) and thread detail (receive), add list badges. Builds on: v0.5 backend — MIMEParser, MessageFormatter.formatMultipart, SyncCoordinator.downloadAttachment, AttachmentRecord, hasAttachments schema field.


1. Compose Attachments

Data Model

// ComposeViewModel
struct ComposeAttachment: Identifiable {
	let id: UUID
	let url: URL
	let filename: String
	let mimeType: String
	let size: Int
	let data: Data
}
var attachments: [ComposeAttachment] = []

Input Paths

All three paths converge to a single addAttachment(url:) method on ComposeViewModel.

  1. Paperclip button.fileImporter(isPresented:allowedContentTypes:allowsMultipleSelection:) in SwiftUI. Allows any file type. Result URLs passed to addAttachment.
  2. Drag-and-drop.onDrop(of: [.fileURL], ...) on the compose text area. Extract file URLs from NSItemProvider, pass to addAttachment.
  3. Paste — Custom NSTextView coordinator (macOS) that intercepts paste, detects NSImage or file items on NSPasteboard. Writes pasted images to a temp file, passes URL to addAttachment.

Validation

addAttachment(url:) logic:

  1. Read file data from URL.
  2. Detect MIME type via UTType(filenameExtension:).
  3. Warn if size > 10 MB (non-blocking alert, user can proceed).
  4. Reject if size > 25 MB (error alert, file not added). This matches the existing MessageFormatter guard.
  5. Create ComposeAttachment, append to attachments.

UI — Horizontal Chip Strip

Below the text editor, above the send button:

  • Horizontal scrolling ScrollView(.horizontal) of attachment chips.
  • Each chip: [icon filename · size ×]
    • Icon: SF Symbol derived from UTType (doc, photo, film, paperclip fallback).
    • Filename: truncated to ~20 chars with ellipsis.
    • Size: formatted (KB/MB).
    • × button: removes from attachments array.
  • Hidden when attachments.isEmpty.

Send Path

MessageFormatter.formatMultipart takes [(filename: String, mimeType: String, data: Data)] (anonymous tuples) and returns the full RFC822 string. The send integration:

  • If attachments.isEmpty: existing flow unchanged — .send(message: outgoing) passes the OutgoingMessage to SMTPClient, .append uses MessageFormatter.format(outgoing).
  • If attachments present:
    1. Call let formatted = try MessageFormatter.formatMultipart(outgoing, attachments: attachments.map { ($0.filename, $0.mimeType, $0.data) }).
    2. formatMultipart is throws (MessageFormatterError.attachmentTooLarge). Catch and surface as errorMessage, matching the existing send() error-handling pattern.
    3. The .send action changes: instead of .send(message: outgoing), use .send(message: outgoingWithAttachments) where OutgoingMessage gains an optional attachments field. SMTPClient.send checks this field and calls formatMultipart if present, or format otherwise.
    4. The .append action uses formatted (the multipart RFC822 string) for the Sent-copy — same as the no-attachment path but with the multipart output.

This means OutgoingMessage needs a new optional field:

public var attachments: [(filename: String, mimeType: String, data: Data)]?

SMTPClient.send uses this to decide between plain and multipart formatting.

Draft Persistence

Out of scope for this iteration. Attachments live only in memory during the compose session. If the user closes the compose view, attachments are lost. Draft auto-save continues to save text-only fields. This matches the current "plain text compose" scope. Future rich-text work will add draft attachment persistence (temp directory keyed by draft ID, paths stored in DraftRecord).


2. Received Attachments Display

Data Loading

Attachments are loaded lazily per message — not included in the thread observation query. When a MessageView appears:

  • Fetch [AttachmentRecord] via MailStore.attachments(messageId:) (add if not present).
  • Store in @State within MessageView.

UI — Disclosure Chips

Below the message body in MessageView, shown only when attachments exist:

  • Header: "Attachments (\(count))" in caption style, secondary color.
  • Below: horizontal scrolling ScrollView(.horizontal) of tappable chips (same layout primitive as compose strip — no wrapping needed for v0.6).
  • Each chip: [icon filename · size]
    • Icon: SF Symbol from UTType of mimeType.
    • Tappable → triggers download and preview.

Download + Preview Flow

MessageView has access to the parent MessageSummary (which carries id). To call SyncCoordinator.downloadAttachment(attachmentId:messageUid:mailboxName:), the view needs messageUid and mailboxName. These are obtained by adding a thin wrapper method on SyncCoordinator (or MailViewModel) that internally looks up MessageRecord.uid and MailboxRecord.name from the message ID, so the view only passes attachmentId and messageId.

  1. User taps chip.
  2. Chip shows spinner (replace icon with ProgressView).
  3. Call wrapper: downloadAttachment(attachmentId:messageId:).
  4. On success: open returned URL via Quick Look (QLPreviewPanel on macOS).
  5. On failure: show error in chip (red tint, retry on next tap).

Cached attachments (where cachePath is non-nil and file exists) skip the download step and go straight to Quick Look.


3. Email List Badges

Thread List

Add public var attachmentCount: Int to ThreadSummary struct (default 0), update the init, and map it in threadSummariesFromDB row-mapping in Queries.swift (attachmentCount: row["attachmentCount"]). Populate via a subquery:

(SELECT COUNT(*) FROM attachment a
 JOIN threadMessage tm ON tm.messageId = a.messageId
 WHERE tm.threadId = t.id) as attachmentCount

In ThreadRow, when attachmentCount > 0, show:

HStack(spacing: 2) {
	Image(systemName: "paperclip")
	Text("\(thread.attachmentCount)")
}
.font(.caption2)
.foregroundStyle(.secondary)

Positioned next to the message count badge.

Item List (GTD perspectives)

MessageSummary.hasAttachments already exists and is mapped from MessageRecord. For ItemRow, show a paperclip icon (no count) when hasAttachments is true. A per-message attachment count is not needed for the item list — the boolean is sufficient and avoids an extra query.


4. Files Touched

File Changes
ComposeViewModel.swift ComposeAttachment model, addAttachment(url:), removeAttachment(id:), size validation, integrate with send path (both .send and .append)
ComposeView.swift Paperclip button, .fileImporter, .onDrop, paste coordinator, attachment chip strip
MessageView (in ThreadDetailView.swift) Attachment chips section, lazy loading, download wrapper + Quick Look
ThreadListView.swift Paperclip badge in ThreadRow and ItemRow
Queries.swift Add attachmentCount subquery to thread summary SQL, add field to ThreadSummary struct + init + row-mapping
Models/OutgoingMessage.swift Add optional attachments field for multipart send
SMTPClient.swift Check outgoing.attachments and call formatMultipart when present
MailStore.swift attachments(messageId:) query
SyncCoordinator.swift or MailViewModel.swift Thin download wrapper that resolves messageUid/mailboxName from messageId

No new modules or files. Everything integrates into existing code.


5. Out of Scope

  • Rich text / inline image composer (future v1+)
  • Draft attachment persistence (future, with rich text)
  • Attachment preview thumbnails in the list
  • Attachment search
  • Attachment size totals per thread
  • iOS-specific paste handling (macOS first, iOS later)