# Magnum Opus v0.4 — GTD Tasks & Unified Triage **Goal:** Add local task management (VTODO files) and GTD triage (defer, file, discard) to the email client. Emails and tasks are the same thing from the user's perspective — a unified list of items to process. No CalDAV sync yet (v0.6); tasks are local VTODO files with SQLite cache. **Builds on:** v0.3 functional email client (IMAP sync, SMTP compose, triage actions, ActionQueue). --- ## Core Concept Every item in the system — whether it arrived as an email or was created as a standalone task — is something the user must process. The user sees one unified stream, not "emails" and "tasks" as separate concepts. Triage actions work identically on both. **Two backing stores, one UX:** | Item type | Storage | Source | |-----------|---------|--------| | Email | IMAP message (existing) | Arrives via IMAP sync | | Task | VTODO `.ics` file + SQLite cache | Created by user | An email can spawn a task that belongs to it (linked via `ATTACH:mid:` in the VTODO). The thread detail view shows both emails and linked tasks in one timeline. --- ## Architecture Overview ``` ┌─────────────────────────────────────────────────────────────┐ │ SwiftUI Apps │ │ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │DeferPicker │ │TaskEditView│ │ SidebarView │ │ │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ └────────────────┼───────────────┘ │ │ ┌─────┴──────┐ │ │ │ ViewModels │ │ │ └─────┬──────┘ │ ├─────────────────────────┼────────────────────────────────────┤ │ MagnumOpusCore (Swift Package) │ │ ┌─────┴──────┐ │ │ │ SyncEngine │ │ │ │ │ │ │ ┌─────────┼─────────┐ │ │ │ │ ActionQueue │ │ │ │ │ + DeferralCheck │ │ │ │ └──┬──────────┬─────┘ │ │ │ ┌────────┘ └──────┐ │ │ │ │ SMTPClient │ │IMAPClient│ │ ┌──────────┐ ┌─────────┐ │ │ └────────────┘ └─────────┘ │ │ MailStore │ │TaskStore│ │ │ │ │(GRDB+FTS5)│ │(VTODO )│ │ │ │ └───────────┘ └─────────┘ │ └───────────────────────────────┼─────────────────────────────┘ │ ┌─────────────────┼──────────────┐ │ │ │ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │ IMAP │ │ SMTP │ │ ~/.../tasks │ │ Server │ │ Server │ │ (VTODO .ics)│ └──────────┘ └──────────┘ └─────────────┘ ``` --- ## Data Model ### Unified Item The UI operates on a union type that wraps both emails and tasks: ```swift public enum ItemSummary: Sendable, Identifiable { case email(MessageSummary) case task(TaskSummary) var id: String { ... } var title: String { ... } // subject or summary var date: Date? { ... } // message date or created date var isRead: Bool { ... } var deferral: Deferral? { ... } var labels: [LabelInfo] { ... } } ``` ### TaskSummary (new) ```swift public struct TaskSummary: Sendable, Identifiable { public var id: String public var accountId: String public var summary: String public var description: String? public var status: TaskStatus public var priority: Int // 0 = undefined, 1 = highest, 9 = lowest public var dueDate: Date? public var deferUntil: Date? // DTSTART in VTODO public var createdAt: Date public var linkedMessageId: String? // mid: reference to email public var categories: [String] } public enum TaskStatus: String, Sendable, Codable { case needsAction = "NEEDS-ACTION" case inProcess = "IN-PROCESS" case completed = "COMPLETED" case cancelled = "CANCELLED" } ``` ### Labels Labels are user-created tags attachable to any item. A label with `isProject = true` appears in the Projects sidebar perspective. ```swift public struct LabelInfo: Sendable, Identifiable { public var id: String public var name: String public var isProject: Bool public var color: String? // hex color, optional } ``` --- ## Schema Changes ### Migration v3_task: Task cache table ```sql CREATE TABLE task ( id TEXT PRIMARY KEY, accountId TEXT NOT NULL REFERENCES account(id), summary TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'NEEDS-ACTION', priority INTEGER NOT NULL DEFAULT 0, dueDate TEXT, deferUntil TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, filePath TEXT NOT NULL, linkedMessageId TEXT, isSomeday INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_task_status ON task(status); CREATE INDEX idx_task_deferUntil ON task(deferUntil); CREATE INDEX idx_task_dueDate ON task(dueDate); CREATE INDEX idx_task_linkedMessageId ON task(linkedMessageId); ``` `updatedAt` is populated from VTODO `LAST-MODIFIED` during parsing and updated alongside it during writes. ### Migration v3_label: Labels and item-label junction ```sql CREATE TABLE label ( id TEXT PRIMARY KEY, accountId TEXT NOT NULL REFERENCES account(id), name TEXT NOT NULL, isProject INTEGER NOT NULL DEFAULT 0, color TEXT ); CREATE UNIQUE INDEX idx_label_name ON label(accountId, name); CREATE TABLE itemLabel ( labelId TEXT NOT NULL REFERENCES label(id) ON DELETE CASCADE, itemType TEXT NOT NULL, -- "email" or "task" itemId TEXT NOT NULL, PRIMARY KEY (labelId, itemType, itemId) ); ``` ### Migration v3_deferral: Email deferral tracking The `deferral` table is for **emails only**. Tasks use `task.deferUntil` and `task.isSomeday` (derived from the VTODO file, survives cache rebuilds). ```sql CREATE TABLE deferral ( id TEXT PRIMARY KEY, messageId TEXT NOT NULL REFERENCES message(id) ON DELETE CASCADE, deferUntil TEXT, -- ISO8601 date, NULL = someday originalMailbox TEXT, -- restore to this mailbox on resurface createdAt TEXT NOT NULL, UNIQUE(messageId) ); CREATE INDEX idx_deferral_until ON deferral(deferUntil); ``` Add `isSomeday` to the task table (in v3_task migration): ```sql -- Added to the task CREATE TABLE above: isSomeday INTEGER NOT NULL DEFAULT 0, ``` --- ## TaskStore Module New module in MagnumOpusCore. Manages VTODO files and their SQLite cache. ### VTODO File Format Standard iCalendar, minimal properties for v0.4: ``` BEGIN:VCALENDAR VERSION:2.0 PRODID:-//MagnumOpus//v0.4//EN BEGIN:VTODO UID: DTSTAMP: CREATED: LAST-MODIFIED: SUMMARY:Task title DESCRIPTION:Optional body text STATUS:NEEDS-ACTION PRIORITY:0 DTSTART: DUE: CATEGORIES:ProjectName,AnotherLabel ATTACH:mid: END:VTODO END:VCALENDAR ``` ### File Storage Tasks stored at: `~/Library/Application Support/MagnumOpus//tasks/.ics` The directory is created on first task creation. When CalDAV arrives (v0.6), vdirsyncer syncs this directory with the server. ### Public API ```swift public struct TaskStore: Sendable { /// Directory where .ics files live private let taskDirectory: URL // File operations public func writeTask(_ task: TaskRecord) throws // write .ics + update cache public func deleteTask(id: String) throws // delete .ics + remove from cache public func rebuildCache() throws // scan directory, rebuild SQLite // VTODO parsing public static func parseVTODO(_ icsContent: String) -> TaskRecord? public static func formatVTODO(_ task: TaskRecord) -> String } ``` ### VTODO Parser Simple line-based parser (no external dependency). Handles: - Unfolding (lines starting with space/tab are continuations) - Property extraction: `SUMMARY`, `DESCRIPTION`, `STATUS`, `PRIORITY`, `DTSTART`, `DUE`, `CATEGORIES`, `ATTACH`, `UID`, `DTSTAMP`, `CREATED`, `LAST-MODIFIED` - Date parsing: `DTSTART;VALUE=DATE:20260315` and `DTSTART:20260315T090000Z` formats ### VTODO Formatter Writes standard iCalendar from a TaskRecord. Line folding at 75 octets per RFC 5545. --- ## Deferral System ### How Deferral Works **Deferring an email:** 1. Create `deferral` record (itemType="email", deferUntil=date, originalMailbox=current) 2. Email disappears from Inbox perspective (filtered by query) 3. Email stays in IMAP mailbox — no server-side change 4. When deferUntil ≤ now: delete deferral record → email reappears in Today **Deferring a task:** 1. Update VTODO `DTSTART` property, rewrite `.ics` file 2. Update `task.deferUntil` in SQLite cache 3. Task disappears from Inbox (query checks `task.deferUntil`), appears in Today when date arrives Note: Tasks do NOT use the `deferral` table. The `task.deferUntil` column is authoritative — it's derived from the VTODO file (source of truth) and survives cache rebuilds. The `deferral` table exists only for emails, which have no native defer field. **Deferring to Someday:** - For emails: create deferral record with `deferUntil = NULL` - For tasks: set a `someday` flag (use `DTSTART` with a sentinel value, or a dedicated `X-MAGNUM-SOMEDAY:TRUE` property in the VTODO, plus `task.isSomeday` in cache) - Item only appears in Someday perspective, never resurfaces automatically - User manually pulls it back by clearing the deferral/someday flag ### Resurfacing On each sync cycle (and on app launch), the SyncEngine checks both sources: ```sql -- Emails SELECT * FROM deferral WHERE deferUntil IS NOT NULL AND deferUntil <= datetime('now') -- Tasks SELECT * FROM task WHERE deferUntil IS NOT NULL AND deferUntil <= datetime('now') AND isSomeday = 0 ``` For each matched email deferral: - Delete the deferral record → email reappears in Inbox For each matched task: - Clear `deferUntil` in the task record and VTODO file → task reappears in Inbox **Resurfaced items go to Inbox, not Today.** The Today perspective shows items with `dueDate = today` (a deadline, not a deferral). This is the GTD distinction: a deferred item was "hidden until ready to process" — when it resurfaces, it goes back to the inbox for triage, just like a new email would. This is a simple poll, not a timer. Items may resurface slightly late (by up to the sync interval, default 5 minutes). Acceptable for v0.4. --- ## Sidebar Perspectives ### Perspective Definitions | Perspective | Content | Query Logic | |-------------|---------|-------------| | Inbox | Unprocessed items | Emails in INBOX (no deferral) + tasks with status=NEEDS-ACTION, deferUntil is NULL, isSomeday=0 | | Today | Due today | Emails/tasks with dueDate = today | | Upcoming | Future dated items | Items with dueDate > today, ordered by date | | Projects | Grouped by project | Items with a label where isProject=true, grouped by label name | | Someday | Parked indefinitely | Emails with deferral where deferUntil IS NULL + tasks with isSomeday=1 | | Archive | Done/filed | Emails in Archive folder + tasks with status=COMPLETED or CANCELLED | ### Unified Query Each perspective runs a query that unions emails and tasks: ```sql -- Example: Inbox perspective SELECT 'email' as itemType, id, subject as title, date, ... FROM message WHERE mailboxId = :inboxId AND id NOT IN (SELECT messageId FROM deferral) UNION ALL SELECT 'task' as itemType, id, summary as title, createdAt as date, ... FROM task WHERE status = 'NEEDS-ACTION' AND deferUntil IS NULL AND isSomeday = 0 ORDER BY date DESC ``` The unified query returns `ItemSummary` values. GRDB ValueObservation on both tables drives reactive UI updates. --- ## Triage Actions ### Unified Triage (works on any item) | Action | Key | Email Effect | Task Effect | |--------|-----|-------------|-------------| | Defer to date | `d` | Create deferral record, hide from inbox | Set DTSTART in VTODO + deferral record | | Defer to Someday | `⇧D` | Create deferral (deferUntil=NULL) | Same | | File to project | `p` | Attach project label | Set CATEGORIES in VTODO + label | | Discard | `⌫` | Archive email (move to Archive folder) | Set STATUS=CANCELLED in VTODO | | Complete | `⌘⏎` | Archive email | Set STATUS=COMPLETED in VTODO | ### Existing v0.3 Email Triage (unchanged) | Action | Key | Effect | |--------|-----|--------| | Archive | `e` | IMAP MOVE to Archive | | Delete | `⌫` | IMAP MOVE to Trash | | Flag/unflag | `s` | IMAP STORE ±\Flagged | | Read/unread | `⇧⌘U` | IMAP STORE ±\Seen | | Move to folder | `⇧⌘M` | IMAP MOVE | **v0.3 → v0.4 behavioral change for `⌫`:** In v0.3, `⌫` always meant "delete" (move to Trash). In v0.4, the behavior depends on the active perspective: - **GTD perspectives** (Inbox, Today, Upcoming, Projects, Someday): `⌫` means "discard" — archive the email or cancel the task. This matches GTD: discarding isn't destroying, it's removing from the action queue. - **Mailbox folder views** (when browsing a specific IMAP folder): `⌫` retains v0.3 behavior — "delete" (move to Trash). This is a deliberate change. The `e` key (archive) remains available in all views as an alternative. ### Defer Picker Pressing `d` opens a compact popover/sheet with: - **Tomorrow** — quick button - **Next Week** (next Monday) — quick button - **Pick Date** — date picker for custom date - **Someday** — equivalent to `⇧D` Selecting any option immediately defers and dismisses the picker. ### Create Task `⌘⇧N` opens a minimal form: - Title (required) - Body (optional, TextEditor) - Due date (optional, date picker) Creates a VTODO file, updates cache. Task appears in Inbox. ### Link Task to Email When viewing an email thread, `t` creates a new task pre-linked to the thread: - Title pre-filled with email subject - VTODO gets `ATTACH:mid:` - Task appears in Inbox AND in the thread's detail view --- ## Thread Detail Integration The thread detail view currently shows messages in chronological order. v0.4 extends this to also show linked tasks: ``` Thread: "Q1 Planning" ├── Email from Alice (Jan 5) ├── Your reply (Jan 6) ├── [Task] Draft Q1 budget — NEEDS-ACTION ├── Email from Bob (Jan 8) └── [Task] Review Bob's proposal — COMPLETED ✓ ``` Linked tasks are queried via: ```sql SELECT * FROM task WHERE linkedMessageId IN ( SELECT messageId FROM message JOIN threadMessage ON threadMessage.messageId = message.id WHERE threadMessage.threadId = :threadId ) ``` Tasks render with a distinct visual style (checkbox icon, status badge) but are inline in the timeline, sorted by `createdAt`. **Orphaned links:** If a linked email is deleted from the server during IMAP sync, the task's `linkedMessageId` becomes orphaned. The task remains visible as a standalone item — it simply no longer appears in any thread detail view. The `ATTACH:mid:` property stays in the VTODO file (harmless). No user notification needed. --- ## Label Management ### Creating Labels Labels are created inline — when filing to a project (`p`), the user sees existing labels and can type a new name. No separate label management UI in v0.4. ### Label Picker Pressing `p` opens a compact popover: - Searchable list of existing project labels - Type-to-create: entering a name that doesn't exist offers "Create project: " - Selecting a label attaches it and dismisses the picker ### Storage Labels are local-only in v0.4. - **Task labels** are written to the VTODO `CATEGORIES` property, so they survive a cache rebuild. During `rebuildCache()`, the TaskStore parses `CATEGORIES` from each VTODO file and populates the `itemLabel` junction table. - **Email labels** are stored only in the `itemLabel` junction table in SQLite. They do NOT survive a cache rebuild. This is a known v0.4 limitation — emails have no standard mechanism for custom labels outside of IMAP keywords (which not all servers support). Acceptable for v0.4; can be addressed later with IMAP keywords or a local manifest file. --- ## Testing Strategy ### New Test Targets - **TaskStoreTests** — VTODO parsing/formatting, file I/O, cache rebuild - Extended **MailStoreTests** — label CRUD, deferral CRUD, unified queries - Extended **SyncEngineTests** — deferral resurfacing on sync ### Key Test Scenarios 1. Parse standard VTODO → correct TaskRecord fields 2. Format TaskRecord → valid iCalendar output, round-trip fidelity 3. Create task → .ics file written, cache updated, appears in Inbox query 4. Defer email → deferral record created, email hidden from Inbox, appears in Today when date arrives 5. Defer task → VTODO DTSTART updated, deferral record created, same visibility behavior 6. Defer to Someday → appears only in Someday perspective 7. Resurface check → items with deferUntil ≤ now have deferral removed 8. File to project → label created/attached, item appears in Projects perspective 9. Link task to email → task appears in thread detail view 10. Complete task → STATUS=COMPLETED in file, appears in Archive 11. Cache rebuild → delete cache, rebuild from .ics files, all tasks restored --- ## Dependencies No new external dependencies. v0.4 uses the same packages as v0.3: | Package | Purpose | |---------|---------| | swift-nio-imap | IMAP protocol (existing) | | swift-nio | Networking (existing) | | swift-nio-ssl | TLS (existing) | | GRDB.swift | SQLite, FTS5, ValueObservation (existing) | VTODO parsing is built in-house (simple line-based format, ~200 lines). --- ## v0.4 Scope ### In - TaskStore module (VTODO parser/formatter, file I/O, SQLite cache) - Standalone task creation (⌘⇧N) - Task linked to email thread (t key from thread view) - Unified item model (ItemSummary wrapping email or task) - GTD triage: defer to date, defer to someday, file to project, discard, complete - Defer picker (tomorrow, next week, pick date, someday) - Deferral resurfacing on sync cycle - Labels with project flag - Label picker for filing - Sidebar perspectives: Inbox, Today, Upcoming, Projects, Someday, Archive - Thread detail shows linked tasks inline - Schema migrations for task, label, itemLabel, deferral tables ### Out (Deferred) - CalDAV sync (v0.6) - Task dependencies / blocking (v0.6) - Delegation / waiting loop (v0.5) - Forecast / calendar view (v0.6, needs VEVENT) - Tags / GTD contexts sidebar (v0.5+) - Defer to time-of-day / notifications (v0.5+) - Task search in FTS5 (v0.5) - Rich text task descriptions (future) - Multiple accounts (future)