mcpbrowser 0.3.34 → 0.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/actions/click-element.js +8 -3
  3. package/src/actions/execute-javascript.js +8 -3
  4. package/src/actions/fetch-page.js +9 -3
  5. package/src/actions/get-current-html.js +9 -3
  6. package/src/actions/plugin-action.js +180 -0
  7. package/src/actions/plugin-info.js +170 -0
  8. package/src/core/logger.js +3 -7
  9. package/src/core/plugin-loader.js +344 -0
  10. package/src/mcp-browser.js +34 -2
  11. package/src/plugins/_example/index.js +140 -0
  12. package/src/plugins/gcal/actions/check-availability.js +185 -0
  13. package/src/plugins/gcal/actions/create-event.js +238 -0
  14. package/src/plugins/gcal/actions/delete-event.js +138 -0
  15. package/src/plugins/gcal/actions/edit-event.js +244 -0
  16. package/src/plugins/gcal/actions/list-events.js +96 -0
  17. package/src/plugins/gcal/actions/read-event.js +174 -0
  18. package/src/plugins/gcal/actions/rsvp-event.js +149 -0
  19. package/src/plugins/gcal/actions/search-events.js +121 -0
  20. package/src/plugins/gcal/helpers.js +415 -0
  21. package/src/plugins/gcal/index.js +148 -0
  22. package/src/plugins/gcal/selectors.js +54 -0
  23. package/src/plugins/gmail/actions/archive-email.js +65 -0
  24. package/src/plugins/gmail/actions/compose-email.js +116 -0
  25. package/src/plugins/gmail/actions/delete-email.js +65 -0
  26. package/src/plugins/gmail/actions/forward-email.js +95 -0
  27. package/src/plugins/gmail/actions/label-email.js +107 -0
  28. package/src/plugins/gmail/actions/list-emails.js +61 -0
  29. package/src/plugins/gmail/actions/mark-read.js +71 -0
  30. package/src/plugins/gmail/actions/mark-unread.js +71 -0
  31. package/src/plugins/gmail/actions/read-email.js +149 -0
  32. package/src/plugins/gmail/actions/reply-email.js +87 -0
  33. package/src/plugins/gmail/actions/search-emails.js +95 -0
  34. package/src/plugins/gmail/helpers.js +419 -0
  35. package/src/plugins/gmail/index.js +195 -0
  36. package/src/plugins/gmail/selectors.js +82 -0
  37. package/src/plugins.json +4 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * helpers.js — Shared tiered utilities for the Gmail plugin.
