20 KiB
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 |
|---|---|---|
| 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:<message-id> 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:
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)
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.
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
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
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).
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):
-- 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:<uuid>
DTSTAMP:<iso8601>
CREATED:<iso8601>
LAST-MODIFIED:<iso8601>
SUMMARY:Task title
DESCRIPTION:Optional body text
STATUS:NEEDS-ACTION
PRIORITY:0
DTSTART:<defer-until-date>
DUE:<due-date>
CATEGORIES:ProjectName,AnotherLabel
ATTACH:mid:<message-id>
END:VTODO
END:VCALENDAR
File Storage
Tasks stored at: ~/Library/Application Support/MagnumOpus/<accountId>/tasks/<taskId>.ics
The directory is created on first task creation. When CalDAV arrives (v0.6), vdirsyncer syncs this directory with the server.
Public API
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:20260315andDTSTART:20260315T090000Zformats
VTODO Formatter
Writes standard iCalendar from a TaskRecord. Line folding at 75 octets per RFC 5545.
Deferral System
How Deferral Works
Deferring an email:
- Create
deferralrecord (itemType="email", deferUntil=date, originalMailbox=current) - Email disappears from Inbox perspective (filtered by query)
- Email stays in IMAP mailbox — no server-side change
- When deferUntil ≤ now: delete deferral record → email reappears in Today
Deferring a task:
- Update VTODO
DTSTARTproperty, rewrite.icsfile - Update
task.deferUntilin SQLite cache - 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
somedayflag (useDTSTARTwith a sentinel value, or a dedicatedX-MAGNUM-SOMEDAY:TRUEproperty in the VTODO, plustask.isSomedayin 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:
-- 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
deferUntilin 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:
-- 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:<message-id> - 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:
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
CATEGORIESproperty, so they survive a cache rebuild. DuringrebuildCache(), the TaskStore parsesCATEGORIESfrom each VTODO file and populates theitemLabeljunction table. - Email labels are stored only in the
itemLabeljunction 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
- Parse standard VTODO → correct TaskRecord fields
- Format TaskRecord → valid iCalendar output, round-trip fidelity
- Create task → .ics file written, cache updated, appears in Inbox query
- Defer email → deferral record created, email hidden from Inbox, appears in Today when date arrives
- Defer task → VTODO DTSTART updated, deferral record created, same visibility behavior
- Defer to Someday → appears only in Someday perspective
- Resurface check → items with deferUntil ≤ now have deferral removed
- File to project → label created/attached, item appears in Projects perspective
- Link task to email → task appears in thread detail view
- Complete task → STATUS=COMPLETED in file, appears in Archive
- 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)