Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions backend/src/sponsorDocs/emailDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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}` : ''}.`;
Expand Down
8 changes: 7 additions & 1 deletion backend/src/sponsorDocs/gmailMessageRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/gmail-message-route.test.ts
Original file line number Diff line number Diff line change
@@ -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 <orin@datatalks.club>' },
{ name: 'To', value: 'Sponsor <s@example.com>' },
] },
});
}) 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),
);
});
10 changes: 5 additions & 5 deletions backend/tests/sponsor-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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({
Expand All @@ -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',
}),
Expand Down Expand Up @@ -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/);
Expand Down