Skip to content

Commit df2eeaa

Browse files
authored
Add Gmail modify support (#1630)
1 parent 75a3c45 commit df2eeaa

11 files changed

Lines changed: 177 additions & 26 deletions

File tree

apps/cloud/src/engine/execution-stack.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,14 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(
8989
*/
9090
export const CLOUD_MOUNT_PREFIX = "/api" as const;
9191

92-
// Initial Google launch boundary. Calendar + Sheets are sensitive scopes but
93-
// not restricted Workspace scopes; Gmail and account-wide Drive remain absent
94-
// until their separate verification/security work is complete. The same scope
95-
// source builds the catalog auth templates, preventing config drift.
92+
// Initial Google launch boundary. Gmail uses gmail.modify for read, send, and
93+
// trash operations while immediate permanent deletion remains absent until the
94+
// broader mail.google.com scope is approved. Account-wide Drive remains absent.
95+
// The same scope source builds the catalog auth templates, preventing drift.
9696
const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
9797
...new Set([
9898
...googleCatalogOAuthScopesForPreset("google-calendar"),
99+
...googleCatalogOAuthScopesForPreset("google-gmail"),
99100
...googleCatalogOAuthScopesForPreset("google-sheets"),
100101
]),
101102
];

apps/marketing/src/pages/index.astro

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,15 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
133133
<h1
134134
class="rise-1 text-[clamp(2.8rem,7.2vw,6rem)] font-semibold tracking-[-0.025em] leading-[1.02] text-ink mb-6 mx-auto max-w-[18ch] text-balance"
135135
>
136-
Connect any agent to <span class="serif-italic">everything</span>.
136+
Executor connects any agent to <span class="serif-italic">everything</span>.
137137
</h1>
138138
<p
139139
class="rise-2 mx-auto mb-12 max-w-[50ch] text-[clamp(1.05rem,2.4vw,1.3rem)] leading-[1.5] text-ink-2"
140140
>
141-
Executor is an MCP gateway. Anything that speaks MCP, like Claude
142-
Code, Cursor, or Codex, points at one endpoint and reaches every
143-
tool you connect.
141+
Executor is an integration platform and MCP gateway for AI agents.
142+
It lets you securely connect services such as Google Workspace,
143+
GitHub, and Slack, then read data and run the actions you request
144+
through one endpoint.
144145
</p>
145146
<div class="rise-3 max-w-[760px] mx-auto mb-12">
146147
<div class="surface-card p-6 sm:p-7">

apps/marketing/src/pages/privacy.astro

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,15 @@ import LegalLayout from "../components/LegalLayout.astro";
5757
<p>
5858
If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you
5959
request through that integration. Depending on the service and permissions you choose, this may include accessing
60-
or modifying Google Calendar events, Google Sheets spreadsheets, or other Google Workspace content. Executor stores
61-
OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace
62-
content when carrying out your requested tool calls and returning their results to you or the agent you directed to
63-
make the call.
60+
or modifying Google Calendar events, Google Sheets spreadsheets, Gmail messages, drafts, threads, attachments,
61+
labels, or other Google Workspace content. For Gmail, this can include reading and searching email, composing and
62+
sending messages, and organizing or moving messages to Trash when you request those actions.
63+
</p>
64+
<p>
65+
Executor stores OAuth credentials and connection metadata so the integration can continue working. It processes
66+
Google Workspace content only while carrying out your requested tool calls and returning their results to you or
67+
the agent you directed to make the call. That content can appear in the resulting agent session or in another
68+
service when you explicitly direct Executor to send it there.
6469
</p>
6570
<p>
6671
We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or
@@ -72,6 +77,11 @@ import LegalLayout from "../components/LegalLayout.astro";
7277
<a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,
7378
including its Limited Use requirements.
7479
</p>
80+
<p>
81+
You can stop Executor&apos;s access by deleting the Google connection in Executor or revoking Executor from your
82+
Google Account permissions. To request deletion of your Executor account, stored OAuth credentials, connection
83+
metadata, or other personal information, email <a href="mailto:rhys@executor.sh">rhys@executor.sh</a>.
84+
</p>
7585

7686
<h3>Website inputs and support communications</h3>
7787
<p>

e2e/scenarios/first-party-oauth.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ scenario(
168168
);
169169

170170
scenario(
171-
"First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes",
171+
"First-party OAuth · Google offers Gmail modify but refuses full Gmail and Drive scopes",
172172
{},
173173
Effect.scoped(
174174
Effect.gen(function* () {
@@ -184,6 +184,7 @@ scenario(
184184
expect(google?.origin.kind).toBe("first_party");
185185
if (google?.origin.kind !== "first_party") return;
186186
expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar");
187+
expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify");
187188
expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets");
188189
expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/");
189190
expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive");
@@ -229,19 +230,50 @@ scenario(
229230
"openid",
230231
"email",
231232
"profile",
232-
"https://mail.google.com/",
233+
"https://www.googleapis.com/auth/gmail.modify",
233234
]),
234235
slug: gmail,
235236
},
236237
});
238+
const gmailStarted = yield* client.oauth.start({
239+
payload: {
240+
client: OAuthClientSlug.make("first-party:google"),
241+
clientOwner: "org",
242+
owner: "org",
243+
name: ConnectionName.make("gmail"),
244+
integration: gmail,
245+
template: AuthTemplateSlug.make("oauth"),
246+
},
247+
});
248+
expect(gmailStarted.status).toBe("redirect");
249+
const gmailAuthorizationUrl =
250+
gmailStarted.status === "redirect" ? gmailStarted.authorizationUrl : "";
251+
expect(
252+
new Set(new URL(gmailAuthorizationUrl).searchParams.get("scope")?.split(" ") ?? []),
253+
).toEqual(
254+
new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/gmail.modify"]),
255+
);
256+
257+
const fullGmail = IntegrationSlug.make(unique("google_gmail_full"));
258+
yield* client.openapi.addSpec({
259+
payload: {
260+
...googleShapedIntegrationSpec([
261+
"openid",
262+
"email",
263+
"profile",
264+
"https://mail.google.com/",
265+
]),
266+
slug: fullGmail,
267+
},
268+
});
237269
const blocked = yield* client.oauth
238270
.start({
239271
payload: {
240272
client: OAuthClientSlug.make("first-party:google"),
241273
clientOwner: "org",
242274
owner: "org",
243-
name: ConnectionName.make("gmail"),
244-
integration: gmail,
275+
name: ConnectionName.make("gmail-full"),
276+
integration: fullGmail,
245277
template: AuthTemplateSlug.make("oauth"),
246278
},
247279
})

packages/plugins/openapi/src/providers/google/discovery.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,78 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI integratio
959959
}),
960960
);
961961

