Files
MagnumOpus/docs/plans/2026-03-14-v0.4-gtd-tasks-design.md

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
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:<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: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:

-- 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:

-- 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.

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 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)