arcade-gmail 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,18 @@
1
+ import os
2
+
3
+ from arcade_gmail.enums import GmailReplyToWhom
4
+
5
+ # The default reply in Gmail is to only the sender. Since Gmail also offers the possibility of
6
+ # changing the default to 'reply to all', we support both options through an env variable.
7
+ # https://support.google.com/mail/answer/6585?hl=en&sjid=15399867888091633568-SA#null
8
+ try:
9
+ GMAIL_DEFAULT_REPLY_TO = GmailReplyToWhom(
10
+ # Values accepted are defined in the arcade_google.tools.models.GmailReplyToWhom Enum
11
+ os.getenv("ARCADE_GMAIL_DEFAULT_REPLY_TO", GmailReplyToWhom.ONLY_THE_SENDER.value).lower()
12
+ )
13
+ except ValueError as e:
14
+ raise ValueError(
15
+ "Invalid value for ARCADE_GMAIL_DEFAULT_REPLY_TO: "
16
+ f"'{os.getenv('ARCADE_GMAIL_DEFAULT_REPLY_TO')}'. Expected one of "
17
+ f"{list(GmailReplyToWhom.__members__.keys())}"
18
+ ) from e
arcade_gmail/enums.py ADDED
@@ -0,0 +1,11 @@
1
+ from enum import Enum
2
+
3
+
4
+ class GmailReplyToWhom(str, Enum):
5
+ EVERY_RECIPIENT = "every_recipient"
6
+ ONLY_THE_SENDER = "only_the_sender"
7
+
8
+
9
+ class GmailAction(str, Enum):
10
+ SEND = "send"
11
+ DRAFT = "draft"
@@ -0,0 +1,19 @@
1
+ class GmailToolError(Exception):
2
+ """Base exception for Google tool errors."""
3
+
4
+ def __init__(self, message: str, developer_message: str | None = None):
5
+ self.message = message
6
+ self.developer_message = developer_message
7
+ super().__init__(self.message)
8
+
9
+ def __str__(self) -> str:
10
+ base_message = self.message
11
+ if self.developer_message:
12
+ return f"{base_message} (Developer: {self.developer_message})"
13
+ return base_message
14
+
15
+
16
+ class GmailServiceError(GmailToolError):
17
+ """Raised when there's an error building or using the Google service."""
18
+
19
+ pass
@@ -0,0 +1,39 @@
1
+ from arcade_gmail.tools.gmail import (
2
+ change_email_labels,
3
+ create_label,
4
+ delete_draft_email,
5
+ get_thread,
6
+ list_draft_emails,
7
+ list_emails,
8
+ list_emails_by_header,
9
+ list_labels,
10
+ list_threads,
11
+ reply_to_email,
12
+ search_threads,
13
+ send_draft_email,
14
+ send_email,
15
+ trash_email,
16
+ update_draft_email,
17
+ write_draft_email,
18
+ write_draft_reply_email,
19
+ )
20
+
21
+ __all__ = [
22
+ "change_email_labels",
23
+ "create_label",
24
+ "delete_draft_email",
25
+ "get_thread",
26
+ "list_draft_emails",
27
+ "list_emails",
28
+ "list_emails_by_header",
29
+ "list_labels",
30
+ "list_threads",
31
+ "reply_to_email",
32
+ "search_threads",
33
+ "send_draft_email",
34
+ "send_email",
35
+ "trash_email",
36
+ "update_draft_email",
37
+ "write_draft_email",
38
+ "write_draft_reply_email",
39
+ ]
@@ -0,0 +1,664 @@
1
+ import base64
2
+ from email.mime.text import MIMEText
3
+ from typing import Annotated, Any
4
+
5
+ from arcade_tdk import ToolContext, tool
6
+ from arcade_tdk.auth import Google
7
+ from arcade_tdk.errors import RetryableToolError
8
+ from googleapiclient.errors import HttpError
9
+
10
+ from arcade_gmail.constants import GMAIL_DEFAULT_REPLY_TO
11
+ from arcade_gmail.enums import GmailAction, GmailReplyToWhom
12
+ from arcade_gmail.exceptions import GmailToolError
13
+ from arcade_gmail.utils import (
14
+ DateRange,
15
+ _build_gmail_service,
16
+ build_email_message,
17
+ build_gmail_query_string,
18
+ build_reply_recipients,
19
+ fetch_messages,
20
+ get_draft_url,
21
+ get_email_details,
22
+ get_email_in_trash_url,
23
+ get_label_ids,
24
+ get_sent_email_url,
25
+ parse_draft_email,
26
+ parse_multipart_email,
27
+ parse_plain_text_email,
28
+ remove_none_values,
29
+ )
30
+
31
+
32
+ # Email sending tools
33
+ @tool(
34
+ requires_auth=Google(
35
+ scopes=["https://www.googleapis.com/auth/gmail.send"],
36
+ )
37
+ )
38
+ async def send_email(
39
+ context: ToolContext,
40
+ subject: Annotated[str, "The subject of the email"],
41
+ body: Annotated[str, "The body of the email"],
42
+ recipient: Annotated[str, "The recipient of the email"],
43
+ cc: Annotated[list[str] | None, "CC recipients of the email"] = None,
44
+ bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None,
45
+ ) -> Annotated[dict, "A dictionary containing the sent email details"]:
46
+ """
47
+ Send an email using the Gmail API.
48
+ """
49
+ service = _build_gmail_service(context)
50
+ email = build_email_message(recipient, subject, body, cc, bcc)
51
+
52
+ sent_message = service.users().messages().send(userId="me", body=email).execute()
53
+
54
+ email = parse_plain_text_email(sent_message)
55
+ email["url"] = get_sent_email_url(sent_message["id"])
56
+ return email
57
+
58
+
59
+ @tool(
60
+ requires_auth=Google(
61
+ scopes=["https://www.googleapis.com/auth/gmail.send"],
62
+ )
63
+ )
64
+ async def send_draft_email(
65
+ context: ToolContext, email_id: Annotated[str, "The ID of the draft to send"]
66
+ ) -> Annotated[dict, "A dictionary containing the sent email details"]:
67
+ """
68
+ Send a draft email using the Gmail API.
69
+ """
70
+
71
+ service = _build_gmail_service(context)
72
+
73
+ # Send the draft email
74
+ sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute()
75
+
76
+ email = parse_plain_text_email(sent_message)
77
+ email["url"] = get_sent_email_url(sent_message["id"])
78
+ return email
79
+
80
+
81
+ # Note: in the Gmail UI, a user can customize the recipient and cc fields before replying.
82
+ # We decided not to support this feature, since we'd need a way for LLMs to tell apart between
83
+ # adding or removing recipients/cc, or replacing with an entirely new list of addresses,
84
+ # which would make the tool more complex to call.
85
+ @tool(
86
+ requires_auth=Google(
87
+ scopes=["https://www.googleapis.com/auth/gmail.send"],
88
+ )
89
+ )
90
+ async def reply_to_email(
91
+ context: ToolContext,
92
+ body: Annotated[str, "The body of the email"],
93
+ reply_to_message_id: Annotated[str, "The ID of the message to reply to"],
94
+ reply_to_whom: Annotated[
95
+ GmailReplyToWhom,
96
+ "Whether to reply to every recipient (including cc) or only to the original sender. "
97
+ f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.",
98
+ ] = GMAIL_DEFAULT_REPLY_TO,
99
+ bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None,
100
+ ) -> Annotated[dict, "A dictionary containing the sent email details"]:
101
+ """
102
+ Send a reply to an email message.
103
+ """
104
+ if isinstance(reply_to_whom, str):
105
+ reply_to_whom = GmailReplyToWhom(reply_to_whom)
106
+
107
+ service = _build_gmail_service(context)
108
+
109
+ current_user = service.users().getProfile(userId="me").execute()
110
+
111
+ try:
112
+ replying_to_email = (
113
+ service.users().messages().get(userId="me", id=reply_to_message_id).execute()
114
+ )
115
+ except HttpError as e:
116
+ raise RetryableToolError(
117
+ message=f"Could not retrieve the message with id {reply_to_message_id}.",
118
+ developer_message=(
119
+ f"Could not retrieve the message with id {reply_to_message_id}. "
120
+ f"Reason: '{e.reason}'. Error details: '{e.error_details}'"
121
+ ),
122
+ ) from e
123
+
124
+ replying_to_email = parse_multipart_email(replying_to_email)
125
+
126
+ recipients = build_reply_recipients(
127
+ replying_to_email, current_user["emailAddress"], reply_to_whom
128
+ )
129
+
130
+ email = build_email_message(
131
+ recipient=recipients,
132
+ subject=f"Re: {replying_to_email['subject']}",
133
+ body=body,
134
+ cc=None
135
+ if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER
136
+ else replying_to_email["cc"].split(","),
137
+ bcc=bcc,
138
+ replying_to=replying_to_email,
139
+ )
140
+
141
+ sent_message = service.users().messages().send(userId="me", body=email).execute()
142
+
143
+ email = parse_plain_text_email(sent_message)
144
+ email["url"] = get_sent_email_url(sent_message["id"])
145
+ return email
146
+
147
+
148
+ # Draft Management Tools
149
+ @tool(
150
+ requires_auth=Google(
151
+ scopes=["https://www.googleapis.com/auth/gmail.compose"],
152
+ )
153
+ )
154
+ async def write_draft_email(
155
+ context: ToolContext,
156
+ subject: Annotated[str, "The subject of the draft email"],
157
+ body: Annotated[str, "The body of the draft email"],
158
+ recipient: Annotated[str, "The recipient of the draft email"],
159
+ cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None,
160
+ bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None,
161
+ ) -> Annotated[dict, "A dictionary containing the created draft email details"]:
162
+ """
163
+ Compose a new email draft using the Gmail API.
164
+ """
165
+ # Set up the Gmail API client
166
+ service = _build_gmail_service(context)
167
+
168
+ draft = {
169
+ "message": build_email_message(recipient, subject, body, cc, bcc, action=GmailAction.DRAFT)
170
+ }
171
+
172
+ draft_message = service.users().drafts().create(userId="me", body=draft).execute()
173
+ email = parse_draft_email(draft_message)
174
+ email["url"] = get_draft_url(draft_message["id"])
175
+ return email
176
+
177
+
178
+ # Note: in the Gmail UI, a user can customize the recipient and cc fields before replying.
179
+ # We decided not to support this feature, since we'd need a way for LLMs to tell apart between
180
+ # adding or removing recipients/cc, or replacing with an entirely new list of addresses,
181
+ # which would make the tool more complex to call.
182
+ @tool(
183
+ requires_auth=Google(
184
+ scopes=["https://www.googleapis.com/auth/gmail.compose"],
185
+ )
186
+ )
187
+ async def write_draft_reply_email(
188
+ context: ToolContext,
189
+ body: Annotated[str, "The body of the draft reply email"],
190
+ reply_to_message_id: Annotated[str, "The Gmail message ID of the message to draft a reply to"],
191
+ reply_to_whom: Annotated[
192
+ GmailReplyToWhom,
193
+ "Whether to reply to every recipient (including cc) or only to the original sender. "
194
+ f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.",
195
+ ] = GMAIL_DEFAULT_REPLY_TO,
196
+ bcc: Annotated[list[str] | None, "BCC recipients of the draft reply email"] = None,
197
+ ) -> Annotated[dict, "A dictionary containing the created draft reply email details"]:
198
+ """
199
+ Compose a draft reply to an email message.
200
+ """
201
+ if isinstance(reply_to_whom, str):
202
+ reply_to_whom = GmailReplyToWhom(reply_to_whom)
203
+
204
+ service = _build_gmail_service(context)
205
+
206
+ current_user = service.users().getProfile(userId="me").execute()
207
+
208
+ try:
209
+ replying_to_email = (
210
+ service.users().messages().get(userId="me", id=reply_to_message_id).execute()
211
+ )
212
+ except HttpError as e:
213
+ raise RetryableToolError(
214
+ message="Could not retrieve the message to respond to.",
215
+ developer_message=(
216
+ "Could not retrieve the message to respond to. "
217
+ f"Reason: '{e.reason}'. Error details: '{e.error_details}'"
218
+ ),
219
+ )
220
+
221
+ replying_to_email = parse_multipart_email(replying_to_email)
222
+
223
+ recipients = build_reply_recipients(
224
+ replying_to_email, current_user["emailAddress"], reply_to_whom
225
+ )
226
+
227
+ draft_message = {
228
+ "message": build_email_message(
229
+ recipient=recipients,
230
+ subject=f"Re: {replying_to_email['subject']}",
231
+ body=body,
232
+ cc=None
233
+ if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER
234
+ else replying_to_email["cc"].split(","),
235
+ bcc=bcc,
236
+ replying_to=replying_to_email,
237
+ action=GmailAction.DRAFT,
238
+ ),
239
+ }
240
+
241
+ draft = service.users().drafts().create(userId="me", body=draft_message).execute()
242
+
243
+ email = parse_draft_email(draft)
244
+ email["url"] = get_draft_url(draft["id"])
245
+ return email
246
+
247
+
248
+ @tool(
249
+ requires_auth=Google(
250
+ scopes=["https://www.googleapis.com/auth/gmail.compose"],
251
+ )
252
+ )
253
+ async def update_draft_email(
254
+ context: ToolContext,
255
+ draft_email_id: Annotated[str, "The ID of the draft email to update."],
256
+ subject: Annotated[str, "The subject of the draft email"],
257
+ body: Annotated[str, "The body of the draft email"],
258
+ recipient: Annotated[str, "The recipient of the draft email"],
259
+ cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None,
260
+ bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None,
261
+ ) -> Annotated[dict, "A dictionary containing the updated draft email details"]:
262
+ """
263
+ Update an existing email draft using the Gmail API.
264
+ """
265
+ service = _build_gmail_service(context)
266
+
267
+ message = MIMEText(body)
268
+ message["to"] = recipient
269
+ message["subject"] = subject
270
+ if cc:
271
+ message["Cc"] = ", ".join(cc)
272
+ if bcc:
273
+ message["Bcc"] = ", ".join(bcc)
274
+
275
+ # Encode the message in base64
276
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
277
+
278
+ # Update the draft
279
+ draft = {"id": draft_email_id, "message": {"raw": raw_message}}
280
+
281
+ updated_draft_message = (
282
+ service.users().drafts().update(userId="me", id=draft_email_id, body=draft).execute()
283
+ )
284
+
285
+ email = parse_draft_email(updated_draft_message)
286
+ email["url"] = get_draft_url(updated_draft_message["id"])
287
+
288
+ return email
289
+
290
+
291
+ @tool(
292
+ requires_auth=Google(
293
+ scopes=["https://www.googleapis.com/auth/gmail.compose"],
294
+ )
295
+ )
296
+ async def delete_draft_email(
297
+ context: ToolContext,
298
+ draft_email_id: Annotated[str, "The ID of the draft email to delete"],
299
+ ) -> Annotated[str, "A confirmation message indicating successful deletion"]:
300
+ """
301
+ Delete a draft email using the Gmail API.
302
+ """
303
+ service = _build_gmail_service(context)
304
+
305
+ # Delete the draft
306
+ service.users().drafts().delete(userId="me", id=draft_email_id).execute()
307
+ return f"Draft email with ID {draft_email_id} deleted successfully."
308
+
309
+
310
+ # Email Management Tools
311
+ @tool(
312
+ requires_auth=Google(
313
+ scopes=["https://www.googleapis.com/auth/gmail.modify"],
314
+ )
315
+ )
316
+ async def trash_email(
317
+ context: ToolContext, email_id: Annotated[str, "The ID of the email to trash"]
318
+ ) -> Annotated[dict, "A dictionary containing the trashed email details"]:
319
+ """
320
+ Move an email to the trash folder using the Gmail API.
321
+ """
322
+
323
+ service = _build_gmail_service(context)
324
+
325
+ # Trash the email
326
+ trashed_email = service.users().messages().trash(userId="me", id=email_id).execute()
327
+
328
+ email = parse_plain_text_email(trashed_email)
329
+ email["url"] = get_email_in_trash_url(trashed_email["id"])
330
+ return email
331
+
332
+
333
+ # Draft Search Tools
334
+ @tool(
335
+ requires_auth=Google(
336
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
337
+ )
338
+ )
339
+ async def list_draft_emails(
340
+ context: ToolContext,
341
+ n_drafts: Annotated[int, "Number of draft emails to read"] = 5,
342
+ ) -> Annotated[dict, "A dictionary containing a list of draft email details"]:
343
+ """
344
+ Lists draft emails in the user's draft mailbox using the Gmail API.
345
+ """
346
+ service = _build_gmail_service(context)
347
+
348
+ listed_drafts = service.users().drafts().list(userId="me").execute()
349
+
350
+ if not listed_drafts:
351
+ return {"emails": []}
352
+
353
+ draft_ids = [draft["id"] for draft in listed_drafts.get("drafts", [])][:n_drafts]
354
+
355
+ emails = []
356
+ for draft_id in draft_ids:
357
+ try:
358
+ draft_data = service.users().drafts().get(userId="me", id=draft_id).execute()
359
+ draft_details = parse_draft_email(draft_data)
360
+ if draft_details:
361
+ emails.append(draft_details)
362
+ except Exception as e:
363
+ raise GmailToolError(
364
+ message=f"Error reading draft email {draft_id}.", developer_message=str(e)
365
+ )
366
+
367
+ return {"emails": emails}
368
+
369
+
370
+ @tool(
371
+ requires_auth=Google(
372
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
373
+ )
374
+ )
375
+ async def list_emails_by_header(
376
+ context: ToolContext,
377
+ sender: Annotated[str | None, "The name or email address of the sender of the email"] = None,
378
+ recipient: Annotated[str | None, "The name or email address of the recipient"] = None,
379
+ subject: Annotated[str | None, "Words to find in the subject of the email"] = None,
380
+ body: Annotated[str | None, "Words to find in the body of the email"] = None,
381
+ date_range: Annotated[DateRange | None, "The date range of the email"] = None,
382
+ label: Annotated[str | None, "The label name to filter by"] = None,
383
+ max_results: Annotated[int, "The maximum number of emails to return"] = 25,
384
+ ) -> Annotated[
385
+ dict, "A dictionary containing a list of email details matching the search criteria"
386
+ ]:
387
+ """
388
+ Search for emails by header using the Gmail API.
389
+
390
+ At least one of the following parameters MUST be provided: sender, recipient,
391
+ subject, date_range, label, or body.
392
+ """
393
+ service = _build_gmail_service(context)
394
+ # Ensure at least one search parameter is provided
395
+ if not any([sender, recipient, subject, body, label, date_range]):
396
+ raise RetryableToolError(
397
+ message=(
398
+ "At least one of sender, recipient, subject, body, label, query, "
399
+ "or date_range must be provided."
400
+ ),
401
+ developer_message=(
402
+ "At least one of sender, recipient, subject, body, label, query, "
403
+ "or date_range must be provided."
404
+ ),
405
+ )
406
+
407
+ # Check if label is valid
408
+ if label:
409
+ label_ids = get_label_ids(service, [label])
410
+
411
+ if not label_ids:
412
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
413
+ label_names = [label["name"] for label in labels]
414
+ raise RetryableToolError(
415
+ message=f"Invalid label: {label}",
416
+ developer_message=f"Invalid label: {label}",
417
+ additional_prompt_content=f"List of valid labels: {label_names}",
418
+ )
419
+
420
+ # Build a Gmail-style query string based on the filters
421
+ query = build_gmail_query_string(sender, recipient, subject, body, date_range, label)
422
+
423
+ # Fetch matching messages. This fetches message metadata from Gmail
424
+ messages = fetch_messages(service, query, max_results)
425
+
426
+ # If no messages found, return an empty list
427
+ if not messages:
428
+ return {"emails": []}
429
+
430
+ # Process each message into a structured email object
431
+ emails = get_email_details(service, messages)
432
+
433
+ # Return the list of emails in a dictionary with key "emails"
434
+ return {"emails": emails}
435
+
436
+
437
+ @tool(
438
+ requires_auth=Google(
439
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
440
+ )
441
+ )
442
+ async def list_emails(
443
+ context: ToolContext,
444
+ n_emails: Annotated[int, "Number of emails to read"] = 5,
445
+ ) -> Annotated[dict, "A dictionary containing a list of email details"]:
446
+ """
447
+ Read emails from a Gmail account and extract plain text content.
448
+ """
449
+ service = _build_gmail_service(context)
450
+
451
+ messages = service.users().messages().list(userId="me").execute().get("messages", [])
452
+
453
+ if not messages:
454
+ return {"emails": []}
455
+
456
+ emails = []
457
+ for msg in messages[:n_emails]:
458
+ try:
459
+ email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
460
+ email_details = parse_plain_text_email(email_data)
461
+ if email_details:
462
+ emails.append(email_details)
463
+ except Exception as e:
464
+ raise GmailToolError(
465
+ message=f"Error reading email {msg['id']}.", developer_message=str(e)
466
+ )
467
+ return {"emails": emails}
468
+
469
+
470
+ @tool(
471
+ requires_auth=Google(
472
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
473
+ )
474
+ )
475
+ async def search_threads(
476
+ context: ToolContext,
477
+ page_token: Annotated[
478
+ str | None, "Page token to retrieve a specific page of results in the list"
479
+ ] = None,
480
+ max_results: Annotated[int, "The maximum number of threads to return"] = 10,
481
+ include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False,
482
+ label_ids: Annotated[list[str] | None, "The IDs of labels to filter by"] = None,
483
+ sender: Annotated[str | None, "The name or email address of the sender of the email"] = None,
484
+ recipient: Annotated[str | None, "The name or email address of the recipient"] = None,
485
+ subject: Annotated[str | None, "Words to find in the subject of the email"] = None,
486
+ body: Annotated[str | None, "Words to find in the body of the email"] = None,
487
+ date_range: Annotated[DateRange | None, "The date range of the email"] = None,
488
+ ) -> Annotated[dict, "A dictionary containing a list of thread details"]:
489
+ """Search for threads in the user's mailbox"""
490
+ service = _build_gmail_service(context)
491
+
492
+ query = (
493
+ build_gmail_query_string(sender, recipient, subject, body, date_range)
494
+ if any([sender, recipient, subject, body, date_range])
495
+ else None
496
+ )
497
+
498
+ params = {
499
+ "userId": "me",
500
+ "maxResults": min(max_results, 500),
501
+ "pageToken": page_token,
502
+ "includeSpamTrash": include_spam_trash,
503
+ "labelIds": label_ids,
504
+ "q": query,
505
+ }
506
+ params = remove_none_values(params)
507
+
508
+ threads: list[dict[str, Any]] = []
509
+ next_page_token = None
510
+ # Paginate through thread pages until we have the desired number of threads
511
+ while len(threads) < max_results:
512
+ response = service.users().threads().list(**params).execute()
513
+
514
+ threads.extend(response.get("threads", []))
515
+ next_page_token = response.get("nextPageToken")
516
+
517
+ if not next_page_token:
518
+ break
519
+
520
+ params["pageToken"] = next_page_token
521
+ params["maxResults"] = min(max_results - len(threads), 500)
522
+
523
+ return {
524
+ "threads": threads,
525
+ "num_threads": len(threads),
526
+ "next_page_token": next_page_token,
527
+ }
528
+
529
+
530
+ @tool(
531
+ requires_auth=Google(
532
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
533
+ )
534
+ )
535
+ async def list_threads(
536
+ context: ToolContext,
537
+ page_token: Annotated[
538
+ str | None, "Page token to retrieve a specific page of results in the list"
539
+ ] = None,
540
+ max_results: Annotated[int, "The maximum number of threads to return"] = 10,
541
+ include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False,
542
+ ) -> Annotated[dict, "A dictionary containing a list of thread details"]:
543
+ """List threads in the user's mailbox."""
544
+ threads: dict[str, Any] = await search_threads(
545
+ context, page_token, max_results, include_spam_trash
546
+ )
547
+ return threads
548
+
549
+
550
+ @tool(
551
+ requires_auth=Google(
552
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
553
+ )
554
+ )
555
+ async def get_thread(
556
+ context: ToolContext,
557
+ thread_id: Annotated[str, "The ID of the thread to retrieve"],
558
+ ) -> Annotated[dict, "A dictionary containing the thread details"]:
559
+ """Get the specified thread by ID."""
560
+ params = {
561
+ "userId": "me",
562
+ "id": thread_id,
563
+ "format": "full",
564
+ }
565
+ params = remove_none_values(params)
566
+
567
+ service = _build_gmail_service(context)
568
+
569
+ thread = service.users().threads().get(**params).execute()
570
+ thread["messages"] = [parse_plain_text_email(message) for message in thread.get("messages", [])]
571
+
572
+ return dict(thread)
573
+
574
+
575
+ @tool(
576
+ requires_auth=Google(
577
+ scopes=["https://www.googleapis.com/auth/gmail.modify"],
578
+ )
579
+ )
580
+ async def change_email_labels(
581
+ context: ToolContext,
582
+ email_id: Annotated[str, "The ID of the email to modify labels for"],
583
+ labels_to_add: Annotated[list[str], "List of label names to add"],
584
+ labels_to_remove: Annotated[list[str], "List of label names to remove"],
585
+ ) -> Annotated[dict, "List of labels that were added, removed, and not found"]:
586
+ """
587
+ Add and remove labels from an email using the Gmail API.
588
+ """
589
+ service = _build_gmail_service(context)
590
+
591
+ add_labels = get_label_ids(service, labels_to_add)
592
+ remove_labels = get_label_ids(service, labels_to_remove)
593
+
594
+ invalid_labels = (
595
+ set(labels_to_add + labels_to_remove) - set(add_labels.keys()) - set(remove_labels.keys())
596
+ )
597
+
598
+ if invalid_labels:
599
+ # prepare the list of valid labels
600
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
601
+ label_names = [label["name"] for label in labels]
602
+
603
+ # raise a retryable error with the list of valid labels
604
+ raise RetryableToolError(
605
+ message=f"Invalid labels: {invalid_labels}",
606
+ developer_message=f"Invalid labels: {invalid_labels}",
607
+ additional_prompt_content=f"List of valid labels: {label_names}",
608
+ )
609
+
610
+ # Prepare the modification body with label IDs.
611
+ body = {
612
+ "addLabelIds": list(add_labels.values()),
613
+ "removeLabelIds": list(remove_labels.values()),
614
+ }
615
+
616
+ try: # Modify the email labels.
617
+ service.users().messages().modify(userId="me", id=email_id, body=body).execute()
618
+
619
+ except Exception as e:
620
+ raise GmailToolError(
621
+ message=f"Error modifying labels for email {email_id}", developer_message=str(e)
622
+ )
623
+
624
+ # Confirmation JSON with lists for added and removed labels.
625
+ confirmation = {
626
+ "addedLabels": list(add_labels.keys()),
627
+ "removedLabels": list(remove_labels.keys()),
628
+ }
629
+
630
+ return {"confirmation": dict(confirmation)}
631
+
632
+
633
+ @tool(
634
+ requires_auth=Google(
635
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
636
+ )
637
+ )
638
+ async def list_labels(
639
+ context: ToolContext,
640
+ ) -> Annotated[dict, "A dictionary containing a list of label details"]:
641
+ """List all the labels in the user's mailbox."""
642
+
643
+ service = _build_gmail_service(context)
644
+
645
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
646
+
647
+ return {"labels": labels}
648
+
649
+
650
+ @tool(
651
+ requires_auth=Google(
652
+ scopes=["https://www.googleapis.com/auth/gmail.labels"],
653
+ )
654
+ )
655
+ async def create_label(
656
+ context: ToolContext,
657
+ label_name: Annotated[str, "The name of the label to create"],
658
+ ) -> Annotated[dict, "The details of the created label"]:
659
+ """Create a new label in the user's mailbox."""
660
+
661
+ service = _build_gmail_service(context)
662
+ label = service.users().labels().create(userId="me", body={"name": label_name}).execute()
663
+
664
+ return {"label": label}
arcade_gmail/utils.py ADDED
@@ -0,0 +1,509 @@
1
+ import logging
2
+ import re
3
+ from base64 import urlsafe_b64decode, urlsafe_b64encode
4
+ from datetime import datetime, timedelta
5
+ from email.message import EmailMessage
6
+ from email.mime.text import MIMEText
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ from arcade_tdk import ToolContext
11
+ from bs4 import BeautifulSoup
12
+ from google.oauth2.credentials import Credentials
13
+ from googleapiclient.discovery import build
14
+
15
+ from arcade_gmail.enums import (
16
+ GmailAction,
17
+ GmailReplyToWhom,
18
+ )
19
+ from arcade_gmail.exceptions import GmailServiceError, GmailToolError
20
+
21
+ logging.basicConfig(
22
+ level=logging.DEBUG,
23
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class DateRange(Enum):
30
+ TODAY = "today"
31
+ YESTERDAY = "yesterday"
32
+ LAST_7_DAYS = "last_7_days"
33
+ LAST_30_DAYS = "last_30_days"
34
+ THIS_MONTH = "this_month"
35
+ LAST_MONTH = "last_month"
36
+ THIS_YEAR = "this_year"
37
+
38
+ def to_date_query(self) -> str:
39
+ today = datetime.now()
40
+ result = "after:"
41
+ comparison_date = today
42
+
43
+ if self == DateRange.YESTERDAY:
44
+ comparison_date = today - timedelta(days=1)
45
+ elif self == DateRange.LAST_7_DAYS:
46
+ comparison_date = today - timedelta(days=7)
47
+ elif self == DateRange.LAST_30_DAYS:
48
+ comparison_date = today - timedelta(days=30)
49
+ elif self == DateRange.THIS_MONTH:
50
+ comparison_date = today.replace(day=1)
51
+ elif self == DateRange.LAST_MONTH:
52
+ comparison_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
53
+ elif self == DateRange.THIS_YEAR:
54
+ comparison_date = today.replace(month=1, day=1)
55
+ elif self == DateRange.LAST_MONTH:
56
+ comparison_date = (today.replace(month=1, day=1) - timedelta(days=1)).replace(
57
+ month=1, day=1
58
+ )
59
+
60
+ return result + comparison_date.strftime("%Y/%m/%d")
61
+
62
+
63
+ def build_email_message(
64
+ recipient: str,
65
+ subject: str,
66
+ body: str,
67
+ cc: list[str] | None = None,
68
+ bcc: list[str] | None = None,
69
+ replying_to: dict[str, Any] | None = None,
70
+ action: GmailAction = GmailAction.SEND,
71
+ ) -> dict[str, Any]:
72
+ if replying_to:
73
+ body = build_reply_body(body, replying_to)
74
+
75
+ message: EmailMessage | MIMEText
76
+
77
+ if action == GmailAction.SEND:
78
+ message = EmailMessage()
79
+ message.set_content(body)
80
+ elif action == GmailAction.DRAFT:
81
+ message = MIMEText(body)
82
+
83
+ message["To"] = recipient
84
+ message["Subject"] = subject
85
+
86
+ if cc:
87
+ message["Cc"] = ",".join(cc)
88
+ if bcc:
89
+ message["Bcc"] = ",".join(bcc)
90
+ if replying_to:
91
+ message["In-Reply-To"] = replying_to["header_message_id"]
92
+ message["References"] = f"{replying_to['header_message_id']}, {replying_to['references']}"
93
+
94
+ encoded_message = urlsafe_b64encode(message.as_bytes()).decode()
95
+
96
+ data = {"raw": encoded_message}
97
+
98
+ if replying_to:
99
+ data["threadId"] = replying_to["thread_id"]
100
+
101
+ return data
102
+
103
+
104
+ def _build_gmail_service(context: ToolContext) -> Any:
105
+ """
106
+ Private helper function to build and return the Gmail service client.
107
+
108
+ Args:
109
+ context (ToolContext): The context containing authorization details.
110
+
111
+ Returns:
112
+ googleapiclient.discovery.Resource: An authorized Gmail API service instance.
113
+ """
114
+ try:
115
+ credentials = Credentials(
116
+ context.authorization.token
117
+ if context.authorization and context.authorization.token
118
+ else ""
119
+ )
120
+ except Exception as e:
121
+ raise GmailServiceError(message="Failed to build Gmail service.", developer_message=str(e))
122
+
123
+ return build("gmail", "v1", credentials=credentials)
124
+
125
+
126
+ def build_gmail_query_string(
127
+ sender: str | None = None,
128
+ recipient: str | None = None,
129
+ subject: str | None = None,
130
+ body: str | None = None,
131
+ date_range: DateRange | None = None,
132
+ label: str | None = None,
133
+ ) -> str:
134
+ """Helper function to build a query string
135
+ for Gmail list_emails_by_header and search_threads tools.
136
+ """
137
+ query = []
138
+ if sender:
139
+ query.append(f"from:{sender}")
140
+ if recipient:
141
+ query.append(f"to:{recipient}")
142
+ if subject:
143
+ query.append(f"subject:{subject}")
144
+ if body:
145
+ query.append(body)
146
+ if date_range:
147
+ query.append(date_range.to_date_query())
148
+ if label:
149
+ query.append(f"label:{label}")
150
+ return " ".join(query)
151
+
152
+
153
+ def get_label_ids(service: Any, label_names: list[str]) -> dict[str, str]:
154
+ """
155
+ Retrieve label IDs for given label names.
156
+ Returns a dictionary mapping label names to their IDs.
157
+
158
+ Args:
159
+ service: Authenticated Gmail API service instance.
160
+ label_names: List of label names to retrieve IDs for.
161
+
162
+ Returns:
163
+ A dictionary mapping found label names to their corresponding IDs.
164
+ """
165
+ try:
166
+ # Fetch all existing labels from Gmail
167
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
168
+ except Exception as e:
169
+ raise GmailToolError(message="Failed to list labels.", developer_message=str(e)) from e
170
+
171
+ # Create a mapping from label names to their IDs
172
+ label_id_map = {label["name"]: label["id"] for label in labels}
173
+
174
+ found_labels = {}
175
+ for name in label_names:
176
+ label_id = label_id_map.get(name)
177
+ if label_id:
178
+ found_labels[name] = label_id
179
+ else:
180
+ logger.warning(f"Label '{name}' does not exist")
181
+
182
+ return found_labels
183
+
184
+
185
+ def fetch_messages(service: Any, query_string: str, limit: int) -> list[dict[str, Any]]:
186
+ """
187
+ Helper function to fetch messages from Gmail API for the list_emails_by_header tool.
188
+ """
189
+ response = (
190
+ service.users()
191
+ .messages()
192
+ .list(userId="me", q=query_string, maxResults=limit or 100)
193
+ .execute()
194
+ )
195
+ return response.get("messages", []) # type: ignore[no-any-return]
196
+
197
+
198
+ def remove_none_values(params: dict) -> dict:
199
+ """
200
+ Remove None values from a dictionary.
201
+ :param params: The dictionary to clean
202
+ :return: A new dictionary with None values removed
203
+ """
204
+ return {k: v for k, v in params.items() if v is not None}
205
+
206
+
207
+ def build_reply_recipients(
208
+ replying_to: dict[str, Any], current_user_email_address: str, reply_to_whom: GmailReplyToWhom
209
+ ) -> str:
210
+ if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER:
211
+ recipients = [replying_to["from"]]
212
+ elif reply_to_whom == GmailReplyToWhom.EVERY_RECIPIENT:
213
+ recipients = [replying_to["from"], *replying_to["to"].split(",")]
214
+ else:
215
+ raise ValueError(f"Unsupported reply_to_whom value: {reply_to_whom}")
216
+
217
+ recipients = [
218
+ email_address.strip()
219
+ for email_address in recipients
220
+ if email_address.strip().lower() != current_user_email_address.lower().strip()
221
+ ]
222
+
223
+ return ", ".join(recipients)
224
+
225
+
226
+ def get_draft_url(draft_id: str) -> str:
227
+ return f"https://mail.google.com/mail/u/0/#drafts/{draft_id}"
228
+
229
+
230
+ def get_sent_email_url(sent_email_id: str) -> str:
231
+ return f"https://mail.google.com/mail/u/0/#sent/{sent_email_id}"
232
+
233
+
234
+ def get_email_details(service: Any, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
235
+ """
236
+ Retrieves full message data for each message ID in the given list and extracts email details.
237
+
238
+ :param service: Authenticated Gmail API service instance.
239
+ :param messages: A list of dictionaries, each representing a message with an 'id' key.
240
+ :return: A list of dictionaries, each containing parsed email details.
241
+ """
242
+
243
+ emails = []
244
+ for msg in messages:
245
+ try:
246
+ # Fetch the full message data from Gmail using the message ID
247
+ email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
248
+ # Parse the raw email data into a structured form
249
+ email_details = parse_plain_text_email(email_data)
250
+ # Only add the details if parsing was successful
251
+ if email_details:
252
+ emails.append(email_details)
253
+ except Exception as e:
254
+ # Log any errors encountered while trying to fetch or parse a message
255
+ raise GmailToolError(
256
+ message=f"Error reading email {msg['id']}.", developer_message=str(e)
257
+ )
258
+ return emails
259
+
260
+
261
+ def get_email_in_trash_url(email_id: str) -> str:
262
+ return f"https://mail.google.com/mail/u/0/#trash/{email_id}"
263
+
264
+
265
+ def parse_draft_email(draft_email_data: dict[str, Any]) -> dict[str, str]:
266
+ """
267
+ Parse draft email data and extract relevant information.
268
+
269
+ Args:
270
+ draft_email_data (Dict[str, Any]): Raw draft email data from Gmail API.
271
+
272
+ Returns:
273
+ dict[str, str]: Parsed draft email details
274
+ """
275
+ message = draft_email_data.get("message", {})
276
+ payload = message.get("payload", {})
277
+ headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
278
+
279
+ body_data = _get_email_plain_text_body(payload)
280
+
281
+ return {
282
+ "id": draft_email_data.get("id", ""),
283
+ "thread_id": draft_email_data.get("threadId", ""),
284
+ "from": headers.get("from", ""),
285
+ "date": headers.get("internaldate", ""),
286
+ "subject": headers.get("subject", ""),
287
+ "body": _clean_email_body(body_data) if body_data else "",
288
+ }
289
+
290
+
291
+ def _clean_email_body(body: str | None) -> str:
292
+ """
293
+ Remove HTML tags and clean up email body text while preserving most content.
294
+
295
+ Args:
296
+ body (str): The raw email body text.
297
+
298
+ Returns:
299
+ str: Cleaned email body text.
300
+ """
301
+ if not body:
302
+ return ""
303
+
304
+ try:
305
+ # Remove HTML tags using BeautifulSoup
306
+ soup = BeautifulSoup(body, "html.parser")
307
+ text = soup.get_text(separator=" ")
308
+
309
+ # Clean up the text
310
+ cleaned_text = _clean_text(text)
311
+
312
+ return cleaned_text.strip()
313
+ except Exception:
314
+ logger.exception("Error cleaning email body")
315
+ return body
316
+
317
+
318
+ def _get_email_plain_text_body(payload: dict[str, Any]) -> str | None:
319
+ """
320
+ Extract email body from payload, handling 'multipart/alternative' parts.
321
+
322
+ Args:
323
+ payload (Dict[str, Any]): Email payload data.
324
+
325
+ Returns:
326
+ str | None: Decoded email body or None if not found.
327
+ """
328
+ # Direct body extraction
329
+ if "body" in payload and payload["body"].get("data"):
330
+ return _clean_email_body(urlsafe_b64decode(payload["body"]["data"]).decode())
331
+
332
+ # Handle multipart and alternative parts
333
+ return _clean_email_body(_extract_plain_body(payload.get("parts", [])))
334
+
335
+
336
+ def _extract_plain_body(parts: list) -> str | None:
337
+ """
338
+ Recursively extract the email body from parts, handling both plain text and HTML.
339
+
340
+ Args:
341
+ parts (List[Dict[str, Any]]): List of email parts.
342
+
343
+ Returns:
344
+ str | None: Decoded and cleaned email body or None if not found.
345
+ """
346
+ for part in parts:
347
+ mime_type = part.get("mimeType")
348
+
349
+ if mime_type == "text/plain" and "data" in part.get("body", {}):
350
+ return urlsafe_b64decode(part["body"]["data"]).decode()
351
+
352
+ elif mime_type.startswith("multipart/"):
353
+ subparts = part.get("parts", [])
354
+ body = _extract_plain_body(subparts)
355
+ if body:
356
+ return body
357
+
358
+ return _extract_html_body(parts)
359
+
360
+
361
+ def _extract_html_body(parts: list) -> str | None:
362
+ """
363
+ Recursively extract the email body from parts, handling only HTML.
364
+
365
+ Args:
366
+ parts (List[Dict[str, Any]]): List of email parts.
367
+
368
+ Returns:
369
+ str | None: Decoded and cleaned email body or None if not found.
370
+ """
371
+ for part in parts:
372
+ mime_type = part.get("mimeType")
373
+
374
+ if mime_type == "text/html" and "data" in part.get("body", {}):
375
+ html_content = urlsafe_b64decode(part["body"]["data"]).decode()
376
+ return html_content
377
+
378
+ elif mime_type.startswith("multipart/"):
379
+ subparts = part.get("parts", [])
380
+ body = _extract_html_body(subparts)
381
+ if body:
382
+ return body
383
+
384
+ return None
385
+
386
+
387
+ def _clean_text(text: str) -> str:
388
+ """
389
+ Clean up the text while preserving most content.
390
+
391
+ Args:
392
+ text (str): The input text.
393
+
394
+ Returns:
395
+ str: Cleaned text.
396
+ """
397
+ # Replace multiple newlines with a single newline
398
+ text = re.sub(r"\n+", "\n", text)
399
+
400
+ # Replace multiple spaces with a single space
401
+ text = re.sub(r"\s+", " ", text)
402
+
403
+ # Remove leading/trailing whitespace from each line
404
+ text = "\n".join(line.strip() for line in text.split("\n"))
405
+
406
+ return text
407
+
408
+
409
+ def parse_plain_text_email(email_data: dict[str, Any]) -> dict[str, Any]:
410
+ """
411
+ Parse email data and extract relevant information.
412
+ Only returns the plain text body.
413
+
414
+ Args:
415
+ email_data (dict[str, Any]): Raw email data from Gmail API.
416
+
417
+ Returns:
418
+ dict[str, str]: Parsed email details
419
+ """
420
+ payload = email_data.get("payload", {})
421
+ headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
422
+
423
+ body_data = _get_email_plain_text_body(payload)
424
+
425
+ email_details = {
426
+ "id": email_data.get("id", ""),
427
+ "thread_id": email_data.get("threadId", ""),
428
+ "label_ids": email_data.get("labelIds", []),
429
+ "history_id": email_data.get("historyId", ""),
430
+ "snippet": email_data.get("snippet", ""),
431
+ "to": headers.get("to", ""),
432
+ "cc": headers.get("cc", ""),
433
+ "from": headers.get("from", ""),
434
+ "reply_to": headers.get("reply-to", ""),
435
+ "in_reply_to": headers.get("in-reply-to", ""),
436
+ "references": headers.get("references", ""),
437
+ "header_message_id": headers.get("message-id", ""),
438
+ "date": headers.get("date", ""),
439
+ "subject": headers.get("subject", ""),
440
+ "body": body_data or "",
441
+ }
442
+
443
+ return email_details
444
+
445
+
446
+ def build_reply_body(body: str, replying_to: dict[str, Any]) -> str:
447
+ attribution = f"On {replying_to['date']}, {replying_to['from']} wrote:"
448
+ lines = replying_to["plain_text_body"].split("\n")
449
+ quoted_plain = "\n".join([f"> {line}" for line in lines])
450
+ return f"{body}\n\n{attribution}\n\n{quoted_plain}"
451
+
452
+
453
+ def parse_multipart_email(email_data: dict[str, Any]) -> dict[str, Any]:
454
+ """
455
+ Parse email data and extract relevant information.
456
+ Returns the plain text and HTML body along with the images.
457
+
458
+ Args:
459
+ email_data (Dict[str, Any]): Raw email data from Gmail API.
460
+
461
+ Returns:
462
+ dict[str, Any]: Parsed email details
463
+ """
464
+
465
+ payload = email_data.get("payload", {})
466
+ headers = {d["name"].lower(): d["value"] for d in payload.get("headers", [])}
467
+
468
+ # Extract different parts of the email
469
+ plain_text_body = _get_email_plain_text_body(payload)
470
+ html_body = _get_email_html_body(payload)
471
+
472
+ email_details = {
473
+ "id": email_data.get("id", ""),
474
+ "thread_id": email_data.get("threadId", ""),
475
+ "label_ids": email_data.get("labelIds", []),
476
+ "history_id": email_data.get("historyId", ""),
477
+ "snippet": email_data.get("snippet", ""),
478
+ "to": headers.get("to", ""),
479
+ "cc": headers.get("cc", ""),
480
+ "from": headers.get("from", ""),
481
+ "reply_to": headers.get("reply-to", ""),
482
+ "in_reply_to": headers.get("in-reply-to", ""),
483
+ "references": headers.get("references", ""),
484
+ "header_message_id": headers.get("message-id", ""),
485
+ "date": headers.get("date", ""),
486
+ "subject": headers.get("subject", ""),
487
+ "plain_text_body": plain_text_body or _clean_email_body(html_body),
488
+ "html_body": html_body or "",
489
+ }
490
+
491
+ return email_details
492
+
493
+
494
+ def _get_email_html_body(payload: dict[str, Any]) -> str | None:
495
+ """
496
+ Extract email html body from payload, handling 'multipart/alternative' parts.
497
+
498
+ Args:
499
+ payload (Dict[str, Any]): Email payload data.
500
+
501
+ Returns:
502
+ str | None: Decoded email body or None if not found.
503
+ """
504
+ # Direct body extraction
505
+ if "body" in payload and payload["body"].get("data"):
506
+ return urlsafe_b64decode(payload["body"]["data"]).decode()
507
+
508
+ # Handle multipart and alternative parts
509
+ return _extract_html_body(payload.get("parts", []))
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: arcade_gmail
3
+ Version: 2.0.0
4
+ Summary: Arcade.dev LLM tools for Gmail
5
+ Author-email: Arcade <dev@arcade.dev>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: arcade-tdk<3.0.0,>=2.0.0
8
+ Requires-Dist: beautifulsoup4<5.0.0,>=4.10.0
9
+ Requires-Dist: google-api-core<3.0.0,>=2.19.1
10
+ Requires-Dist: google-api-python-client<3.0.0,>=2.137.0
11
+ Requires-Dist: google-auth-httplib2<1.0.0,>=0.2.0
12
+ Requires-Dist: google-auth<3.0.0,>=2.32.0
13
+ Requires-Dist: googleapis-common-protos<2.0.0,>=1.63.2
14
+ Provides-Extra: dev
15
+ Requires-Dist: arcade-ai[evals]<3.0.0,>=2.0.0; extra == 'dev'
16
+ Requires-Dist: arcade-serve<3.0.0,>=2.0.0; extra == 'dev'
17
+ Requires-Dist: mypy<1.6.0,>=1.5.1; extra == 'dev'
18
+ Requires-Dist: pre-commit<3.5.0,>=3.4.0; extra == 'dev'
19
+ Requires-Dist: pytest-asyncio<0.25.0,>=0.24.0; extra == 'dev'
20
+ Requires-Dist: pytest-cov<4.1.0,>=4.0.0; extra == 'dev'
21
+ Requires-Dist: pytest-mock<3.12.0,>=3.11.1; extra == 'dev'
22
+ Requires-Dist: pytest<8.4.0,>=8.3.0; extra == 'dev'
23
+ Requires-Dist: ruff<0.8.0,>=0.7.4; extra == 'dev'
24
+ Requires-Dist: tox<4.12.0,>=4.11.1; extra == 'dev'
@@ -0,0 +1,10 @@
1
+ arcade_gmail/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ arcade_gmail/constants.py,sha256=PjhKJuWIPk5yUfYpXXVsZwpzEEHeoPjR978hF_uexzc,833
3
+ arcade_gmail/enums.py,sha256=cBksG9CV5rpA2tnN7X1Sm40DgWrVf4QtmG1S24LuMPc,209
4
+ arcade_gmail/exceptions.py,sha256=uVSnm_KcCdI8ZfRORi__bIyQYo_-ahi4hHj88BFJkmw,615
5
+ arcade_gmail/utils.py,sha256=WHwemtgAlNO37YKCu0KDmDbflwlCdHoykHMGK1v78pE,16135
6
+ arcade_gmail/tools/__init__.py,sha256=Pgl3P0ZCEuEuHuwJxWr_zAROefwo3bOeF4D3WXJGzNk,802
7
+ arcade_gmail/tools/gmail.py,sha256=cXtO90IFQuqFic7PI-u-6xYB630SttLCoXGx9NBG2eU,23009
8
+ arcade_gmail-2.0.0.dist-info/METADATA,sha256=KhJ23V3wiFHO54UZSBO5Rg4floRByA4aChOKh_UHMNo,1068
9
+ arcade_gmail-2.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ arcade_gmail-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any