7.7 KiB
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.
- Paperclip button —
.fileImporter(isPresented:allowedContentTypes:allowsMultipleSelection:)in SwiftUI. Allows any file type. Result URLs passed toaddAttachment. - Drag-and-drop —
.onDrop(of: [.fileURL], ...)on the compose text area. Extract file URLs fromNSItemProvider, pass toaddAttachment. - Paste — Custom
NSTextViewcoordinator (macOS) that intercepts paste, detectsNSImageor file items onNSPasteboard. Writes pasted images to a temp file, passes URL toaddAttachment.
Validation
addAttachment(url:) logic:
- Read file data from URL.
- Detect MIME type via
UTType(filenameExtension:). - Warn if size > 10 MB (non-blocking alert, user can proceed).
- Reject if size > 25 MB (error alert, file not added). This matches the existing
MessageFormatterguard. - Create
ComposeAttachment, append toattachments.
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,paperclipfallback). - Filename: truncated to ~20 chars with ellipsis.
- Size: formatted (KB/MB).
- × button: removes from
attachmentsarray.
- Icon: SF Symbol derived from UTType (
- 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 theOutgoingMessageto SMTPClient,.appendusesMessageFormatter.format(outgoing). - If attachments present:
- Call
let formatted = try MessageFormatter.formatMultipart(outgoing, attachments: attachments.map { ($0.filename, $0.mimeType, $0.data) }). formatMultipartisthrows(MessageFormatterError.attachmentTooLarge). Catch and surface aserrorMessage, matching the existingsend()error-handling pattern.- The
.sendaction changes: instead of.send(message: outgoing), use.send(message: outgoingWithAttachments)whereOutgoingMessagegains an optionalattachmentsfield.SMTPClient.sendchecks this field and callsformatMultipartif present, orformatotherwise. - The
.appendaction usesformatted(the multipart RFC822 string) for the Sent-copy — same as the no-attachment path but with the multipart output.
- Call
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]viaMailStore.attachments(messageId:)(add if not present). - Store in
@StatewithinMessageView.
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.
- Icon: SF Symbol from UTType of
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.
- User taps chip.
- Chip shows spinner (replace icon with
ProgressView). - Call wrapper:
downloadAttachment(attachmentId:messageId:). - On success: open returned
URLvia Quick Look (QLPreviewPanelon macOS). - 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)