From a3e3618d345cf8fe296e96ab8fd0b9e3e040154a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sun, 15 Mar 2026 10:54:14 +0100 Subject: [PATCH] add attachment UI design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/specs/2026-03-15-attachment-ui-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/specs/2026-03-15-attachment-ui-design.md diff --git a/docs/specs/2026-03-15-attachment-ui-design.md b/docs/specs/2026-03-15-attachment-ui-design.md new file mode 100644 index 0000000..3943136 --- /dev/null +++ b/docs/specs/2026-03-15-attachment-ui-design.md @@ -0,0 +1,163 @@ +# 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 + +```swift +// 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: +```swift +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: + +```sql +(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: +```swift +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)