Skip to content

Commit 748d0fb

Browse files
authored
Google oauth support + fix tools for API v1 (#7780)
1 parent 8f783fd commit 748d0fb

File tree

11 files changed

+273
-193
lines changed

11 files changed

+273
-193
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ docs/api_refs/typedoc.json
4848
credentials.json
4949
**/typedoc.json
5050
.vercel
51+
52+
bun.lock

docs/core_docs/docs/integrations/tools/gmail.mdx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,17 @@ The Gmail Tool allows your agent to create and view messages from a linked email
1010

1111
## Setup
1212

13-
You will need to get an API key from [Google here](https://developers.google.com/gmail/api/guides)
14-
and [enable the new Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com).
15-
Then, set the environment variables for `GMAIL_CLIENT_EMAIL`, and either `GMAIL_PRIVATE_KEY`, or `GMAIL_KEYFILE`.
13+
You can authenticate via two methods:
14+
15+
1. Provide an access token, obtained via OAuth2 token exchange, to the credentials object.
16+
This can be a string or a function so that token expiry and validation can be handled.
17+
This can be done using an Identity Provider that supports getting access tokens from federated connections.
18+
This is the most secure method as the access and scope will be limited to the specific end user.
19+
This method will be more appropriate when using the tool in an application that is meant to be used by end
20+
users with their own Gmail account.
21+
2. You will need to get an API key from [Google here](https://developers.google.com/gmail/api/guides)
22+
and [enable the new Gmail API](https://console.cloud.google.com/apis/library/gmail.googleapis.com).
23+
Then, set the environment variables for `GMAIL_CLIENT_EMAIL`, and either `GMAIL_PRIVATE_KEY`, or `GMAIL_KEYFILE`.
1624

1725
To use the Gmail Tool you need to install the following official peer dependency:
1826

examples/src/tools/gmail.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ export async function run() {
2020
// credentials: {
2121
// clientEmail: process.env.GMAIL_CLIENT_EMAIL,
2222
// privateKey: process.env.GMAIL_PRIVATE_KEY,
23+
// // Either (privateKey + clientEmail) or accessToken is required
24+
// accessToken: "an access token or function to get access token",
2325
// },
24-
// scopes: ["https://mail.google.com/"],
26+
// scopes: ["https://mail.google.com/"], // Not required if using access token
2527
// };
2628

2729
// For custom parameters, uncomment the code above, replace the values with your own, and pass it to the tools below
Lines changed: 102 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { gmail_v1, google } from "googleapis";
2-
import { z } from "zod";
32
import { StructuredTool } from "@langchain/core/tools";
43
import { getEnvironmentVariable } from "@langchain/core/utils/env";
54

@@ -9,73 +8,125 @@ export interface GmailBaseToolParams {
98
privateKey?: string;
109
keyfile?: string;
1110
subject?: string;
11+
// support string and async function to handle token validation and expiration
12+
accessToken?: string | (() => Promise<string>);
1213
};
1314
scopes?: string[];
1415
}
1516

1617
export abstract class GmailBaseTool extends StructuredTool {
17-
private CredentialsSchema = z
18-
.object({
19-
clientEmail: z
20-
.string()
21-
.min(1)
22-
.default(getEnvironmentVariable("GMAIL_CLIENT_EMAIL") ?? ""),
23-
privateKey: z
24-
.string()
25-
.default(getEnvironmentVariable("GMAIL_PRIVATE_KEY") ?? ""),
26-
keyfile: z
27-
.string()
28-
.default(getEnvironmentVariable("GMAIL_KEYFILE") ?? ""),
29-
subject: z
30-
.string()
31-
.default(getEnvironmentVariable("GMAIL_SUBJECT") ?? ""),
32-
})
33-
.refine(
34-
(credentials) =>
35-
credentials.privateKey !== "" || credentials.keyfile !== "",
36-
{
37-
message:
38-
"Missing GMAIL_PRIVATE_KEY or GMAIL_KEYFILE to interact with Gmail",
39-
}
40-
);
41-
42-
private GmailBaseToolParamsSchema = z
43-
.object({
44-
credentials: this.CredentialsSchema.default({}),
45-
scopes: z.array(z.string()).default(["https://mail.google.com/"]),
46-
})
47-
.default({});
48-
4918
name = "Gmail";
5019

5120
description = "A tool to send and view emails through Gmail";
5221

53-
protected gmail: gmail_v1.Gmail;
22+
protected params: GmailBaseToolParams;
23+
24+
protected gmail?: gmail_v1.Gmail;
5425

55-
constructor(fields?: Partial<GmailBaseToolParams>) {
26+
constructor(
27+
{ credentials, scopes }: GmailBaseToolParams = {
28+
credentials: {
29+
clientEmail: getEnvironmentVariable("GMAIL_CLIENT_EMAIL"),
30+
privateKey: getEnvironmentVariable("GMAIL_PRIVATE_KEY"),
31+
keyfile: getEnvironmentVariable("GMAIL_KEYFILE"),
32+
subject: getEnvironmentVariable("GMAIL_SUBJECT"),
33+
},
34+
scopes: ["https://mail.google.com/"],
35+
}
36+
) {
5637
super(...arguments);
5738

58-
const { credentials, scopes } =
59-
this.GmailBaseToolParamsSchema.parse(fields);
39+
if (!credentials) {
40+
throw new Error("Missing credentials to authenticate to Gmail");
41+
}
42+
43+
if (!credentials.accessToken) {
44+
if (!credentials.clientEmail) {
45+
throw new Error("Missing GMAIL_CLIENT_EMAIL to interact with Gmail");
46+
}
47+
48+
if (!credentials.privateKey && !credentials.keyfile) {
49+
throw new Error(
50+
"Missing GMAIL_PRIVATE_KEY or GMAIL_KEYFILE or accessToken to interact with Gmail"
51+
);
52+
}
53+
}
54+
55+
this.params = { credentials, scopes };
56+
}
57+
58+
async getGmailClient() {
59+
const { credentials, scopes } = this.params;
60+
61+
if (credentials?.accessToken) {
62+
// always return a new instance so that we don't end up using expired access tokens
63+
const auth = new google.auth.OAuth2();
64+
const accessToken =
65+
typeof credentials.accessToken === "function"
66+
? await credentials.accessToken()
67+
: credentials.accessToken;
68+
69+
auth.setCredentials({
70+
// get fresh access token if a function is provided
71+
access_token: accessToken,
72+
});
73+
return google.gmail({ version: "v1", auth });
74+
}
6075

61-
this.gmail = this.getGmail(
76+
// when not using access token its ok to use singleton instance
77+
if (this.gmail) {
78+
return this.gmail;
79+
}
80+
81+
const auth = new google.auth.JWT(
82+
credentials?.clientEmail,
83+
credentials?.keyfile,
84+
credentials?.privateKey,
6285
scopes,
63-
credentials.clientEmail,
64-
credentials.privateKey,
65-
credentials.keyfile,
66-
credentials.subject
86+
credentials?.subject
6787
);
88+
89+
this.gmail = google.gmail({ version: "v1", auth });
90+
return this.gmail;
6891
}
6992

70-
private getGmail(
71-
scopes: string[],
72-
email: string,
73-
key?: string,
74-
keyfile?: string,
75-
subject?: string
76-
) {
77-
const auth = new google.auth.JWT(email, keyfile, key, scopes, subject);
93+
parseHeaderAndBody(payload: gmail_v1.Schema$MessagePart | undefined) {
94+
if (!payload) {
95+
return { body: "" };
96+
}
7897

79-
return google.gmail({ version: "v1", auth });
98+
const headers = payload.headers || [];
99+
100+
const subject = headers.find((header) => header.name === "Subject");
101+
const sender = headers.find((header) => header.name === "From");
102+
103+
let body = "";
104+
if (payload.parts) {
105+
body = payload.parts
106+
.map((part) =>
107+
part.mimeType === "text/plain"
108+
? this.decodeBody(part.body?.data ?? "")
109+
: ""
110+
)
111+
.join("");
112+
} else if (payload.body?.data) {
113+
body = this.decodeBody(payload.body.data);
114+
}
115+
116+
return { subject, sender, body };
117+
}
118+
119+
decodeBody(body: string) {
120+
if (body) {
121+
try {
122+
// Gmail uses URL-safe base64 encoding, so we need to handle it properly
123+
// Replace URL-safe characters and decode
124+
return atob(body.replace(/-/g, "+").replace(/_/g, "/"));
125+
} catch (error) {
126+
// Keep the original encoded body if decoding fails
127+
return body;
128+
}
129+
}
130+
return "";
80131
}
81132
}

libs/langchain-community/src/tools/gmail/create_draft.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ export class GmailCreateDraft extends GmailBaseTool {
5656
bcc
5757
);
5858

59-
const response = await this.gmail.users.drafts.create({
59+
const gmail = await this.getGmailClient();
60+
61+
const response = await gmail.users.drafts.create({
6062
userId: "me",
6163
requestBody: create_message,
6264
});

libs/langchain-community/src/tools/gmail/descriptions.ts

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -78,42 +78,37 @@ Example Output:
7878
"Message sent. Message Id: unique_message_id_string"
7979
`;
8080

81-
export const SEARCH_DESCRIPTION = `A tool for searching email messages or threads in Gmail using a specific query. It offers the flexibility to choose between messages and threads as the search resource.
81+
export const SEARCH_DESCRIPTION = `A tool for searching Gmail messages or threads using a specific query. Offers the flexibility to choose between messages and threads as the search resource.
8282
83-
INPUT example:
83+
INPUT:
8484
{
85-
"query": "specific search query",
85+
"query": "search query",
8686
"maxResults": 10, // Optional: number of results to return
8787
"resource": "messages" // Optional: can be "messages" or "threads"
8888
}
8989
9090
OUTPUT:
91-
The output is a JSON list of either email messages or threads, depending on the specified resource, that matches the search query. For 'messages', the output includes details like the message ID, thread ID, snippet, body, subject, and sender of each message. For 'threads', it includes the thread ID, snippet, body, subject, and sender of the first message in each thread. If no data is returned, or if the specified resource is invalid, the tool throws an error with a relevant message.
92-
93-
Example Output for 'messages':
94-
"Result for the query 'specific search query':
95-
[
96-
{
97-
'id': 'message_id',
98-
'threadId': 'thread_id',
99-
'snippet': 'message snippet',
100-
'body': 'message body',
101-
'subject': 'message subject',
102-
'sender': 'sender's email'
103-
},
104-
... (other messages matching the query)
91+
JSON list of matching email messages or threads based on the specified resource. If no data is returned, or if the specified resource is invalid, throw error with a relevant message.
92+
93+
Example result for messages:
94+
"[{
95+
'id': 'message_id',
96+
'threadId': 'thread_id',
97+
'snippet': 'message snippet',
98+
'body': 'message body',
99+
'subject': 'message subject',
100+
'sender': 'message sender'
101+
},
102+
... (other messages matching the query)
105103
]"
106104
107-
Example Output for 'threads':
108-
"Result for the query 'specific search query':
109-
[
110-
{
111-
'id': 'thread_id',
112-
'snippet': 'thread snippet',
113-
'body': 'first message body',
114-
'subject': 'first message subject',
115-
'sender': 'first message sender'
116-
},
117-
... (other threads matching the query)
118-
]"
119-
`;
105+
Example result for threads:
106+
"[{
107+
'id': 'thread_id',
108+
'snippet': 'thread snippet',
109+
'body': 'first message body',
110+
'subject': 'first message subject',
111+
'sender': 'first message sender'
112+
},
113+
... (other threads matching the query)
114+
]"`;

libs/langchain-community/src/tools/gmail/get_message.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ export class GmailGetMessage extends GmailBaseTool {
1818
async _call(arg: z.output<typeof this.schema>) {
1919
const { messageId } = arg;
2020

21-
const message = await this.gmail.users.messages.get({
21+
const gmail = await this.getGmailClient();
22+
23+
const { data } = await gmail.users.messages.get({
2224
userId: "me",
25+
format: "full",
26+
2327
id: messageId,
2428
});
2529

26-
const { data } = message;
27-
2830
if (!data) {
2931
throw new Error("No data returned from Gmail");
3032
}
@@ -41,21 +43,17 @@ export class GmailGetMessage extends GmailBaseTool {
4143
throw new Error("No headers returned from Gmail");
4244
}
4345

44-
const subject = headers.find((header) => header.name === "Subject");
46+
const { subject, sender, body } = this.parseHeaderAndBody(payload);
4547

4648
if (!subject) {
4749
throw new Error("No subject returned from Gmail");
4850
}
4951

50-
const body = headers.find((header) => header.name === "Body");
51-
5252
if (!body) {
5353
throw new Error("No body returned from Gmail");
5454
}
5555

56-
const from = headers.find((header) => header.name === "From");
57-
58-
if (!from) {
56+
if (!sender) {
5957
throw new Error("No from returned from Gmail");
6058
}
6159

@@ -81,8 +79,8 @@ export class GmailGetMessage extends GmailBaseTool {
8179

8280
return `Result for the prompt ${messageId} \n${JSON.stringify({
8381
subject: subject.value,
84-
body: body.value,
85-
from: from.value,
82+
body,
83+
from: sender.value,
8684
to: to.value,
8785
date: date.value,
8886
messageId: messageIdHeader.value,

0 commit comments

Comments
 (0)