diff --git a/README.md b/README.md index 4ff9929..d02e1c6 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The catalog separates two collection paths. **Vana Desktop (local)** runs regist | `claude` | `claude.conversations`, `claude.projects` | claude-export-playwright (experimental) | | `doordash` | `doordash.orders` | doordash-playwright (beta) | | `github` | `github.contributions`, `github.events`, `github.history`, `github.profile`, `github.repositories`, `github.starred` | github-playwright (stable) | +| `gmail` | `gmail.receipts` | gmail-playwright (experimental) | | `heb` | `heb.nutrition`, `heb.orders`, `heb.profile` | heb-playwright (experimental) | | `icloud_notes` | `icloud_notes.folders`, `icloud_notes.notes` | icloud-notes-playwright (experimental) | | `instagram` | `instagram.ads`, `instagram.following`, `instagram.posts`, `instagram.profile` | instagram-playwright (stable); instagram-ads-playwright (beta) | diff --git a/SCOPES.md b/SCOPES.md index 4f65ae5..310221d 100644 --- a/SCOPES.md +++ b/SCOPES.md @@ -31,6 +31,7 @@ Every immutable `connectors-` GitHub release includes `scope-catalog.j | github | `github.profile` | Account profile information such as name, bio, follower counts, pinned repositories, organization memberships, and achievement badges | [JSON Schema](connectors/github/schemas/github.profile.json) | Yes | Yes | None | github-playwright (stable) | | github | `github.repositories` | Your repository list with metadata like language, stars, and update time | [JSON Schema](connectors/github/schemas/github.repositories.json) | No | Yes | None | github-playwright (stable) | | github | `github.starred` | Repositories you have starred on GitHub | [JSON Schema](connectors/github/schemas/github.starred.json) | No | Yes | None | github-playwright (stable) | +| gmail | `gmail.receipts` | Recent Gmail messages matching purchases, flights, or tickets, including sender, subject, date, and preview text. | [JSON Schema](connectors/google/schemas/gmail.receipts.json) | No | Yes | Vana Desktop (local; gmail-playwright): The Desktop connector returns at most the 100 most recent Gmail rows matching the purchase and travel search. | gmail-playwright (experimental) | | heb | `heb.nutrition` | Nutrition facts (calories, macros, sodium, fiber, vitamins) for each product ordered. Aligned to Schema.org NutritionInformation. | [JSON Schema](connectors/heb/schemas/heb.nutrition.json) | No | Yes | None | heb-playwright (experimental) | | heb | `heb.orders` | Curbside and delivery orders with item names, quantities, prices, and order dates | [JSON Schema](connectors/heb/schemas/heb.orders.json) | No | Yes | None | heb-playwright (experimental) | | heb | `heb.profile` | Your name, email, phone number, and saved delivery addresses | [JSON Schema](connectors/heb/schemas/heb.profile.json) | No | Yes | None | heb-playwright (experimental) | diff --git a/artifacts/gmail-playwright/gmail-playwright-1.0.0.tgz b/artifacts/gmail-playwright/gmail-playwright-1.0.0.tgz new file mode 100644 index 0000000..edb3007 Binary files /dev/null and b/artifacts/gmail-playwright/gmail-playwright-1.0.0.tgz differ diff --git a/connector-index.json b/connector-index.json index aea6302..d4357cc 100644 --- a/connector-index.json +++ b/connector-index.json @@ -306,6 +306,34 @@ } } ], + "gmail-playwright": [ + { + "connectorId": "gmail-playwright", + "company": "google", + "version": "1.0.0", + "name": "Gmail Receipts", + "status": "experimental", + "description": "Exports up to 100 recent Gmail purchase receipts and travel confirmations using a local authenticated browser session.", + "sourceFiles": { + "script": "google/gmail-playwright.js", + "metadata": "google/gmail-playwright.json" + }, + "publishedAt": "2026-07-13T00:00:00Z", + "sourceTag": "feat/gmail-connector", + "sourceCommit": "239092da52f87e3917819828b3e1250d770018fe", + "releaseId": "connectors-239092da52f8", + "pageApiVersion": 1, + "manifestSha256": "sha256:b3f80f45f9c38e0580099bfc612a847592f34af8815b0657f38333c017a663c4", + "scriptSha256": "sha256:21d7e4853f41a86a6de47c7dd833f0ba21dce22d9ed7fd51548f7b33c27b7279", + "artifactSha256": "sha256:e147f0313c25c9ef8f233cdb4de6e04500d95f7f540978f7e490fe1a23ab29ad", + "artifactPath": "artifacts/gmail-playwright/gmail-playwright-1.0.0.tgz", + "artifactUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/239092da52f87e3917819828b3e1250d770018fe/artifacts/gmail-playwright/gmail-playwright-1.0.0.tgz", + "scopes": [ + "gmail.receipts" + ], + "consumerMetadata": null + } + ], "heb-playwright": [ { "connectorId": "heb-playwright", diff --git a/connectors/google/fixtures/gmail.receipts.empty.json b/connectors/google/fixtures/gmail.receipts.empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/connectors/google/fixtures/gmail.receipts.empty.json @@ -0,0 +1 @@ +[] diff --git a/connectors/google/fixtures/gmail.receipts.small.json b/connectors/google/fixtures/gmail.receipts.small.json new file mode 100644 index 0000000..76cc04b --- /dev/null +++ b/connectors/google/fixtures/gmail.receipts.small.json @@ -0,0 +1,23 @@ +[ + { + "id": "synthetic-order-2026-001", + "date": "Jul 9, 2026, 10:42 AM", + "sender": "Example Store ", + "subject": "Your order is confirmed", + "snippet": "Thanks for your purchase. Your synthetic order will be dispatched shortly." + }, + { + "id": "synthetic-flight-2026-002", + "date": "Jul 7, 2026, 4:15 PM", + "sender": "Example Airways ", + "subject": "Flight booking confirmation", + "snippet": "Your synthetic itinerary from Lagos to Accra is confirmed for August 18." + }, + { + "id": "synthetic-ticket-2026-003", + "date": "Jul 3, 2026, 8:05 AM", + "sender": "Example Tickets ", + "subject": "Your event tickets", + "snippet": "Your two synthetic admission tickets are attached and ready to use." + } +] diff --git a/connectors/google/gmail-playwright.js b/connectors/google/gmail-playwright.js new file mode 100644 index 0000000..a5a666c --- /dev/null +++ b/connectors/google/gmail-playwright.js @@ -0,0 +1,501 @@ +/** + * Gmail Receipts Connector (Playwright) + * + * Exports the 100 most recent Gmail rows matching purchases, flights, or + * tickets. Google authentication is always completed by the user in a visible + * browser; credentials and message data remain in the local connector runtime. + * + * Extraction method: authenticated Gmail search + DOM extraction. + */ + +const PLATFORM = "gmail"; +const VERSION = "1.0.0-playwright"; +const SCOPE = "gmail.receipts"; +const CANONICAL_SCOPES = [SCOPE]; +const MAX_MESSAGES = 100; +const MAX_PAGES = 10; +const SEARCH_URL = + "https://mail.google.com/mail/u/0/#search/category%3Apurchases+OR+flight+OR+ticket"; + +const makeConnectorError = ( + errorClass, + reason, + disposition, + extras = {}, +) => ({ + errorClass, + reason, + disposition, + ...extras, +}); + +const makeFatalRunError = (errorClass, reason, phase = "collect") => { + const error = new Error(reason); + error.telemetryError = makeConnectorError(errorClass, reason, "fatal", { + phase, + }); + return error; +}; + +const inferErrorClass = (message, fallback = "runtime_error") => { + const text = String(message || "").toLowerCase(); + if (text.includes("auth") || text.includes("login")) return "auth_failed"; + if (text.includes("timeout") || text.includes("timed out")) return "timeout"; + if (text.includes("navigate") || text.includes("navigation")) { + return "navigation_error"; + } + if (text.includes("selector") || text.includes("message row")) { + return "selector_error"; + } + return fallback; +}; + +const buildResult = ({ requestedScopes, messages, errors }) => { + const result = { + requestedScopes: [...requestedScopes], + timestamp: new Date().toISOString(), + version: VERSION, + platform: PLATFORM, + exportSummary: { + count: messages?.length || 0, + label: messages?.length === 1 ? "message" : "messages", + details: { + messages: messages?.length || 0, + limit: MAX_MESSAGES, + query: "category:purchases OR flight OR ticket", + }, + }, + errors, + }; + + if (Array.isArray(messages) && requestedScopes.includes(SCOPE)) { + Object.assign(result, { "gmail.receipts": messages }); + } + + return result; +}; + +const resolveRequestedScopes = () => { + const raw = + typeof page.requestedScopes === "function" ? page.requestedScopes() : null; + + if (raw == null) return [...CANONICAL_SCOPES]; + if (!Array.isArray(raw) || raw.length === 0) { + throw makeFatalRunError( + "protocol_violation", + "Gmail connector received an empty or invalid requestedScopes array.", + "init", + ); + } + + const deduped = Array.from(new Set(raw)); + const unsupported = deduped.filter( + (scope) => !CANONICAL_SCOPES.includes(scope), + ); + if (unsupported.length > 0) { + throw makeFatalRunError( + "protocol_violation", + `Gmail connector received unsupported requestedScopes: ${unsupported.join(", ")}.`, + "init", + ); + } + + return deduped; +}; + +const safeGoto = async (url, attempts = 3) => { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await page.goto(url, { timeout: 60000 }); + return true; + } catch (_error) { + if (attempt < attempts) await page.sleep(1500); + } + } + return false; +}; + +const checkLoginStatus = async () => { + try { + return await page.evaluate(` + (() => { + if (window.location.hostname !== 'mail.google.com') return false; + if (document.querySelector('input[type="password"]')) return false; + return !!document.querySelector( + '[aria-label="Search mail"], [role="main"]' + ); + })() + `); + } catch (_error) { + return false; + } +}; + +const inspectSearchState = async () => { + try { + return await page.evaluate(` + (() => { + const main = document.querySelector('[role="main"]'); + const rowSelector = + 'tr[data-legacy-thread-id], tr[data-thread-id]'; + const rows = main ? main.querySelectorAll(rowSelector) : []; + const text = (main?.innerText || '').replace(/\\s+/g, ' ').trim(); + const empty = /no (?:emails|messages).*match|no conversations in this view/i.test(text); + return { + authenticated: window.location.hostname === 'mail.google.com' && + !!document.querySelector('[aria-label="Search mail"], [role="main"]'), + rowCount: rows.length, + empty, + }; + })() + `); + } catch (_error) { + return { authenticated: false, rowCount: 0, empty: false }; + } +}; + +const waitForSearchResults = async () => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const state = await inspectSearchState(); + if (state.rowCount > 0 || state.empty) return state; + await page.sleep(1000); + } + return inspectSearchState(); +}; + +const extractVisibleMessages = async () => { + try { + const value = await page.evaluate(` + (() => { + const clean = (value) => String(value || '') + .replace(/[\\u200B-\\u200D\\uFEFF]/g, '') + .replace(/\\s+/g, ' ') + .trim(); + + const fallbackId = (parts) => { + const input = parts.join('\\u001f'); + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return 'gmail-' + (hash >>> 0).toString(16).padStart(8, '0'); + }; + + const main = document.querySelector('[role="main"]'); + if (!main) return { messages: [], hasNext: false, selectorFound: false }; + + let rows = Array.from(main.querySelectorAll( + 'tr[data-legacy-thread-id], tr[data-thread-id]' + )); + + // Some Gmail rollouts omit thread attributes. Restrict the fallback to + // table rows containing Gmail's semantic sender email attribute. + if (rows.length === 0) { + rows = Array.from(main.querySelectorAll('tr')).filter( + (row) => row.querySelector('[email]') + ); + } + + const messages = rows.map((row, rowIndex) => { + try { + const senderNode = row.querySelector('[email]'); + const senderName = clean( + senderNode?.getAttribute('name') || senderNode?.textContent + ); + const senderEmail = clean(senderNode?.getAttribute('email')); + const sender = senderName && senderEmail && + senderName.toLowerCase() !== senderEmail.toLowerCase() + ? senderName + ' <' + senderEmail + '>' + : (senderName || senderEmail); + + // Gmail does not expose semantic subject/snippet attributes. These + // short class names are used only after the stable row/sender gate, + // with structural fallbacks for alternate rollouts. + const subjectNode = row.querySelector('.bog') || + row.querySelector('[data-subject]') || + Array.from(row.querySelectorAll('span')).find((span) => { + const candidate = clean(span.textContent); + return candidate && span !== senderNode && candidate.length > 2; + }); + const snippetNode = row.querySelector('.y2') || + row.querySelector('[data-snippet]'); + const dateNode = row.querySelector( + 'td.xW [title], td.xW time, td.xW span, ' + + 'td:last-of-type [title], td:last-of-type time, td:last-of-type span' + ); + + const subject = clean( + subjectNode?.getAttribute('data-subject') || subjectNode?.textContent + ); + const snippet = clean( + snippetNode?.getAttribute('data-snippet') || snippetNode?.textContent + ).replace(/^[-–—]\\s*/, ''); + const date = clean( + dateNode?.getAttribute('title') || + dateNode?.getAttribute('datetime') || + dateNode?.textContent + ); + + const id = clean( + row.getAttribute('data-legacy-last-message-id') || + row.getAttribute('data-legacy-thread-id') || + row.getAttribute('data-thread-id') || + row.id + ) || fallbackId([date, sender, subject, snippet, String(rowIndex)]); + + return { id, date, sender, subject, snippet }; + } catch (_error) { + return null; + } + }).filter(Boolean); + + const next = Array.from(document.querySelectorAll( + '[aria-label="Older"], [aria-label="Next page"], ' + + '[data-tooltip="Older"], [data-tooltip="Next page"]' + )).find((element) => { + const disabled = element.getAttribute('aria-disabled') === 'true' || + element.hasAttribute('disabled'); + const visible = !!(element.offsetWidth || element.offsetHeight || + element.getClientRects().length); + return visible && !disabled; + }); + + return { + messages, + hasNext: !!next, + selectorFound: rows.length > 0, + firstId: messages[0]?.id || '', + }; + })() + `); + + return value || { + messages: [], + hasNext: false, + selectorFound: false, + firstId: "", + }; + } catch (_error) { + return { + messages: [], + hasNext: false, + selectorFound: false, + firstId: "", + }; + } +}; + +const clickNextPage = async () => { + try { + return await page.evaluate(` + (() => { + const next = Array.from(document.querySelectorAll( + '[aria-label="Older"], [aria-label="Next page"], ' + + '[data-tooltip="Older"], [data-tooltip="Next page"]' + )).find((element) => { + const disabled = element.getAttribute('aria-disabled') === 'true' || + element.hasAttribute('disabled'); + const visible = !!(element.offsetWidth || element.offsetHeight || + element.getClientRects().length); + return visible && !disabled; + }); + if (!next) return false; + next.click(); + return true; + })() + `); + } catch (_error) { + return false; + } +}; + +const waitForPageChange = async (previousFirstId) => { + for (let attempt = 0; attempt < 15; attempt += 1) { + await page.sleep(1000); + const current = await extractVisibleMessages(); + if (current.firstId && current.firstId !== previousFirstId) return true; + } + return false; +}; + +const collectMessages = async () => { + const messages = []; + const seenIds = new Set(); + let paginationFailed = false; + let selectorFound = false; + + for (let pageNumber = 1; pageNumber <= MAX_PAGES; pageNumber += 1) { + const pageData = await extractVisibleMessages(); + selectorFound = selectorFound || pageData.selectorFound; + + for (const message of pageData.messages || []) { + if (!message?.id || seenIds.has(message.id)) continue; + seenIds.add(message.id); + messages.push(message); + if (messages.length >= MAX_MESSAGES) break; + } + + await page.setProgress({ + phase: { step: 2, total: 2, label: "Collecting confirmations" }, + message: `Collected ${messages.length} of up to ${MAX_MESSAGES} messages...`, + count: messages.length, + }); + + if (messages.length >= MAX_MESSAGES || !pageData.hasNext) break; + if (!(await clickNextPage())) { + paginationFailed = true; + break; + } + if (!(await waitForPageChange(pageData.firstId))) { + paginationFailed = true; + break; + } + await page.sleep(500); + } + + return { + messages: messages.slice(0, MAX_MESSAGES), + paginationFailed, + selectorFound, + }; +}; + +(async () => { + let requestedScopes = [...CANONICAL_SCOPES]; + + try { + requestedScopes = resolveRequestedScopes(); + + await page.setProgress({ + phase: { step: 1, total: 2, label: "Signing in" }, + message: "Checking your saved Gmail session...", + }); + + if (!(await safeGoto(SEARCH_URL))) { + throw makeFatalRunError( + "navigation_error", + "Could not reach Gmail after multiple attempts.", + "auth", + ); + } + await page.sleep(2500); + + let loggedIn = await checkLoginStatus(); + if (!loggedIn) { + const browserResult = await page.showBrowser(SEARCH_URL); + if (!browserResult?.headed) { + throw makeFatalRunError( + "auth_failed", + "Gmail login requires a visible browser session.", + "auth", + ); + } + + await page.setData( + "status", + "Sign in to Google in the browser. Your credentials remain on this device.", + ); + await page.promptUser( + "Complete Google sign-in, including any two-factor verification. Continue when Gmail is open.", + async () => await checkLoginStatus(), + 2000, + ); + loggedIn = await checkLoginStatus(); + } + + if (!loggedIn) { + throw makeFatalRunError( + "auth_failed", + "Gmail login could not be confirmed.", + "auth", + ); + } + + await page.goHeadless(); + if (!(await safeGoto(SEARCH_URL))) { + throw makeFatalRunError( + "navigation_error", + "Gmail opened, but the purchase and travel search could not be loaded.", + ); + } + + await page.setProgress({ + phase: { step: 2, total: 2, label: "Collecting confirmations" }, + message: "Loading purchase and travel confirmation messages...", + count: 0, + }); + + const searchState = await waitForSearchResults(); + if (!searchState.authenticated) { + throw makeFatalRunError( + "auth_failed", + "The Gmail session expired before collection completed.", + "collect", + ); + } + + if (searchState.empty) { + const result = buildResult({ requestedScopes, messages: [], errors: [] }); + await page.setData("result", result); + await page.setData("status", "Complete! No matching Gmail messages found."); + return result; + } + + const collection = await collectMessages(); + const errors = []; + + if (!collection.selectorFound && collection.messages.length === 0) { + errors.push( + makeConnectorError( + "selector_error", + "Gmail loaded, but no recognizable message rows were found.", + "omitted", + { scope: SCOPE, phase: "collect" }, + ), + ); + } else if (collection.paginationFailed) { + errors.push( + makeConnectorError( + "navigation_error", + "Gmail pagination stopped before all available matching rows could be read.", + "degraded", + { scope: SCOPE, phase: "collect" }, + ), + ); + } + + const producedMessages = collection.selectorFound + ? collection.messages + : null; + const result = buildResult({ + requestedScopes, + messages: producedMessages, + errors, + }); + + await page.setData("result", result); + await page.setData( + "status", + `Complete! Exported ${collection.messages.length} Gmail messages.`, + ); + return result; + } catch (error) { + const telemetryError = + error?.telemetryError || + makeConnectorError( + inferErrorClass(error?.message || String(error)), + error?.message || String(error), + "fatal", + { phase: "collect" }, + ); + const result = buildResult({ + requestedScopes, + messages: null, + errors: [telemetryError], + }); + await page.setData("result", result); + await page.setData("error", telemetryError.reason); + return result; + } +})(); diff --git a/connectors/google/gmail-playwright.json b/connectors/google/gmail-playwright.json new file mode 100644 index 0000000..71bb6b0 --- /dev/null +++ b/connectors/google/gmail-playwright.json @@ -0,0 +1,35 @@ +{ + "manifest_version": "1.1", + "connector_id": "gmail-playwright", + "source_id": "gmail", + "version": "1.0.0", + "name": "Gmail Receipts", + "company": "Google", + "description": "Exports up to 100 recent Gmail purchase receipts and travel confirmations using a local authenticated browser session.", + "runtime": "playwright", + "page_api_version": 1, + "connect_url": "https://mail.google.com/mail/u/0/#search/category%3Apurchases+OR+flight+OR+ticket", + "connect_selector": "[aria-label=\"Search mail\"], [role=\"main\"]", + "icon": "icons/gmail.svg", + "scopes": [ + { + "scope": "gmail.receipts", + "label": "Your purchase and travel confirmations", + "description": "Recent Gmail messages matching purchases, flights, or tickets, including sender, subject, date, and preview text.", + "limits": [ + { + "type": "maxItems", + "value": 100, + "unit": "messages", + "description": "The Desktop connector returns at most the 100 most recent Gmail rows matching the purchase and travel search." + } + ] + } + ], + "capabilities": ["headed-takeover"], + "id": "gmail-playwright", + "connectURL": "https://mail.google.com/mail/u/0/#search/category%3Apurchases+OR+flight+OR+ticket", + "connectSelector": "[aria-label=\"Search mail\"], [role=\"main\"]", + "iconURL": "icons/gmail.svg", + "exportFrequency": "weekly" +} diff --git a/connectors/google/icons/gmail.svg b/connectors/google/icons/gmail.svg new file mode 100644 index 0000000..ff594b3 --- /dev/null +++ b/connectors/google/icons/gmail.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/connectors/google/schemas/gmail.receipts.json b/connectors/google/schemas/gmail.receipts.json new file mode 100644 index 0000000..16a028e --- /dev/null +++ b/connectors/google/schemas/gmail.receipts.json @@ -0,0 +1,38 @@ +{ + "name": "Gmail Receipts and Travel Confirmations", + "version": "1.0.0", + "scope": "gmail.receipts", + "dialect": "json", + "description": "A chronologically ordered list of up to 100 recent Gmail rows matching purchase receipts, flight confirmations, or ticket messages.", + "schema": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique Gmail message or thread-row identifier exposed by the authenticated mailbox." + }, + "date": { + "type": "string", + "description": "Gmail's displayed received date or timestamp for the message row." + }, + "sender": { + "type": "string", + "description": "Sender name, email address, or both when Gmail exposes both values." + }, + "subject": { + "type": "string", + "description": "Subject line displayed in the Gmail search result." + }, + "snippet": { + "type": "string", + "description": "Short preview text displayed alongside the message subject." + } + }, + "required": ["id", "date", "sender", "subject", "snippet"], + "additionalProperties": false + } + } +} diff --git a/fixture-index.json b/fixture-index.json index 56f33f5..5bdefa6 100644 --- a/fixture-index.json +++ b/fixture-index.json @@ -4,6 +4,32 @@ "sourceRepo": "https://github.com/vana-com/data-connectors", "generatedAt": "2026-07-13T00:00:00Z", "fixtures": [ + { + "id": "gmail-playwright.gmail.receipts.empty", + "connectorId": "gmail-playwright", + "sourceId": "gmail", + "scope": "gmail.receipts", + "scenario": "empty", + "recordCount": 0, + "path": "connectors/google/fixtures/gmail.receipts.empty.json", + "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/gmail.receipts.empty.json", + "schemaPath": "connectors/google/schemas/gmail.receipts.json", + "sizeBytes": 3, + "sha256": "sha256:37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" + }, + { + "id": "gmail-playwright.gmail.receipts.small", + "connectorId": "gmail-playwright", + "sourceId": "gmail", + "scope": "gmail.receipts", + "scenario": "small", + "recordCount": 3, + "path": "connectors/google/fixtures/gmail.receipts.small.json", + "rawUrl": "https://raw.githubusercontent.com/vana-com/data-connectors/main/connectors/google/fixtures/gmail.receipts.small.json", + "schemaPath": "connectors/google/schemas/gmail.receipts.json", + "sizeBytes": 834, + "sha256": "sha256:9ba2cde62c7ca80b5c7717954d0573786c21e68634cae443cbf4cccf331784db" + }, { "id": "instagram-playwright.instagram.profile.empty", "connectorId": "instagram-playwright", diff --git a/registry.json b/registry.json index 27fdab1..cc39dd5 100644 --- a/registry.json +++ b/registry.json @@ -373,6 +373,22 @@ "script": "sha256:ab465031ce09187c70890f4498fb0d650d0ea0dd7a11d558496d834a00076188", "metadata": "sha256:1b43c2ab74a7dc8bb2271b5be9d6f1d1e64d3f636604332f67439663bfd62bce" } + }, + { + "id": "gmail-playwright", + "company": "google", + "version": "1.0.0", + "name": "Gmail Receipts", + "status": "experimental", + "description": "Exports up to 100 recent Gmail purchase receipts and travel confirmations using a local authenticated browser session.", + "files": { + "script": "google/gmail-playwright.js", + "metadata": "google/gmail-playwright.json" + }, + "checksums": { + "script": "sha256:21d7e4853f41a86a6de47c7dd833f0ba21dce22d9ed7fd51548f7b33c27b7279", + "metadata": "sha256:b3f80f45f9c38e0580099bfc612a847592f34af8815b0657f38333c017a663c4" + } } ] } diff --git a/scope-catalog.json b/scope-catalog.json index 49b5657..7989aa7 100644 --- a/scope-catalog.json +++ b/scope-catalog.json @@ -19,6 +19,7 @@ "connectors/apple/icloud-notes-playwright.json", "connectors/doordash/doordash-playwright.json", "connectors/github/github-playwright.json", + "connectors/google/gmail-playwright.json", "connectors/google/youtube-playwright.json", "connectors/heb/heb-playwright.json", "connectors/linkedin/linkedin-playwright.json", @@ -364,6 +365,37 @@ } } }, + { + "sourceId": "gmail", + "scopeId": "gmail.receipts", + "description": "Recent Gmail messages matching purchases, flights, or tickets, including sender, subject, date, and preview text.", + "schema": { + "path": "connectors/google/schemas/gmail.receipts.json" + }, + "maturity": "experimental", + "fulfillment": { + "desktop": { + "status": "supported", + "connectors": [ + { + "id": "gmail-playwright", + "status": "experimental", + "limits": [ + { + "type": "maxItems", + "value": 100, + "unit": "messages", + "description": "The Desktop connector returns at most the 100 most recent Gmail rows matching the purchase and travel search." + } + ] + } + ] + }, + "web": { + "status": "unsupported" + } + } + }, { "sourceId": "heb", "scopeId": "heb.nutrition", diff --git a/scopes/web-capabilities.json b/scopes/web-capabilities.json index 6d3e826..715b919 100644 --- a/scopes/web-capabilities.json +++ b/scopes/web-capabilities.json @@ -31,6 +31,7 @@ { "scopeId": "github.profile", "status": "supported" }, { "scopeId": "github.repositories", "status": "unsupported" }, { "scopeId": "github.starred", "status": "unsupported" }, + { "scopeId": "gmail.receipts", "status": "unsupported" }, { "scopeId": "heb.nutrition", "status": "unsupported" }, { "scopeId": "heb.orders", "status": "unsupported" }, { "scopeId": "heb.profile", "status": "unsupported" },