From e5f873bbb5c7c2de8114a6d5ab9c5a59a691944e Mon Sep 17 00:00:00 2001 From: Amir Moradi Date: Mon, 28 Sep 2026 01:25:11 +0200 Subject: [PATCH] feat(calendar): task CRUD + deterministic action-item extraction (ODIAA-2329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds VTODO task list/create/update through a trimmed, vendored calendar_tasks Experiment API (cal.manager/CalTodo), surfaced consistently across extension routes, CLI (`tb tasks`), and MCP tools, plus a deterministic (no LLM) action-item extractor for turning an email body into a checklist (`tb action-items`). Every new write path is access-controlled behind a default-false `tasksWrite` switch. Live-smoke-tested against a real, isolated Thunderbird instance (separate profile/port, --no-remote) per the required manual verification, which surfaced and fixed two real bugs in the Experiment API glue that the unit tests couldn't catch since they don't exercise real cal.manager objects: - `calIDateTime.getInTimezone(...).jsDate` is unreliable (always undefined) on any date handed back by the storage calendar after an adoptItem/ modifyItem round-trip; switched to reading `nativeTime` directly. - The WebExtension schema layer normalizes optional `CalendarTaskProperties` fields the caller didn't set to `null`, not `undefined`, so the partial-update logic's `!== undefined` checks were spuriously true for every field on every update — wiping title/priority and resetting due dates on updates that only meant to change one field. Checks now use `!= null`. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- SPEC.md | 58 ++++- access.example.json | 3 +- cli/src/cli.js | 86 ++++++ docs/ACCESS-CONTROL.md | 10 +- docs/COMMANDS.md | 24 +- .../calendar/parent/ext-calendar-calendars.js | 7 +- .../calendar/parent/ext-calendar-tasks.js | 190 ++++++++++++++ .../calendar/schema/calendar-tasks.json | 79 ++++++ extension/manifest.json | 12 +- extension/src/access-control.js | 10 +- extension/src/action-items.js | 122 +++++++++ extension/src/background.js | 42 +++ mcp/src/tools.js | 104 ++++++++ package.json | 2 +- skills/thunderbird-cli/SKILL.md | 39 ++- test/access-control.test.mjs | 58 ++++- test/action-items.test.mjs | 244 ++++++++++++++++++ test/mcp-test.mjs | 4 +- 19 files changed, 1069 insertions(+), 27 deletions(-) create mode 100644 extension/experiments/calendar/parent/ext-calendar-tasks.js create mode 100644 extension/experiments/calendar/schema/calendar-tasks.json create mode 100644 extension/src/action-items.js create mode 100644 test/action-items.test.mjs diff --git a/README.md b/README.md index eaac5bc..0eee145 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ Upstream baseline is [vitalio-sh/thunderbird-cli@`465613d`](https://github.com/v Planned, **not on `main`**: -- Calendar events/tasks CRUD, contacts write, notes and tasks, toward feature parity with [atbridge.ai](https://atbridge.ai). A read-only calendar-listing spike (`tb calendars`) landed first, using a chrome-privileged Experiment API since Thunderbird's WebExtension model has no calendar access — see [docs/decisions/calendar-backend.md](docs/decisions/calendar-backend.md) for the tradeoffs, including the ATN manual-review requirement this adds to every signed release that touches it. +- Calendar events CRUD, toward feature parity with [atbridge.ai](https://atbridge.ai). A read-only calendar-listing spike (`tb calendars`) landed first, and tasks CRUD (`tb tasks`, gated by `tasksWrite`) plus deterministic action-item extraction (`tb action-items`) have since landed too, using a chrome-privileged Experiment API since Thunderbird's WebExtension model has no calendar access — see [docs/decisions/calendar-backend.md](docs/decisions/calendar-backend.md) for the tradeoffs, including the ATN manual-review requirement this adds to every signed release that touches it. - Extension stability pass: audit against the known reconnect/backoff and lifecycle fixes, with regression tests. diff --git a/SPEC.md b/SPEC.md index 6e5123d..5ad52c9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -41,7 +41,7 @@ | **Extension** (background.js) | Inside Thunderbird | Pure WebExtension. Connects to bridge via WebSocket. Checks every request against the build-time access policy (`access-control.js`, see [docs/ACCESS-CONTROL.md](docs/ACCESS-CONTROL.md)), then translates it into `messenger.*` API calls | | **Bridge** (bridge.js) | Host (daemon) | Stateless HTTP↔WebSocket proxy. Receives HTTP from CLI/MCP, forwards to extension, returns response. No business logic | | **CLI** (tb) | Host or Docker | Thin HTTP client. Parses args, calls bridge, outputs JSON to stdout. 43 commands. Auto-starts bridge daemon if not running. Zero state | -| **MCP Server** (tb-mcp) | Host (alongside Claude Desktop) | Stdio-based MCP server. Exposes 16 curated tools to Claude Desktop and other MCP clients. Auto-starts bridge daemon if not running. Reuses CLI's HTTP client to call bridge | +| **MCP Server** (tb-mcp) | Host (alongside Claude Desktop) | Stdio-based MCP server. Exposes 26 curated tools to Claude Desktop and other MCP clients. Auto-starts bridge daemon if not running. Reuses CLI's HTTP client to call bridge | ### Request flow @@ -594,8 +594,8 @@ reaches a compose draft. Read-only, experimental — requires the `calendar` Experiment API vendored into the extension (see [docs/decisions/calendar-backend.md](docs/decisions/calendar-backend.md) for the tech-decision doc, including the ATN manual-review requirement this adds -to signed releases). Lists local Thunderbird calendars only; no event or task -read/write yet. +to signed releases). Lists local Thunderbird calendars only; no event read or +write yet. ```bash # List calendars @@ -603,6 +603,45 @@ tb calendars # Returns: [{id, name, type, url, readOnly, enabled, color}] ``` +### 20. Tasks + +Calendar tasks (VTODO), through the vendored `calendar.tasks` Experiment API +(same `calendar` Experiment as Calendars above — see +[docs/decisions/calendar-backend.md](docs/decisions/calendar-backend.md)). +Writes are gated by the `tasksWrite` access switch (default off, see +[docs/ACCESS-CONTROL.md](docs/ACCESS-CONTROL.md)). + +```bash +# List tasks, optionally filtered by calendar or completion state +tb tasks list [--calendar ] [--completed] [--pending] +# Returns: [{id, calendarId, title, due, allDay, priority, completed, description, source}] + +# Create a task (requires tasksWrite) +tb tasks create --calendar --title [--due <date>] [--all-day] \ + [--priority <n>] [--description <description>] [--source <messageId>] + +# Update a task's fields (requires tasksWrite) +tb tasks update <taskId> --calendar <calendarId> [--title <title>] [--due <date>] [--all-day] \ + [--priority <n>] [--description <description>] [--source <messageId>] [--completed] [--pending] +``` + +`priority` follows RFC 5545: 0 = undefined, 1-4 = high, 5 = normal, 6-9 = low. +`source` links a task back to the email it was created from, mirroring Notes' +`--source` convention. + +### 21. Action Items + +Deterministic (no LLM) extraction of candidate action items from a message +body — checklist/bullet syntax, imperative sentences ("Send the report..."), +request phrases ("please...", "can you...", "need to..."), and "by \<date\>" +due-date hints. Pure function (`extension/src/action-items.js`), unit tested +against fixtures independently of the bridge. + +```bash +tb action-items <messageId> +# Returns: {items: [{text, checked, dueHint}], markdown} +``` + --- ## Implementation Details @@ -931,7 +970,9 @@ Note: Extension development cannot happen in Docker. Edit `extension/src/backgro - [x] `tb extension-reload` + `/bridge/events` (#17) - [x] Opt-in v2 output format, `--output-version 2` (#19) - [x] Calendar read-only spike (`tb calendars`, Experiment API) — see [docs/decisions/calendar-backend.md](docs/decisions/calendar-backend.md) (ODIAA-2327) -- [ ] Calendar events/tasks CRUD, contacts write, notes, tasks — roadmap, see [docs/PLAN.md](docs/PLAN.md) +- [x] Contacts write (`contactsWrite`), Notes (`tb notes`) +- [x] Tasks CRUD (`tasksWrite`) + deterministic action-item extraction (`tb tasks`, `tb action-items`) (ODIAA-2329) +- [ ] Calendar events CRUD — roadmap, see [docs/PLAN.md](docs/PLAN.md) --- @@ -1194,15 +1235,16 @@ Claude Desktop ──stdio JSON-RPC──> tb-mcp ──HTTP──> Bridge ─ The MCP server: - Has **no state** — every tool call is independent - **Reuses** `cli/src/client.js` for HTTP calls (no code duplication) -- Exposes **16 high-level tools** rather than all 43 CLI commands +- Exposes **26 high-level tools** rather than all CLI commands - Defaults to **safe behavior** (compose/reply/forward/edit → draft, not send) ### Tool Catalog -The 16 MCP tools are **curated** for AI agent use cases. Bulk admin operations (folder CRUD, identity management, bulk delete, etc.) are intentionally excluded — they belong in the CLI for explicit human control. +The 26 MCP tools are **curated** for AI agent use cases. Bulk admin operations (folder CRUD, identity management, bulk delete, etc.) are intentionally excluded — they belong in the CLI for explicit human control. | MCP Tool | Maps to CLI commands | |----------|---------------------| +| `calendar_list` | `tb calendars` | | `email_stats` | `tb stats` | | `email_search` | `tb search` | | `email_list` | `tb list` | @@ -1224,6 +1266,10 @@ The 16 MCP tools are **curated** for AI agent use cases. Bulk admin operations ( | `contact_search` | `tb contacts`, `tb contacts-search` | | `contact_create` | `tb contacts create` (requires `contactsWrite`) | | `contact_update` | `tb contacts update` (requires `contactsWrite`) | +| `task_list` | `tb tasks list` | +| `task_create` | `tb tasks create` (requires `tasksWrite`) | +| `task_update` | `tb tasks update` (requires `tasksWrite`) | +| `email_action_items` | `tb action-items` | Notes are also exposed as MCP **resources** (`note://<name>`, `text/markdown`) so clients that browse resources rather than call tools can list and read the diff --git a/access.example.json b/access.example.json index 454d669..e7bcef4 100644 --- a/access.example.json +++ b/access.example.json @@ -12,5 +12,6 @@ "folderCreate": true, "folderRename": true, "folderDelete": false, - "contactsWrite": false + "contactsWrite": false, + "tasksWrite": false } diff --git a/cli/src/cli.js b/cli/src/cli.js index 40850ac..7f49150 100755 --- a/cli/src/cli.js +++ b/cli/src/cli.js @@ -1339,6 +1339,92 @@ notes output(data, getFormat(g), getOutputOpts(g)); })); +// ─── Tasks (ODIAA-2329, VTODO through the calendar_tasks Experiment API) ──── + +function taskPropertiesFromOpts(opts) { + const properties = {}; + if (opts.title) properties.title = opts.title; + if (opts.due) properties.due = opts.due; + if (opts.allDay) properties.allDay = true; + if (opts.priority !== undefined) properties.priority = parseInt(opts.priority, 10); + if (opts.description) properties.description = opts.description; + if (opts.source) properties.source = opts.source; + return properties; +} + +const tasks = program.command("tasks").description("Calendar tasks (VTODO)"); + +tasks + .command("list") + .description("List tasks, filtered by calendar/completion") + .option("--calendar <calendarId>", "limit to one calendar") + .option("--completed", "only completed tasks") + .option("--pending", "only pending (not completed) tasks") + .action(run(async (opts) => { + const g = program.opts(); + const body = {}; + if (opts.calendar) body.calendarId = opts.calendar; + if (opts.completed) body.completed = true; + else if (opts.pending) body.completed = false; + const data = await api("POST", "/tasks/list", body, getTimeout(g)); + output(data, getFormat(g), getOutputOpts(g)); + })); + +tasks + .command("create") + .description("Create a task (requires tasksWrite access)") + .requiredOption("--calendar <calendarId>", "target calendar") + .requiredOption("--title <title>", "task title") + .option("--due <date>", "due date (ISO date/time, or YYYY-MM-DD with --all-day)") + .option("--all-day", "all-day due date") + .option("--priority <n>", "priority 0-9 (1-4 high, 5 normal, 6-9 low)") + .option("--description <description>", "description") + .option("--source <messageId>", "message id this task was created from") + .action(run(async (opts) => { + const g = program.opts(); + const properties = taskPropertiesFromOpts(opts); + const data = await api("POST", "/tasks/create", { calendarId: opts.calendar, ...properties }, getTimeout(g)); + output(data, getFormat(g), getOutputOpts(g)); + })); + +tasks + .command("update <taskId>") + .description("Update a task's fields (tasksWrite required)") + .requiredOption("--calendar <calendarId>", "calendar the task belongs to") + .option("--title <title>", "task title") + .option("--due <date>", "due date (ISO date/time, or YYYY-MM-DD with --all-day)") + .option("--all-day", "all-day due date") + .option("--priority <n>", "priority 0-9 (1-4 high, 5 normal, 6-9 low)") + .option("--description <description>", "description") + .option("--source <messageId>", "message id this task was created from") + .option("--completed", "mark completed") + .option("--pending", "mark not completed") + .action(run(async (taskId, opts) => { + const g = program.opts(); + const fmt = getFormat(g); + const outOpts = getOutputOpts(g); + const properties = taskPropertiesFromOpts(opts); + if (opts.completed) properties.completed = true; + else if (opts.pending) properties.completed = false; + if (Object.keys(properties).length === 0) { + outputError({ message: "Provide at least one task property to update", code: "INVALID_ARGS" }, fmt, outOpts); + return; + } + const data = await api("POST", "/tasks/update", { calendarId: opts.calendar, id: taskId, ...properties }, getTimeout(g)); + output(data, fmt, outOpts); + })); + +// ─── Action items (ODIAA-2329, deterministic extraction from an email body) ─ + +program + .command("action-items <messageId>") + .description("Extract candidate action items from a message body as a Markdown checklist (deterministic, no LLM)") + .action(run(async (messageId) => { + const g = program.opts(); + const data = await api("POST", `/messages/${messageId}/action-items`, {}, getTimeout(g)); + output(data, getFormat(g), getOutputOpts(g)); + })); + // ─── Parse & Run ────────────────────────────────────────────────────── program.parseAsync(process.argv).catch(err => { diff --git a/docs/ACCESS-CONTROL.md b/docs/ACCESS-CONTROL.md index 35ac5dc..01aeee0 100644 --- a/docs/ACCESS-CONTROL.md +++ b/docs/ACCESS-CONTROL.md @@ -25,6 +25,7 @@ closed instead of shipping unrestricted. | `folderRename` | `true` | Renaming a folder | | `folderDelete` | `false` | `tb folder-delete` (removes the folder and potentially its contents) | | `contactsWrite` | `false` | `tb contacts create`/`tb contacts update`, MCP `contact_create`/`contact_update` | +| `tasksWrite` | `false` | `tb tasks create`/`tb tasks update`, MCP `task_create`/`task_update` | With a switch off, the route returns `FORBIDDEN: '<key>' is disabled by the add-on access policy ...` before touching the mailbox. Routes with no switch at all (search, list, read, @@ -36,10 +37,11 @@ operations that create, mutate, or send. Bridge-local endpoints (`/bridge/status gated rather than removed from the extension entirely. Every write switch that predates ODIAA-2306 defaults on, so an unmodified install behaves exactly as before this policy existed; operators who want a more restrictive posture set the switches they want to disable -in their config file. `contactsWrite` is the exception: like `delete`/`folderDelete`, new -*write* switches default off (opt-in), matching atbridge's off-by-default write posture — -enable it explicitly to let `tb contacts create`/`tb contacts update` (or the equivalent MCP -tools) mutate an address book. +in their config file. `contactsWrite` and `tasksWrite` are the exception: like +`delete`/`folderDelete`, new *write* switches default off (opt-in), matching atbridge's +off-by-default write posture — enable them explicitly to let `tb contacts create`/`tb +contacts update` or `tb tasks create`/`tb tasks update` (or the equivalent MCP tools) mutate +an address book or a task. ## How a request is checked diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index baa5e4a..e1b7fde 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -278,12 +278,34 @@ tb notes to-draft <name> --to <address> [--cc <a>] [--bcc <a>] [--subject <t>] [ ## Calendars Read-only, experimental (see `docs/decisions/calendar-backend.md`). Lists local Thunderbird -calendars only; no event/task read or write yet. +calendars only; no event read or write yet. ```bash tb calendars # list calendars ``` +## Tasks + +Calendar tasks (VTODO), through the vendored `calendar.tasks` Experiment API (see +`docs/decisions/calendar-backend.md`). + +```bash +tb tasks list [--calendar <calendarId>] [--completed] [--pending] # list tasks + +# Requires tasksWrite access switch (default off, see docs/ACCESS-CONTROL.md) +tb tasks create --calendar <calendarId> --title <title> [--due <date>] [--all-day] \ + [--priority <n>] [--description <description>] [--source <messageId>] +tb tasks update <taskId> --calendar <calendarId> [--title <title>] [--due <date>] [--all-day] \ + [--priority <n>] [--description <description>] [--source <messageId>] [--completed] [--pending] +``` + +## Action Items + +```bash +tb action-items <messageId> # deterministic (no LLM) extraction of candidate action items + # from a message body, rendered as a Markdown checklist +``` + ## Bulk Operations ```bash diff --git a/extension/experiments/calendar/parent/ext-calendar-calendars.js b/extension/experiments/calendar/parent/ext-calendar-calendars.js index 187f685..18d2668 100644 --- a/extension/experiments/calendar/parent/ext-calendar-calendars.js +++ b/extension/experiments/calendar/parent/ext-calendar-calendars.js @@ -52,10 +52,11 @@ this.calendar_calendars = class extends ExtensionAPI { }; } - // This is currently the only experiment_apis entry in the manifest, so it owns the - // startup-cache invalidation Thunderbird's Experiments docs require on non-shutdown + // Thunderbird's Experiments docs require a startup-cache invalidation on non-shutdown // unload (disable/update/reload) — see - // https://developer.thunderbird.net/add-ons/mailextensions/experiments. + // https://developer.thunderbird.net/add-ons/mailextensions/experiments. This add-on has + // two experiment_apis entries (calendar_calendars, calendar_tasks) but only needs one + // invalidation for the whole add-on; this one (the first one registered) owns it. onShutdown(isAppShutdown) { if (isAppShutdown) return; Services.obs.notifyObservers(null, "startupcache-invalidate"); diff --git a/extension/experiments/calendar/parent/ext-calendar-tasks.js b/extension/experiments/calendar/parent/ext-calendar-tasks.js new file mode 100644 index 0000000..38aa9ea --- /dev/null +++ b/extension/experiments/calendar/parent/ext-calendar-tasks.js @@ -0,0 +1,190 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * ODIAA-2329: task (VTODO) CRUD slice of the `calendar_items` Experiment API drafted at + * https://github.com/thunderbird/webext-experiments (calendar/experiments/calendar/parent/ + * ext-calendar-items.js, `calendar.items` namespace), pinned commit + * b7f7cb3e76807903a785a03784d6e7df7b213f21. + * + * Adapted, not copied verbatim: + * 1. Given its own namespace (`calendar.tasks`, this file) rather than folding into upstream's + * combined `calendar.items` (events + tasks): the event half of that surface is a separate, + * concurrently in-flight issue (ODIAA-2306c) not yet on `main`, and this PR branches from + * `main` alone. A future cleanup can merge the two namespaces once both have landed. + * 2. Trimmed to query/create/update on tasks only — no `get`, `remove`, `move`, `getCurrent`, + * or the onCreated/onUpdated/onRemoved/onAlarm listeners; outside this issue's scope + * (`POST /tasks/list|create|update`, no delete route). + * 3. Dropped upstream's calendar-*provider* metadata/cache branches (isOwnCalendar, + * getCachedCalendar, isCachedCalendar, the "#cache" calendar-id suffix): those exist so a + * calendar *implemented by* the extension itself (calendar.provider) can round-trip its + * own metadata. This add-on is a plain consumer of Thunderbird's built-in calendars + * (storage/caldav/ics), never a provider, so none of that applies. + * 4. Replaced upstream's ical/jcal round-trip contract (propsToItem/convertItem, built on + * ICAL.js and a full RFC 5545 blob per item) with plain field getters/setters — title, + * due, allDay, priority, completed, description — plus one custom `X-THUNDERBIRD-CLI- + * SOURCE-MESSAGE-ID` property (exposed as `source`) so a task can link back to the email + * it was created from, per this issue's scope. The underlying calendar.manager / + * calICalendar calls (getItemsAsArray, adoptItem, modifyItem, getItem) are unchanged from + * upstream. + * + * Everything else — the `.sys.mjs`/`ChromeUtils.importESModule` imports and `cal.manager` + * accessor — matches upstream `main` at the pinned commit. + */ + +var { ExtensionCommon } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); +var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); +var { CalTodo } = ChromeUtils.importESModule("resource:///modules/CalTodo.sys.mjs"); + +var { ExtensionAPI } = ExtensionCommon; + +const SOURCE_MESSAGE_ID_PROPERTY = "X-THUNDERBIRD-CLI-SOURCE-MESSAGE-ID"; + +function toCalDateTime(value, allDay) { + if (allDay) { + const [y, m, d] = String(value).slice(0, 10).split("-").map(Number); + const dt = cal.dtz.jsDateToDateTime(new Date(Date.UTC(y, m - 1, d)), cal.dtz.floating); + dt.isDate = true; + return dt; + } + const jsDate = new Date(value); + if (Number.isNaN(jsDate.getTime())) { + throw new Error(`Invalid date/time: ${value}`); + } + return cal.dtz.jsDateToDateTime(jsDate, cal.dtz.UTC); +} + +function fromCalDateTime(dt) { + if (!dt) return null; + if (dt.isDate) { + const y = String(dt.year).padStart(4, "0"); + const m = String(dt.month + 1).padStart(2, "0"); + const d = String(dt.day).padStart(2, "0"); + return `${y}-${m}-${d}`; + } + // `dt.jsDate`/`dt.getInTimezone(...).jsDate` are unreliable (always undefined) on a + // calIDateTime handed back by the storage calendar after an adoptItem/modifyItem round-trip, + // even for a date that was set correctly — `nativeTime` (epoch microseconds) is always present. + const ms = Number(dt.nativeTime) / 1000; + if (!Number.isFinite(ms)) return null; + // The storage calendar also round-trips an *unset* DUE/COMPLETED as an epoch-ish sentinel + // (epoch shifted by the local UTC offset) rather than as null. No real due/completed date is + // ever within a day of the Unix epoch, so treat that range as "no date". + if (Math.abs(ms) < 24 * 60 * 60 * 1000) return null; + return new Date(ms).toISOString(); +} + +function markCompletion(item, completed) { + if (completed) { + item.status = "COMPLETED"; + item.setProperty("PERCENT-COMPLETE", 100); + item.completedDate = cal.dtz.jsDateToDateTime(new Date(), cal.dtz.UTC); + } else { + item.status = "NEEDS-ACTION"; + item.setProperty("PERCENT-COMPLETE", 0); + item.completedDate = null; + } +} + +// The WebExtension schema layer normalizes the `CalendarTaskProperties` object by filling every +// optional field the caller didn't set with `null` before it reaches this Experiment API — it's +// never actually `undefined` here. So "did the caller mean to set this" must check `!= null` +// (which also still treats explicit `0`/`false`/`""` as real values), not `!== undefined`. +function propsToItem(properties) { + const item = new CalTodo(); + item.title = properties.title || ""; + if (properties.due != null) item.dueDate = toCalDateTime(properties.due, !!properties.allDay); + if (properties.description) item.setProperty("DESCRIPTION", properties.description); + if (properties.priority != null) item.priority = properties.priority; + if (properties.source) item.setProperty(SOURCE_MESSAGE_ID_PROPERTY, properties.source); + if (properties.status != null) item.status = properties.status; + if (properties.completed != null) markCompletion(item, !!properties.completed); + return item; +} + +// Partial update: unlike upstream's propsToItem (a full replacement built from a complete +// ical/jcal blob every time), this only touches fields the caller actually sent, on a clone +// of the existing item — so an update that only changes `completed` doesn't drop the task's +// due date, priority, etc. +function applyPropsToItem(item, properties) { + if (properties.title != null) item.title = properties.title; + if (properties.due != null) { + const allDay = properties.allDay != null ? !!properties.allDay : !!(item.dueDate?.isDate); + item.dueDate = toCalDateTime(properties.due, allDay); + } + if (properties.description != null) item.setProperty("DESCRIPTION", properties.description || ""); + if (properties.priority != null) item.priority = properties.priority; + if (properties.source != null) item.setProperty(SOURCE_MESSAGE_ID_PROPERTY, properties.source || ""); + if (properties.completed != null) markCompletion(item, !!properties.completed); + else if (properties.status != null) item.status = properties.status; + return item; +} + +function convertItem(item) { + if (!item) return null; + const percentComplete = Number(item.getProperty("PERCENT-COMPLETE") || 0); + return { + id: item.id, + calendarId: item.calendar.id, + calendarName: item.calendar.name, + title: item.title || "", + description: item.getProperty("DESCRIPTION") || "", + due: fromCalDateTime(item.dueDate), + allDay: !!item.dueDate?.isDate, + priority: item.priority || 0, + completed: item.status === "COMPLETED" || percentComplete >= 100, + completedDate: fromCalDateTime(item.completedDate), + status: item.status || "NEEDS-ACTION", + source: item.getProperty(SOURCE_MESSAGE_ID_PROPERTY) || undefined, + }; +} + +function resolveCalendar(calendarId) { + const calendar = cal.manager.getCalendarById(calendarId); + if (!calendar) throw new Error(`Invalid calendar: ${calendarId}`); + return calendar; +} + +this.calendar_tasks = class extends ExtensionAPI { + getAPI() { + return { + calendar: { + tasks: { + async query({ calendarId, completed } = {}) { + const calendars = calendarId + ? [resolveCalendar(calendarId)] + : cal.manager.getCalendars().filter((calendar) => !calendar.getProperty("disabled")); + + let filter = Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + filter |= completed === true + ? Ci.calICalendar.ITEM_FILTER_COMPLETED_YES + : completed === false + ? Ci.calICalendar.ITEM_FILTER_COMPLETED_NO + : Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL; + + const results = await Promise.all( + calendars.map((calendar) => calendar.getItemsAsArray(filter, 0, null, null)) + ); + return results.flat().map(convertItem); + }, + async create(calendarId, properties) { + const calendar = resolveCalendar(calendarId); + const item = propsToItem(properties); + item.calendar = calendar.superCalendar; + const createdItem = await calendar.adoptItem(item); + return convertItem(createdItem); + }, + async update(calendarId, id, properties) { + const calendar = resolveCalendar(calendarId); + const oldItem = await calendar.getItem(id); + if (!oldItem) throw new Error(`Could not find task ${id}`); + const newItem = applyPropsToItem(oldItem.clone(), properties); + const modifiedItem = await calendar.modifyItem(newItem, oldItem); + return convertItem(modifiedItem); + }, + }, + }, + }; + } +}; diff --git a/extension/experiments/calendar/schema/calendar-tasks.json b/extension/experiments/calendar/schema/calendar-tasks.json new file mode 100644 index 0000000..4ef523d --- /dev/null +++ b/extension/experiments/calendar/schema/calendar-tasks.json @@ -0,0 +1,79 @@ +[ + { + "namespace": "calendar.tasks", + "types": [ + { + "id": "CalendarTask", + "type": "object", + "properties": { + "id": { "type": "string" }, + "calendarId": { "type": "string" }, + "calendarName": { "type": "string" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "due": { "type": "string", "optional": true, "description": "ISO 8601 date-time (UTC), or a plain YYYY-MM-DD date when allDay is true" }, + "allDay": { "type": "boolean" }, + "priority": { "type": "integer", "description": "0 (undefined) to 9 (lowest); 1-4 high, 5 normal, 6-9 low, per RFC 5545" }, + "completed": { "type": "boolean" }, + "completedDate": { "type": "string", "optional": true, "description": "ISO 8601 date-time (UTC); set when completed is true" }, + "status": { "type": "string", "enum": ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"] }, + "source": { "type": "string", "optional": true, "description": "Message id this task was created from, if any" } + } + }, + { + "id": "CalendarTaskProperties", + "type": "object", + "properties": { + "title": { "type": "string", "optional": true }, + "description": { "type": "string", "optional": true }, + "due": { "type": "string", "optional": true }, + "allDay": { "type": "boolean", "optional": true }, + "priority": { "type": "integer", "optional": true }, + "completed": { "type": "boolean", "optional": true }, + "status": { "type": "string", "enum": ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"], "optional": true }, + "source": { "type": "string", "optional": true } + } + } + ], + "functions": [ + { + "name": "query", + "async": true, + "type": "function", + "description": "List tasks across one or all calendars, optionally filtered by completion state.", + "parameters": [ + { + "type": "object", + "name": "queryInfo", + "optional": true, + "properties": { + "calendarId": { "type": "string", "optional": true, "description": "Limit to one calendar; all calendars if omitted" }, + "completed": { "type": "boolean", "optional": true, "description": "Limit to completed or pending tasks; all tasks if omitted" } + } + } + ] + }, + { + "name": "create", + "async": true, + "type": "function", + "description": "Create a task in a calendar.", + "parameters": [ + { "type": "string", "name": "calendarId" }, + { "$ref": "CalendarTaskProperties", "name": "properties" } + ] + }, + { + "name": "update", + "async": true, + "type": "function", + "description": "Update the given fields of an existing task.", + "parameters": [ + { "type": "string", "name": "calendarId" }, + { "type": "string", "name": "id" }, + { "$ref": "CalendarTaskProperties", "name": "properties" } + ] + } + ] + } +] diff --git a/extension/manifest.json b/extension/manifest.json index 9cfa1bf..6669c92 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Thunderbird CLI Enhanced", - "version": "2.2.0", + "version": "2.3.0", "description": "Connects Thunderbird to CLI agents via WebSocket bridge", "author": "thunderbird-cli-enhanced", "browser_specific_settings": { @@ -42,7 +42,7 @@ "tabs" ], "background": { - "scripts": ["src/access-config.js", "src/access-control.js", "src/thread-utils.js", "src/background.js"] + "scripts": ["src/access-config.js", "src/access-control.js", "src/thread-utils.js", "src/action-items.js", "src/background.js"] }, "experiment_apis": { "calendar_calendars": { @@ -52,6 +52,14 @@ "script": "experiments/calendar/parent/ext-calendar-calendars.js", "paths": [["calendar", "calendars"]] } + }, + "calendar_tasks": { + "schema": "experiments/calendar/schema/calendar-tasks.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiments/calendar/parent/ext-calendar-tasks.js", + "paths": [["calendar", "tasks"]] + } } } } diff --git a/extension/src/access-control.js b/extension/src/access-control.js index 672dcaa..c399d5f 100644 --- a/extension/src/access-control.js +++ b/extension/src/access-control.js @@ -25,6 +25,7 @@ const ACCESS_DEFAULTS = Object.freeze({ folderRename: true, folderDelete: false, contactsWrite: false, + tasksWrite: false, }); function normalizeAccessPolicy(config) { @@ -56,7 +57,7 @@ const UNGATED_GET = /^\/(health|access|accounts(?:\/[^/]+(?:\/folders)?)?|identi const UNGATED_POST = new Set([ "/folders/info", "/messages/search", "/messages/list", "/messages/read-batch", "/messages/fetch", "/stats", "/recent", "/contacts/search", "/sync", "/sync/status", - "/bulk/fetch", "/extension/reload", + "/bulk/fetch", "/extension/reload", "/tasks/list", ]); // POST routes with one fixed policy switch each. @@ -73,6 +74,8 @@ const WRITE_PATHS = Object.freeze({ "/folders/delete": "folderDelete", "/contacts/create": "contactsWrite", "/contacts/update": "contactsWrite", + "/tasks/create": "tasksWrite", + "/tasks/update": "tasksWrite", }); function enforceAccess(method, path, body, policy = ACCESS_POLICY) { @@ -93,6 +96,11 @@ function enforceAccess(method, path, body, policy = ACCESS_POLICY) { return requireAccess("downloadAttachments"); } + // Deterministic, read-only extraction — no mutation, so no policy switch needed. + if (method === "POST" && /^\/messages\/\d+\/action-items$/.test(path)) { + return; + } + if (method === "POST" && (path === "/compose" || path === "/compose/edit" || path === "/reply" || path === "/forward")) { requireAccess("compose"); if (body?.send) requireAccess("send"); diff --git a/extension/src/action-items.js b/extension/src/action-items.js new file mode 100644 index 0000000..a3e4662 --- /dev/null +++ b/extension/src/action-items.js @@ -0,0 +1,122 @@ +/** + * Deterministic (no LLM) extraction of candidate action items from a plain-text message + * body into a Markdown checklist. + * + * Loaded as a background script before background.js (see manifest.json), so these are plain + * globals rather than ES exports. test/action-items.test.mjs loads this file in a Node vm. + */ + +const IMPERATIVE_VERBS = [ + "send", "review", "update", "confirm", "schedule", "call", "email", "complete", + "finish", "prepare", "submit", "check", "attach", "sign off", "sign", "approve", + "provide", "share", "follow up", "followup", "reply", "respond", "add", "remove", + "fix", "create", "set up", "setup", "book", "arrange", "verify", "upload", + "download", "draft", "cancel", "reschedule", "renew", "pay", "order", "ship", + "deliver", "install", "configure", "test", "deploy", "merge", "let me know", + "make sure", "ensure", "finalize", "file", "print", +]; + +const REQUEST_PATTERNS = [ + /\bplease\b/i, + /\bcan you\b/i, + /\bcould you\b/i, + /\bwould you\b/i, + /\bneed(?:s)? to\b/i, +]; + +const DATE_HINT_PATTERN = + /\bby\s+((?:next\s+)?(?:mon|tues?|wednes?|thurs?|fri|satur|sun)day|today|tomorrow|tonight|end of (?:day|week|month)|\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?(?:,?\s+\d{4})?)/i; + +// Drop quoted reply history (RFC 3676 leading '>') and anything from a signature delimiter +// ("-- " on its own line) onward, so items aren't re-extracted from earlier thread layers. +function stripQuotedAndSignature(body) { + const lines = String(body || "").split(/\r?\n/); + const kept = []; + for (const line of lines) { + if (/^\s*>/.test(line)) continue; + if (/^--\s*$/.test(line)) break; + kept.push(line); + } + return kept.join("\n"); +} + +function startsWithImperative(text) { + const lower = text.trim().toLowerCase(); + return IMPERATIVE_VERBS.some((verb) => lower === verb || lower.startsWith(`${verb} `)); +} + +function hasRequestPhrase(text) { + return REQUEST_PATTERNS.some((re) => re.test(text)); +} + +function extractDateHint(text) { + const match = text.match(DATE_HINT_PATTERN); + return match ? match[1] : null; +} + +function cleanBulletText(line) { + return line + .replace(/^\s*[-*+]\s*(\[[ xX]\]\s*)?/, "") + .replace(/^\s*\d+[.)]\s*/, "") + .trim(); +} + +// Split a paragraph into sentences so a single imperative/request phrase inside a longer +// line doesn't pull the whole paragraph in as one item. +function splitSentences(text) { + return text + .split(/(?<=[.!?])\s+(?=[A-Z(])/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function extractActionItems(body) { + const lines = stripQuotedAndSignature(body).split(/\r?\n/); + const items = []; + const seen = new Set(); + + const addItem = (text, checked) => { + const trimmed = text.trim().replace(/[.!?\s]+$/, ""); + if (trimmed.length < 3) return; + const key = trimmed.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + items.push({ text: trimmed, checked: !!checked, dueHint: extractDateHint(text) }); + }; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + const checklistMatch = /^\s*[-*+]\s*\[([ xX])\]\s*(.+)/.exec(line); + if (checklistMatch) { + addItem(checklistMatch[2], /[xX]/.test(checklistMatch[1])); + continue; + } + + const bulletMatch = /^\s*(?:[-*+]|\d+[.)])\s+(.+)/.exec(line); + if (bulletMatch) { + addItem(cleanBulletText(line)); + continue; + } + + for (const sentence of splitSentences(line)) { + if (startsWithImperative(sentence) || hasRequestPhrase(sentence)) { + addItem(sentence); + } + } + } + + return items; +} + +function actionItemsToMarkdown(items) { + if (!items.length) return ""; + return items + .map((item) => { + const box = item.checked ? "[x]" : "[ ]"; + const suffix = item.dueHint ? ` (by ${item.dueHint})` : ""; + return `- ${box} ${item.text}${suffix}`; + }) + .join("\n"); +} diff --git a/extension/src/background.js b/extension/src/background.js index 4217b98..dfbb81b 100644 --- a/extension/src/background.js +++ b/extension/src/background.js @@ -618,6 +618,17 @@ async function handleRequest({ method, path, body }) { return { thread, count: thread.length }; } + // Deterministic (no LLM) candidate action-item extraction — see extension/src/action-items.js + // (pure function, unit tested independently of this bridge). + const actionItemsMatch = path.match(/^\/messages\/(\d+)\/action-items$/); + if (actionItemsMatch && method === "POST") { + const msgId = parseInt(actionItemsMatch[1]); + const full = await messenger.messages.getFull(msgId); + const text = extractParts(full).text || ""; + const items = extractActionItems(text); + return { items, markdown: actionItemsToMarkdown(items) }; + } + // Read message (default — must be AFTER all /messages/:id/* sub-routes) const msgMatch = path.match(/^\/messages\/(\d+)$/); if (msgMatch && method === "GET") { @@ -972,6 +983,37 @@ async function handleRequest({ method, path, body }) { return calendars; } + // ─── Tasks (ODIAA-2329, VTODO through the calendar_tasks Experiment API) ──── + // Requires the calendar_tasks Experiment API (manifest experiment_apis, see + // extension/experiments/calendar/) to have loaded successfully. + + if (path === "/tasks/list" && method === "POST") { + if (!messenger.calendar?.tasks?.query) { + return { error: "calendar experiment not loaded" }; + } + const { calendarId, completed } = body || {}; + return await messenger.calendar.tasks.query({ calendarId, completed }); + } + + if (path === "/tasks/create" && method === "POST") { + if (!messenger.calendar?.tasks?.create) { + return { error: "calendar experiment not loaded" }; + } + const { calendarId, ...properties } = body || {}; + if (!calendarId) return { error: "calendarId is required" }; + if (!properties.title) return { error: "title is required" }; + return await messenger.calendar.tasks.create(calendarId, properties); + } + + if (path === "/tasks/update" && method === "POST") { + if (!messenger.calendar?.tasks?.update) { + return { error: "calendar experiment not loaded" }; + } + const { calendarId, id, ...properties } = body || {}; + if (!calendarId || !id) return { error: "calendarId and id are required" }; + return await messenger.calendar.tasks.update(calendarId, id, properties); + } + // ─── Contacts search (must be before /contacts/:id) ───────────── if (path === "/contacts/search" && method === "POST") { diff --git a/mcp/src/tools.js b/mcp/src/tools.js index 8d35125..3adff4a 100644 --- a/mcp/src/tools.js +++ b/mcp/src/tools.js @@ -808,4 +808,108 @@ export const tools = [ return await api("POST", "/compose", payload); }, }, + + // ─── 22. Tasks: list (ODIAA-2329) ───────────────────────────────── + { + name: "task_list", + description: + "List calendar tasks (VTODO), optionally scoped to one calendar or filtered by completion state. Requires the calendar Experiment API — see docs/decisions/calendar-backend.md.", + inputSchema: { + type: "object", + properties: { + calendarId: { type: "string", description: "Optional: limit to one calendar" }, + completed: { type: "boolean", description: "Optional: true for completed tasks only, false for pending only" }, + }, + }, + handler: async (args, api) => { + const body = {}; + if (args.calendarId) body.calendarId = args.calendarId; + if (args.completed !== undefined) body.completed = args.completed; + return await api("POST", "/tasks/list", body); + }, + }, + + // ─── 23. Tasks: create (ODIAA-2329) ─────────────────────────────── + { + name: "task_create", + description: + "Create a calendar task (VTODO). Requires the tasksWrite access switch to be enabled (disabled by default) — see docs/ACCESS-CONTROL.md.", + inputSchema: { + type: "object", + properties: { + calendarId: { type: "string", description: "Target calendar" }, + title: { type: "string", description: "Task title" }, + due: { type: "string", description: "Due date (ISO date/time, or YYYY-MM-DD if allDay)" }, + allDay: { type: "boolean", description: "All-day due date" }, + priority: { type: "integer", description: "0-9; 1-4 high, 5 normal, 6-9 low, per RFC 5545" }, + description: { type: "string", description: "Task description" }, + source: { type: "string", description: "Message id this task was created from, if any" }, + }, + required: ["calendarId", "title"], + }, + handler: async (args, api) => { + if (!args.calendarId) return { error: "calendarId is required" }; + if (!args.title) return { error: "title is required" }; + const properties = { title: args.title }; + if (args.due !== undefined) properties.due = args.due; + if (args.allDay !== undefined) properties.allDay = args.allDay; + if (args.priority !== undefined) properties.priority = args.priority; + if (args.description !== undefined) properties.description = args.description; + if (args.source !== undefined) properties.source = args.source; + return await api("POST", "/tasks/create", { calendarId: args.calendarId, ...properties }); + }, + }, + + // ─── 24. Tasks: update (ODIAA-2329) ─────────────────────────────── + { + name: "task_update", + description: + "Update properties on an existing calendar task by id. Only the properties you provide are changed. Requires the tasksWrite access switch to be enabled (disabled by default) — see docs/ACCESS-CONTROL.md.", + inputSchema: { + type: "object", + properties: { + calendarId: { type: "string", description: "Calendar the task belongs to" }, + taskId: { type: "string", description: "Task id to update" }, + title: { type: "string", description: "Task title" }, + due: { type: "string", description: "Due date (ISO date/time, or YYYY-MM-DD if allDay)" }, + allDay: { type: "boolean", description: "All-day due date" }, + priority: { type: "integer", description: "0-9; 1-4 high, 5 normal, 6-9 low, per RFC 5545" }, + description: { type: "string", description: "Task description" }, + source: { type: "string", description: "Message id this task was created from, if any" }, + completed: { type: "boolean", description: "Mark completed (true) or not completed (false)" }, + }, + required: ["calendarId", "taskId"], + }, + handler: async (args, api) => { + if (!args.calendarId || !args.taskId) return { error: "calendarId and taskId are required" }; + const properties = {}; + if (args.title !== undefined) properties.title = args.title; + if (args.due !== undefined) properties.due = args.due; + if (args.allDay !== undefined) properties.allDay = args.allDay; + if (args.priority !== undefined) properties.priority = args.priority; + if (args.description !== undefined) properties.description = args.description; + if (args.source !== undefined) properties.source = args.source; + if (args.completed !== undefined) properties.completed = args.completed; + if (Object.keys(properties).length === 0) return { error: "at least one task property is required" }; + return await api("POST", "/tasks/update", { calendarId: args.calendarId, id: args.taskId, ...properties }); + }, + }, + + // ─── 25. Email: action items (ODIAA-2329) ───────────────────────── + { + name: "email_action_items", + description: + "Extract candidate action items from a message body as a Markdown checklist. Deterministic (no LLM) — looks for imperative lines, bullets/checklists, and 'please/can you' request phrasing, plus 'by <date>' due-date hints. Returns both the structured items and the rendered Markdown.", + inputSchema: { + type: "object", + properties: { + messageId: { type: "string", description: "Message id to extract action items from" }, + }, + required: ["messageId"], + }, + handler: async (args, api) => { + if (!args.messageId) return { error: "messageId is required" }; + return await api("POST", `/messages/${args.messageId}/action-items`, {}); + }, + }, ]; diff --git a/package.json b/package.json index 3e7d5cc..759af94 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test:mcp-concurrency": "node mcp/test/test_concurrency.mjs", "test:bridge-auth": "node test/bridge-auth.mjs", "test:bridge-security": "node test/bridge-security.mjs", - "test:extension": "node test/thread-utils.test.mjs && node test/extension.test.mjs && node test/access-control.test.mjs && node test/draft-routing-test.mjs", + "test:extension": "node test/thread-utils.test.mjs && node test/action-items.test.mjs && node test/extension.test.mjs && node test/access-control.test.mjs && node test/draft-routing-test.mjs", "test:draft": "node test/draft-routing-test.mjs", "test:sign": "node test/sign-xpi.test.mjs", "test:docs": "node test/docs.test.mjs", diff --git a/skills/thunderbird-cli/SKILL.md b/skills/thunderbird-cli/SKILL.md index 1d6c5f9..9f397f4 100644 --- a/skills/thunderbird-cli/SKILL.md +++ b/skills/thunderbird-cli/SKILL.md @@ -38,9 +38,9 @@ Equivalent to `tb health` — returns account count and bridge status. If it err - **EXTENSION_DISCONNECTED** — open Thunderbird. The WebExtension auto-connects within 3s of Thunderbird being open. - **NOT_FOUND** on account/folder — the user hasn't added that account to Thunderbird yet. -## The 16 MCP tools +## The 26 MCP tools -Use these; don't reach for the 43-command CLI unless the user explicitly asks for a bulk operation not covered here. +Use these; don't reach for the CLI unless the user explicitly asks for a bulk operation not covered here. | Tool | Purpose | Safe by default? | |---|---|---| @@ -65,6 +65,11 @@ Use these; don't reach for the 43-command CLI unless the user explicitly asks fo | `contact_search` | Search/list address book contacts across all books, matching name or any email | ✅ read-only | | `contact_create` | Create a contact in an address book | ⚠️ requires `contactsWrite` access switch (default off) | | `contact_update` | Update a contact's properties by id | ⚠️ requires `contactsWrite` access switch (default off) | +| `calendar_list` | List Thunderbird calendars (id, name, url, read-only/enabled, color) | ✅ read-only | +| `task_list` | List calendar tasks (VTODO), optionally filtered by calendar or completion state | ✅ read-only | +| `task_create` | Create a calendar task | ⚠️ requires `tasksWrite` access switch (default off) | +| `task_update` | Update a task's fields (title, due, priority, description, completed) by id | ⚠️ requires `tasksWrite` access switch (default off) | +| `email_action_items` | Deterministic (no LLM) extraction of candidate action items from a message body as a Markdown checklist | ✅ read-only | Notes live entirely on disk (`~/.config/thunderbird-cli/notes` by default) — no Thunderbird round-trip except `note_to_draft`, which reuses the same @@ -201,6 +206,27 @@ To turn a note into an email, use `note_to_draft name="q3-planning" to="team@co. it renders the Markdown to sanitized HTML and saves a draft (never sends). Notes are local files; nothing is uploaded anywhere. +### J. "Turn this email into a task" / "What am I on the hook for in this thread?" + +``` +email_action_items messageId=<id> +``` + +This is deterministic extraction (no LLM) — checklist/bullet syntax, imperative +sentences ("Send the report..."), request phrases ("please...", "can you..."), +and "by \<date\>" hints. Show the user the returned Markdown checklist before +turning any item into a real task — extraction can over- or under-match. + +To actually create a task from a confirmed item: + +``` +task_create calendarId=<id> title="<item text>" due="<dueHint if any>" source="<messageId>" +``` + +`task_create`/`task_update` require the `tasksWrite` access switch (default off) — if the +call returns `FORBIDDEN`, tell the user it's disabled and how to enable it +(`docs/ACCESS-CONTROL.md`), don't work around it. + ## Safety ### Destructive operations @@ -262,7 +288,7 @@ Some IMAP servers don't preload attachments. Call `email_read id=<id> mode="chec ## When NOT to use this skill -- **Calendar events or tasks, contacts write, or address book editing** — not exposed via `tb-mcp`. `calendar_list`/`tb calendars` can list calendar names only (read-only, experimental — see `docs/decisions/calendar-backend.md`); for anything beyond that, use Thunderbird directly or a dedicated calendar skill. +- **Calendar events** (as opposed to tasks) — not exposed via `tb-mcp` yet. `calendar_list` only lists calendar names (read-only, experimental — see `docs/decisions/calendar-backend.md`). Tasks (`task_list`/`task_create`/`task_update`) and contact writes (`contact_create`/`contact_update`) *are* exposed, gated by the `tasksWrite`/`contactsWrite` access switches (default off — tell the user if a call comes back `FORBIDDEN`). - **Accounts not configured in Thunderbird** — ask the user to add the account first. - **Sending to many recipients** — use a mailing tool (Mailchimp, etc.) via its MCP server. `tb-mcp` is for 1:1 or small-group mail. - **Server-side rules / filters** — not exposed. Thunderbird sees the client-side view only. @@ -293,7 +319,12 @@ MCP tool → CLI command mapping: | `contact_search` | `tb contacts-search <query>` | | `contact_create` | `tb contacts create --book <bookId> ...` | | `contact_update` | `tb contacts update <contactId> ...` | +| `calendar_list` | `tb calendars` | +| `task_list` | `tb tasks list` | +| `task_create` | `tb tasks create --calendar <calendarId> --title <title> ...` | +| `task_update` | `tb tasks update <taskId> --calendar <calendarId> ...` | +| `email_action_items` | `tb action-items <messageId>` | ## Version -This skill tracks the `tb-mcp` server on `main` of thunderbird-cli-enhanced (package version 1.1.0). The tool surface (21 tools, parameter names, defaults) is stable within the 1.x line. Check [CHANGELOG](https://github.com/odience-network/thunderbird-cli-enhanced/blob/main/CHANGELOG.md) for additions. +This skill tracks the `tb-mcp` server on `main` of thunderbird-cli-enhanced (package version 1.1.0). The tool surface (26 tools, parameter names, defaults) is stable within the 1.x line. Check [CHANGELOG](https://github.com/odience-network/thunderbird-cli-enhanced/blob/main/CHANGELOG.md) for additions. diff --git a/test/access-control.test.mjs b/test/access-control.test.mjs index 323fa8e..eed3019 100644 --- a/test/access-control.test.mjs +++ b/test/access-control.test.mjs @@ -28,6 +28,7 @@ function load(config) { messagesCopy: [], messagesArchive: [], tagsCreated: [], foldersCreated: [], foldersRenamed: [], composeSent: [], composeSaved: [], contactsCreated: [], contactsUpdated: [], + tasksCreated: [], tasksUpdated: [], }; const messenger = { runtime: { getManifest: () => manifest, reload: () => {} }, @@ -49,6 +50,21 @@ function load(config) { calendars: { query: async () => [{ id: "cal1", type: "storage", name: "Home", url: "moz-storage-calendar://cal1", readOnly: false, enabled: true, color: null }], }, + tasks: { + query: async ({ calendarId, completed } = {}) => [ + { id: "t1", calendarId: calendarId || "cal1", title: "Existing task", completed: completed === true }, + ], + create: async (calendarId, properties) => { + const task = { id: "t2", calendarId, ...properties }; + calls.tasksCreated.push(task); + return task; + }, + update: async (calendarId, id, properties) => { + const task = { id, calendarId, ...properties }; + calls.tasksUpdated.push(task); + return task; + }, + }, }, messages: { list: async () => ({ id: null, messages: [{ id: 1, read: true, flagged: false, tags: [] }] }), @@ -60,7 +76,10 @@ function load(config) { archive: async (ids) => { calls.messagesArchive.push(ids); }, listTags: async () => [], createTag: async (key, tag, color) => { calls.tagsCreated.push({ key, tag, color }); }, - getFull: async () => ({ contentType: "multipart/mixed", parts: [] }), + getFull: async () => ({ + contentType: "multipart/mixed", + parts: [{ contentType: "text/plain", body: "Please review the attached doc by Friday." }], + }), getAttachmentFile: async (id, partName) => ({ name: "f.pdf", size: 3, type: "application/pdf", arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, @@ -188,6 +207,31 @@ console.log("\n\x1b[1mContacts write policy\x1b[0m"); updated.id === "c1" && updated.properties.LastName === "Doe" && calls.contactsUpdated.length === 1); } +// ─── Tasks write: new switch, default closed like contactsWrite (ODIAA-2329) ─── + +console.log("\n\x1b[1mTasks write policy\x1b[0m"); +{ + const { calls, handle } = load(); + const access = await handle("GET", "/access"); + test("GET /access reports tasksWrite off", access.policy.tasksWrite === false); + test("task create refused by default", + await rejects(handle("POST", "/tasks/create", { calendarId: "cal1", title: "New task" }), /^FORBIDDEN: 'tasksWrite'/)); + test("task update refused by default", + await rejects(handle("POST", "/tasks/update", { calendarId: "cal1", id: "t1", completed: true }), /^FORBIDDEN: 'tasksWrite'/)); + test("no tasks side effect was made", calls.tasksCreated.length === 0 && calls.tasksUpdated.length === 0); + test("tasks list still works (ungated)", + Array.isArray(await handle("POST", "/tasks/list", {}))); +} +{ + const { calls, handle } = load({ tasksWrite: true }); + const created = await handle("POST", "/tasks/create", { calendarId: "cal1", title: "New task" }); + test("task create allowed with tasksWrite=true", + created.calendarId === "cal1" && created.title === "New task" && calls.tasksCreated.length === 1); + const updated = await handle("POST", "/tasks/update", { calendarId: "cal1", id: "t1", completed: true }); + test("task update allowed with tasksWrite=true", + updated.id === "t1" && updated.completed === true && calls.tasksUpdated.length === 1); +} + // ─── New switches: default open (unmodified install behaves as before this policy) ── console.log("\n\x1b[1mNew switches — default open\x1b[0m"); @@ -368,6 +412,18 @@ console.log("\n\x1b[1mCalendars route\x1b[0m"); test("GET /calendars is ungated with the default (all-closed) policy", Array.isArray(calendars) && calendars[0]?.id === "cal1"); } +// ─── Action items: new route, ungated (deterministic, read-only) (ODIAA-2329) ── + +console.log("\n\x1b[1mAction items route\x1b[0m"); +{ + const { handle } = load(); + const result = await handle("POST", "/messages/1/action-items", {}); + test("POST /messages/:id/action-items is ungated with the default (all-closed) policy", + Array.isArray(result.items) && result.items.length === 1 && result.items[0].text === "Please review the attached doc by Friday"); + test("action-items markdown reflects the extracted item", + result.markdown === "- [ ] Please review the attached doc by Friday (by Friday)"); +} + console.log(`\n\x1b[1m${"─".repeat(40)}\x1b[0m`); console.log(`\x1b[1m${passed} passed, ${failed} failed, ${passed + failed} total\x1b[0m\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/test/action-items.test.mjs b/test/action-items.test.mjs new file mode 100644 index 0000000..3c43703 --- /dev/null +++ b/test/action-items.test.mjs @@ -0,0 +1,244 @@ +#!/usr/bin/env node +/** + * Unit tests for extension/src/action-items.js (deterministic action-item extraction). + * The file is a classic background script, so it is loaded in a vm. + */ + +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import vm from "vm"; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "extension", "src", "action-items.js"); +const ctx = vm.createContext({}); +vm.runInContext(readFileSync(SRC, "utf-8"), ctx, { filename: SRC }); +const { extractActionItems, actionItemsToMarkdown } = ctx; + +let passed = 0, failed = 0; +function test(name, actual, expected) { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (ok) { passed++; console.log(` \x1b[32m✓\x1b[0m ${name}`); } + else { + failed++; + console.log(` \x1b[31m✗\x1b[0m ${name}`); + console.log(` expected: ${JSON.stringify(expected)}`); + console.log(` actual: ${JSON.stringify(actual)}`); + } +} + +console.log("\n\x1b[1m=== action-items tests ===\x1b[0m"); + +// ─── checklist / bullets ───────────────────────────────────────────────────── + +console.log("\n\x1b[1mextractActionItems: checklist & bullets\x1b[0m"); + +test( + "unchecked checklist item", + extractActionItems("- [ ] Send the invoice"), + [{ text: "Send the invoice", checked: false, dueHint: null }] +); + +test( + "checked checklist item", + extractActionItems("- [x] Book the flight"), + [{ text: "Book the flight", checked: true, dueHint: null }] +); + +test( + "uppercase X checklist item", + extractActionItems("* [X] Renew passport"), + [{ text: "Renew passport", checked: true, dueHint: null }] +); + +test( + "plain dash bullet", + extractActionItems("- Update the roadmap doc"), + [{ text: "Update the roadmap doc", checked: false, dueHint: null }] +); + +test( + "plain asterisk bullet", + extractActionItems("* Confirm the venue"), + [{ text: "Confirm the venue", checked: false, dueHint: null }] +); + +test( + "numbered bullet", + extractActionItems("1. Prepare the slides"), + [{ text: "Prepare the slides", checked: false, dueHint: null }] +); + +test( + "numbered bullet with parenthesis", + extractActionItems("2) Ship the package"), + [{ text: "Ship the package", checked: false, dueHint: null }] +); + +// ─── imperative sentences ───────────────────────────────────────────────────── + +console.log("\n\x1b[1mextractActionItems: imperative sentences\x1b[0m"); + +test( + "imperative verb at start of line", + extractActionItems("Send the report to finance."), + [{ text: "Send the report to finance", checked: false, dueHint: null }] +); + +test( + "non-imperative sentence is ignored", + extractActionItems("The report was sent to finance yesterday."), + [] +); + +test( + "second sentence in a paragraph is still picked up", + extractActionItems("Thanks for the update. Please confirm the budget by Friday."), + [{ text: "Please confirm the budget by Friday", checked: false, dueHint: "Friday" }] +); + +// ─── request phrases ───────────────────────────────────────────────────── + +console.log("\n\x1b[1mextractActionItems: request phrases\x1b[0m"); + +test( + "please phrase", + extractActionItems("Please review the attached contract."), + [{ text: "Please review the attached contract", checked: false, dueHint: null }] +); + +test( + "can you phrase", + extractActionItems("Can you check the numbers again?"), + [{ text: "Can you check the numbers again", checked: false, dueHint: null }] +); + +test( + "could you phrase", + extractActionItems("Could you follow up with legal on this."), + [{ text: "Could you follow up with legal on this", checked: false, dueHint: null }] +); + +test( + "need to phrase", + extractActionItems("We need to schedule a call before launch."), + [{ text: "We need to schedule a call before launch", checked: false, dueHint: null }] +); + +// ─── date hints ───────────────────────────────────────────────────── + +console.log("\n\x1b[1mextractActionItems: date hints\x1b[0m"); + +test( + "by weekday", + extractActionItems("Please send the invoice by Monday."), + [{ text: "Please send the invoice by Monday", checked: false, dueHint: "Monday" }] +); + +test( + "by tomorrow", + extractActionItems("Confirm the venue by tomorrow."), + [{ text: "Confirm the venue by tomorrow", checked: false, dueHint: "tomorrow" }] +); + +test( + "by end of week", + extractActionItems("Please finish the draft by end of week."), + [{ text: "Please finish the draft by end of week", checked: false, dueHint: "end of week" }] +); + +test( + "by numeric date", + extractActionItems("Submit the form by 10/31."), + [{ text: "Submit the form by 10/31", checked: false, dueHint: "10/31" }] +); + +test( + "by month-day date", + extractActionItems("Renew the license by Dec 5th."), + [{ text: "Renew the license by Dec 5th", checked: false, dueHint: "Dec 5th" }] +); + +test( + "checklist items can still carry a date hint", + extractActionItems("- [ ] Ship the package by Friday"), + [{ text: "Ship the package by Friday", checked: false, dueHint: "Friday" }] +); + +// ─── quoted reply / signature stripping ───────────────────────────────────────────────────── + +console.log("\n\x1b[1mextractActionItems: quoted reply & signature stripping\x1b[0m"); + +test( + "quoted lines are ignored", + extractActionItems([ + "Please send the invoice.", + "> Please ignore this quoted request.", + ].join("\n")), + [{ text: "Please send the invoice", checked: false, dueHint: null }] +); + +test( + "content after a signature delimiter is ignored", + extractActionItems([ + "Please review the contract.", + "--", + "Please ignore this line in my signature.", + ].join("\n")), + [{ text: "Please review the contract", checked: false, dueHint: null }] +); + +// ─── dedup ───────────────────────────────────────────────────── + +console.log("\n\x1b[1mextractActionItems: dedup\x1b[0m"); + +test( + "duplicate items (case-insensitive) are only kept once", + extractActionItems([ + "- [ ] Send the invoice", + "- send the INVOICE", + ].join("\n")), + [{ text: "Send the invoice", checked: false, dueHint: null }] +); + +test( + "no action items returns an empty array", + extractActionItems("Just a regular update with no requests at all."), + [] +); + +// ─── actionItemsToMarkdown ───────────────────────────────────────────────────── + +console.log("\n\x1b[1mactionItemsToMarkdown\x1b[0m"); + +test("empty list renders empty string", actionItemsToMarkdown([]), ""); + +test( + "unchecked item renders as an open checkbox", + actionItemsToMarkdown([{ text: "Send the invoice", checked: false, dueHint: null }]), + "- [ ] Send the invoice" +); + +test( + "checked item renders as a checked checkbox", + actionItemsToMarkdown([{ text: "Book the flight", checked: true, dueHint: null }]), + "- [x] Book the flight" +); + +test( + "due hint is appended in parentheses", + actionItemsToMarkdown([{ text: "Send the invoice", checked: false, dueHint: "Friday" }]), + "- [ ] Send the invoice (by Friday)" +); + +test( + "multiple items render as separate lines", + actionItemsToMarkdown([ + { text: "Send the invoice", checked: false, dueHint: "Friday" }, + { text: "Book the flight", checked: true, dueHint: null }, + ]), + "- [ ] Send the invoice (by Friday)\n- [x] Book the flight" +); + +console.log(`\n\x1b[1m${"─".repeat(40)}\x1b[0m`); +console.log(`\x1b[1m${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed > 0 ? 1 : 0); diff --git a/test/mcp-test.mjs b/test/mcp-test.mjs index 54a785e..a985006 100644 --- a/test/mcp-test.mjs +++ b/test/mcp-test.mjs @@ -339,7 +339,7 @@ await client.initialize(); console.log("\x1b[1mProtocol\x1b[0m"); const toolList = await client.listTools(); -test("tools/list returns 22 tools", toolList, (r) => Array.isArray(r) && r.length === 22); +test("tools/list returns 26 tools", toolList, (r) => Array.isArray(r) && r.length === 26); test("each tool has name+description+inputSchema", toolList, (r) => r.every((t) => t.name && t.description && t.inputSchema) ); @@ -662,7 +662,7 @@ const toolsB = await clientB.listTools(); test( "concurrent MCP server instances initialize and list tools", { toolsACount: toolsA.length, toolsBCount: toolsB.length }, - (r) => r.toolsACount === 22 && r.toolsBCount === 22 + (r) => r.toolsACount === 26 && r.toolsBCount === 26 ); clientA.close(); clientB.close();