962+
it.effect("filters Gmail operations to the explicitly selected consent scope", () =>
963+
Effect.gen(function* () {
964+
const modifyScope = "https://www.googleapis.com/auth/gmail.modify";
965+
const fullScope = "https://mail.google.com/";
966+
const result = yield* convertGoogleDiscoveryBundleToOpenApi({
967+
consentScopes: [modifyScope],
968+
documents: [
969+
{
970+
discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
971+
// @effect-diagnostics-next-line preferSchemaOverJson:off
972+
documentText: JSON.stringify({
973+
name: "gmail",
974+
version: "v1",
975+
title: "Gmail API",
976+
rootUrl: "https://gmail.googleapis.com/",
977+
servicePath: "",
978+
auth: {
979+
oauth2: {
980+
scopes: {
981+
[modifyScope]: { description: "Read and modify Gmail" },
982+
[fullScope]: { description: "Full Gmail access" },
983+
},
984+
},
985+
},
986+
resources: {
987+
users: {
988+
resources: {
989+
messages: {
990+
methods: {
991+
list: {
992+
id: "gmail.users.messages.list",
993+
httpMethod: "GET",
994+
path: "gmail/v1/users/{userId}/messages",
995+
scopes: [modifyScope, fullScope],
996+
parameters: {
997+
userId: { location: "path", required: true, type: "string" },
998+
},
999+
},
1000+
delete: {
1001+
id: "gmail.users.messages.delete",
1002+
httpMethod: "DELETE",
1003+
path: "gmail/v1/users/{userId}/messages/{id}",
1004+
scopes: [fullScope],
1005+
parameters: {
1006+
userId: { location: "path", required: true, type: "string" },
1007+
id: { location: "path", required: true, type: "string" },
1008+
},
1009+
},
1010+
},
1011+
},
1012+
},
1013+
},
1014+
},
1015+
schemas: {},
1016+
}),
1017+
},
1018+
],
1019+
});
1020+
1021+
const spec = decodeConvertedSpec(result.specText);
1022+
const operationIds = Object.values(spec.paths).flatMap((path) =>
1023+
Object.values(path).map((operation) => operation.operationId),
1024+
);
1025+
expect(operationIds).toContain("gmail.users.messages.list");
1026+
expect(operationIds).not.toContain("gmail.users.messages.delete");
1027+
const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
1028+
expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
1029+
modifyScope,
1030+
]);
1031+
}),
1032+
);
1033+
9621034
// ---------------------------------------------------------------------------
9631035
// The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes
9641036
// collapse under their broad parent (`gmail.*` → `mail.google.com/`,

