Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Read-only `tb calendars` / `calendar_list` / `GET /calendars`, backed by a vendored `calendar_calendars` Experiment API. See [docs/decisions/calendar-backend.md](docs/decisions/calendar-backend.md).
- Fast Actions: one-click Email → Note / Task / Event / Contact (`tb email-to-note`/`email-to-task`/`email-to-event`/`email-to-contact`, matching MCP tools, and context-menu items in Thunderbird's message list/display). `email-to-event` uses a new deterministic (no LLM) date/time/location parser (`extension/src/email-event-parse.js`) and falls back to a tentative draft event when it can't detect one. `email-to-contact` dedupes by sender email. Two new ungated routes (`GET /addressbooks`, `POST /messages/:id/event-draft`); no new access switch — writes are gated by the existing `tasksWrite`/`calendarWrite`/`contactsWrite` switches. (ODIAA-2333)

### Changed
- Extension 2.2.0 declares `strict_max_version: "155.*"`. ATN requires a max version for any add-on with Experiment APIs, so each new Thunderbird major needs a manifest bump and re-signing. Signing also waits for ATN's manual review.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ Upstream baseline is [vitalio-sh/thunderbird-cli@`465613d`](https://github.com/v

Planned, **not on `main`**:

- Calendar events CRUD, cross-calendar clash detection, task CRUD, and deterministic action-item extraction from email, 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; event CRUD and clash detection (`tb calendar events`/`create`/`update`/`delete`/`clashes`, gated by `calendarWrite`) and task CRUD (`tb tasks`, gated by `tasksWrite`) plus action-item extraction (`tb action-items`) landed next — 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, cross-calendar clash detection, task CRUD, and deterministic action-item extraction from email, 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; event CRUD and clash detection (`tb calendar events`/`create`/`update`/`delete`/`clashes`, gated by `calendarWrite`) and task CRUD (`tb tasks`, gated by `tasksWrite`) plus action-item extraction (`tb action-items`) landed next, followed by one-click Fast Actions (`tb email-to-note`/`email-to-task`/`email-to-event`/`email-to-contact`, plus matching context-menu items in Thunderbird) that compose those routes without a new access switch — 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.

<a href="docs/diagrams/roadmap.html"><picture>
Expand Down
46 changes: 45 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,44 @@ tb action-items <messageId>
# Returns: {items: [{text, checked, dueHint}], markdown}
```

### 22. Fast Actions

One-click Email → Note / Task / Event / Contact (ODIAA-2333). Each is a thin
composite over an existing route, so it obeys that route's access switch —
there is no separate switch for the composite itself. Extension `menus`
entries ("Save to Notes", "Create Task", "Create Event", "Add Sender to
Contacts") on the message list / message display call the same
`handleRequest()` router as the bridge, so a menu click enforces access
identically to the CLI/MCP path.

```bash
tb address-books
# Returns: [{id, name}]

tb email-to-note <messageId> [--name <name>] [--append]
# Saves the message to the local notes workspace (no access switch)

tb email-to-task <messageId> --calendar <calendarId> [--due <date>] [--all-day] [--priority <n>]
# Uses deterministic action-item extraction (#21) for the task title/description
# (requires tasksWrite)

tb email-to-event <messageId> --calendar <calendarId>
# Deterministic date/time/location parsing (extension/src/email-event-parse.js,
# unit tested independently of the bridge). Falls back to a draft event with
# status TENTATIVE when no date/time is detected, for the user to correct
# (requires calendarWrite)

tb email-to-contact <messageId> --book <bookId>
# Sender -> contact, deduped by email address via /contacts/search
# (requires contactsWrite)
```

Since Thunderbird's WebExtension sandbox can't write to the local filesystem, "Save to
Notes" pushes a `note-save-requested` event over the bridge's existing long-poll event
feed rather than writing directly; `tb notes listen` (or MCP `notes_listen_once`) is the
CLI-side listener that turns the push into a saved note. It must be running (or started
right after the click, within the listener's timeout) to catch the event.

---

## Implementation Details
Expand Down Expand Up @@ -1252,7 +1290,7 @@ The MCP server:

### Tool Catalog

The 31 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 37 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 |
|----------|---------------------|
Expand Down Expand Up @@ -1288,6 +1326,12 @@ The 31 MCP tools are **curated** for AI agent use cases. Bulk admin operations (
| `task_create` | `tb tasks create` (requires `tasksWrite`) |
| `task_update` | `tb tasks update` (requires `tasksWrite`) |
| `email_action_items` | `tb action-items` |
| `address_book_list` | `tb address-books` |
| `email_to_note` | `tb email-to-note` |
| `email_to_task` | `tb email-to-task` (requires `tasksWrite`) |
| `email_to_event` | `tb email-to-event` (requires `calendarWrite`) |
| `email_to_contact` | `tb email-to-contact` (requires `contactsWrite`) |
| `notes_listen_once` | `tb notes listen` |

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
Expand Down
157 changes: 157 additions & 0 deletions cli/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,163 @@ program
output(data, getFormat(g), getOutputOpts(g));
}));

// ─── Fast Actions (ODIAA-2333, one-click Email→Note/Task/Event/Contact) ─────

function slugify(text) {
return String(text || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}

// Parses a WebExtension "author" header (e.g. `"Jane Doe" <jane@x.com>`) into name/email.
function parseSenderAuthor(author) {
const str = String(author || "");
const m = str.match(/^\s*"?([^"<]*)"?\s*<([^>]+)>\s*$/);
if (m) return { name: m[1].trim() || null, email: m[2].trim() };
const emailOnly = str.match(/[^\s<>]+@[^\s<>]+/);
return { name: null, email: emailOnly ? emailOnly[0] : null };
}

program
.command("address-books")
.description("List address books")
.action(run(async () => {
const g = program.opts();
const data = await api("GET", "/addressbooks", null, getTimeout(g));
output(data, getFormat(g), getOutputOpts(g));
}));

program
.command("email-to-note <messageId>")
.description("Save an email to the local notes workspace")
.option("--name <name>", "note name (defaults to a slug of the subject)")
.option("--append", "append to an existing note instead of overwriting")
.action(run(async (messageId, opts) => {
const g = program.opts();
const msg = await api("GET", `/messages/${messageId}/full`, null, getTimeout(g));
const name = opts.name || slugify(msg.subject) || `message-${messageId}`;
const body = [
`**From:** ${msg.author || "unknown"}`,
`**Date:** ${msg.date || "unknown"}`,
"",
msg.parts?.text || msg.parts?.html || "(no body)",
].join("\n");
const result = opts.append
? appendNote(name, body, { title: msg.subject, source: String(messageId) })
: saveNote(name, body, { title: msg.subject, source: String(messageId) });
output(result, getFormat(g), getOutputOpts(g));
}));

program
.command("email-to-task <messageId>")
.description("Create a task from an email, using action-item extraction (requires tasksWrite)")
.requiredOption("--calendar <calendarId>", "target calendar")
.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)")
.action(run(async (messageId, opts) => {
const g = program.opts();
const msg = await api("GET", `/messages/${messageId}/full`, null, getTimeout(g));
const extracted = await api("POST", `/messages/${messageId}/action-items`, {}, getTimeout(g));
const properties = {
title: extracted.items?.[0]?.text || msg.subject || `Task from message ${messageId}`,
source: String(messageId),
};
if (extracted.markdown) properties.description = extracted.markdown;
if (opts.due) properties.due = opts.due;
if (opts.allDay) properties.allDay = true;
if (opts.priority !== undefined) properties.priority = parseInt(opts.priority, 10);
const data = await api("POST", "/tasks/create", { calendarId: opts.calendar, ...properties }, getTimeout(g));
output(data, getFormat(g), getOutputOpts(g));
}));

program
.command("email-to-event <messageId>")
.description("Create a calendar event from an email via deterministic date/time/location parsing (requires calendarWrite)")
.requiredOption("--calendar <calendarId>", "target calendar")
.action(run(async (messageId, opts) => {
const g = program.opts();
const draft = await api("POST", `/messages/${messageId}/event-draft`, {}, getTimeout(g));
const properties = {
title: draft.title,
description: draft.description,
start: draft.start,
end: draft.end,
allDay: draft.allDay,
// No date/time was detected — flagged as tentative so the user reviews it before it's treated as confirmed.
status: draft.needsReview ? "TENTATIVE" : "CONFIRMED",
};
if (draft.location) properties.location = draft.location;
const data = await api("POST", "/calendar/events/create", { calendarId: opts.calendar, ...properties }, getTimeout(g));
output({ ...data, needsReview: draft.needsReview }, getFormat(g), getOutputOpts(g));
}));

program
.command("email-to-contact <messageId>")
.description("Add an email's sender as a contact, deduped by email address (requires contactsWrite)")
.requiredOption("--book <bookId>", "target address book, by id or name")
.action(run(async (messageId, opts) => {
const g = program.opts();
const fmt = getFormat(g);
const outOpts = getOutputOpts(g);
const msg = await api("GET", `/messages/${messageId}/full`, null, getTimeout(g));
const { name, email } = parseSenderAuthor(msg.author);
if (!email) {
outputError({ message: "Could not extract a sender email address from this message", code: "INVALID_ARGS" }, fmt, outOpts);
return;
}
const existing = await api("POST", "/contacts/search", { query: email, book: opts.book }, getTimeout(g));
if (existing.length > 0) {
output({ ...existing[0], deduped: true }, fmt, outOpts);
return;
}
const properties = { PrimaryEmail: email };
if (name) properties.DisplayName = name;
const data = await api("POST", "/contacts/create", { book: opts.book, properties }, getTimeout(g));
output({ ...data, deduped: false }, fmt, outOpts);
}));

notes
.command("listen")
.description("Wait for a 'Save to Notes' click, save the note")
.option("--timeout <ms>", "how long to wait for a pending request", "120000")
.option("--name <name>", "note name (defaults to a slug of the subject)")
.option("--append", "append to an existing note instead of overwriting")
.action(run(async (opts) => {
const g = program.opts();
const fmt = getFormat(g);
const outOpts = getOutputOpts(g);
const timeout = parseInt(opts.timeout) || 120000;
// 1s buffer guards against clock skew between the CLI and bridge processes.
const since = Date.now() - 1000;
let eventResp;
try {
eventResp = await api(
"GET",
`/bridge/events?wait=note-save-requested&since=${since}&timeout=${timeout}`,
null,
timeout + 5000
);
} catch (err) {
if (err.code !== "EVENT_TIMEOUT" && err.code !== "TIMEOUT") throw err;
outputError({ message: "No 'Save to Notes' request received before the timeout", code: "TIMEOUT" }, fmt, outOpts);
return;
}
const payload = eventResp.event?.data || {};
const name = opts.name || slugify(payload.subject) || `message-${payload.messageId || "unknown"}`;
const body = [
`**From:** ${payload.author || "unknown"}`,
`**Date:** ${payload.date || "unknown"}`,
"",
payload.body || "(no body)",
].join("\n");
const noteOpts = { title: payload.subject, source: payload.messageId ? String(payload.messageId) : undefined };
const result = opts.append ? appendNote(name, body, noteOpts) : saveNote(name, body, noteOpts);
output(result, fmt, outOpts);
}));

// ─── Parse & Run ──────────────────────────────────────────────────────

program.parseAsync(process.argv).catch(err => {
Expand Down
16 changes: 13 additions & 3 deletions docs/ACCESS-CONTROL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ closed instead of shipping unrestricted.

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,
stats, sync, `POST /extension/reload`, ...) are always available — the policy only gates
operations that create, mutate, or send. Bridge-local endpoints (`/bridge/status`,
`/bridge/events`) never reach the add-on, so the policy doesn't apply to them.
stats, sync, `GET /addressbooks`, `POST /messages/:id/action-items`, `POST
/messages/:id/event-draft`, `POST /extension/reload`, ...) are always available — the policy
only gates operations that create, mutate, or send. The last two are deterministic,
non-mutating text extraction (candidate action items / an event draft parsed from the message
body), same as read routes. Bridge-local endpoints (`/bridge/status`, `/bridge/events`) never
reach the add-on, so the policy doesn't apply to them.

`delete` and `folderDelete` default off: the board decided (ODIAA-2304) that deletion stays
gated rather than removed from the extension entirely. Every write switch that predates
Expand Down Expand Up @@ -78,3 +81,10 @@ build. Without `--access-config`, the source defaults above are used.
bridge authentication (`TB_AUTH_TOKEN`) or OS-level protections.
- Previously installed or signed XPIs predate this policy and still allow everything; install a
new build to get it.
- The Fast Actions (`tb email-to-*`, the equivalent MCP tools, and the "Save to Notes"/"Create
Task"/"Create Event"/"Add Sender to Contacts" context-menu items — ODIAA-2333) are composites
over existing routes and introduce no new write switch: `email-to-task`/"Create Task" and
`email-to-event`/"Create Event" are gated by `tasksWrite`/`calendarWrite` at `/tasks/create`
and `/calendar/events/create` respectively, `email-to-contact`/"Add Sender to Contacts" by
`contactsWrite` at `/contacts/create`, and `email-to-note`/"Save to Notes" needs no switch
because it never mutates Thunderbird state — it only writes to the local notes workspace.
31 changes: 31 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,37 @@ tb action-items <messageId> # deterministic (no LLM) extraction of candidate a
# from a message body, rendered as a Markdown checklist
```

## Fast Actions

One-click Email → Note / Task / Event / Contact (ODIAA-2333). Each command reuses
the same route (and access switch) as its underlying resource — there is no
bypass for these composite commands.

```bash
tb address-books # list address books

tb email-to-note <messageId> [--name <name>] [--append]
# save a message to the local notes workspace (no access switch — notes are local-only)

tb email-to-task <messageId> --calendar <calendarId> [--due <date>] [--all-day] [--priority <n>]
# create a task from deterministic action-item extraction (requires tasksWrite)

tb email-to-event <messageId> --calendar <calendarId>
# create a calendar event via deterministic date/time/location parsing (requires calendarWrite)
# falls back to a draft event (status TENTATIVE) when no date/time could be detected —
# review and correct it before relying on it

tb email-to-contact <messageId> --book <bookId>
# add the message's sender as a contact, deduped by email address (requires contactsWrite)

tb notes listen [--timeout <ms>] [--name <name>] [--append]
# waits for a "Save to Notes" context-menu click in Thunderbird, then saves the note.
# Thunderbird can't write to the local filesystem, so the extension pushes a
# note-save-requested event over the bridge and this command is the CLI-side
# listener that turns it into a note; run it (or the equivalent MCP tool,
# notes_listen_once) before clicking "Save to Notes" in Thunderbird.
```

## Bulk Operations

```bash
Expand Down
6 changes: 4 additions & 2 deletions extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@
"messagesImport",
"accountsFolders",
"idle",
"tabs"
"tabs",
"menus",
"notifications"
],
"background": {
"scripts": ["src/access-config.js", "src/access-control.js", "src/thread-utils.js", "src/action-items.js", "src/calendar-clash.js", "src/background.js"]
"scripts": ["src/access-config.js", "src/access-control.js", "src/thread-utils.js", "src/action-items.js", "src/email-event-parse.js", "src/calendar-clash.js", "src/background.js"]
},
"experiment_apis": {
"calendar_calendars": {
Expand Down
4 changes: 2 additions & 2 deletions extension/src/access-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function accessPermissions(policy, permissions) {
}

// GET routes, and POST routes that only read, never need a policy switch.
const UNGATED_GET = /^\/(health|access|accounts(?:\/[^/]+(?:\/folders)?)?|identities|tags|stats|contacts(?:\/[^/]+)?|calendars|messages\/\d+(?:\/(raw|headers|full|check-download|download-status|attachments|thread))?)$/;
const UNGATED_GET = /^\/(health|access|accounts(?:\/[^/]+(?:\/folders)?)?|identities|tags|stats|contacts(?:\/[^/]+)?|addressbooks|calendars|messages\/\d+(?:\/(raw|headers|full|check-download|download-status|attachments|thread))?)$/;
const UNGATED_POST = new Set([
"/folders/info", "/messages/search", "/messages/list", "/messages/read-batch",
"/messages/fetch", "/stats", "/recent", "/contacts/search", "/sync", "/sync/status",
Expand Down Expand Up @@ -101,7 +101,7 @@ function enforceAccess(method, path, body, policy = ACCESS_POLICY) {
}

// Deterministic, read-only extraction — no mutation, so no policy switch needed.
if (method === "POST" && /^\/messages\/\d+\/action-items$/.test(path)) {
if (method === "POST" && /^\/messages\/\d+\/(action-items|event-draft)$/.test(path)) {
return;
}

Expand Down
Loading
Loading