3
+ *
4
+ * Provides URL-based navigation (T1), keyboard shortcut verification (T2),
5
+ * ARIA/data-attr DOM utilities (T3), and CSS-selector data extraction (T4).
6
+ *
7
+ * All helpers are stateless — they inspect the page at invocation time
8
+ * per FR-017. No internal state is maintained between calls.
9
+ */
10
+
11
+ import { MCPResponse } from '../../core/responses.js';
12
+ import * as sel from './selectors.js';
13
+ import logger from '../../core/logger.js';
14
+
15
+ // ============================================================================
16
+ // CONSTANTS
17
+ // ============================================================================
18
+
19
+ /** Default timeout for waiting on Gmail content (FR-012) */
20
+ export const DEFAULT_TIMEOUT = 10_000;
21
+
22
+ /** Gmail view states detected from URL hash + DOM (FR-024) */
23
+ export const VIEW = Object.freeze({
24
+ EMAIL_LIST: 'email_list',
25
+ THREAD: 'thread',
26
+ COMPOSE: 'compose',
27
+ SEARCH_RESULTS: 'search_results',
28
+ LOADING: 'loading',
29
+ NOT_GMAIL: 'not_gmail',
30
+ NOT_READY: 'not_ready'
31
+ });
32
+
33
+ /** Standard Gmail folder hashes */
34
+ const STANDARD_FOLDERS = new Set([
35
+ 'inbox', 'sent', 'drafts', 'trash', 'spam', 'starred', 'all', 'important'
36
+ ]);
37
+
38
+ // ============================================================================
39
+ // T1: URL-BASED NAVIGATION (FR-020)
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Extract the Google account index from a Gmail URL.
44
+ * Handles /u/0/, /u/1/, /u/2/, etc. Defaults to '0' if not found.
45
+ * @param {string} url - Current page URL
46
+ * @returns {string} Account index (e.g., '0', '1', '2')
47
+ */
48
+ export function getAccountIndex(url) {
49
+ const match = url.match(/\/u\/(\d+)\//);
50
+ return match ? match[1] : '0';
51
+ }
52
+
53
+ /**
54
+ * Navigate to a Gmail URL hash, preserving the current account index.
55
+ * @param {import('puppeteer-core').Page} page
56
+ * @param {string} hash - Hash fragment (e.g., '#inbox', '#search/query', '#inbox/threadId')
57
+ * @returns {Promise<void>}
58
+ */
59
+ export async function gmailNavigate(page, hash) {
60
+ const currentUrl = page.url();
61
+ const accountIndex = getAccountIndex(currentUrl);
62
+ const targetUrl = `https://mail.google.com/mail/u/${accountIndex}/${hash}`;
63
+ logger.debug(`gmailNavigate: ${targetUrl}`);
64
+ await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: DEFAULT_TIMEOUT });
65
+ }
66
+
67
+ /**
68
+ * Build a Gmail URL hash for a folder name.
69
+ * Standard folders use #folder, custom labels use #label/Name.
70
+ * @param {string} folder - Folder name (inbox, sent, etc.) or label name
71
+ * @returns {string} Hash fragment
72
+ */
73
+ export function folderToHash(folder) {
74
+ const lower = folder.toLowerCase();
75
+ if (STANDARD_FOLDERS.has(lower)) {
76
+ return `#${lower}`;
77
+ }
78
+ return `#label/${encodeURIComponent(folder)}`;
79
+ }
80
+
81
+ // ============================================================================
82
+ // VIEW DETECTION (FR-024 — URL hash primary, DOM fallback)
83
+ // ============================================================================
84
+
85
+ /**
86
+ * Detect the current Gmail view by inspecting the URL hash first (T1),
87
+ * then falling back to DOM inspection for ambiguous cases (T3).
88
+ * Also detects CAPTCHA/interstitial states returning NOT_READY.
89
+ * @param {import('puppeteer-core').Page} page
90
+ * @returns {Promise<string>} One of VIEW enum values
91
+ */
92
+ export async function detectView(page) {
93
+ const url = page.url();
94
+
95
+ // Not Gmail at all
96
+ if (!url.includes('mail.google.com')) {
97
+ logger.debug('detectView: not Gmail');
98
+ return VIEW.NOT_GMAIL;
99
+ }
100
+
101
+ // Check for CAPTCHA / security interstitial (edge case)
102
+ const hasInterstitial = await page.evaluate(() => {
103
+ const body = document.body?.textContent || '';
104
+ return body.includes('Confirm it') ||
105
+ !!document.querySelector('iframe[src*="accounts.google.com"]') ||
106
+ !!document.querySelector('#captcha');
107
+ });
108
+ if (hasInterstitial) {
109
+ logger.debug('detectView: not_ready (interstitial/CAPTCHA)');
110
+ return VIEW.NOT_READY;
111
+ }
112
+
113
+ // Parse URL hash
114
+ const hashMatch = url.match(/#(.+)/);
115
+ const hash = hashMatch ? hashMatch[1] : '';
116
+
117
+ // Check for compose overlay via DOM (compose can appear over any view)
118
+ const hasComposeDialog = await page.evaluate(() =>
119
+ !!document.querySelector('div[role="dialog"]')
120
+ );
121
+ if (hasComposeDialog) {
122
+ logger.debug('detectView: compose (dialog overlay)');
123
+ return VIEW.COMPOSE;
124
+ }
125
+
126
+ // Search results
127
+ if (hash.startsWith('search/')) {
128
+ logger.debug('detectView: search_results');
129
+ return VIEW.SEARCH_RESULTS;
130
+ }
131
+
132
+ // Thread view — hash contains folder/threadId pattern
133
+ if (/^(inbox|sent|all|drafts|trash|spam|starred|important|label\/[^/]+)\/[A-Za-z0-9]+/.test(hash)) {
134
+ logger.debug('detectView: thread');
135
+ return VIEW.THREAD;
136
+ }
137
+
138
+ // Standard folder or label view
139
+ if (hash === '' || hash === 'inbox' || STANDARD_FOLDERS.has(hash) || hash.startsWith('label/')) {
140
+ // Check if content has loaded
141
+ const hasMain = await page.evaluate(() =>
142
+ !!document.querySelector('div[role="main"]')
143
+ );
144
+ if (!hasMain) {
145
+ logger.debug('detectView: loading');
146
+ return VIEW.LOADING;
147
+ }
148
+ logger.debug('detectView: email_list');
149
+ return VIEW.EMAIL_LIST;
150
+ }
151
+
152
+ // Fallback — check if main content exists
153
+ const hasMain = await page.evaluate(() =>
154
+ !!document.querySelector('div[role="main"]')
155
+ );
156
+ if (hasMain) {
157
+ logger.debug('detectView: email_list (fallback)');
158
+ return VIEW.EMAIL_LIST;
159
+ }
160
+
161
+ logger.debug('detectView: loading (no main container)');
162
+ return VIEW.LOADING;
163
+ }
164
+
165
+ // ============================================================================
166
+ // T2: KEYBOARD SHORTCUT VERIFICATION (FR-019)
167
+ // ============================================================================
168
+
169
+ /**
170
+ * Check whether Gmail keyboard shortcuts are enabled.
171
+ * Sends '?' to trigger the shortcuts help dialog, then detects it.
172
+ * @param {import('puppeteer-core').Page} page
173
+ * @returns {Promise<{enabled: boolean, error?: string}>}
174
+ */
175
+ export async function checkKeyboardShortcuts(page) {
176
+ try {
177
+ // Ensure no input is focused before sending shortcut
178
+ await page.evaluate(() => {
179
+ if (document.activeElement && document.activeElement.tagName !== 'BODY') {
180
+ document.activeElement.blur();
181
+ }
182
+ });
183
+
184
+ // Send ? (Shift+/) to open shortcuts help
185
+ await page.keyboard.down('Shift');
186
+ await page.keyboard.press('/');
187
+ await page.keyboard.up('Shift');
188
+
189
+ // Wait briefly for the dialog
190
+ const dialog = await page.waitForSelector(
191
+ 'div[role="dialog"]',
192
+ { timeout: 2000 }
193
+ ).catch(() => null);
194
+
195
+ if (dialog) {
196
+ // Close the help dialog
197
+ await page.keyboard.press('Escape');
198
+ return { enabled: true };
199
+ }
200
+
201
+ return {
202
+ enabled: false,
203
+ error: 'Gmail keyboard shortcuts are disabled. Enable them in Gmail Settings → General → Keyboard shortcuts → ON, then reload Gmail.'
204
+ };
205
+ } catch {
206
+ return {
207
+ enabled: false,
208
+ error: 'Could not verify Gmail keyboard shortcuts. Ensure Gmail is fully loaded.'
209
+ };
210
+ }
211
+ }
212
+
213
+ // ============================================================================
214
+ // PRECONDITION CHECKING (FR-025)
215
+ // ============================================================================
216
+
217
+ /**
218
+ * Validate a precondition before sending a keyboard shortcut.
219
+ * Fails early with an actionable error if the precondition is not met.
220
+ * @param {import('puppeteer-core').Page} page
221
+ * @param {'on_gmail'|'thread_open'|'list_view'} requirement
222
+ * @returns {Promise<{met: boolean, error?: string, suggestion?: string}>}
223
+ */
224
+ export async function checkPrecondition(page, requirement) {
225
+ const url = page.url();
226
+
227
+ switch (requirement) {
228
+ case 'on_gmail': {
229
+ if (!url.includes('mail.google.com')) {
230
+ return {
231
+ met: false,
232
+ error: 'Gmail is not the active page.',
233
+ suggestion: "Use fetch_webpage({ url: 'https://mail.google.com' }) to navigate to Gmail first."
234
+ };
235
+ }
236
+ return { met: true };
237
+ }
238
+
239
+ case 'thread_open': {
240
+ const hash = url.match(/#(.+)/)?.[1] || '';
241
+ const isThread = /^(inbox|sent|all|drafts|trash|spam|starred|important|label\/[^/]+)\/[A-Za-z0-9]+/.test(hash);
242
+ if (!isThread) {
243
+ return {
244
+ met: false,
245
+ error: 'No email thread is currently open.',
246
+ suggestion: "Use plugin_action({ plugin: 'gmail', action: 'read_email', params: { index: 0 } }) to open an email first."
247
+ };
248
+ }
249
+ return { met: true };
250
+ }
251
+
252
+ case 'list_view': {
253
+ const view = await detectView(page);
254
+ if (view !== VIEW.EMAIL_LIST && view !== VIEW.SEARCH_RESULTS) {
255
+ return {
256
+ met: false,
257
+ error: 'Not in email list view.',
258
+ suggestion: "Use plugin_action({ plugin: 'gmail', action: 'list_emails' }) to return to the email list."
259
+ };
260
+ }
261
+ return { met: true };
262
+ }
263
+
264
+ default:
265
+ return { met: false, error: `Unknown precondition: ${requirement}` };
266
+ }
267
+ }
268
+
269
+ // ============================================================================
270
+ // CONTENT WAITING (FR-012 — 10s timeout with diagnostics)
271
+ // ============================================================================
272
+
273
+ /**
274
+ * Wait for a Gmail element to appear, with timeout and diagnostic error.
275
+ * Timeout errors include the selector name per Constitution IV.
276
+ * @param {import('puppeteer-core').Page} page
277
+ * @param {string} selector - CSS selector to wait for
278
+ * @param {number} [timeout=DEFAULT_TIMEOUT]
279
+ * @returns {Promise<void>}
280
+ * @throws {Error} With selector name and tier in message on timeout
281
+ */
282
+ export async function waitForGmail(page, selector, timeout = DEFAULT_TIMEOUT) {
283
+ try {
284
+ await page.waitForSelector(selector, { timeout });
285
+ } catch {
286
+ throw new Error(
287
+ `Gmail content did not load within ${timeout}ms. ` +
288
+ `Selector that failed: "${selector}". ` +
289
+ `The page may still be loading or Gmail's UI may have changed.`
290
+ );
291
+ }
292
+ }
293
+
294
+ // ============================================================================
295
+ // EMAIL ROW SELECTION — Hybrid DOM+keyboard (FR-016)
296
+ // ============================================================================
297
+
298
+ /**
299
+ * Locate and select an email row by ID or positional index.
300
+ * Uses T3 (data-legacy-message-id, role="checkbox") with T4 fallback.
301
+ * Clicks the row's checkbox to select it for keyboard actions.
302
+ * @param {import('puppeteer-core').Page} page
303
+ * @param {{ index?: number, id?: string }} target
304
+ * @returns {Promise<{selected: boolean, error?: string}>}
305
+ */
306
+ export async function selectEmailRow(page, { index, id } = {}) {
307
+ if (id !== undefined && id !== null) {
308
+ // T3: Prefer ID-based selection via data attribute
309
+ const row = await page.$(`tr[data-legacy-message-id="${id}"]`);
310
+ if (row) {
311
+ const checkbox = await row.$('div[role="checkbox"]');
312
+ if (checkbox) {
313
+ await checkbox.click();
314
+ logger.debug(`selectEmailRow: selected by ID "${id}"`);
315
+ return { selected: true };
316
+ }
317
+ }
318
+ logger.debug(`selectEmailRow: ID "${id}" not found, trying index fallback`);
319
+ }
320
+
321
+ if (index !== undefined && index !== null) {
322
+ // T4: Positional index via CSS class selector
323
+ const rows = await page.$$(sel.EMAIL_ROW);
324
+ if (index >= 0 && index < rows.length) {
325
+ const checkbox = await rows[index].$('div[role="checkbox"]');
326
+ if (checkbox) {
327
+ await checkbox.click();
328
+ logger.debug(`selectEmailRow: selected by index ${index}`);
329
+ return { selected: true };
330
+ }
331
+ }
332
+ return {
333
+ selected: false,
334
+ error: `Email index ${index} is out of range. There are ${(await page.$$(sel.EMAIL_ROW)).length} visible emails.`
335
+ };
336
+ }
337
+
338
+ return { selected: false, error: 'No index or id provided for email selection.' };
339
+ }
340
+
341
+ // ============================================================================
342
+ // EMAIL ROW EXTRACTION — T3+T4 data extraction
343
+ // ============================================================================
344
+
345
+ /**
346
+ * Extract structured email summary data from visible email rows.
347
+ * Uses T3 (span[email], [data-legacy-message-id]) and T4 (CSS selectors).
348
+ * @param {import('puppeteer-core').Page} page
349
+ * @param {number} [limit=25]
350
+ * @returns {Promise<Array>} Array of EmailSummary objects per data-model.md
351
+ */
352
+ export async function extractEmailRows(page, limit = 25) {
353
+ return page.evaluate((selectors, lim) => {
354
+ const rows = document.querySelectorAll(selectors.emailRow);
355
+ const results = [];
356
+ const count = Math.min(rows.length, lim);
357
+
358
+ for (let i = 0; i < count; i++) {
359
+ const row = rows[i];
360
+ // T3: span[email] for sender data
361
+ const senderEl = row.querySelector('span[email]');
362
+ // T4: CSS class selectors for subject, snippet, date
363
+ const subjectEl = row.querySelector(selectors.subjectSpan);
364
+ const snippetEl = row.querySelector(selectors.snippetSpan);
365
+ const dateEl = row.querySelector(selectors.dateCell);
366
+ // T4: Unread detection via class
367
+ const isUnread = row.classList.contains(selectors.unreadClass);
368
+ // T3: data attribute for ID
369
+ const id = row.getAttribute('data-legacy-message-id') || undefined;
370
+
371
+ results.push({
372
+ index: i,
373
+ id,
374
+ sender: senderEl?.getAttribute('name') || senderEl?.textContent?.trim() || '',
375
+ senderEmail: senderEl?.getAttribute('email') || '',
376
+ subject: subjectEl?.textContent?.trim() || '',
377
+ snippet: snippetEl?.textContent?.trim() || '',
378
+ date: dateEl?.getAttribute('title') || dateEl?.textContent?.trim() || '',
379
+ isUnread
380
+ });
381
+ }
382
+ return results;
383
+ }, {
384
+ emailRow: sel.EMAIL_ROW,
385
+ subjectSpan: sel.SUBJECT_SPAN,
386
+ snippetSpan: sel.SNIPPET_SPAN,
387
+ dateCell: sel.DATE_CELL,
388
+ unreadClass: sel.EMAIL_ROW_UNREAD
389
+ }, limit);
390
+ }
391
+
392
+ // ============================================================================
393
+ // RESPONSE CLASS
394
+ // ============================================================================
395
+
396
+ /**
397
+ * Gmail-specific response class extending MCPResponse.
398
+ * Carries structured email data alongside nextSteps.
399
+ */
400
+ export class GmailActionResponse extends MCPResponse {
401
+ /**
402
+ * @param {object} data - Structured data payload
403
+ * @param {string} summary - Human-readable summary text
404
+ * @param {string[]} nextSteps - Suggested next actions
405
+ */
406
+ constructor(data, summary, nextSteps) {
407
+ super(nextSteps);
408
+ this.data = data;
409
+ this._summary = summary;
410
+ }
411
+
412
+ _getAdditionalFields() {
413
+ return { ...this.data };
414
+ }
415
+
416
+ getTextSummary() {
417
+ return this._summary;
418
+ }
419
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Gmail plugin — Site-specific automation for Gmail (mail.google.com).
3
+ * Implements the MCPBrowser plugin interface (interfaceVersion 1).
4
+ *
5
+ * Uses a tiered interaction strategy (FR-011):
6
+ * T1: URL hash navigation for folders/search
7
+ * T2: Keyboard shortcuts for actions (compose, reply, archive, etc.)
8
+ * T3: ARIA / data attributes / name attrs for data extraction and form filling
9
+ * T4: CSS class selectors (last resort, centralized in selectors.js)
10
+ */
11
+
12
+ import { listEmails } from './actions/list-emails.js';
13
+ import { readEmail } from './actions/read-email.js';
14
+ import { searchEmails } from './actions/search-emails.js';
15
+ import { composeEmail } from './actions/compose-email.js';
16
+ import { replyEmail } from './actions/reply-email.js';
17
+ import { forwardEmail } from './actions/forward-email.js';
18
+ import { archiveEmail } from './actions/archive-email.js';
19
+ import { deleteEmail } from './actions/delete-email.js';
20
+ import { labelEmail } from './actions/label-email.js';
21
+ import { markRead } from './actions/mark-read.js';
22
+ import { markUnread } from './actions/mark-unread.js';
23
+
24
+ // ============================================================================
25
+ // MANIFEST
26
+ // ============================================================================
27
+
28
+ export const manifest = {
29
+ name: "gmail",
30
+ version: "1.0.0",
31
+ description: "Gmail plugin for MCPBrowser — email management with hybrid UI resilience (URL navigation, keyboard shortcuts, ARIA selectors, CSS fallback)",
32
+ interfaceVersion: 1,
33
+ urlPatterns: ["mail.google.com"],
34
+ domPatterns: ["div[data-ogsr-up]", ".aH2"]
35
+ };
36
+
37
+ // ============================================================================
38
+ // DETECTION
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Detect whether this plugin is applicable for the given page.
43
+ * @param {string} url - Current page URL
44
+ * @param {string} html - Extracted page HTML
45
+ * @returns {{ matched: boolean, confidence?: number }}
46
+ */
47
+ export function matchesPage(url, html) {
48
+ try {
49
+ if (url && url.includes('mail.google.com')) {
50
+ return { matched: true, confidence: 1.0 };
51
+ }
52
+ if (html && (html.includes('data-ogsr-up') || html.includes('aH2'))) {
53
+ return { matched: true, confidence: 0.8 };
54
+ }
55
+ return { matched: false };
56
+ } catch {
57
+ return { matched: false };
58
+ }
59
+ }
60
+
61
+ // ============================================================================
62
+ // ACTIONS
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Return the complete list of actions this plugin provides.
67
+ * @returns {Array} ActionDescriptor[] per plugin interface contract
68
+ */
69
+ export function getActions() {
70
+ return [
71
+ {
72
+ name: "list_emails",
73
+ description: "List visible emails from the current Gmail folder/view",
74
+ params: [
75
+ { name: "folder", type: "string", description: "Gmail folder: inbox, sent, drafts, trash, spam, or a label name", required: false },
76
+ { name: "limit", type: "number", description: "Maximum number of emails to return (default: 25)", required: false, default: 25 }
77
+ ],
78
+ execute: listEmails
79
+ },
80
+ {
81
+ name: "read_email",
82
+ description: "Open and read a specific email by index or ID",
83
+ params: [
84
+ { name: "index", type: "number", description: "0-based position in current email list", required: false },
85
+ { name: "id", type: "string", description: "Gmail internal message/thread ID", required: false }
86
+ ],
87
+ execute: readEmail
88
+ },
89
+ {
90
+ name: "search_emails",
91
+ description: "Search Gmail using URL hash navigation and return matching emails",
92
+ params: [
93
+ { name: "query", type: "string", description: "Gmail search query (supports from:, to:, subject:, has:attachment, etc.)", required: true },
94
+ { name: "limit", type: "number", description: "Maximum results to return (default: 25)", required: false, default: 25 }
95
+ ],
96
+ execute: searchEmails
97
+ },
98
+ {
99
+ name: "compose_email",
100
+ description: "Open Gmail compose window via keyboard shortcut and fill in email fields",
101
+ params: [
102
+ { name: "to", type: "string", description: "Recipient email address", required: true },
103
+ { name: "subject", type: "string", description: "Email subject", required: false, default: "" },
104
+ { name: "body", type: "string", description: "Email body text", required: false, default: "" },
105
+ { name: "cc", type: "string", description: "CC recipient email address", required: false },
106
+ { name: "send", type: "boolean", description: "If true, send immediately via Ctrl+Enter. Default: leave as draft", required: false, default: false }
107
+ ],
108
+ execute: composeEmail
109
+ },
110
+ {
111
+ name: "reply_email",
112
+ description: "Reply to the currently open email thread via keyboard shortcut",
113
+ params: [
114
+ { name: "body", type: "string", description: "Reply body text", required: true },
115
+ { name: "replyAll", type: "boolean", description: "If true, reply to all participants (keyboard 'a')", required: false, default: false },
116
+ { name: "send", type: "boolean", description: "If true, send immediately via Ctrl+Enter. Default: leave as draft", required: false, default: false }
117
+ ],
118
+ execute: replyEmail
119
+ },
120
+ {
121
+ name: "forward_email",
122
+ description: "Forward the currently open email thread via keyboard shortcut",
123
+ params: [
124
+ { name: "to", type: "string", description: "Recipient email address to forward to", required: true },
125
+ { name: "body", type: "string", description: "Additional body text above forwarded content", required: false, default: "" },
126
+ { name: "send", type: "boolean", description: "If true, send immediately via Ctrl+Enter. Default: leave as draft", required: false, default: false }
127
+ ],
128
+ execute: forwardEmail
129
+ },
130
+ {
131
+ name: "archive_email",
132
+ description: "Archive an email via keyboard shortcut (remove from inbox, keep in All Mail)",
133
+ params: [
134
+ { name: "index", type: "number", description: "0-based index in email list", required: false },
135
+ { name: "id", type: "string", description: "Gmail message/thread ID", required: false }
136
+ ],
137
+ execute: archiveEmail
138
+ },
139
+ {
140
+ name: "delete_email",
141
+ description: "Move an email to Trash via keyboard shortcut",
142
+ params: [
143
+ { name: "index", type: "number", description: "0-based index in email list", required: false },
144
+ { name: "id", type: "string", description: "Gmail message/thread ID", required: false }
145
+ ],
146
+ execute: deleteEmail
147
+ },
148
+ {
149
+ name: "label_email",
150
+ description: "Apply a Gmail label to an email via keyboard shortcut",
151
+ params: [
152
+ { name: "index", type: "number", description: "0-based index in email list", required: false },
153
+ { name: "id", type: "string", description: "Gmail message/thread ID", required: false },
154
+ { name: "label", type: "string", description: "Label name to apply", required: true }
155
+ ],
156
+ execute: labelEmail
157
+ },
158
+ {
159
+ name: "mark_read",
160
+ description: "Mark an email as read via keyboard shortcut (Shift+i)",
161
+ params: [
162
+ { name: "index", type: "number", description: "0-based index in email list", required: false },
163
+ { name: "id", type: "string", description: "Gmail message/thread ID", required: false }
164
+ ],
165
+ execute: markRead
166
+ },
167
+ {
168
+ name: "mark_unread",
169
+ description: "Mark an email as unread via keyboard shortcut (Shift+u)",
170
+ params: [
171
+ { name: "index", type: "number", description: "0-based index in email list", required: false },
172
+ { name: "id", type: "string", description: "Gmail message/thread ID", required: false }
173
+ ],
174
+ execute: markUnread
175
+ }
176
+ ];
177
+ }
178
+
179
+ // ============================================================================
180
+ // INFO
181
+ // ============================================================================
182
+
183
+ /**
184
+ * Return high-level plugin context for the AI agent.
185
+ * @returns {object} PluginInfo per plugin interface contract
186
+ */
187
+ export function getInfo() {
188
+ return {
189
+ recommendation: "Manage Gmail emails — list, read, search, compose, reply, forward, archive, delete, label, and mark as read/unread.",
190
+ description: "Gmail email management with hybrid UI resilience — list, read, search, compose, reply, forward, archive, delete, label, and mark emails using URL navigation (T1), keyboard shortcuts (T2), ARIA selectors (T3), and CSS fallback (T4).",
191
+ targetPages: ["Gmail inbox (mail.google.com)"],
192
+ authFlow: "User must be signed into Gmail in the browser before using plugin actions. The plugin does not handle Google account authentication.",
193
+ actions: getActions().map(({ name, description, params }) => ({ name, description, params }))
194
+ };
195
+ }