packages/plugins/openapi/src/providers/google/discovery.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -831,9 +831,6 @@ const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photosli
831831
const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
832832
const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads";
833833

834-
const isGooglePhotosService = (service: string): boolean =>
835-
service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
836-
837834
const discoveryScopesForService = (
838835
service: string,
839836
document: DiscoveryDocument,
@@ -1124,10 +1121,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
11241121
const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
11251122
const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
11261123
const scopeDescriptions = discoveryScopesForService(info.service, info.document);
1127-
const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);
1124+
const filterConsentScopes = consentScopeSet !== null;
11281125

11291126
for (const [scope, description] of Object.entries(scopeDescriptions)) {
1130-
if (filterPhotosScopes && !consentScopeSet.has(scope)) continue;
1127+
if (filterConsentScopes && !consentScopeSet.has(scope)) continue;
11311128
rawScopes[scope] ??= description;
11321129
}
11331130

@@ -1140,10 +1137,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
11401137
const rawPathTemplate = Option.getOrUndefined(method.path);
11411138
if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
11421139
const methodScopes = discoveryMethodScopesForService(info.service, method);
1143-
const oauthScopes = filterPhotosScopes
1140+
const oauthScopes = filterConsentScopes
11441141
? methodScopes.filter((scope) => consentScopeSet.has(scope))
11451142
: methodScopes;
1146-
if (filterPhotosScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue;
1143+
if (filterConsentScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue;
11471144

11481145
const toolPath = methodId;
11491146
const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`;

packages/plugins/openapi/src/providers/google/presets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] =
250250

251251
export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = {
252252
"google-calendar": ["https://www.googleapis.com/auth/calendar"],
253-
"google-gmail": ["https://mail.google.com/"],
253+
"google-gmail": ["https://www.googleapis.com/auth/gmail.modify"],
254254
"google-sheets": ["https://www.googleapis.com/auth/spreadsheets"],
255255
"google-drive": ["https://www.googleapis.com/auth/drive"],
256256
"google-docs": ["https://www.googleapis.com/auth/documents"],

packages/plugins/openapi/src/providers/google/spec-format-adapter.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ export const googleDiscoveryAdapter: SpecFormatAdapter = {
6161
),
6262
{ concurrency: 4 },
6363
);
64-
const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ documents });
64+
const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({
65+
documents,
66+
...(input.consentScopes ? { consentScopes: input.consentScopes } : {}),
67+
});
6568
const document =
6669
documents.length === 1
6770
? yield* parseJson(documents[0]!.documentText)

packages/plugins/openapi/src/sdk/plugin.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,13 @@ export const openApiPlugin = definePlugin<
646646
const resolveSpecForInput = (
647647
config: Pick<
648648
OpenApiSpecConfig,
649-
"spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl"
649+
| "spec"
650+
| "specFormat"
651+
| "specOverrides"
652+
| "headers"
653+
| "queryParams"
654+
| "baseUrl"
655+
| "authenticationTemplate"
650656
>,
651657
httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
652658
): Effect.Effect<
@@ -670,6 +676,13 @@ export const openApiPlugin = definePlugin<
670676
...(config.headers ? { headers: config.headers } : {}),
671677
...(config.queryParams ? { queryParams: config.queryParams } : {}),
672678
},
679+
...(config.authenticationTemplate
680+
? {
681+
consentScopes: config.authenticationTemplate.flatMap((template) =>
682+
"kind" in template && template.kind === "oauth2" ? template.scopes : [],
683+
),
684+
}
685+
: {}),
673686
httpClientLayer,
674687
});
675688
return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides);

packages/plugins/openapi/src/sdk/spec-format.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export interface SpecFetchCredentials {
1515
export interface SpecFetchInput {
1616
readonly urls: readonly string[];
1717
readonly credentials?: SpecFetchCredentials;
18+
/** Explicit OAuth scopes selected by the caller. Format adapters may use
19+
* these to omit operations that the resulting connection cannot invoke. */
20+
readonly consentScopes?: readonly string[];
1821
readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;
1922
}
2023

0 commit comments

Comments
 (0)