From 6d7c804d7376d5c50f66cddfec59e5cc4730247f Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Mon, 9 Nov 2026 15:32:11 +0100 Subject: [PATCH] Resolve Orin's Gmail to the real google-calendar-orin Dapier connection and name connection and account when it is missing --- backend/src/sponsorDocs/emailDelivery.ts | 9 ++--- backend/src/sponsorDocs/gmailMessageRoute.ts | 8 +++- backend/tests/gmail-message-route.test.ts | 42 ++++++++++++++++++++ backend/tests/sponsor-docs.test.ts | 10 ++--- 4 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 backend/tests/gmail-message-route.test.ts diff --git a/backend/src/sponsorDocs/emailDelivery.ts b/backend/src/sponsorDocs/emailDelivery.ts index dbac4ec4..337eeb73 100644 --- a/backend/src/sponsorDocs/emailDelivery.ts +++ b/backend/src/sponsorDocs/emailDelivery.ts @@ -10,8 +10,7 @@ * 2. DataOps reads the machine credential `personal-scheduler.dapier-api-token` * and exchanges it at `POST https://dapier.dtcdev.click/api/agent/token` * for a short-lived Google access token on Orin's Dapier connection - * (`gmail-orin` or `DATAOPS_ORIN_GMAIL_CONNECTION_ID`), kept separate from - * the calendar connection so Gmail send scope does not widen calendar grants. + * (`google-calendar-orin` or `DATAOPS_ORIN_GMAIL_CONNECTION_ID`). * 3. DataOps verifies that the Dapier connection is bound to * `orin@datatalks.club` before sending, then posts a raw RFC 2822 MIME * message to `POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send`. @@ -40,8 +39,8 @@ import { export const SPONSOR_DOC_EMAIL_FROM = 'orin@datatalks.club'; export const SPONSOR_DOC_EMAIL_CC = 'valeriia@datatalks.club'; -export const DEFAULT_ORIN_GMAIL_CONNECTION_ID = 'gmail-orin'; -export const DEDICATED_ORIN_GMAIL_CONNECTION_ID = 'gmail-orin'; +/** Orin's Gmail grants live on her Google connection in Dapier (the one that also holds calendar). */ +export const DEFAULT_ORIN_GMAIL_CONNECTION_ID = 'google-calendar-orin'; const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'; @@ -212,7 +211,7 @@ function summarizeGmailError(status: number, bodyText: string, connectionId: str `Gmail API returned HTTP 403${detail ? ` (${detail})` : ''}. ` + `Dapier connection "${connectionId}" (${SPONSOR_DOC_EMAIL_FROM}) needs the ` + `https://www.googleapis.com/auth/gmail.send scope granted and re-consented in Dapier ` - + `(or connect "${DEDICATED_ORIN_GMAIL_CONNECTION_ID}" as ${SPONSOR_DOC_EMAIL_FROM}).` + + `(set DATAOPS_ORIN_GMAIL_CONNECTION_ID to the Dapier connection holding ${SPONSOR_DOC_EMAIL_FROM}).` ); } return `Gmail API returned HTTP ${status}${detail ? `: ${detail}` : ''}.`; diff --git a/backend/src/sponsorDocs/gmailMessageRoute.ts b/backend/src/sponsorDocs/gmailMessageRoute.ts index 02d3a182..6a0e7b00 100644 --- a/backend/src/sponsorDocs/gmailMessageRoute.ts +++ b/backend/src/sponsorDocs/gmailMessageRoute.ts @@ -38,7 +38,13 @@ export async function resolveGmailMessageRoute( body: JSON.stringify({ connection_id: connectionId, agent: DAPIER_AGENT }), signal: AbortSignal.timeout(10000), }).catch(() => null); - if (!tokenRes?.ok) throw new GmailMessageRouteError(`The Dapier broker refused the Gmail token for connection "${connectionId}" (HTTP ${tokenRes?.status ?? 'unreachable'}).`); + if (tokenRes?.status === 404) { + throw new GmailMessageRouteError( + `Dapier has no connection "${connectionId}" for ${SPONSOR_DOC_EMAIL_FROM}. Expected the Dapier connection holding that account's Gmail ` + + `(default "${DEFAULT_ORIN_GMAIL_CONNECTION_ID}"); set DATAOPS_ORIN_GMAIL_CONNECTION_ID to the right connection id.`, + ); + } + if (!tokenRes?.ok) throw new GmailMessageRouteError(`The Dapier broker refused the Gmail token for connection "${connectionId}" (${SPONSOR_DOC_EMAIL_FROM}) (HTTP ${tokenRes?.status ?? 'unreachable'}).`); const token = (await tokenRes.json().catch(() => ({}))) as { access_token?: string; provider_account_id?: string }; if (!token.access_token) throw new GmailMessageRouteError(`Dapier connection "${connectionId}" returned no access token.`); if (String(token.provider_account_id || '').toLowerCase() !== SPONSOR_DOC_EMAIL_FROM) { diff --git a/backend/tests/gmail-message-route.test.ts b/backend/tests/gmail-message-route.test.ts new file mode 100644 index 00000000..96383fde --- /dev/null +++ b/backend/tests/gmail-message-route.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { GmailMessageRouteError, resolveGmailMessageRoute } from '../src/sponsorDocs/gmailMessageRoute'; + +const saved = process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID; +afterEach(() => { + if (saved === undefined) delete process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID; + else process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID = saved; +}); + +test('Orin Gmail route resolves through the real google-calendar-orin connection', async () => { + delete process.env.DATAOPS_ORIN_GMAIL_CONNECTION_ID; + const requested: string[] = []; + const fetchImpl = (async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/api/agent/token')) { + requested.push(JSON.parse(String(init?.body)).connection_id); + return Response.json({ access_token: 't', provider_account_id: 'orin@datatalks.club' }); + } + return Response.json({ + threadId: 'th1', + payload: { headers: [ + { name: 'From', value: 'Orin ' }, + { name: 'To', value: 'Sponsor ' }, + ] }, + }); + }) as unknown as typeof fetch; + const route = await resolveGmailMessageRoute('m1', { fetchImpl, credential: 'c' }); + assert.deepEqual(requested, ['google-calendar-orin']); + assert.equal(route.threadRef, 'th1'); +}); + +test('a missing Dapier connection names the connection and account', async () => { + const fetchImpl = (async () => new Response('{}', { status: 404 })) as unknown as typeof fetch; + await assert.rejects( + resolveGmailMessageRoute('m1', { fetchImpl, credential: 'c' }), + (error: unknown) => error instanceof GmailMessageRouteError + && /no connection "google-calendar-orin"/.test(error.message) + && /orin@datatalks\.club/.test(error.message) + && /DATAOPS_ORIN_GMAIL_CONNECTION_ID/.test(error.message), + ); +}); diff --git a/backend/tests/sponsor-docs.test.ts b/backend/tests/sponsor-docs.test.ts index 34cf48f2..673bbb06 100644 --- a/backend/tests/sponsor-docs.test.ts +++ b/backend/tests/sponsor-docs.test.ts @@ -683,7 +683,7 @@ describe('Sponsor Content Docs API (/api/sponsor-docs)', () => { access_token: 'ya29.orin-gmail-access-token', expires_in: 3600, token_type: 'Bearer', - connection_id: 'gmail-orin', + connection_id: 'google-calendar-orin', }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); @@ -731,7 +731,7 @@ describe('Sponsor Content Docs API (/api/sponsor-docs)', () => { assert.strictEqual(created.emailDelivery?.taskId, ownerTask.id); assert.match(created.emailDelivery?.subject || '', /^ClickHouse \+ DataTalks\.Club Newsletter - 11-16-2026$/); assert.strictEqual(created.doc.emailDelivery?.state, 'sent'); - assert.deepEqual(requestedConnections, ['gmail-orin'], 'the dedicated send connection cannot be overridden by the API request'); + assert.deepEqual(requestedConnections, ['google-calendar-orin'], 'the dedicated send connection cannot be overridden by the API request'); const taskAfterCreate = await getTask(client, ownerTask.id); assert.match(taskAfterCreate?.notesLog?.[0]?.text || '', /gmail-msg-orin-1/); assert.equal(taskAfterCreate?.taskHistory.at(-1)?.action, 'noted'); @@ -819,7 +819,7 @@ describe('Sponsor Content Docs API (/api/sponsor-docs)', () => { assert.strictEqual(retried.emailDelivery?.state, 'deferred'); assert.strictEqual(retried.emailDelivery?.reason, 'insufficient_gmail_scope'); assert.match(retried.emailDelivery?.message || '', /gmail\.send/); - assert.strictEqual(retryConnection, 'gmail-orin', 'the send-email API also pins the dedicated connection'); + assert.strictEqual(retryConnection, 'google-calendar-orin', 'the send-email API also pins the dedicated connection'); // Verify the dedicated gmail-orin connection sends after the prior scope failure. setSponsorDocEmailDepsForTests({ @@ -832,7 +832,7 @@ describe('Sponsor Content Docs API (/api/sponsor-docs)', () => { const connId = reqBody.connection_id || ''; return new Response( JSON.stringify({ - access_token: connId === 'gmail-orin' ? 'ya29.orin-dedicated-gmail' : 'ya29.orin-calendar-only', + access_token: connId === 'google-calendar-orin' ? 'ya29.orin-dedicated-gmail' : 'ya29.orin-calendar-only', expires_in: 3600, provider_account_id: 'orin@datatalks.club', }), @@ -863,7 +863,7 @@ describe('Sponsor Content Docs API (/api/sponsor-docs)', () => { assert.strictEqual(fallbackRes.statusCode, 200, fallbackRes.body); const fallbackBody = JSON.parse(fallbackRes.body); assert.strictEqual(fallbackBody.emailDelivery?.state, 'sent'); - assert.strictEqual(fallbackBody.emailDelivery?.connectionId, 'gmail-orin'); + assert.strictEqual(fallbackBody.emailDelivery?.connectionId, 'google-calendar-orin'); assert.strictEqual(fallbackBody.emailDelivery?.messageId, 'gmail-msg-orin-fallback'); const taskAfterRetry = await getTask(client, ownerTask.id); assert.match(taskAfterRetry?.notesLog?.at(-1)?.text || '', /gmail-msg-orin-fallback/);