fix v0.5 spec: address 14 review issues (MIME, IDLE handler, hasAttachments, etc.)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,14 +97,14 @@ public struct MIMEPart: Sendable {
|
||||
public var disposition: ContentDisposition?
|
||||
public var filename: String?
|
||||
public var contentId: String? // for inline images (cid: references)
|
||||
public var body: String // raw encoded content
|
||||
public var body: Data // raw content (decoded from transfer encoding)
|
||||
public var subparts: [MIMEPart] // for nested multipart
|
||||
}
|
||||
|
||||
public struct MIMEAttachment: Sendable {
|
||||
public var filename: String
|
||||
public var mimeType: String
|
||||
public var size: Int // decoded size in bytes
|
||||
public var size: Int // estimated decoded size (base64 length * 3/4)
|
||||
public var contentId: String? // for inline images
|
||||
public var sectionPath: String // IMAP section number (e.g., "1.2")
|
||||
public var isInline: Bool
|
||||
@@ -130,11 +130,20 @@ public enum ContentDisposition: Sendable {
|
||||
2. Check `Content-Type` for `multipart/*` — if so, extract boundary and split body on boundary markers
|
||||
3. Recursively parse each part
|
||||
4. For `multipart/alternative`: pick text/plain and text/html parts for body
|
||||
5. For `multipart/mixed`: first text part is body, rest are attachments
|
||||
6. For nested structures (`multipart/mixed` containing `multipart/alternative`): recurse
|
||||
7. Extract `filename` from `Content-Disposition: attachment; filename="..."` or `Content-Type: ...; name="..."`
|
||||
8. Calculate decoded size from base64 content length (base64 inflates ~33%)
|
||||
9. Assign IMAP section paths based on part position (1, 1.1, 1.2, 2, etc.)
|
||||
5. For `multipart/mixed`: first text part (or multipart/alternative) is body, rest are attachments
|
||||
6. For `multipart/related`: HTML body + inline images (Content-ID references for `cid:` URLs). This is the standard container for HTML newsletters with embedded images.
|
||||
7. For nested structures (e.g., `multipart/mixed` containing `multipart/related` containing `multipart/alternative`): recurse
|
||||
8. Extract `filename` from `Content-Disposition: attachment; filename="..."` or `Content-Type: ...; name="..."`. Decode RFC 2047 encoded words (`=?charset?encoding?text?=`) for non-ASCII filenames.
|
||||
9. Estimate decoded size from base64 content length (base64 inflates ~33%): `encodedLength * 3 / 4`
|
||||
10. Assign IMAP section paths based on part position (1, 1.1, 1.2, 2, etc.)
|
||||
|
||||
### Boundary Generation
|
||||
|
||||
`MIMEParser.generateBoundary()` returns `"=_MagnumOpus_\(UUID().uuidString)"`. The `=_` prefix makes collision with base64 content essentially impossible (base64 alphabet doesn't include `=_` at line starts).
|
||||
|
||||
### RFC 2047 Decoding
|
||||
|
||||
Filenames may be encoded as `=?utf-8?B?<base64>?=` or `=?utf-8?Q?<quoted-printable>?=`. The parser detects this pattern and decodes to produce human-readable filenames.
|
||||
|
||||
### Content Decoding
|
||||
|
||||
@@ -148,15 +157,25 @@ public enum ContentDisposition: Sendable {
|
||||
|
||||
### IMAP Sync Integration
|
||||
|
||||
The existing `IMAPClient.fetchBody(uid:)` fetches only body text (BODY[TEXT]), which strips headers needed for MIME parsing. Replace with a new method that fetches the full RFC822 message:
|
||||
|
||||
```swift
|
||||
func fetchFullMessage(uid: Int) async throws -> String
|
||||
```
|
||||
|
||||
This uses `BODY.PEEK[]` (full message including headers) instead of `BODY[TEXT]`. The existing `fetchBody(uid:)` remains available for non-MIME use cases.
|
||||
|
||||
During body fetch in `SyncCoordinator`, replace the current raw body storage with MIME-aware parsing:
|
||||
|
||||
1. Fetch full message via `IMAPClient.fetchBody(uid:)` (existing method — may need to fetch full RFC822 instead of just body text)
|
||||
1. Fetch full message via `IMAPClient.fetchFullMessage(uid:)`
|
||||
2. Parse with `MIMEParser.parse(rawMessage)`
|
||||
3. Store `textBody` → `message.bodyText`, `htmlBody` → `message.bodyHtml`
|
||||
4. For each attachment in `MIMEMessage.attachments` + `inlineImages`:
|
||||
- Create `AttachmentRecord` with filename, mimeType, size, contentId, sectionPath
|
||||
- Insert into `attachment` table (existing table, already has the right columns)
|
||||
5. Set `message.hasAttachments = true` if any attachments found (field exists but not populated)
|
||||
5. Update `message.hasAttachments` (see migration below)
|
||||
|
||||
**Note on fetchEnvelopes:** The existing `fetchEnvelopes` method also fetches body text inline during initial sync. This path should also be updated to use MIME-aware parsing, or body fetching should be deferred to the separate `prefetchBodies` phase (which already exists). The simpler approach: keep `fetchEnvelopes` fetching only envelopes (no body), and let `prefetchBodies` do the full RFC822 fetch with MIME parsing. This avoids doubling the bandwidth during initial sync.
|
||||
|
||||
### IMAPClient Extension
|
||||
|
||||
@@ -194,6 +213,7 @@ This stores the IMAP section number for on-demand fetch.
|
||||
2. If cached: open with QuickLook
|
||||
3. If not cached:
|
||||
- Show download progress
|
||||
- Check UID validity: compare stored `mailbox.uidValidity` with server. If changed, UIDs are stale — show error "Message may have moved, please re-sync"
|
||||
- Call `IMAPClient.fetchSection(uid:mailbox:section:)` with the stored `sectionPath`
|
||||
- Save to cache directory
|
||||
- Update `attachment.cachePath`
|
||||
@@ -207,8 +227,9 @@ This stores the IMAP section number for on-demand fetch.
|
||||
|
||||
**ComposeViewModel additions:**
|
||||
- `attachments: [(url: URL, filename: String, mimeType: String, data: Data)]`
|
||||
- `attachFile()` — triggered by file picker, reads file data, adds to list
|
||||
- `attachFile()` — triggered by file picker, reads file data, adds to list. Refuses files > 25 MB with user-facing error (the SMTP server will ultimately enforce limits, but loading a 500 MB video into memory crashes the app).
|
||||
- `removeAttachment(at:)` — remove from list
|
||||
- `totalAttachmentSize: Int` — computed sum, shown in UI
|
||||
|
||||
**ComposeView additions:**
|
||||
- "Attach" toolbar button + `⌘⇧A` shortcut → `.fileImporter` system file picker
|
||||
@@ -239,6 +260,8 @@ Content-Transfer-Encoding: base64
|
||||
|
||||
When no attachments, keep the current simple single-part format unchanged.
|
||||
|
||||
The existing `MessageFormatter.format(_:)` is updated to accept an optional attachments parameter. If attachments are present, it delegates to `formatMultipart`; otherwise it produces the existing single-part output. The ActionQueue/SMTP send path calls `format()` as before — no changes needed downstream.
|
||||
|
||||
New public methods:
|
||||
|
||||
```swift
|
||||
@@ -319,13 +342,40 @@ public func stopIdleMonitoring() // stop IDLE, cancel task
|
||||
### NIO Implementation
|
||||
|
||||
`IMAPIdleClient` uses the same NIO patterns as `IMAPClient`:
|
||||
- `IMAPConnection` for the TLS channel (can reuse existing class)
|
||||
- `IMAPCommandRunner` for LOGIN/SELECT (can reuse)
|
||||
- Custom IDLE handler that watches for untagged `* <n> EXISTS` responses
|
||||
- Use `CheckedContinuation` for async notification delivery
|
||||
- `IMAPConnection` for the TLS channel (reuse existing class)
|
||||
- `IMAPCommandRunner` for LOGIN/SELECT (reuse)
|
||||
- **New: `IMAPIdleHandler`** — a separate `ChannelInboundHandler` that replaces the standard `IMAPResponseHandler` after entering IDLE mode. Unlike the standard handler (which holds a single `CheckedContinuation` resolved on tagged responses), `IMAPIdleHandler` uses an `AsyncStream<IMAPIdleEvent>` to deliver a stream of untagged responses. The IDLE loop consumes this stream:
|
||||
|
||||
```swift
|
||||
enum IMAPIdleEvent: Sendable {
|
||||
case exists(Int) // * <n> EXISTS — new mail
|
||||
case expunge(Int) // * <n> EXPUNGE — message removed
|
||||
case idleTerminated // OK IDLE terminated (after DONE)
|
||||
}
|
||||
```
|
||||
|
||||
The IDLE loop in `IMAPIdleClient`:
|
||||
1. Send `IDLE` command
|
||||
2. Consume `AsyncStream<IMAPIdleEvent>` in a for-await loop
|
||||
3. On `.exists`: call `onNewMail()`, send `DONE`, wait for `.idleTerminated`, re-enter IDLE
|
||||
4. On connection drop: the stream ends, trigger reconnect
|
||||
|
||||
This avoids the single-continuation limitation of the standard response handler.
|
||||
|
||||
The IDLE connection is separate from the sync connection — two concurrent connections to the IMAP server.
|
||||
|
||||
### INBOX Resolution
|
||||
|
||||
The mailbox name for IDLE is resolved via `MailStore.mailboxWithRole("inbox", accountId:)` (using the role detection from v0.3). If no inbox role is found, falls back to the literal string "INBOX" (RFC 3501 requires this name).
|
||||
|
||||
### Capability Check
|
||||
|
||||
IDLE capability is checked via the sync `IMAPClient` (which caches capabilities from the initial connection). `startIdleMonitoring()` reads this cached value — it does not require the IDLE connection to query capabilities separately.
|
||||
|
||||
### iOS Background Behavior
|
||||
|
||||
IMAP IDLE is **foreground-only on iOS**. When the app enters background, `stopIdleMonitoring()` is called. On foreground resume, a fresh `syncNow()` is triggered to catch any mail that arrived while backgrounded, then IDLE monitoring restarts. On macOS, IDLE runs continuously while the app window is open.
|
||||
|
||||
---
|
||||
|
||||
## Attachment UI
|
||||
@@ -381,6 +431,8 @@ Thread rows show a 📎 icon when any message in the thread has attachments. Use
|
||||
- Section path assignment (1, 1.1, 1.2, 2)
|
||||
- Handle missing Content-Disposition (infer attachment from non-text content type)
|
||||
- Handle malformed MIME (missing boundary, truncated parts) gracefully
|
||||
- Parse multipart/related (HTML + inline images with cid: references)
|
||||
- Decode RFC 2047 encoded filenames (=?utf-8?B?...?= and =?utf-8?Q?...?=)
|
||||
|
||||
### MessageFormatter Tests (extended)
|
||||
|
||||
@@ -426,12 +478,17 @@ No new external dependencies. Uses existing packages + Foundation's `UTType` for
|
||||
|
||||
## Schema Changes
|
||||
|
||||
### Migration v4_attachmentSection
|
||||
### Migration v4_attachment: Attachment MIME support
|
||||
|
||||
```sql
|
||||
ALTER TABLE attachment ADD COLUMN sectionPath TEXT;
|
||||
ALTER TABLE message ADD COLUMN hasAttachments INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
The `hasAttachments` column is populated during MIME parsing. The `sectionPath` stores the IMAP section number for on-demand part fetch.
|
||||
|
||||
Note: v4 follows after v3 migrations from v0.4 (v3_task, v3_label, v3_deferral).
|
||||
|
||||
---
|
||||
|
||||
## v0.5 Scope
|
||||
@@ -444,7 +501,9 @@ ALTER TABLE attachment ADD COLUMN sectionPath TEXT;
|
||||
- Attachment UI: attachment strip in thread detail, inline image rendering, compose chips, paperclip icon
|
||||
- IMAP IDLE: dedicated connection, EXISTS notification, auto-sync trigger, 29-min re-IDLE, reconnect
|
||||
- Periodic sync interval bumped to 15 min (IDLE is the fast path)
|
||||
- Schema migration for attachment sectionPath
|
||||
- Schema migration for attachment sectionPath + message hasAttachments
|
||||
- RFC 2047 filename decoding
|
||||
- Per-file attachment size guard (25 MB)
|
||||
|
||||
### Out (Deferred)
|
||||
|
||||
@@ -457,4 +516,4 @@ ALTER TABLE attachment ADD COLUMN sectionPath TEXT;
|
||||
- Rich text compose (future)
|
||||
- Multiple accounts (future)
|
||||
- Drag-and-drop attachments (future)
|
||||
- Attachment size limits / compression (future)
|
||||
- Attachment compression (future)
|
||||
|
||||
Reference in New Issue
Block a user