diff --git a/CHANGELOG.md b/CHANGELOG.md index 83bf2ab..8f54d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index b23f8e0..bad938f 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 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. diff --git a/SPEC.md b/SPEC.md index 2d8745d..78427f2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -654,6 +654,44 @@ tb action-items # 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 [--name ] [--append] +# Saves the message to the local notes workspace (no access switch) + +tb email-to-task --calendar [--due ] [--all-day] [--priority ] +# Uses deterministic action-item extraction (#21) for the task title/description +# (requires tasksWrite) + +tb email-to-event --calendar +# 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 --book +# 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 @@ -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 | |----------|---------------------| @@ -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://`, `text/markdown`) so clients that browse resources rather than call tools can list and read the diff --git a/cli/src/cli.js b/cli/src/cli.js index fd74ccf..9a8d6f0 100755 --- a/cli/src/cli.js +++ b/cli/src/cli.js @@ -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" `) 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 ") + .description("Save an email to the local notes workspace") + .option("--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 ") + .description("Create a task from an email, using action-item extraction (requires tasksWrite)") + .requiredOption("--calendar ", "target calendar") + .option("--due ", "due date (ISO date/time, or YYYY-MM-DD with --all-day)") + .option("--all-day", "all-day due date") + .option("--priority ", "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 ") + .description("Create a calendar event from an email via deterministic date/time/location parsing (requires calendarWrite)") + .requiredOption("--calendar ", "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 ") + .description("Add an email's sender as a contact, deduped by email address (requires contactsWrite)") + .requiredOption("--book ", "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 ", "how long to wait for a pending request", "120000") + .option("--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 => { diff --git a/docs/ACCESS-CONTROL.md b/docs/ACCESS-CONTROL.md index 76bc64d..340cfc4 100644 --- a/docs/ACCESS-CONTROL.md +++ b/docs/ACCESS-CONTROL.md @@ -30,9 +30,12 @@ closed instead of shipping unrestricted. With a switch off, the route returns `FORBIDDEN: '' 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 @@ -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. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 9224da2..6c6cd44 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -319,6 +319,37 @@ tb action-items # 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 [--name ] [--append] +# save a message to the local notes workspace (no access switch — notes are local-only) + +tb email-to-task --calendar [--due ] [--all-day] [--priority ] +# create a task from deterministic action-item extraction (requires tasksWrite) + +tb email-to-event --calendar +# 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 --book +# add the message's sender as a contact, deduped by email address (requires contactsWrite) + +tb notes listen [--timeout ] [--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 diff --git a/extension/manifest.json b/extension/manifest.json index c4f63b8..fb613a3 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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": { diff --git a/extension/src/access-control.js b/extension/src/access-control.js index 8cab1c2..5fd6535 100644 --- a/extension/src/access-control.js +++ b/extension/src/access-control.js @@ -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", @@ -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; } diff --git a/extension/src/background.js b/extension/src/background.js index 2d1ba45..bc1fe00 100644 --- a/extension/src/background.js +++ b/extension/src/background.js @@ -191,6 +191,143 @@ if (typeof messenger !== "undefined" && messenger.idle?.onStateChanged) { }); } +// ─── Fast Actions: context menus (ODIAA-2333) ──────────────────────── +// One-click Email → Note / Task / Event / Contact. Thunderbird's "message_list" +// context fires for a right-click both on a row in the message list and inside +// the message display (reading pane / tab), which covers the two surfaces the +// spec asks for with a single registration. Every write goes through +// handleRequest() so it gets exactly the same access-control enforcement +// (and default-off write switches) as the CLI/MCP surface — no new bypass. + +const FAST_ACTION_MENU_IDS = { + note: "tb-ai-fast-note", + task: "tb-ai-fast-task", + event: "tb-ai-fast-event", + contact: "tb-ai-fast-contact", +}; + +function pushEvent(name, data) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "event", name, data })); + return true; + } + return false; +} + +function notifyFastAction(message) { + if (!messenger.notifications?.create) return; + messenger.notifications + .create({ type: "basic", iconUrl: "icons/icon-48.png", title: "Thunderbird CLI Enhanced", message }) + .catch(() => {}); +} + +// Parses a WebExtension "author" header (e.g. `"Jane Doe" `) into name/email. +// Mirrors cli/src/cli.js and mcp/src/tools.js, which parse the same header on their own +// side of the bridge and so can't share this function with the extension's global scope. +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 }; +} + +async function pickDefaultCalendarId() { + if (!messenger.calendar?.calendars?.query) return null; + const calendars = await messenger.calendar.calendars.query({ readOnly: false, enabled: true }); + return calendars?.[0]?.id || null; +} + +async function pickDefaultAddressBookId() { + const books = await messenger.addressBooks.list(); + return books?.[0]?.id || null; +} + +async function handleFastActionClick(menuItemId, messageId) { + if (menuItemId === FAST_ACTION_MENU_IDS.note) { + const msg = await messenger.messages.get(messageId); + const full = await messenger.messages.getFull(messageId); + const text = extractParts(full).text || ""; + const sent = pushEvent("note-save-requested", { + messageId, subject: msg.subject, author: msg.author, date: msg.date?.toISOString(), body: text, + }); + notifyFastAction(sent + ? "Save to Notes: waiting for `tb notes listen` to pick this up." + : "Save to Notes: bridge is not connected."); + return; + } + + if (menuItemId === FAST_ACTION_MENU_IDS.task) { + const calendarId = await pickDefaultCalendarId(); + if (!calendarId) return notifyFastAction("Create Task: no writable calendar found."); + const msg = await messenger.messages.get(messageId); + const full = await messenger.messages.getFull(messageId); + const items = extractActionItems(extractParts(full).text || ""); + const properties = { + title: items[0]?.text || msg.subject || `Task from message ${messageId}`, + source: String(messageId), + }; + const markdown = actionItemsToMarkdown(items); + if (markdown) properties.description = markdown; + const result = await handleRequest({ method: "POST", path: "/tasks/create", body: { calendarId, ...properties } }); + notifyFastAction(result?.error ? `Create Task: ${result.error}` : `Task created: ${properties.title}`); + return; + } + + if (menuItemId === FAST_ACTION_MENU_IDS.event) { + const calendarId = await pickDefaultCalendarId(); + if (!calendarId) return notifyFastAction("Create Event: no writable calendar found."); + const msg = await messenger.messages.get(messageId); + const full = await messenger.messages.getFull(messageId); + const draft = parseEventDraft({ subject: msg.subject, body: extractParts(full).text || "", date: msg.date?.toISOString() }); + const properties = { + title: draft.title, description: draft.description, start: draft.start, end: draft.end, + allDay: draft.allDay, status: draft.needsReview ? "TENTATIVE" : "CONFIRMED", + }; + if (draft.location) properties.location = draft.location; + const result = await handleRequest({ method: "POST", path: "/calendar/events/create", body: { calendarId, ...properties } }); + notifyFastAction(result?.error + ? `Create Event: ${result.error}` + : draft.needsReview + ? `Event created as tentative (couldn't confirm date/time) — please review: ${properties.title}` + : `Event created: ${properties.title}`); + return; + } + + if (menuItemId === FAST_ACTION_MENU_IDS.contact) { + const book = await pickDefaultAddressBookId(); + if (!book) return notifyFastAction("Add Sender to Contacts: no address book found."); + const msg = await messenger.messages.get(messageId); + const { name, email } = parseSenderAuthor(msg.author); + if (!email) return notifyFastAction("Add Sender to Contacts: could not find a sender email address."); + const existing = await handleRequest({ method: "POST", path: "/contacts/search", body: { query: email, book } }); + if (Array.isArray(existing) && existing.length > 0) { + return notifyFastAction(`Add Sender to Contacts: ${email} is already a contact.`); + } + const properties = { PrimaryEmail: email }; + if (name) properties.DisplayName = name; + const result = await handleRequest({ method: "POST", path: "/contacts/create", body: { book, properties } }); + notifyFastAction(result?.error ? `Add Sender to Contacts: ${result.error}` : `Contact added: ${name || email}`); + return; + } +} + +if (typeof messenger !== "undefined" && messenger.menus?.create) { + messenger.menus.create({ id: FAST_ACTION_MENU_IDS.note, title: "Save to Notes", contexts: ["message_list"] }); + messenger.menus.create({ id: FAST_ACTION_MENU_IDS.task, title: "Create Task", contexts: ["message_list"] }); + messenger.menus.create({ id: FAST_ACTION_MENU_IDS.event, title: "Create Event", contexts: ["message_list"] }); + messenger.menus.create({ id: FAST_ACTION_MENU_IDS.contact, title: "Add Sender to Contacts", contexts: ["message_list"] }); + + messenger.menus.onClicked.addListener((info) => { + const message = info.selectedMessages?.messages?.[0]; + if (!message) return; + handleFastActionClick(info.menuItemId, message.id).catch((err) => { + console.log("[tb-ai] fast action failed:", err.message); + notifyFastAction(`Action failed: ${err.message}`); + }); + }); +} + // ─── Request Router ───────────────────────────────────────────────── async function handleRequest({ method, path, body }) { @@ -629,6 +766,17 @@ async function handleRequest({ method, path, body }) { return { items, markdown: actionItemsToMarkdown(items) }; } + // Deterministic (no LLM) event-draft extraction — see extension/src/email-event-parse.js + // (pure function, unit tested independently of the bridge). (ODIAA-2333) + const eventDraftMatch = path.match(/^\/messages\/(\d+)\/event-draft$/); + if (eventDraftMatch && method === "POST") { + const msgId = parseInt(eventDraftMatch[1]); + const msg = await messenger.messages.get(msgId); + const full = await messenger.messages.getFull(msgId); + const text = extractParts(full).text || ""; + return parseEventDraft({ subject: msg?.subject, body: text, date: msg?.date?.toISOString() }); + } + // Read message (default — must be AFTER all /messages/:id/* sub-routes) const msgMatch = path.match(/^\/messages\/(\d+)$/); if (msgMatch && method === "GET") { @@ -1140,6 +1288,13 @@ async function handleRequest({ method, path, body }) { return { id: contact.id, properties: contact.properties }; } + // ─── Address books ────────────────────────────────────────────── (ODIAA-2333) + + if (path === "/addressbooks" && method === "GET") { + const books = await messenger.addressBooks.list(); + return books.map((b) => ({ id: b.id, name: b.name })); + } + // ─── Sync ─────────────────────────────────────────────────────── if (path === "/sync" && method === "POST") { diff --git a/extension/src/email-event-parse.js b/extension/src/email-event-parse.js new file mode 100644 index 0000000..e6be7cf --- /dev/null +++ b/extension/src/email-event-parse.js @@ -0,0 +1,239 @@ +/** + * Deterministic (no LLM) event draft extraction from an email's subject/body: + * a candidate date, time (or time range), and location, used by + * `POST /messages/:id/event-draft` and by the "Create Event" context-menu + * action (which calls parseEventDraft() directly, in-process). + * + * Loaded as a background script before background.js (see manifest.json), so + * plain globals rather than ES exports — same convention as action-items.js. + * test/email-event-parse.test.mjs loads this file in a Node vm. + * + * Heuristic and intentionally conservative: when no date/time is found, the + * caller (background.js / cli / mcp) falls back to a placeholder draft the + * user edits, rather than guessing wildly at meaning. + */ + +const WEEKDAY_RE = + /\b(next\s+)?(sunday|sun|monday|mon|tuesday|tues|tue|wednesday|weds|wed|thursday|thurs|thur|thu|friday|fri|saturday|sat)\b/i; + +const WEEKDAY_INDEX = { + sun: 0, sunday: 0, + mon: 1, monday: 1, + tue: 2, tues: 2, tuesday: 2, + wed: 3, weds: 3, wednesday: 3, + thu: 4, thur: 4, thurs: 4, thursday: 4, + fri: 5, friday: 5, + sat: 6, saturday: 6, +}; + +const MONTH_RE = + /\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\.?\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?\b/i; + +const MONTH_INDEX = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +const NUMERIC_DATE_RE = /\b(\d{1,2})[/-](\d{1,2})(?:[/-](\d{2,4}))?\b/; +const TOMORROW_RE = /\btomorrow\b/i; +const TONIGHT_RE = /\btonight\b/i; +const TODAY_RE = /\btoday\b/i; + +const RANGE_TIME_RE = + /\b(\d{1,2})(?::([0-5]\d))?\s*(am|pm)?\s*(?:-|–|to)\s*(\d{1,2})(?::([0-5]\d))?\s*(am|pm)\b/i; +const SINGLE_TIME_12H_RE = /\b(\d{1,2})(?::([0-5]\d))?\s*(am|pm)\b/i; +const SINGLE_TIME_24H_RE = /\b([01]?\d|2[0-3]):([0-5]\d)\b/; + +const MEETING_URL_RE = /\bhttps?:\/\/\S*(zoom\.us|meet\.google\.com|teams\.microsoft\.com|webex\.com)\S*/i; +const LOCATION_LABEL_RE = /\b(?:location|where)\s*:\s*([^\n]{2,80})/i; +const ROOM_RE = /\broom\s*#?\s*(\d+[a-z]?)\b/i; +const AT_PLACE_RE = /\bat\s+(?:the\s+)?([A-Z][A-Za-z0-9&'.-]*(?:\s+[A-Z][A-Za-z0-9&'.-]*){0,3})/; + +function atMidnightUTC(date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function nextWeekday(referenceDate, targetDow, skipCurrentWeek) { + const ref = atMidnightUTC(referenceDate); + const refDow = ref.getUTCDay(); + let diff = (targetDow - refDow + 7) % 7; + if (diff === 0) diff = skipCurrentWeek ? 7 : 0; + else if (skipCurrentWeek) diff += 7; + const result = new Date(ref); + result.setUTCDate(ref.getUTCDate() + diff); + return result; +} + +function resolveMonthDay(monthIdx, day, year, referenceDate) { + const y = year || referenceDate.getUTCFullYear(); + let d = new Date(Date.UTC(y, monthIdx, day)); + if (!year && d.getTime() < atMidnightUTC(referenceDate).getTime() - 24 * 3600 * 1000) { + d = new Date(Date.UTC(y + 1, monthIdx, day)); + } + return d; +} + +function normalizeYear(y) { + if (y === undefined) return undefined; + const n = parseInt(y, 10); + return n < 100 ? 2000 + n : n; +} + +// Returns { date: Date (UTC midnight), matched: string } or null. +function resolveDateOnly(text, referenceDate) { + let m = text.match(MONTH_RE); + if (m) { + const monthIdx = MONTH_INDEX[m[1].slice(0, 3).toLowerCase()]; + const day = parseInt(m[2], 10); + const year = normalizeYear(m[3]); + return { date: resolveMonthDay(monthIdx, day, year, referenceDate), matched: m[0] }; + } + + m = text.match(NUMERIC_DATE_RE); + if (m) { + const monthIdx = parseInt(m[1], 10) - 1; + const day = parseInt(m[2], 10); + const year = normalizeYear(m[3]); + if (monthIdx >= 0 && monthIdx <= 11 && day >= 1 && day <= 31) { + return { date: resolveMonthDay(monthIdx, day, year, referenceDate), matched: m[0] }; + } + } + + if (TOMORROW_RE.test(text) || TONIGHT_RE.test(text)) { + const d = atMidnightUTC(referenceDate); + d.setUTCDate(d.getUTCDate() + 1); + return { date: d, matched: TONIGHT_RE.test(text) && !TOMORROW_RE.test(text) ? "tonight" : "tomorrow" }; + } + + if (TODAY_RE.test(text)) { + return { date: atMidnightUTC(referenceDate), matched: "today" }; + } + + m = text.match(WEEKDAY_RE); + if (m) { + const skipCurrentWeek = !!m[1]; + const dow = WEEKDAY_INDEX[m[2].toLowerCase()]; + return { date: nextWeekday(referenceDate, dow, skipCurrentWeek), matched: m[0] }; + } + + return null; +} + +function parseTimeToken(hourStr, minuteStr, meridiem) { + let hour = parseInt(hourStr, 10); + const minute = minuteStr ? parseInt(minuteStr, 10) : 0; + if (meridiem) { + const mer = meridiem.toLowerCase(); + if (mer === "pm" && hour !== 12) hour += 12; + if (mer === "am" && hour === 12) hour = 0; + } + return { hour, minute }; +} + +// Returns { start: {hour, minute}, end: {hour, minute} | null } or null (no time found). +function resolveTimeRange(text) { + let m = text.match(RANGE_TIME_RE); + if (m) { + const endMeridiem = m[6]; + const startMeridiem = m[3] || endMeridiem; // "2-3pm" -> both pm + return { + start: parseTimeToken(m[1], m[2], startMeridiem), + end: parseTimeToken(m[4], m[5], endMeridiem), + }; + } + + m = text.match(SINGLE_TIME_12H_RE); + if (m) return { start: parseTimeToken(m[1], m[2], m[3]), end: null }; + + m = text.match(SINGLE_TIME_24H_RE); + if (m) return { start: parseTimeToken(m[1], m[2], null), end: null }; + + return null; +} + +function stripTrailingPunctuation(s) { + return s.replace(/[.,;:!?]+$/, ""); +} + +function extractLocation(text) { + let m = text.match(MEETING_URL_RE); + if (m) return stripTrailingPunctuation(m[0]); + + m = text.match(LOCATION_LABEL_RE); + if (m) return stripTrailingPunctuation(m[1].trim()); + + m = text.match(ROOM_RE); + if (m) return `Room ${m[1]}`; + + m = text.match(AT_PLACE_RE); + if (m) return stripTrailingPunctuation(m[1].trim()); + + return null; +} + +function cleanSubject(subject) { + return String(subject || "") + .replace(/^\s*(re|fwd?|fw)\s*:\s*/i, "") + .replace(/^\s*(re|fwd?|fw)\s*:\s*/i, "") + .trim(); +} + +function withTime(date, time) { + const d = new Date(date); + d.setUTCHours(time.hour, time.minute, 0, 0); + return d; +} + +/** + * Parses a candidate calendar event out of an email's subject/body. + * + * @param {{subject?: string, body?: string, date?: string}} email + * @returns {{title: string, start: string, end: string, allDay: boolean, location: string|null, description: string, needsReview: boolean}} + */ +function parseEventDraft({ subject, body, date } = {}) { + const referenceDate = date && !isNaN(new Date(date)) ? new Date(date) : new Date(); + const haystack = `${subject || ""}\n${body || ""}`; + const title = cleanSubject(subject) || "Untitled event"; + const location = extractLocation(haystack); + + const dateMatch = resolveDateOnly(haystack, referenceDate); + const timeMatch = dateMatch ? resolveTimeRange(haystack) : null; + + let start, end, allDay, needsReview; + + if (dateMatch && timeMatch) { + start = withTime(dateMatch.date, timeMatch.start); + end = timeMatch.end ? withTime(dateMatch.date, timeMatch.end) : new Date(start.getTime() + 60 * 60 * 1000); + allDay = false; + needsReview = false; + } else if (dateMatch) { + start = dateMatch.date; + end = dateMatch.date; + allDay = true; + needsReview = false; + } else { + // No date detected at all — placeholder draft the user must edit. + start = atMidnightUTC(referenceDate); + start.setUTCDate(start.getUTCDate() + 1); + start.setUTCHours(9, 0, 0, 0); + end = new Date(start.getTime() + 30 * 60 * 1000); + allDay = false; + needsReview = true; + } + + let description = String(body || "").trim(); + if (description.length > 2000) description = `${description.slice(0, 2000)}\n...[truncated]`; + if (needsReview) { + description = `⚠ Could not detect a date/time in this email — verify before confirming.\n\n${description}`; + } + + return { + title, + start: start.toISOString(), + end: end.toISOString(), + allDay, + location, + description, + needsReview, + }; +} diff --git a/mcp/src/tools.js b/mcp/src/tools.js index 2fad62a..b4e5840 100644 --- a/mcp/src/tools.js +++ b/mcp/src/tools.js @@ -1034,4 +1034,180 @@ export const tools = [ return await api("POST", "/calendar/clashes", { start: args.start, end: args.end }); }, }, + + // ─── 31. Address books (ODIAA-2333) ──────────────────────────────── + { + name: "address_book_list", + description: "List address books (id and name). Used to pick a target book for contact_create / email_to_contact.", + inputSchema: { type: "object", properties: {} }, + handler: async (args, api) => await api("GET", "/addressbooks"), + }, + + // ─── 32. Fast actions: email to note (ODIAA-2333) ────────────────── + { + name: "email_to_note", + description: "Save an email to the local notes workspace (\"Save to Notes\" fast action). Purely local once the message body is fetched — no access switch required.", + inputSchema: { + type: "object", + properties: { + messageId: { type: "string", description: "Message id to save" }, + name: { type: "string", description: "Note name (defaults to a slug of the subject)" }, + append: { type: "boolean", description: "Append to an existing note instead of overwriting" }, + }, + required: ["messageId"], + }, + handler: async (args, api) => { + if (!args.messageId) return { error: "messageId is required" }; + const msg = await api("GET", `/messages/${args.messageId}/full`); + const name = args.name || slugify(msg.subject) || `message-${args.messageId}`; + const body = [ + `**From:** ${msg.author || "unknown"}`, + `**Date:** ${msg.date || "unknown"}`, + "", + msg.parts?.text || msg.parts?.html || "(no body)", + ].join("\n"); + const opts = { title: msg.subject, source: String(args.messageId) }; + return args.append ? appendNote(name, body, opts) : saveNote(name, body, opts); + }, + }, + + // ─── 33. Fast actions: email to task (ODIAA-2333) ────────────────── + { + name: "email_to_task", + description: "Create a task from an email, using action-item extraction for the title/description (\"Create Task\" fast action). Requires the tasksWrite access switch to be enabled (disabled by default) — see docs/ACCESS-CONTROL.md.", + inputSchema: { + type: "object", + properties: { + messageId: { type: "string", description: "Message id to create a task from" }, + calendarId: { type: "string", description: "Target calendar" }, + 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" }, + }, + required: ["messageId", "calendarId"], + }, + handler: async (args, api) => { + if (!args.messageId || !args.calendarId) return { error: "messageId and calendarId required" }; + const msg = await api("GET", `/messages/${args.messageId}/full`); + const extracted = await api("POST", `/messages/${args.messageId}/action-items`, {}); + const properties = { + title: extracted.items?.[0]?.text || msg.subject || `Task from message ${args.messageId}`, + source: String(args.messageId), + }; + if (extracted.markdown) properties.description = extracted.markdown; + if (args.due) properties.due = args.due; + if (args.allDay) properties.allDay = true; + if (args.priority !== undefined) properties.priority = args.priority; + return await api("POST", "/tasks/create", { calendarId: args.calendarId, ...properties }); + }, + }, + + // ─── 34. Fast actions: email to event (ODIAA-2333) ───────────────── + { + name: "email_to_event", + description: "Create a calendar event from an email via deterministic (no LLM) date/time/location parsing (\"Create Event\" fast action). When no date/time is found, falls back to a placeholder draft with status TENTATIVE for the user to edit. Requires the calendarWrite access switch to be enabled (disabled by default) — see docs/ACCESS-CONTROL.md.", + inputSchema: { + type: "object", + properties: { + messageId: { type: "string", description: "Message id to create an event from" }, + calendarId: { type: "string", description: "Target calendar" }, + }, + required: ["messageId", "calendarId"], + }, + handler: async (args, api) => { + if (!args.messageId || !args.calendarId) return { error: "messageId and calendarId required" }; + const draft = await api("POST", `/messages/${args.messageId}/event-draft`, {}); + const properties = { + title: draft.title, + description: draft.description, + start: draft.start, + end: draft.end, + allDay: draft.allDay, + status: draft.needsReview ? "TENTATIVE" : "CONFIRMED", + }; + if (draft.location) properties.location = draft.location; + const created = await api("POST", "/calendar/events/create", { calendarId: args.calendarId, ...properties }); + return { ...created, needsReview: draft.needsReview }; + }, + }, + + // ─── 35. Fast actions: email to contact (ODIAA-2333) ─────────────── + { + name: "email_to_contact", + description: "Add an email's sender as a contact, deduped by email address (\"Add Sender to Contacts\" fast action). Requires the contactsWrite access switch to be enabled (disabled by default) — see docs/ACCESS-CONTROL.md.", + inputSchema: { + type: "object", + properties: { + messageId: { type: "string", description: "Message id whose sender should be added" }, + book: { type: "string", description: "Target address book, by id or name" }, + }, + required: ["messageId", "book"], + }, + handler: async (args, api) => { + if (!args.messageId || !args.book) return { error: "messageId and book required" }; + const msg = await api("GET", `/messages/${args.messageId}/full`); + const { name, email } = parseSenderAuthor(msg.author); + if (!email) return { error: "could not extract a sender email address from this message" }; + const existing = await api("POST", "/contacts/search", { query: email, book: args.book }); + if (existing.length > 0) return { ...existing[0], deduped: true }; + const properties = { PrimaryEmail: email }; + if (name) properties.DisplayName = name; + const created = await api("POST", "/contacts/create", { book: args.book, properties }); + return { ...created, deduped: false }; + }, + }, + + // ─── 36. Fast actions: notes listen-once (ODIAA-2333) ────────────── + { + name: "notes_listen_once", + description: "Wait for a 'Save to Notes' click in Thunderbird, then save the resulting note. Long-polls the bridge event feed, so this call blocks until either a request arrives or the timeout elapses.", + inputSchema: { + type: "object", + properties: { + timeoutMs: { type: "integer", description: "How long to wait for a pending request, in ms (default 120000)" }, + name: { type: "string", description: "Note name (defaults to a slug of the subject)" }, + append: { type: "boolean", description: "Append to an existing note instead of overwriting" }, + }, + }, + handler: async (args, api) => { + const timeout = args.timeoutMs || 120000; + 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") { + return { error: "no 'Save to Notes' request received before the timeout" }; + } + throw err; + } + const payload = eventResp.event?.data || {}; + const name = args.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 opts = { title: payload.subject, source: payload.messageId ? String(payload.messageId) : undefined }; + return args.append ? appendNote(name, body, opts) : saveNote(name, body, opts); + }, + }, ]; + +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" `) 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 }; +} diff --git a/skills/thunderbird-cli/SKILL.md b/skills/thunderbird-cli/SKILL.md index 07cbf7b..899e21d 100644 --- a/skills/thunderbird-cli/SKILL.md +++ b/skills/thunderbird-cli/SKILL.md @@ -38,7 +38,7 @@ 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 31 MCP tools +## The 37 MCP tools Use these; don't reach for the CLI unless the user explicitly asks for a bulk operation not covered here. @@ -232,6 +232,26 @@ task_create calendarId= title="" due="" source="< call returns `FORBIDDEN`, tell the user it's disabled and how to enable it (`docs/ACCESS-CONTROL.md`), don't work around it. +### K. "Save/task/event/contact this email" (Fast Actions, one click) + +`email_to_note`, `email_to_task`, `email_to_event`, and `email_to_contact` collapse the +manual extract-then-create flow above into one call — same underlying routes, same +access switches, no bypass. They're also available as context-menu items in Thunderbird +itself ("Save to Notes", "Create Task", "Create Event", "Add Sender to Contacts"). + +``` +email_to_task messageId= calendarId= # requires tasksWrite +email_to_event messageId= calendarId= # requires calendarWrite +email_to_contact messageId= book= # requires contactsWrite, deduped by email +email_to_note messageId= # no switch — local notes workspace only +``` + +Creating a task/event/contact is consequential and, unlike a plain read, isn't easily +undone by re-reading the mailbox — get explicit user approval before calling these (not +just before enabling the access switch). `email_to_event` parses the date/time/location +deterministically (no LLM) and may not find one; when it can't, it still creates the +event but marks it tentative for the user to fix — say so, don't present it as confirmed. + ## Safety ### Destructive operations @@ -329,6 +349,12 @@ MCP tool → CLI command mapping: | `task_create` | `tb tasks create --calendar --title ...` | | `task_update` | `tb tasks update <taskId> --calendar <calendarId> ...` | | `email_action_items` | `tb action-items <messageId>` | +| `address_book_list` | `tb address-books` | +| `email_to_note` | `tb email-to-note <messageId>` | +| `email_to_task` | `tb email-to-task <messageId> --calendar <calendarId>` | +| `email_to_event` | `tb email-to-event <messageId> --calendar <calendarId>` | +| `email_to_contact` | `tb email-to-contact <messageId> --book <bookId>` | +| `notes_listen_once` | `tb notes listen` | ## Version diff --git a/test/access-control.test.mjs b/test/access-control.test.mjs index 13b0176..b641082 100644 --- a/test/access-control.test.mjs +++ b/test/access-control.test.mjs @@ -473,6 +473,26 @@ console.log("\n\x1b[1mAction items route\x1b[0m"); result.markdown === "- [ ] Please review the attached doc by Friday (by Friday)"); } +// ─── Address books: new route, ungated read-only (ODIAA-2333) ─────────── + +console.log("\n\x1b[1mAddress books route\x1b[0m"); +{ + const { handle } = load(); + const books = await handle("GET", "/addressbooks"); + test("GET /addressbooks is ungated with the default (all-closed) policy", + Array.isArray(books) && books[0]?.id === "ab1"); +} + +// ─── Event draft: new route, ungated (deterministic, read-only) (ODIAA-2333) ── + +console.log("\n\x1b[1mEvent draft route\x1b[0m"); +{ + const { handle } = load(); + const draft = await handle("POST", "/messages/1/event-draft", {}); + test("POST /messages/:id/event-draft is ungated with the default (all-closed) policy", + typeof draft.title === "string" && typeof draft.start === "string"); +} + 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/email-event-parse.test.mjs b/test/email-event-parse.test.mjs new file mode 100644 index 0000000..dc07e62 --- /dev/null +++ b/test/email-event-parse.test.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * Unit tests for extension/src/email-event-parse.js (deterministic event-draft 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", "email-event-parse.js"); +const ctx = vm.createContext({}); +vm.runInContext(readFileSync(SRC, "utf-8"), ctx, { filename: SRC }); +const { parseEventDraft } = ctx; + +let passed = 0, failed = 0; +function test(name, ok, detail = "") { + if (ok) { passed++; console.log(` \x1b[32m✓\x1b[0m ${name}`); } + else { failed++; console.log(` \x1b[31m✗\x1b[0m ${name}${detail ? ` — ${detail}` : ""}`); } +} + +// Thursday, 2026-01-01T12:00:00Z — a fixed, known reference point for every test below. +const REF = "2026-01-01T12:00:00Z"; + +console.log("\n\x1b[1m=== email-event-parse tests ===\x1b[0m"); + +console.log("\n\x1b[1mparseEventDraft: relative dates\x1b[0m"); + +{ + const d = parseEventDraft({ subject: "Quick sync", body: "Let's meet tomorrow at 3pm at the Downtown Office.", date: REF }); + test("tomorrow resolves to the day after the reference date", d.start.startsWith("2026-01-02")); + test("time is parsed as 3pm", new Date(d.start).getUTCHours() === 15); + test("not flagged for review", d.needsReview === false); + test("not all-day", d.allDay === false); + test("location picked up from 'at <Place>'", d.location === "Downtown Office"); +} + +{ + const d = parseEventDraft({ subject: "Heads up", body: "We're on for today, no specific time though.", date: REF }); + test("today resolves to the reference date itself", d.start.startsWith("2026-01-01")); + test("no time found means all-day", d.allDay === true); +} + +console.log("\n\x1b[1mparseEventDraft: weekday resolution\x1b[0m"); + +{ + const d = parseEventDraft({ subject: "Planning", body: "Can we sync on Friday at 10am?", date: REF }); + const start = new Date(d.start); + test("resolved date falls on a Friday", start.getUTCDay() === 5); + test("resolved date is on/after the reference date", start.getTime() >= new Date(REF).getTime()); + test("time is parsed as 10am", start.getUTCHours() === 10); +} + +{ + const d = parseEventDraft({ subject: "Planning", body: "Let's push it to next Friday instead.", date: REF }); + const baseline = parseEventDraft({ subject: "Planning", body: "Let's meet Friday instead.", date: REF }); + test("'next Friday' resolves a week later than plain 'Friday'", new Date(d.start).getTime() === new Date(baseline.start).getTime() + 7 * 24 * 3600 * 1000); +} + +console.log("\n\x1b[1mparseEventDraft: explicit dates\x1b[0m"); + +{ + const d = parseEventDraft({ subject: "Deadline", body: "Reminder: submit the report by 3/15.", date: REF }); + const start = new Date(d.start); + test("numeric month/day parsed", start.getUTCMonth() === 2 && start.getUTCDate() === 15); +} + +{ + const d = parseEventDraft({ subject: "Save the date", body: "The offsite is on Dec 5th, hope to see you there.", date: REF }); + const start = new Date(d.start); + test("month-name date parsed", start.getUTCMonth() === 11 && start.getUTCDate() === 5); + test("no explicit year rolls forward into the same year as the reference date when still upcoming", start.getUTCFullYear() === 2026); +} + +console.log("\n\x1b[1mparseEventDraft: time ranges\x1b[0m"); + +{ + const d = parseEventDraft({ subject: "Workshop", body: "Join us Monday from 2-3pm for the workshop.", date: REF }); + const start = new Date(d.start), end = new Date(d.end); + test("range start hour inherits trailing meridiem", start.getUTCHours() === 14); + test("range end hour parsed", end.getUTCHours() === 15); +} + +{ + const d = parseEventDraft({ subject: "Standup", body: "Standup is on Wednesday at 9am, should take about 15 minutes.", date: REF }); + const start = new Date(d.start), end = new Date(d.end); + test("single time with no explicit end defaults to a 1 hour block", end.getTime() - start.getTime() === 60 * 60 * 1000); +} + +console.log("\n\x1b[1mparseEventDraft: location extraction\x1b[0m"); + +{ + const d = parseEventDraft({ subject: "Meeting", body: "Location: Conference Room B, 4th Floor\nMonday at 10am.", date: REF }); + test("explicit 'Location:' label wins", d.location === "Conference Room B, 4th Floor"); +} + +{ + const d = parseEventDraft({ subject: "Standup", body: "We'll meet in Room 204 on Tuesday at 9am.", date: REF }); + test("'Room ###' pattern detected", d.location === "Room 204"); +} + +{ + const d = parseEventDraft({ subject: "Kickoff", body: "Join via https://zoom.us/j/1234567890 on Monday at 1pm.", date: REF }); + test("meeting URL detected as location", d.location === "https://zoom.us/j/1234567890"); +} + +{ + const d = parseEventDraft({ subject: "Catch up", body: "Let's grab coffee sometime next week.", date: REF }); + test("no location found returns null", d.location === null); +} + +console.log("\n\x1b[1mparseEventDraft: fallback when nothing is detected\x1b[0m"); + +{ + const d = parseEventDraft({ subject: "Random update", body: "Nothing time-related in this email at all.", date: REF }); + test("needsReview is true when no date/time is found", d.needsReview === true); + test("description carries a review warning", d.description.includes("Could not detect")); + test("start is still a valid ISO date", !isNaN(new Date(d.start).getTime())); +} + +console.log("\n\x1b[1mparseEventDraft: title\x1b[0m"); + +{ + const d = parseEventDraft({ subject: "Re: Fwd: Team lunch", body: "Friday at noon.", date: REF }); + test("Re:/Fwd: prefixes are stripped from the title", d.title === "Team lunch"); +} + +{ + const d = parseEventDraft({ subject: "", body: "Meeting tomorrow.", date: REF }); + test("empty subject falls back to a placeholder title", d.title === "Untitled event"); +} + +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 19ce714..498f0a0 100644 --- a/test/mcp-test.mjs +++ b/test/mcp-test.mjs @@ -131,7 +131,7 @@ function handle({ method, path, body }) { if (path?.match(/\/raw$/)) return { raw: "From: a@b.com\nSubject: Test\n\nBody" }; if (path?.match(/\/headers$/)) return { id: 1, subject: "Test", author: "a@b.com" }; if (path?.match(/\/full$/)) - return { id: 1, subject: "Test", parts: { text: "Hello", html: "<p>Hi</p>", attachments: [] } }; + return { id: 1, subject: "Test", author: "a@b.com", date: "2026-04-01", parts: { text: "Hello", html: "<p>Hi</p>", attachments: [] } }; if (path?.match(/\/check-download$/)) return { id: 1, downloadState: "full", size: 100, hasBody: true }; if (path?.match(/\/thread$/)) return { thread: [{ id: 1, subject: "Test" }], count: 1 }; @@ -164,9 +164,29 @@ function handle({ method, path, body }) { if (path === "/stats") return { totalAccounts: 1, totalUnread: 5, totalMessages: 100, accounts: [] }; if (path === "/sync") return { success: true, synced: body?.all ? "all" : body?.folderId }; - if (path === "/contacts/search") return [{ id: "c1", name: "John", email: "j@e.com", emails: ["j@e.com"], book: "P", bookId: "ab1" }]; + if (path === "/contacts/search") { + // "a@b.com" (the mock message sender) is treated as not-yet-a-contact, for the + // email_to_contact dedupe test; every other query matches the fixture contact. + if (body?.query === "a@b.com") return []; + return [{ id: "c1", name: "John", email: "j@e.com", emails: ["j@e.com"], book: "P", bookId: "ab1" }]; + } if (path === "/contacts/create") return { id: "c2", book: "P", bookId: "ab1", properties: body?.properties || {} }; if (path === "/contacts/update") return { id: body?.id, properties: body?.properties || {} }; + if (path === "/addressbooks" && method === "GET") return [{ id: "ab1", name: "Personal" }]; + if (path === "/tasks/create") + return { id: "t1", calendarId: body?.calendarId, title: body?.title, description: body?.description, source: body?.source }; + if (path?.match(/\/event-draft$/) && method === "POST") + return { + title: "Test", + description: "Hello", + start: "2026-01-02T15:00:00.000Z", + end: "2026-01-02T16:00:00.000Z", + allDay: false, + location: "Somewhere", + needsReview: false, + }; + if (path?.match(/\/action-items$/) && method === "POST") + return { items: [{ text: "Please review the attached doc", checked: false, dueHint: null }], markdown: "- [ ] Please review the attached doc" }; return { error: `Not found: ${method} ${path}` }; } @@ -196,6 +216,25 @@ async function startBridge() { res.end(JSON.stringify({ bridge: "running", extension: "connected" })); return; } + // Bridge-local event feed, mirroring bridge.js's /bridge/events — not routed to + // the mock extension. Answers a "note-save-requested" wait immediately. + if (req.url.startsWith("/bridge/events")) { + const url = new URL(req.url, "http://127.0.0.1"); + if (url.searchParams.get("wait") === "note-save-requested") { + res.writeHead(200); + res.end(JSON.stringify({ + event: { + name: "note-save-requested", + receivedAt: Date.now(), + data: { subject: "Lunch Friday", author: "a@b.com", date: "2026-04-01", body: "Let's grab lunch.", messageId: 5 }, + }, + })); + return; + } + res.writeHead(200); + res.end(JSON.stringify({ events: [] })); + return; + } let b = ""; for await (const c of req) b += c; let pb = null; @@ -361,7 +400,7 @@ await client.initialize(); console.log("\x1b[1mProtocol\x1b[0m"); const toolList = await client.listTools(); -test("tools/list returns 31 tools", toolList, (r) => Array.isArray(r) && r.length === 31); +test("tools/list returns 37 tools", toolList, (r) => Array.isArray(r) && r.length === 37); test("each tool has name+description+inputSchema", toolList, (r) => r.every((t) => t.name && t.description && t.inputSchema) ); @@ -695,6 +734,43 @@ test( (r) => r.success === true && r.action === "draft_opened" ); +console.log("\n\x1b[1mFast actions\x1b[0m"); +test( + "address_book_list", + await client.callTool("address_book_list", {}), + (r) => Array.isArray(r) && r[0]?.id === "ab1" +); +test( + "email_to_note saves a note from a message", + await client.callTool("email_to_note", { messageId: "1" }), + (r) => r.name === "test" && r.title === "Test" +); +test( + "email_to_task creates a task from action-item extraction", + await client.callTool("email_to_task", { messageId: "1", calendarId: "cal1" }), + (r) => r.calendarId === "cal1" && r.title === "Please review the attached doc" && r.source === "1" +); +test( + "email_to_event creates a calendar event from the parsed draft", + await client.callTool("email_to_event", { messageId: "1", calendarId: "cal1" }), + (r) => r.id === "ev2" && r.calendarId === "cal1" && r.title === "Test" && r.needsReview === false +); +test( + "email_to_contact adds the sender as a new contact when not already present", + await client.callTool("email_to_contact", { messageId: "1", book: "ab1" }), + (r) => r.deduped === false && r.properties?.PrimaryEmail === "a@b.com" +); +test( + "email_to_contact requires messageId and book", + await client.callTool("email_to_contact", { messageId: "1" }), + (r) => typeof r.error === "string" +); +test( + "notes_listen_once saves the pushed note", + await client.callTool("notes_listen_once", { timeoutMs: 5000 }), + (r) => r.name === "lunch-friday" && r.title === "Lunch Friday" +); + const resourceList = await client.listResources(); test( "resources/list includes the notes workspace", @@ -726,7 +802,7 @@ const toolsB = await clientB.listTools(); test( "concurrent MCP server instances initialize and list tools", { toolsACount: toolsA.length, toolsBCount: toolsB.length }, - (r) => r.toolsACount === 31 && r.toolsBCount === 31 + (r) => r.toolsACount === 37 && r.toolsBCount === 37 ); clientA.close(); clientB.close(); diff --git a/test/quick-test.mjs b/test/quick-test.mjs index 531b9a7..e6348e6 100644 --- a/test/quick-test.mjs +++ b/test/quick-test.mjs @@ -64,6 +64,9 @@ function handle({ method, path, body }) { if (path === "/contacts/update") return { id: body?.id, properties: body?.properties || {} }; if (path === "/contacts" && method === "GET") return [{ id: "c1", name: "John", email: "j@e.com", book: "P" }]; if (path?.match(/^\/contacts\/[^/]+$/)) return { id: "c1", properties: { DisplayName: "John" } }; + if (path === "/addressbooks" && method === "GET") return [{ id: "ab1", name: "Personal" }]; + if (path?.match(/\/action-items$/) && method === "POST") return { items: [{ text: "Please review the attached doc", checked: false, dueHint: null }], markdown: "- [ ] Please review the attached doc" }; + if (path?.match(/\/event-draft$/) && method === "POST") return { title: "T", description: "Hello", start: "2026-01-02T15:00:00.000Z", end: "2026-01-02T16:00:00.000Z", allDay: false, location: "Somewhere", needsReview: false }; if (path === "/calendars" && method === "GET") return [{ id: "cal1", type: "storage", name: "Home", url: "moz-storage-calendar://cal1", readOnly: false, enabled: true, color: null }]; if (path === "/calendar/events/list") return [{ id: "ev1", calendarId: "cal1", title: "Standup", start: "2026-01-15T10:00:00Z", end: "2026-01-15T10:30:00Z", allDay: false, status: "CONFIRMED", transparency: "OPAQUE" }]; if (path === "/calendar/events/create") return { id: "ev2", calendarId: body?.calendarId, title: body?.title, start: body?.start, end: body?.end }; @@ -265,6 +268,15 @@ test("POST /contacts/create", test("POST /contacts/update", await httpCall("POST", "/contacts/update", { id: "c1", properties: { LastName: "Doe" } }), r => r.id === "c1" && r.properties.LastName === "Doe"); +test("GET /addressbooks", await httpCall("GET", "/addressbooks"), r => Array.isArray(r) && r[0].id === "ab1"); + +console.log("\n\x1b[1mFast Actions\x1b[0m"); +test("POST /messages/1/action-items", + await httpCall("POST", "/messages/1/action-items", {}), + r => r.items?.[0]?.text === "Please review the attached doc"); +test("POST /messages/1/event-draft", + await httpCall("POST", "/messages/1/event-draft", {}), + r => r.title === "T" && r.allDay === false && r.needsReview === false); console.log("\n\x1b[1mCalendar\x1b[0m"); test("GET /calendars", await httpCall("GET", "/calendars"), r => Array.isArray(r) && r[0].id === "cal1");