mcpbrowser 0.3.34 → 0.3.35

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.
@@ -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,194 @@
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
+ 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).",
190
+ targetPages: ["Gmail inbox (mail.google.com)"],
191
+ authFlow: "User must be signed into Gmail in the browser before using plugin actions. The plugin does not handle Google account authentication.",
192
+ actions: getActions().map(({ name, description, params }) => ({ name, description, params }))
193
+ };
194
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * selectors.js — Tier 4 CSS class selectors for the Gmail plugin.
3
+ *
4
+ * TIER 4 ONLY — These are Closure Compiler-generated class names that may
5
+ * change when Google deploys Gmail updates. All other interaction tiers
6
+ * (T1: URL hash, T2: keyboard shortcuts, T3: ARIA / data attributes / name attrs)
7
+ * are defined inline in helpers.js since they use stable identifiers.
8
+ *
9
+ * Per FR-023: All CSS class selectors are centralized here so a Gmail UI
10
+ * update can be resolved by updating values in ONE place.
11
+ *
12
+ * Per SC-008: No action file should import CSS class names directly.
13
+ * All CSS access goes through this module.
14
+ *
15
+ * Tier coverage (SC-007): ~73% of interactions use T1/T2 (URL + keyboard).
16
+ * These T4 selectors cover only data extraction from email row internals
17
+ * and thread message internals.
18
+ *
19
+ * @version 2026-04-03 — Verified against Gmail web UI
20
+ */
21
+
22
+ // ============================================================================
23
+ // EMAIL LIST VIEW — Row internals (used by extractEmailRows)
24
+ // ============================================================================
25
+
26
+ /** Email row in list — no ARIA role="row" on Gmail's custom table rows */
27
+ export const EMAIL_ROW = 'tr.zA';
28
+
29
+ /** Unread email indicator — additional class on row, no aria-label */
30
+ export const EMAIL_ROW_UNREAD = 'zE';
31
+
32
+ /** Subject text within a row — no distinguishing data attribute */
33
+ export const SUBJECT_SPAN = 'span.bog';
34
+
35
+ /** Snippet/preview text within a row — no distinguishing data attribute */
36
+ export const SNIPPET_SPAN = 'span.y2';
37
+
38
+ /** Date cell within a row — no name or aria-label on date elements */
39
+ export const DATE_CELL = 'td.xW span';
40
+
41
+ // ============================================================================
42
+ // THREAD / MESSAGE VIEW — Message internals (used by read_email)
43
+ // ============================================================================
44
+
45
+ /** Individual message container within a thread — lacks ARIA roles */
46
+ export const MESSAGE_CONTAINER = 'div.adn';
47
+
48
+ /** Message body content div — the actual HTML body */
49
+ export const MSG_BODY = 'div.a3s.aiL';
50
+
51
+ /** Date span in message header — title attribute has full timestamp */
52
+ export const MSG_DATE = 'span.g3';
53
+
54
+ /** Thread subject heading — h2 can be T3 but .hP adds specificity */
55
+ export const THREAD_SUBJECT = 'h2.hP';
56
+
57
+ // ============================================================================
58
+ // ATTACHMENTS — Within message containers
59
+ // ============================================================================
60
+
61
+ /** Attachment area within a message */
62
+ export const ATTACHMENT_AREA = 'div.aQH';
63
+
64
+ /** Attachment filename */
65
+ export const ATTACHMENT_NAME = 'span.aV3';
66
+
67
+ /** Attachment file size */
68
+ export const ATTACHMENT_SIZE = 'span.SaH2Ve';
69
+
70
+ // ============================================================================
71
+ // LABEL PICKER — Dropdown items (used by label_email)
72
+ // ============================================================================
73
+
74
+ /** Individual label items in the label picker dropdown */
75
+ export const LABEL_ITEM = 'div.J-N-Jz';
76
+
77
+ // ============================================================================
78
+ // NO-RESULTS INDICATOR — Search empty state
79
+ // ============================================================================
80
+
81
+ /** No-results indicator in search/list view */
82
+ export const NO_RESULTS = 'td.TC';