camel-ai 0.2.73a4__py3-none-any.whl → 0.2.80a2__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.
- camel/__init__.py +1 -1
- camel/agents/_utils.py +38 -0
- camel/agents/chat_agent.py +2217 -519
- camel/agents/mcp_agent.py +30 -27
- camel/configs/__init__.py +15 -0
- camel/configs/aihubmix_config.py +88 -0
- camel/configs/amd_config.py +70 -0
- camel/configs/cometapi_config.py +104 -0
- camel/configs/minimax_config.py +93 -0
- camel/configs/nebius_config.py +103 -0
- camel/data_collectors/alpaca_collector.py +15 -6
- camel/datasets/base_generator.py +39 -10
- camel/environments/single_step.py +28 -3
- camel/environments/tic_tac_toe.py +1 -1
- camel/interpreters/__init__.py +2 -0
- camel/interpreters/docker/Dockerfile +3 -12
- camel/interpreters/e2b_interpreter.py +34 -1
- camel/interpreters/microsandbox_interpreter.py +395 -0
- camel/loaders/__init__.py +11 -2
- camel/loaders/chunkr_reader.py +9 -0
- camel/memories/agent_memories.py +48 -4
- camel/memories/base.py +26 -0
- camel/memories/blocks/chat_history_block.py +122 -4
- camel/memories/context_creators/score_based.py +25 -384
- camel/memories/records.py +88 -8
- camel/messages/base.py +153 -34
- camel/models/__init__.py +10 -0
- camel/models/aihubmix_model.py +83 -0
- camel/models/aiml_model.py +1 -16
- camel/models/amd_model.py +101 -0
- camel/models/anthropic_model.py +6 -19
- camel/models/aws_bedrock_model.py +2 -33
- camel/models/azure_openai_model.py +114 -89
- camel/models/base_audio_model.py +3 -1
- camel/models/base_model.py +32 -14
- camel/models/cohere_model.py +1 -16
- camel/models/cometapi_model.py +83 -0
- camel/models/crynux_model.py +1 -16
- camel/models/deepseek_model.py +1 -16
- camel/models/fish_audio_model.py +6 -0
- camel/models/gemini_model.py +36 -18
- camel/models/groq_model.py +1 -17
- camel/models/internlm_model.py +1 -16
- camel/models/litellm_model.py +1 -16
- camel/models/lmstudio_model.py +1 -17
- camel/models/minimax_model.py +83 -0
- camel/models/mistral_model.py +1 -16
- camel/models/model_factory.py +27 -1
- camel/models/modelscope_model.py +1 -16
- camel/models/moonshot_model.py +105 -24
- camel/models/nebius_model.py +83 -0
- camel/models/nemotron_model.py +0 -5
- camel/models/netmind_model.py +1 -16
- camel/models/novita_model.py +1 -16
- camel/models/nvidia_model.py +1 -16
- camel/models/ollama_model.py +4 -19
- camel/models/openai_compatible_model.py +62 -41
- camel/models/openai_model.py +62 -57
- camel/models/openrouter_model.py +1 -17
- camel/models/ppio_model.py +1 -16
- camel/models/qianfan_model.py +1 -16
- camel/models/qwen_model.py +1 -16
- camel/models/reka_model.py +1 -16
- camel/models/samba_model.py +34 -47
- camel/models/sglang_model.py +64 -31
- camel/models/siliconflow_model.py +1 -16
- camel/models/stub_model.py +0 -4
- camel/models/togetherai_model.py +1 -16
- camel/models/vllm_model.py +1 -16
- camel/models/volcano_model.py +0 -17
- camel/models/watsonx_model.py +1 -16
- camel/models/yi_model.py +1 -16
- camel/models/zhipuai_model.py +60 -16
- camel/parsers/__init__.py +18 -0
- camel/parsers/mcp_tool_call_parser.py +176 -0
- camel/retrievers/auto_retriever.py +1 -0
- camel/runtimes/daytona_runtime.py +11 -12
- camel/societies/__init__.py +2 -0
- camel/societies/workforce/__init__.py +2 -0
- camel/societies/workforce/events.py +122 -0
- camel/societies/workforce/prompts.py +146 -66
- camel/societies/workforce/role_playing_worker.py +15 -11
- camel/societies/workforce/single_agent_worker.py +302 -65
- camel/societies/workforce/structured_output_handler.py +30 -18
- camel/societies/workforce/task_channel.py +163 -27
- camel/societies/workforce/utils.py +107 -13
- camel/societies/workforce/workflow_memory_manager.py +772 -0
- camel/societies/workforce/workforce.py +1949 -579
- camel/societies/workforce/workforce_callback.py +74 -0
- camel/societies/workforce/workforce_logger.py +168 -145
- camel/societies/workforce/workforce_metrics.py +33 -0
- camel/storages/key_value_storages/json.py +15 -2
- camel/storages/key_value_storages/mem0_cloud.py +48 -47
- camel/storages/object_storages/google_cloud.py +1 -1
- camel/storages/vectordb_storages/oceanbase.py +13 -13
- camel/storages/vectordb_storages/qdrant.py +3 -3
- camel/storages/vectordb_storages/tidb.py +8 -6
- camel/tasks/task.py +4 -3
- camel/toolkits/__init__.py +20 -7
- camel/toolkits/aci_toolkit.py +45 -0
- camel/toolkits/base.py +6 -4
- camel/toolkits/code_execution.py +28 -1
- camel/toolkits/context_summarizer_toolkit.py +684 -0
- camel/toolkits/dappier_toolkit.py +5 -1
- camel/toolkits/dingtalk.py +1135 -0
- camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
- camel/toolkits/excel_toolkit.py +1 -1
- camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +430 -36
- camel/toolkits/function_tool.py +13 -3
- camel/toolkits/github_toolkit.py +104 -17
- camel/toolkits/gmail_toolkit.py +1839 -0
- camel/toolkits/google_calendar_toolkit.py +38 -4
- camel/toolkits/google_drive_mcp_toolkit.py +12 -31
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +15 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +77 -8
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +884 -88
- camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
- camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +959 -89
- camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +9 -2
- camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +281 -213
- camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +23 -3
- camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +72 -7
- camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +582 -132
- camel/toolkits/hybrid_browser_toolkit_py/actions.py +158 -0
- camel/toolkits/hybrid_browser_toolkit_py/browser_session.py +55 -8
- camel/toolkits/hybrid_browser_toolkit_py/config_loader.py +43 -0
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +321 -8
- camel/toolkits/hybrid_browser_toolkit_py/snapshot.py +10 -4
- camel/toolkits/hybrid_browser_toolkit_py/unified_analyzer.js +45 -4
- camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +151 -53
- camel/toolkits/klavis_toolkit.py +5 -1
- camel/toolkits/markitdown_toolkit.py +27 -1
- camel/toolkits/math_toolkit.py +64 -10
- camel/toolkits/mcp_toolkit.py +366 -71
- camel/toolkits/memory_toolkit.py +5 -1
- camel/toolkits/message_integration.py +18 -13
- camel/toolkits/minimax_mcp_toolkit.py +195 -0
- camel/toolkits/note_taking_toolkit.py +19 -10
- camel/toolkits/notion_mcp_toolkit.py +16 -26
- camel/toolkits/openbb_toolkit.py +5 -1
- camel/toolkits/origene_mcp_toolkit.py +8 -49
- camel/toolkits/playwright_mcp_toolkit.py +12 -31
- camel/toolkits/resend_toolkit.py +168 -0
- camel/toolkits/search_toolkit.py +264 -91
- camel/toolkits/slack_toolkit.py +64 -10
- camel/toolkits/terminal_toolkit/__init__.py +18 -0
- camel/toolkits/terminal_toolkit/terminal_toolkit.py +957 -0
- camel/toolkits/terminal_toolkit/utils.py +532 -0
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/video_analysis_toolkit.py +17 -11
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/toolkits/zapier_toolkit.py +5 -1
- camel/types/__init__.py +2 -2
- camel/types/enums.py +274 -7
- camel/types/openai_types.py +2 -2
- camel/types/unified_model_type.py +15 -0
- camel/utils/commons.py +36 -5
- camel/utils/constants.py +3 -0
- camel/utils/context_utils.py +1003 -0
- camel/utils/mcp.py +138 -4
- camel/utils/token_counting.py +43 -20
- {camel_ai-0.2.73a4.dist-info → camel_ai-0.2.80a2.dist-info}/METADATA +223 -83
- {camel_ai-0.2.73a4.dist-info → camel_ai-0.2.80a2.dist-info}/RECORD +170 -141
- camel/loaders/pandas_reader.py +0 -368
- camel/toolkits/openai_agent_toolkit.py +0 -135
- camel/toolkits/terminal_toolkit.py +0 -1550
- {camel_ai-0.2.73a4.dist-info → camel_ai-0.2.80a2.dist-info}/WHEEL +0 -0
- {camel_ai-0.2.73a4.dist-info → camel_ai-0.2.80a2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1839 @@
|
|
|
1
|
+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
18
|
+
|
|
19
|
+
from camel.logger import get_logger
|
|
20
|
+
from camel.toolkits import FunctionTool
|
|
21
|
+
from camel.toolkits.base import BaseToolkit
|
|
22
|
+
from camel.utils import MCPServer
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
SCOPES = [
|
|
27
|
+
'https://www.googleapis.com/auth/gmail.readonly',
|
|
28
|
+
'https://www.googleapis.com/auth/gmail.send',
|
|
29
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
30
|
+
'https://www.googleapis.com/auth/gmail.compose',
|
|
31
|
+
'https://www.googleapis.com/auth/gmail.labels',
|
|
32
|
+
'https://www.googleapis.com/auth/contacts.readonly',
|
|
33
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@MCPServer()
|
|
38
|
+
class GmailToolkit(BaseToolkit):
|
|
39
|
+
r"""A comprehensive toolkit for Gmail operations.
|
|
40
|
+
|
|
41
|
+
This class provides methods for Gmail operations including sending emails,
|
|
42
|
+
managing drafts, fetching messages, managing labels, and handling contacts.
|
|
43
|
+
API keys can be accessed in google cloud console (https://console.cloud.google.com/)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
timeout: Optional[float] = None,
|
|
49
|
+
):
|
|
50
|
+
r"""Initializes a new instance of the GmailToolkit class.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
timeout (Optional[float]): The timeout value for API requests
|
|
54
|
+
in seconds. If None, no timeout is applied.
|
|
55
|
+
(default: :obj:`None`)
|
|
56
|
+
"""
|
|
57
|
+
super().__init__(timeout=timeout)
|
|
58
|
+
|
|
59
|
+
self._credentials = self._authenticate()
|
|
60
|
+
|
|
61
|
+
self.gmail_service: Any = self._get_gmail_service()
|
|
62
|
+
self._people_service: Any = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def people_service(self) -> Any:
|
|
66
|
+
r"""Lazily initialize and return the Google People service."""
|
|
67
|
+
if self._people_service is None:
|
|
68
|
+
self._people_service = self._get_people_service()
|
|
69
|
+
return self._people_service
|
|
70
|
+
|
|
71
|
+
@people_service.setter
|
|
72
|
+
def people_service(self, service: Any) -> None:
|
|
73
|
+
r"""Allow overriding/injecting the People service (e.g., in tests)."""
|
|
74
|
+
self._people_service = service
|
|
75
|
+
|
|
76
|
+
def send_email(
|
|
77
|
+
self,
|
|
78
|
+
to: Union[str, List[str]],
|
|
79
|
+
subject: str,
|
|
80
|
+
body: str,
|
|
81
|
+
cc: Optional[Union[str, List[str]]] = None,
|
|
82
|
+
bcc: Optional[Union[str, List[str]]] = None,
|
|
83
|
+
attachments: Optional[List[str]] = None,
|
|
84
|
+
is_html: bool = False,
|
|
85
|
+
) -> Dict[str, Any]:
|
|
86
|
+
r"""Send an email through Gmail.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
to (Union[str, List[str]]): Recipient email address(es).
|
|
90
|
+
subject (str): Email subject.
|
|
91
|
+
body (str): Email body content.
|
|
92
|
+
cc (Optional[Union[str, List[str]]]): CC recipient email
|
|
93
|
+
address(es).
|
|
94
|
+
bcc (Optional[Union[str, List[str]]]): BCC recipient email
|
|
95
|
+
address(es).
|
|
96
|
+
attachments (Optional[List[str]]): List of file paths to attach.
|
|
97
|
+
is_html (bool): Whether the body is HTML format. Set to True when
|
|
98
|
+
sending formatted emails with HTML tags (e.g., bold,
|
|
99
|
+
links, images). Use False (default) for plain text emails.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
103
|
+
operation.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
# Normalize recipients to lists
|
|
107
|
+
to_list = [to] if isinstance(to, str) else to
|
|
108
|
+
cc_list = [cc] if isinstance(cc, str) else (cc or [])
|
|
109
|
+
bcc_list = [bcc] if isinstance(bcc, str) else (bcc or [])
|
|
110
|
+
|
|
111
|
+
# Validate email addresses
|
|
112
|
+
all_recipients = to_list + cc_list + bcc_list
|
|
113
|
+
for email in all_recipients:
|
|
114
|
+
if not self._is_valid_email(email):
|
|
115
|
+
return {"error": f"Invalid email address: {email}"}
|
|
116
|
+
|
|
117
|
+
# Create message
|
|
118
|
+
message = self._create_message(
|
|
119
|
+
to_list, subject, body, cc_list, bcc_list, attachments, is_html
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Send message
|
|
123
|
+
sent_message = (
|
|
124
|
+
self.gmail_service.users()
|
|
125
|
+
.messages()
|
|
126
|
+
.send(userId='me', body=message)
|
|
127
|
+
.execute()
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"success": True,
|
|
132
|
+
"message_id": sent_message.get('id'),
|
|
133
|
+
"thread_id": sent_message.get('threadId'),
|
|
134
|
+
"message": "Email sent successfully",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error("Failed to send email: %s", e)
|
|
139
|
+
return {"error": f"Failed to send email: {e!s}"}
|
|
140
|
+
|
|
141
|
+
def reply_to_email(
|
|
142
|
+
self,
|
|
143
|
+
message_id: str,
|
|
144
|
+
reply_body: str,
|
|
145
|
+
reply_all: bool = False,
|
|
146
|
+
is_html: bool = False,
|
|
147
|
+
) -> Dict[str, Any]:
|
|
148
|
+
r"""Reply to an email message.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
message_id (str): The unique identifier of the message to reply to.
|
|
152
|
+
To get a message ID, first use fetch_emails() to list messages,
|
|
153
|
+
or use the 'message_id' returned from send_email() or
|
|
154
|
+
create_email_draft().
|
|
155
|
+
reply_body (str): The reply message body.
|
|
156
|
+
reply_all (bool): Whether to reply to all recipients.
|
|
157
|
+
is_html (bool): Whether the body is HTML format. Set to True when
|
|
158
|
+
sending formatted emails with HTML tags (e.g., bold,
|
|
159
|
+
links, images). Use False (default) for plain text emails.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
163
|
+
operation.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
# Get the original message
|
|
167
|
+
original_message = (
|
|
168
|
+
self.gmail_service.users()
|
|
169
|
+
.messages()
|
|
170
|
+
.get(userId='me', id=message_id)
|
|
171
|
+
.execute()
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Extract headers (single pass, case-insensitive)
|
|
175
|
+
headers = original_message['payload'].get('headers', [])
|
|
176
|
+
subject = from_email = to_emails = cc_emails = None
|
|
177
|
+
missing = {'subject', 'from', 'to', 'cc'}
|
|
178
|
+
|
|
179
|
+
for header in headers:
|
|
180
|
+
name = (header.get('name') or '').lower()
|
|
181
|
+
if name not in missing:
|
|
182
|
+
continue
|
|
183
|
+
value = header.get('value')
|
|
184
|
+
if name == 'subject':
|
|
185
|
+
subject = value
|
|
186
|
+
elif name == 'from':
|
|
187
|
+
from_email = value
|
|
188
|
+
elif name == 'to':
|
|
189
|
+
to_emails = value
|
|
190
|
+
elif name == 'cc':
|
|
191
|
+
cc_emails = value
|
|
192
|
+
missing.discard(name)
|
|
193
|
+
if not missing:
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
# Extract identifiers for reply context
|
|
197
|
+
message_id_header = self._get_header_value(
|
|
198
|
+
headers, 'Message-Id'
|
|
199
|
+
) or self._get_header_value(headers, 'Message-ID')
|
|
200
|
+
thread_id = original_message.get('threadId')
|
|
201
|
+
|
|
202
|
+
# Prepare reply subject
|
|
203
|
+
if subject and not subject.startswith('Re: '):
|
|
204
|
+
subject = f"Re: {subject}"
|
|
205
|
+
elif not subject:
|
|
206
|
+
subject = "Re: (No Subject)"
|
|
207
|
+
|
|
208
|
+
# Validate from_email
|
|
209
|
+
if not from_email:
|
|
210
|
+
return {"error": "Original message has no sender address"}
|
|
211
|
+
|
|
212
|
+
# Prepare recipients
|
|
213
|
+
if reply_all:
|
|
214
|
+
recipients = [from_email]
|
|
215
|
+
if to_emails:
|
|
216
|
+
recipients.extend(
|
|
217
|
+
[email.strip() for email in to_emails.split(',')]
|
|
218
|
+
)
|
|
219
|
+
if cc_emails:
|
|
220
|
+
recipients.extend(
|
|
221
|
+
[email.strip() for email in cc_emails.split(',')]
|
|
222
|
+
)
|
|
223
|
+
# Remove duplicates and None values
|
|
224
|
+
recipients = [r for r in list(set(recipients)) if r]
|
|
225
|
+
|
|
226
|
+
# Get current user's email and remove it from recipients
|
|
227
|
+
try:
|
|
228
|
+
profile_result = self.get_profile()
|
|
229
|
+
if profile_result.get('success'):
|
|
230
|
+
current_user_email = profile_result['profile'][
|
|
231
|
+
'email_address'
|
|
232
|
+
]
|
|
233
|
+
# Remove current user from recipients (handle both
|
|
234
|
+
# plain email and "Name <email>" format)
|
|
235
|
+
filtered_recipients = []
|
|
236
|
+
for email in recipients:
|
|
237
|
+
# Extract email from "Name <email>" format
|
|
238
|
+
match = re.search(r'<([^>]+)>$', email.strip())
|
|
239
|
+
email_addr = (
|
|
240
|
+
match.group(1) if match else email.strip()
|
|
241
|
+
)
|
|
242
|
+
if email_addr != current_user_email:
|
|
243
|
+
filtered_recipients.append(email)
|
|
244
|
+
recipients = filtered_recipients
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.warning(
|
|
247
|
+
"Could not get current user email to filter from "
|
|
248
|
+
"recipients: %s",
|
|
249
|
+
e,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
recipients = [from_email]
|
|
253
|
+
|
|
254
|
+
# Create reply message with reply headers
|
|
255
|
+
message = self._create_message(
|
|
256
|
+
recipients,
|
|
257
|
+
subject,
|
|
258
|
+
reply_body,
|
|
259
|
+
is_html=is_html,
|
|
260
|
+
in_reply_to=message_id_header or original_message.get('id'),
|
|
261
|
+
references=[message_id_header] if message_id_header else None,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Send reply in the same thread
|
|
265
|
+
sent_message = (
|
|
266
|
+
self.gmail_service.users()
|
|
267
|
+
.messages()
|
|
268
|
+
.send(userId='me', body={**message, 'threadId': thread_id})
|
|
269
|
+
.execute()
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
"success": True,
|
|
274
|
+
"message_id": sent_message.get('id'),
|
|
275
|
+
"thread_id": sent_message.get('threadId'),
|
|
276
|
+
"message": "Reply sent successfully",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
logger.error("Failed to reply to email: %s", e)
|
|
281
|
+
return {"error": f"Failed to reply to email: {e!s}"}
|
|
282
|
+
|
|
283
|
+
def forward_email(
|
|
284
|
+
self,
|
|
285
|
+
message_id: str,
|
|
286
|
+
to: Union[str, List[str]],
|
|
287
|
+
forward_body: Optional[str] = None,
|
|
288
|
+
cc: Optional[Union[str, List[str]]] = None,
|
|
289
|
+
bcc: Optional[Union[str, List[str]]] = None,
|
|
290
|
+
include_attachments: bool = True,
|
|
291
|
+
) -> Dict[str, Any]:
|
|
292
|
+
r"""Forward an email message.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
message_id (str): The unique identifier of the message to forward.
|
|
296
|
+
To get a message ID, first use fetch_emails() to list messages,
|
|
297
|
+
or use the 'message_id' returned from send_email() or
|
|
298
|
+
create_email_draft().
|
|
299
|
+
to (Union[str, List[str]]): Recipient email address(es).
|
|
300
|
+
forward_body (Optional[str]): Additional message to include at
|
|
301
|
+
the top of the forwarded email, before the original message
|
|
302
|
+
content. If not provided, only the original message will be
|
|
303
|
+
forwarded.
|
|
304
|
+
cc (Optional[Union[str, List[str]]]): CC recipient email
|
|
305
|
+
address(es).
|
|
306
|
+
bcc (Optional[Union[str, List[str]]]): BCC recipient email
|
|
307
|
+
address(es).
|
|
308
|
+
include_attachments (bool): Whether to include original
|
|
309
|
+
attachments. Defaults to True. Only includes real
|
|
310
|
+
attachments, not inline images.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
314
|
+
operation, including the number of attachments forwarded.
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
import tempfile
|
|
318
|
+
|
|
319
|
+
# Get the original message
|
|
320
|
+
original_message = (
|
|
321
|
+
self.gmail_service.users()
|
|
322
|
+
.messages()
|
|
323
|
+
.get(userId='me', id=message_id)
|
|
324
|
+
.execute()
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Extract headers (single pass, case-insensitive)
|
|
328
|
+
headers = original_message['payload'].get('headers', [])
|
|
329
|
+
subject = from_email = date = None
|
|
330
|
+
missing = {'subject', 'from', 'date'}
|
|
331
|
+
|
|
332
|
+
for header in headers:
|
|
333
|
+
name = (header.get('name') or '').lower()
|
|
334
|
+
if name not in missing:
|
|
335
|
+
continue
|
|
336
|
+
value = header.get('value')
|
|
337
|
+
if name == 'subject':
|
|
338
|
+
subject = value
|
|
339
|
+
elif name == 'from':
|
|
340
|
+
from_email = value
|
|
341
|
+
elif name == 'date':
|
|
342
|
+
date = value
|
|
343
|
+
missing.discard(name)
|
|
344
|
+
if not missing:
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
# Prepare forward subject
|
|
348
|
+
if subject and not subject.startswith('Fwd: '):
|
|
349
|
+
subject = f"Fwd: {subject}"
|
|
350
|
+
elif not subject:
|
|
351
|
+
subject = "Fwd: (No Subject)"
|
|
352
|
+
|
|
353
|
+
# Prepare forward body
|
|
354
|
+
if forward_body:
|
|
355
|
+
body = f"{forward_body}\n\n--- Forwarded message ---\n"
|
|
356
|
+
else:
|
|
357
|
+
body = "--- Forwarded message ---\n"
|
|
358
|
+
|
|
359
|
+
body += f"From: {from_email}\n"
|
|
360
|
+
body += f"Date: {date}\n"
|
|
361
|
+
body += f"Subject: {subject.replace('Fwd: ', '')}\n\n"
|
|
362
|
+
|
|
363
|
+
# Add original message body
|
|
364
|
+
body += self._extract_message_body(original_message)
|
|
365
|
+
|
|
366
|
+
# Normalize recipients
|
|
367
|
+
to_list = [to] if isinstance(to, str) else to
|
|
368
|
+
cc_list = [cc] if isinstance(cc, str) else (cc or [])
|
|
369
|
+
bcc_list = [bcc] if isinstance(bcc, str) else (bcc or [])
|
|
370
|
+
|
|
371
|
+
# Handle attachments
|
|
372
|
+
attachment_paths = []
|
|
373
|
+
temp_files: List[str] = []
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
if include_attachments:
|
|
377
|
+
# Extract attachment metadata
|
|
378
|
+
attachments = self._extract_attachments(original_message)
|
|
379
|
+
for att in attachments:
|
|
380
|
+
try:
|
|
381
|
+
# Create temp file
|
|
382
|
+
temp_file = tempfile.NamedTemporaryFile(
|
|
383
|
+
delete=False, suffix=f"_{att['filename']}"
|
|
384
|
+
)
|
|
385
|
+
temp_files.append(temp_file.name)
|
|
386
|
+
|
|
387
|
+
# Download attachment
|
|
388
|
+
result = self.get_attachment(
|
|
389
|
+
message_id=message_id,
|
|
390
|
+
attachment_id=att['attachment_id'],
|
|
391
|
+
save_path=temp_file.name,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if result.get('success'):
|
|
395
|
+
attachment_paths.append(temp_file.name)
|
|
396
|
+
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.warning(
|
|
399
|
+
f"Failed to download attachment "
|
|
400
|
+
f"{att['filename']}: {e}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Create forward message (now with attachments!)
|
|
404
|
+
message = self._create_message(
|
|
405
|
+
to_list,
|
|
406
|
+
subject,
|
|
407
|
+
body,
|
|
408
|
+
cc_list,
|
|
409
|
+
bcc_list,
|
|
410
|
+
attachments=attachment_paths if attachment_paths else None,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Send forward
|
|
414
|
+
sent_message = (
|
|
415
|
+
self.gmail_service.users()
|
|
416
|
+
.messages()
|
|
417
|
+
.send(userId='me', body=message)
|
|
418
|
+
.execute()
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
"success": True,
|
|
423
|
+
"message_id": sent_message.get('id'),
|
|
424
|
+
"thread_id": sent_message.get('threadId'),
|
|
425
|
+
"message": "Email forwarded successfully",
|
|
426
|
+
"attachments_forwarded": len(attachment_paths),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
finally:
|
|
430
|
+
# Clean up temp files
|
|
431
|
+
for temp_file_path in temp_files:
|
|
432
|
+
try:
|
|
433
|
+
os.unlink(temp_file_path)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.warning(
|
|
436
|
+
f"Failed to delete temp file {temp_file_path}: {e}"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
except Exception as e:
|
|
440
|
+
logger.error("Failed to forward email: %s", e)
|
|
441
|
+
return {"error": f"Failed to forward email: {e!s}"}
|
|
442
|
+
|
|
443
|
+
def create_email_draft(
|
|
444
|
+
self,
|
|
445
|
+
to: Union[str, List[str]],
|
|
446
|
+
subject: str,
|
|
447
|
+
body: str,
|
|
448
|
+
cc: Optional[Union[str, List[str]]] = None,
|
|
449
|
+
bcc: Optional[Union[str, List[str]]] = None,
|
|
450
|
+
attachments: Optional[List[str]] = None,
|
|
451
|
+
is_html: bool = False,
|
|
452
|
+
) -> Dict[str, Any]:
|
|
453
|
+
r"""Create an email draft.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
to (Union[str, List[str]]): Recipient email address(es).
|
|
457
|
+
subject (str): Email subject.
|
|
458
|
+
body (str): Email body content.
|
|
459
|
+
cc (Optional[Union[str, List[str]]]): CC recipient email
|
|
460
|
+
address(es).
|
|
461
|
+
bcc (Optional[Union[str, List[str]]]): BCC recipient email
|
|
462
|
+
address(es).
|
|
463
|
+
attachments (Optional[List[str]]): List of file paths to attach.
|
|
464
|
+
is_html (bool): Whether the body is HTML format. Set to True when
|
|
465
|
+
sending formatted emails with HTML tags (e.g., bold,
|
|
466
|
+
links, images). Use False (default) for plain text emails.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
470
|
+
operation.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
# Normalize recipients to lists
|
|
474
|
+
to_list = [to] if isinstance(to, str) else to
|
|
475
|
+
cc_list = [cc] if isinstance(cc, str) else (cc or [])
|
|
476
|
+
bcc_list = [bcc] if isinstance(bcc, str) else (bcc or [])
|
|
477
|
+
|
|
478
|
+
# Validate email addresses
|
|
479
|
+
all_recipients = to_list + cc_list + bcc_list
|
|
480
|
+
for email in all_recipients:
|
|
481
|
+
if not self._is_valid_email(email):
|
|
482
|
+
return {"error": f"Invalid email address: {email}"}
|
|
483
|
+
|
|
484
|
+
# Create message
|
|
485
|
+
message = self._create_message(
|
|
486
|
+
to_list, subject, body, cc_list, bcc_list, attachments, is_html
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Create draft
|
|
490
|
+
draft = (
|
|
491
|
+
self.gmail_service.users()
|
|
492
|
+
.drafts()
|
|
493
|
+
.create(userId='me', body={'message': message})
|
|
494
|
+
.execute()
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
"success": True,
|
|
499
|
+
"draft_id": draft.get('id'),
|
|
500
|
+
"message_id": draft.get('message', {}).get('id'),
|
|
501
|
+
"message": "Draft created successfully",
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.error("Failed to create draft: %s", e)
|
|
506
|
+
return {"error": f"Failed to create draft: {e!s}"}
|
|
507
|
+
|
|
508
|
+
def send_draft(self, draft_id: str) -> Dict[str, Any]:
|
|
509
|
+
r"""Send a draft email.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
draft_id (str): The unique identifier of the draft to send.
|
|
513
|
+
To get a draft ID, first use list_drafts() to list drafts,
|
|
514
|
+
or use the 'draft_id' returned from create_email_draft().
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
518
|
+
operation.
|
|
519
|
+
"""
|
|
520
|
+
try:
|
|
521
|
+
# Send draft
|
|
522
|
+
sent_message = (
|
|
523
|
+
self.gmail_service.users()
|
|
524
|
+
.drafts()
|
|
525
|
+
.send(userId='me', body={'id': draft_id})
|
|
526
|
+
.execute()
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
"success": True,
|
|
531
|
+
"message_id": sent_message.get('id'),
|
|
532
|
+
"thread_id": sent_message.get('threadId'),
|
|
533
|
+
"message": "Draft sent successfully",
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
except Exception as e:
|
|
537
|
+
logger.error("Failed to send draft: %s", e)
|
|
538
|
+
return {"error": f"Failed to send draft: {e!s}"}
|
|
539
|
+
|
|
540
|
+
def fetch_emails(
|
|
541
|
+
self,
|
|
542
|
+
query: str = "",
|
|
543
|
+
max_results: int = 10,
|
|
544
|
+
include_spam_trash: bool = False,
|
|
545
|
+
label_ids: Optional[List[str]] = None,
|
|
546
|
+
page_token: Optional[str] = None,
|
|
547
|
+
) -> Dict[str, Any]:
|
|
548
|
+
r"""Fetch emails with filters and pagination.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
query (str): Gmail search query string. Use Gmail's search syntax:
|
|
552
|
+
- 'from:example@domain.com' - emails from specific sender
|
|
553
|
+
- 'subject:meeting' - emails with specific subject text
|
|
554
|
+
- 'has:attachment' - emails with attachments
|
|
555
|
+
- 'is:unread' - unread emails
|
|
556
|
+
- 'in:sent' - emails in sent folder
|
|
557
|
+
- 'after:2024/01/01 before:2024/12/31' - date range
|
|
558
|
+
Examples: 'from:john@example.com subject:project', 'is:unread
|
|
559
|
+
has:attachment'
|
|
560
|
+
max_results (int): Maximum number of emails to fetch.
|
|
561
|
+
include_spam_trash (bool): Whether to include spam and trash.
|
|
562
|
+
label_ids (Optional[List[str]]): List of label IDs to filter
|
|
563
|
+
emails by. Only emails with ALL of the specified
|
|
564
|
+
labels will be returned.
|
|
565
|
+
Label IDs can be:
|
|
566
|
+
- System labels: 'INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH',
|
|
567
|
+
'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PERSONAL', etc.
|
|
568
|
+
- Custom label IDs: Retrieved from list_gmail_labels() method.
|
|
569
|
+
page_token (Optional[str]): Pagination token from a previous
|
|
570
|
+
response. If provided, fetches the next page of results.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Dict[str, Any]: A dictionary containing the fetched emails.
|
|
574
|
+
"""
|
|
575
|
+
try:
|
|
576
|
+
# Build request parameters
|
|
577
|
+
request_params = {
|
|
578
|
+
'userId': 'me',
|
|
579
|
+
'maxResults': max_results,
|
|
580
|
+
'includeSpamTrash': include_spam_trash,
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if query:
|
|
584
|
+
request_params['q'] = query
|
|
585
|
+
if label_ids:
|
|
586
|
+
request_params['labelIds'] = label_ids
|
|
587
|
+
|
|
588
|
+
# List messages
|
|
589
|
+
if page_token:
|
|
590
|
+
request_params['pageToken'] = page_token
|
|
591
|
+
|
|
592
|
+
messages_result = (
|
|
593
|
+
self.gmail_service.users()
|
|
594
|
+
.messages()
|
|
595
|
+
.list(**request_params)
|
|
596
|
+
.execute()
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
messages = messages_result.get('messages', [])
|
|
600
|
+
emails = []
|
|
601
|
+
|
|
602
|
+
# Fetch detailed information for each message
|
|
603
|
+
for msg in messages:
|
|
604
|
+
email_detail = self._get_message_details(msg['id'])
|
|
605
|
+
if email_detail:
|
|
606
|
+
emails.append(email_detail)
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
"success": True,
|
|
610
|
+
"emails": emails,
|
|
611
|
+
"total_count": len(emails),
|
|
612
|
+
"next_page_token": messages_result.get('nextPageToken'),
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
except Exception as e:
|
|
616
|
+
logger.error("Failed to fetch emails: %s", e)
|
|
617
|
+
return {"error": f"Failed to fetch emails: {e!s}"}
|
|
618
|
+
|
|
619
|
+
def fetch_thread_by_id(self, thread_id: str) -> Dict[str, Any]:
|
|
620
|
+
r"""Fetch a thread by ID.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
thread_id (str): The unique identifier of the thread to fetch.
|
|
624
|
+
To get a thread ID, first use list_threads() to list threads,
|
|
625
|
+
or use the 'thread_id' returned from send_email() or
|
|
626
|
+
reply_to_email().
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
Dict[str, Any]: A dictionary containing the thread details.
|
|
630
|
+
"""
|
|
631
|
+
try:
|
|
632
|
+
thread = (
|
|
633
|
+
self.gmail_service.users()
|
|
634
|
+
.threads()
|
|
635
|
+
.get(userId='me', id=thread_id)
|
|
636
|
+
.execute()
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
messages = []
|
|
640
|
+
for message in thread.get('messages', []):
|
|
641
|
+
message_detail = self._get_message_details(message['id'])
|
|
642
|
+
if message_detail:
|
|
643
|
+
messages.append(message_detail)
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
"success": True,
|
|
647
|
+
"thread_id": thread_id,
|
|
648
|
+
"messages": messages,
|
|
649
|
+
"message_count": len(messages),
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
except Exception as e:
|
|
653
|
+
logger.error("Failed to fetch thread: %s", e)
|
|
654
|
+
return {"error": f"Failed to fetch thread: {e!s}"}
|
|
655
|
+
|
|
656
|
+
def modify_email_labels(
|
|
657
|
+
self,
|
|
658
|
+
message_id: str,
|
|
659
|
+
add_labels: Optional[List[str]] = None,
|
|
660
|
+
remove_labels: Optional[List[str]] = None,
|
|
661
|
+
) -> Dict[str, Any]:
|
|
662
|
+
r"""Modify labels on an email message.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
message_id (str): The unique identifier of the message to modify.
|
|
666
|
+
To get a message ID, first use fetch_emails() to list messages,
|
|
667
|
+
or use the 'message_id' returned from send_email() or
|
|
668
|
+
create_email_draft().
|
|
669
|
+
add_labels (Optional[List[str]]): List of label IDs to add to
|
|
670
|
+
the message.
|
|
671
|
+
Label IDs can be:
|
|
672
|
+
- System labels: 'INBOX', 'STARRED', 'IMPORTANT',
|
|
673
|
+
'UNREAD', etc.
|
|
674
|
+
- Custom label IDs: Retrieved from list_gmail_labels() method.
|
|
675
|
+
Example: ['STARRED', 'IMPORTANT'] marks email as starred
|
|
676
|
+
and important.
|
|
677
|
+
remove_labels (Optional[List[str]]): List of label IDs to
|
|
678
|
+
remove from the message. Uses the same format as add_labels.
|
|
679
|
+
Example: ['UNREAD'] marks the email as read.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
683
|
+
operation.
|
|
684
|
+
"""
|
|
685
|
+
try:
|
|
686
|
+
body = {}
|
|
687
|
+
if add_labels:
|
|
688
|
+
body['addLabelIds'] = add_labels
|
|
689
|
+
if remove_labels:
|
|
690
|
+
body['removeLabelIds'] = remove_labels
|
|
691
|
+
|
|
692
|
+
if not body:
|
|
693
|
+
return {"error": "No labels to add or remove"}
|
|
694
|
+
|
|
695
|
+
modified_message = (
|
|
696
|
+
self.gmail_service.users()
|
|
697
|
+
.messages()
|
|
698
|
+
.modify(userId='me', id=message_id, body=body)
|
|
699
|
+
.execute()
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
"success": True,
|
|
704
|
+
"message_id": message_id,
|
|
705
|
+
"label_ids": modified_message.get('labelIds', []),
|
|
706
|
+
"message": "Labels modified successfully",
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
except Exception as e:
|
|
710
|
+
logger.error("Failed to modify labels: %s", e)
|
|
711
|
+
return {"error": f"Failed to modify labels: {e!s}"}
|
|
712
|
+
|
|
713
|
+
def move_to_trash(self, message_id: str) -> Dict[str, Any]:
|
|
714
|
+
r"""Move a message to trash.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
message_id (str): The unique identifier of the message to move to
|
|
718
|
+
trash. To get a message ID, first use fetch_emails() to list
|
|
719
|
+
messages, or use the 'message_id' returned from send_email()
|
|
720
|
+
or create_email_draft().
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
724
|
+
operation.
|
|
725
|
+
"""
|
|
726
|
+
try:
|
|
727
|
+
trashed_message = (
|
|
728
|
+
self.gmail_service.users()
|
|
729
|
+
.messages()
|
|
730
|
+
.trash(userId='me', id=message_id)
|
|
731
|
+
.execute()
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
"success": True,
|
|
736
|
+
"message_id": message_id,
|
|
737
|
+
"label_ids": trashed_message.get('labelIds', []),
|
|
738
|
+
"message": "Message moved to trash successfully",
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
except Exception as e:
|
|
742
|
+
logger.error("Failed to move message to trash: %s", e)
|
|
743
|
+
return {"error": f"Failed to move message to trash: {e!s}"}
|
|
744
|
+
|
|
745
|
+
def get_attachment(
|
|
746
|
+
self,
|
|
747
|
+
message_id: str,
|
|
748
|
+
attachment_id: str,
|
|
749
|
+
save_path: Optional[str] = None,
|
|
750
|
+
) -> Dict[str, Any]:
|
|
751
|
+
r"""Get an attachment from a message.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
message_id (str): The unique identifier of the message containing
|
|
755
|
+
the attachment. To get a message ID, first use fetch_emails()
|
|
756
|
+
to list messages, or use the 'message_id' returned from
|
|
757
|
+
send_email() or create_email_draft().
|
|
758
|
+
attachment_id (str): The unique identifier of the attachment to
|
|
759
|
+
download. To get an attachment ID, first use fetch_emails() to
|
|
760
|
+
get message details, then look for 'attachment_id' in the
|
|
761
|
+
'attachments' list of each message.
|
|
762
|
+
save_path (Optional[str]): Local file path where the attachment
|
|
763
|
+
should be saved. If provided, the attachment will be saved to
|
|
764
|
+
this location and the response will include a success message.
|
|
765
|
+
If not provided, the attachment data will be returned as
|
|
766
|
+
base64-encoded content in the response.
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
Dict[str, Any]: A dictionary containing the attachment data or
|
|
770
|
+
save result.
|
|
771
|
+
"""
|
|
772
|
+
try:
|
|
773
|
+
import base64
|
|
774
|
+
|
|
775
|
+
attachment = (
|
|
776
|
+
self.gmail_service.users()
|
|
777
|
+
.messages()
|
|
778
|
+
.attachments()
|
|
779
|
+
.get(userId='me', messageId=message_id, id=attachment_id)
|
|
780
|
+
.execute()
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# Decode the attachment data
|
|
784
|
+
file_data = base64.urlsafe_b64decode(attachment['data'])
|
|
785
|
+
|
|
786
|
+
if save_path:
|
|
787
|
+
with open(save_path, 'wb') as f:
|
|
788
|
+
f.write(file_data)
|
|
789
|
+
return {
|
|
790
|
+
"success": True,
|
|
791
|
+
"message": f"Attachment saved to {save_path}",
|
|
792
|
+
"file_size": len(file_data),
|
|
793
|
+
}
|
|
794
|
+
else:
|
|
795
|
+
return {
|
|
796
|
+
"success": True,
|
|
797
|
+
"data": base64.b64encode(file_data).decode('utf-8'),
|
|
798
|
+
"file_size": len(file_data),
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
except Exception as e:
|
|
802
|
+
logger.error("Failed to get attachment: %s", e)
|
|
803
|
+
return {"error": f"Failed to get attachment: {e!s}"}
|
|
804
|
+
|
|
805
|
+
def list_threads(
|
|
806
|
+
self,
|
|
807
|
+
query: str = "",
|
|
808
|
+
max_results: int = 10,
|
|
809
|
+
include_spam_trash: bool = False,
|
|
810
|
+
label_ids: Optional[List[str]] = None,
|
|
811
|
+
page_token: Optional[str] = None,
|
|
812
|
+
) -> Dict[str, Any]:
|
|
813
|
+
r"""List email threads.
|
|
814
|
+
|
|
815
|
+
Args:
|
|
816
|
+
query (str): Gmail search query string. Use Gmail's search syntax:
|
|
817
|
+
- 'from:example@domain.com' - threads from specific sender
|
|
818
|
+
- 'subject:meeting' - threads with specific subject text
|
|
819
|
+
- 'has:attachment' - threads with attachments
|
|
820
|
+
- 'is:unread' - unread threads
|
|
821
|
+
- 'in:sent' - threads in sent folder
|
|
822
|
+
- 'after:2024/01/01 before:2024/12/31' - date range
|
|
823
|
+
Examples: 'from:john@example.com subject:project', 'is:unread
|
|
824
|
+
has:attachment'
|
|
825
|
+
max_results (int): Maximum number of threads to fetch.
|
|
826
|
+
include_spam_trash (bool): Whether to include spam and trash.
|
|
827
|
+
label_ids (Optional[List[str]]): List of label IDs to filter
|
|
828
|
+
threads by. Only threads with ALL of the specified labels
|
|
829
|
+
will be returned.
|
|
830
|
+
Label IDs can be:
|
|
831
|
+
- System labels: 'INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH',
|
|
832
|
+
'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PERSONAL', etc.
|
|
833
|
+
- Custom label IDs: Retrieved from list_gmail_labels() method.
|
|
834
|
+
page_token (Optional[str]): Pagination token from a previous
|
|
835
|
+
response. If provided, fetches the next page of results.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
Dict[str, Any]: A dictionary containing the thread list.
|
|
839
|
+
"""
|
|
840
|
+
try:
|
|
841
|
+
# Build request parameters
|
|
842
|
+
request_params = {
|
|
843
|
+
'userId': 'me',
|
|
844
|
+
'maxResults': max_results,
|
|
845
|
+
'includeSpamTrash': include_spam_trash,
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if query:
|
|
849
|
+
request_params['q'] = query
|
|
850
|
+
if label_ids:
|
|
851
|
+
request_params['labelIds'] = label_ids
|
|
852
|
+
|
|
853
|
+
# List threads
|
|
854
|
+
if page_token:
|
|
855
|
+
request_params['pageToken'] = page_token
|
|
856
|
+
|
|
857
|
+
threads_result = (
|
|
858
|
+
self.gmail_service.users()
|
|
859
|
+
.threads()
|
|
860
|
+
.list(**request_params)
|
|
861
|
+
.execute()
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
threads = threads_result.get('threads', [])
|
|
865
|
+
thread_list = []
|
|
866
|
+
|
|
867
|
+
for thread in threads:
|
|
868
|
+
thread_list.append(
|
|
869
|
+
{
|
|
870
|
+
"thread_id": thread['id'],
|
|
871
|
+
"snippet": thread.get('snippet', ''),
|
|
872
|
+
"history_id": thread.get('historyId', ''),
|
|
873
|
+
}
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
return {
|
|
877
|
+
"success": True,
|
|
878
|
+
"threads": thread_list,
|
|
879
|
+
"total_count": len(thread_list),
|
|
880
|
+
"next_page_token": threads_result.get('nextPageToken'),
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
except Exception as e:
|
|
884
|
+
logger.error("Failed to list threads: %s", e)
|
|
885
|
+
return {"error": f"Failed to list threads: {e!s}"}
|
|
886
|
+
|
|
887
|
+
def list_drafts(
|
|
888
|
+
self, max_results: int = 10, page_token: Optional[str] = None
|
|
889
|
+
) -> Dict[str, Any]:
|
|
890
|
+
r"""List email drafts.
|
|
891
|
+
|
|
892
|
+
Args:
|
|
893
|
+
max_results (int): Maximum number of drafts to fetch.
|
|
894
|
+
page_token (Optional[str]): Pagination token from a previous
|
|
895
|
+
response. If provided, fetches the next page of results.
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
Dict[str, Any]: A dictionary containing the draft list.
|
|
899
|
+
"""
|
|
900
|
+
try:
|
|
901
|
+
drafts_result = (
|
|
902
|
+
self.gmail_service.users()
|
|
903
|
+
.drafts()
|
|
904
|
+
.list(
|
|
905
|
+
userId='me',
|
|
906
|
+
maxResults=max_results,
|
|
907
|
+
**({"pageToken": page_token} if page_token else {}),
|
|
908
|
+
)
|
|
909
|
+
.execute()
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
drafts = drafts_result.get('drafts', [])
|
|
913
|
+
draft_list = []
|
|
914
|
+
|
|
915
|
+
for draft in drafts:
|
|
916
|
+
draft_info = {
|
|
917
|
+
"draft_id": draft['id'],
|
|
918
|
+
"message_id": draft.get('message', {}).get('id', ''),
|
|
919
|
+
"thread_id": draft.get('message', {}).get('threadId', ''),
|
|
920
|
+
"snippet": draft.get('message', {}).get('snippet', ''),
|
|
921
|
+
}
|
|
922
|
+
draft_list.append(draft_info)
|
|
923
|
+
|
|
924
|
+
return {
|
|
925
|
+
"success": True,
|
|
926
|
+
"drafts": draft_list,
|
|
927
|
+
"total_count": len(draft_list),
|
|
928
|
+
"next_page_token": drafts_result.get('nextPageToken'),
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
except Exception as e:
|
|
932
|
+
logger.error("Failed to list drafts: %s", e)
|
|
933
|
+
return {"error": f"Failed to list drafts: {e!s}"}
|
|
934
|
+
|
|
935
|
+
def list_gmail_labels(self) -> Dict[str, Any]:
|
|
936
|
+
r"""List all Gmail labels.
|
|
937
|
+
|
|
938
|
+
Returns:
|
|
939
|
+
Dict[str, Any]: A dictionary containing the label list.
|
|
940
|
+
"""
|
|
941
|
+
try:
|
|
942
|
+
labels_result = (
|
|
943
|
+
self.gmail_service.users().labels().list(userId='me').execute()
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
labels = labels_result.get('labels', [])
|
|
947
|
+
label_list = []
|
|
948
|
+
|
|
949
|
+
for label in labels:
|
|
950
|
+
label_info = {
|
|
951
|
+
"id": label['id'],
|
|
952
|
+
"name": label['name'],
|
|
953
|
+
"type": label.get('type', 'user'),
|
|
954
|
+
"messages_total": label.get('messagesTotal', 0),
|
|
955
|
+
"messages_unread": label.get('messagesUnread', 0),
|
|
956
|
+
"threads_total": label.get('threadsTotal', 0),
|
|
957
|
+
"threads_unread": label.get('threadsUnread', 0),
|
|
958
|
+
}
|
|
959
|
+
label_list.append(label_info)
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
"success": True,
|
|
963
|
+
"labels": label_list,
|
|
964
|
+
"total_count": len(label_list),
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
except Exception as e:
|
|
968
|
+
logger.error("Failed to list labels: %s", e)
|
|
969
|
+
return {"error": f"Failed to list labels: {e!s}"}
|
|
970
|
+
|
|
971
|
+
def create_label(
|
|
972
|
+
self,
|
|
973
|
+
name: str,
|
|
974
|
+
label_list_visibility: Literal["labelShow", "labelHide"] = "labelShow",
|
|
975
|
+
message_list_visibility: Literal["show", "hide"] = "show",
|
|
976
|
+
) -> Dict[str, Any]:
|
|
977
|
+
r"""Create a new Gmail label.
|
|
978
|
+
|
|
979
|
+
Args:
|
|
980
|
+
name (str): The name of the label to create.
|
|
981
|
+
label_list_visibility (str): How the label appears in Gmail's
|
|
982
|
+
label list. - 'labelShow': Label is visible in the label list
|
|
983
|
+
sidebar (default) - 'labelHide': Label is hidden from the
|
|
984
|
+
label list sidebar
|
|
985
|
+
message_list_visibility (str): How the label appears in message
|
|
986
|
+
lists. - 'show': Label is visible on messages in inbox/lists
|
|
987
|
+
(default) - 'hide': Label is hidden from message displays
|
|
988
|
+
|
|
989
|
+
Returns:
|
|
990
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
991
|
+
operation.
|
|
992
|
+
"""
|
|
993
|
+
try:
|
|
994
|
+
label_object = {
|
|
995
|
+
'name': name,
|
|
996
|
+
'labelListVisibility': label_list_visibility,
|
|
997
|
+
'messageListVisibility': message_list_visibility,
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
created_label = (
|
|
1001
|
+
self.gmail_service.users()
|
|
1002
|
+
.labels()
|
|
1003
|
+
.create(userId='me', body=label_object)
|
|
1004
|
+
.execute()
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
return {
|
|
1008
|
+
"success": True,
|
|
1009
|
+
"label_id": created_label['id'],
|
|
1010
|
+
"label_name": created_label['name'],
|
|
1011
|
+
"message": "Label created successfully",
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
logger.error("Failed to create label: %s", e)
|
|
1016
|
+
return {"error": f"Failed to create label: {e!s}"}
|
|
1017
|
+
|
|
1018
|
+
def delete_label(self, label_id: str) -> Dict[str, Any]:
|
|
1019
|
+
r"""Delete a Gmail label.
|
|
1020
|
+
|
|
1021
|
+
Args:
|
|
1022
|
+
label_id (str): The unique identifier of the user-created label to
|
|
1023
|
+
delete. To get a label ID, first use list_gmail_labels() to
|
|
1024
|
+
list all labels. Note: System labels (e.g., 'INBOX', 'SENT',
|
|
1025
|
+
'DRAFT', 'SPAM', 'TRASH', 'UNREAD', 'STARRED', 'IMPORTANT',
|
|
1026
|
+
'CATEGORY_PERSONAL', etc.) cannot be deleted.
|
|
1027
|
+
|
|
1028
|
+
Returns:
|
|
1029
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
1030
|
+
operation.
|
|
1031
|
+
"""
|
|
1032
|
+
try:
|
|
1033
|
+
self.gmail_service.users().labels().delete(
|
|
1034
|
+
userId='me', id=label_id
|
|
1035
|
+
).execute()
|
|
1036
|
+
|
|
1037
|
+
return {
|
|
1038
|
+
"success": True,
|
|
1039
|
+
"label_id": label_id,
|
|
1040
|
+
"message": "Label deleted successfully",
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
logger.error("Failed to delete label: %s", e)
|
|
1045
|
+
return {"error": f"Failed to delete label: {e!s}"}
|
|
1046
|
+
|
|
1047
|
+
def modify_thread_labels(
|
|
1048
|
+
self,
|
|
1049
|
+
thread_id: str,
|
|
1050
|
+
add_labels: Optional[List[str]] = None,
|
|
1051
|
+
remove_labels: Optional[List[str]] = None,
|
|
1052
|
+
) -> Dict[str, Any]:
|
|
1053
|
+
r"""Modify labels on a thread.
|
|
1054
|
+
|
|
1055
|
+
Args:
|
|
1056
|
+
thread_id (str): The unique identifier of the thread to modify.
|
|
1057
|
+
To get a thread ID, first use list_threads() to list threads,
|
|
1058
|
+
or use the 'thread_id' returned from send_email() or
|
|
1059
|
+
reply_to_email().
|
|
1060
|
+
add_labels (Optional[List[str]]): List of label IDs to add to all
|
|
1061
|
+
messages in the thread.
|
|
1062
|
+
Label IDs can be:
|
|
1063
|
+
- System labels: 'INBOX', 'STARRED', 'IMPORTANT',
|
|
1064
|
+
'UNREAD', etc.
|
|
1065
|
+
- Custom label IDs: Retrieved from list_gmail_labels().
|
|
1066
|
+
Example: ['STARRED', 'IMPORTANT'] marks thread as
|
|
1067
|
+
starred and important.
|
|
1068
|
+
remove_labels (Optional[List[str]]): List of label IDs to
|
|
1069
|
+
remove from all messages in the thread. Uses the same
|
|
1070
|
+
format as add_labels.
|
|
1071
|
+
Example: ['UNREAD'] marks the entire thread as read.
|
|
1072
|
+
|
|
1073
|
+
Returns:
|
|
1074
|
+
Dict[str, Any]: A dictionary containing the result of the
|
|
1075
|
+
operation.
|
|
1076
|
+
"""
|
|
1077
|
+
try:
|
|
1078
|
+
body = {}
|
|
1079
|
+
if add_labels:
|
|
1080
|
+
body['addLabelIds'] = add_labels
|
|
1081
|
+
if remove_labels:
|
|
1082
|
+
body['removeLabelIds'] = remove_labels
|
|
1083
|
+
|
|
1084
|
+
if not body:
|
|
1085
|
+
return {"error": "No labels to add or remove"}
|
|
1086
|
+
|
|
1087
|
+
modified_thread = (
|
|
1088
|
+
self.gmail_service.users()
|
|
1089
|
+
.threads()
|
|
1090
|
+
.modify(userId='me', id=thread_id, body=body)
|
|
1091
|
+
.execute()
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
return {
|
|
1095
|
+
"success": True,
|
|
1096
|
+
"thread_id": thread_id,
|
|
1097
|
+
"label_ids": modified_thread.get('labelIds', []),
|
|
1098
|
+
"message": "Thread labels modified successfully",
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
except Exception as e:
|
|
1102
|
+
logger.error("Failed to modify thread labels: %s", e)
|
|
1103
|
+
return {"error": f"Failed to modify thread labels: {e!s}"}
|
|
1104
|
+
|
|
1105
|
+
def get_profile(self) -> Dict[str, Any]:
|
|
1106
|
+
r"""Get Gmail profile information.
|
|
1107
|
+
|
|
1108
|
+
Returns:
|
|
1109
|
+
Dict[str, Any]: A dictionary containing the profile information.
|
|
1110
|
+
"""
|
|
1111
|
+
try:
|
|
1112
|
+
profile = (
|
|
1113
|
+
self.gmail_service.users().getProfile(userId='me').execute()
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
"success": True,
|
|
1118
|
+
"profile": {
|
|
1119
|
+
"email_address": profile.get('emailAddress', ''),
|
|
1120
|
+
"messages_total": profile.get('messagesTotal', 0),
|
|
1121
|
+
"threads_total": profile.get('threadsTotal', 0),
|
|
1122
|
+
"history_id": profile.get('historyId', ''),
|
|
1123
|
+
},
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
except Exception as e:
|
|
1127
|
+
logger.error("Failed to get profile: %s", e)
|
|
1128
|
+
return {"error": f"Failed to get profile: {e!s}"}
|
|
1129
|
+
|
|
1130
|
+
def get_contacts(
|
|
1131
|
+
self,
|
|
1132
|
+
max_results: int = 100,
|
|
1133
|
+
page_token: Optional[str] = None,
|
|
1134
|
+
) -> Dict[str, Any]:
|
|
1135
|
+
r"""List connections from Google People API.
|
|
1136
|
+
|
|
1137
|
+
Args:
|
|
1138
|
+
max_results (int): Maximum number of contacts to fetch.
|
|
1139
|
+
page_token (Optional[str]): Pagination token from a previous
|
|
1140
|
+
response. If provided, fetches the next page of results.
|
|
1141
|
+
|
|
1142
|
+
Returns:
|
|
1143
|
+
Dict[str, Any]: A dictionary containing the contacts.
|
|
1144
|
+
"""
|
|
1145
|
+
try:
|
|
1146
|
+
# Build request parameters
|
|
1147
|
+
request_params = {
|
|
1148
|
+
'resourceName': 'people/me',
|
|
1149
|
+
'personFields': (
|
|
1150
|
+
'names,emailAddresses,phoneNumbers,organizations'
|
|
1151
|
+
),
|
|
1152
|
+
'pageSize': max_results,
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
# Search contacts
|
|
1156
|
+
if page_token:
|
|
1157
|
+
request_params['pageToken'] = page_token
|
|
1158
|
+
|
|
1159
|
+
contacts_result = (
|
|
1160
|
+
self.people_service.people()
|
|
1161
|
+
.connections()
|
|
1162
|
+
.list(**request_params)
|
|
1163
|
+
.execute()
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
connections = contacts_result.get('connections', [])
|
|
1167
|
+
contact_list = []
|
|
1168
|
+
|
|
1169
|
+
for person in connections:
|
|
1170
|
+
contact_info = {
|
|
1171
|
+
"resource_name": person.get('resourceName', ''),
|
|
1172
|
+
"names": person.get('names', []),
|
|
1173
|
+
"email_addresses": person.get('emailAddresses', []),
|
|
1174
|
+
"phone_numbers": person.get('phoneNumbers', []),
|
|
1175
|
+
"organizations": person.get('organizations', []),
|
|
1176
|
+
}
|
|
1177
|
+
contact_list.append(contact_info)
|
|
1178
|
+
|
|
1179
|
+
return {
|
|
1180
|
+
"success": True,
|
|
1181
|
+
"contacts": contact_list,
|
|
1182
|
+
"total_count": len(contact_list),
|
|
1183
|
+
"next_page_token": contacts_result.get('nextPageToken'),
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
except Exception as e:
|
|
1187
|
+
logger.error("Failed to get contacts: %s", e)
|
|
1188
|
+
return {"error": f"Failed to get contacts: {e!s}"}
|
|
1189
|
+
|
|
1190
|
+
def search_people(
|
|
1191
|
+
self,
|
|
1192
|
+
query: str,
|
|
1193
|
+
max_results: int = 10,
|
|
1194
|
+
) -> Dict[str, Any]:
|
|
1195
|
+
r"""Search for people in contacts.
|
|
1196
|
+
|
|
1197
|
+
Args:
|
|
1198
|
+
query (str): Search query for people in contacts. Can search by:
|
|
1199
|
+
- Name: 'John Smith' or partial names like 'John'
|
|
1200
|
+
- Email: 'john@example.com'
|
|
1201
|
+
- Organization: 'Google' or 'Acme Corp'
|
|
1202
|
+
- Phone number: '+1234567890'
|
|
1203
|
+
Examples: 'John Smith', 'john@example.com', 'Google'
|
|
1204
|
+
max_results (int): Maximum number of results to fetch.
|
|
1205
|
+
|
|
1206
|
+
Returns:
|
|
1207
|
+
Dict[str, Any]: A dictionary containing the search results.
|
|
1208
|
+
"""
|
|
1209
|
+
try:
|
|
1210
|
+
# Search people
|
|
1211
|
+
search_result = (
|
|
1212
|
+
self.people_service.people()
|
|
1213
|
+
.searchContacts(
|
|
1214
|
+
query=query,
|
|
1215
|
+
readMask='names,emailAddresses,phoneNumbers,organizations',
|
|
1216
|
+
pageSize=max_results,
|
|
1217
|
+
)
|
|
1218
|
+
.execute()
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
results = search_result.get('results', [])
|
|
1222
|
+
people_list = []
|
|
1223
|
+
|
|
1224
|
+
for result in results:
|
|
1225
|
+
person = result.get('person', {})
|
|
1226
|
+
person_info = {
|
|
1227
|
+
"resource_name": person.get('resourceName', ''),
|
|
1228
|
+
"names": person.get('names', []),
|
|
1229
|
+
"email_addresses": person.get('emailAddresses', []),
|
|
1230
|
+
"phone_numbers": person.get('phoneNumbers', []),
|
|
1231
|
+
"organizations": person.get('organizations', []),
|
|
1232
|
+
}
|
|
1233
|
+
people_list.append(person_info)
|
|
1234
|
+
|
|
1235
|
+
return {
|
|
1236
|
+
"success": True,
|
|
1237
|
+
"people": people_list,
|
|
1238
|
+
"total_count": len(people_list),
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
except Exception as e:
|
|
1242
|
+
logger.error("Failed to search people: %s", e)
|
|
1243
|
+
return {"error": f"Failed to search people: {e!s}"}
|
|
1244
|
+
|
|
1245
|
+
# Helper methods
|
|
1246
|
+
def _get_gmail_service(self):
|
|
1247
|
+
r"""Get Gmail service object."""
|
|
1248
|
+
from googleapiclient.discovery import build
|
|
1249
|
+
|
|
1250
|
+
try:
|
|
1251
|
+
# Build service with optional timeout
|
|
1252
|
+
if self.timeout is not None:
|
|
1253
|
+
import httplib2
|
|
1254
|
+
|
|
1255
|
+
http = httplib2.Http(timeout=self.timeout)
|
|
1256
|
+
http = self._credentials.authorize(http)
|
|
1257
|
+
service = build('gmail', 'v1', http=http)
|
|
1258
|
+
else:
|
|
1259
|
+
service = build('gmail', 'v1', credentials=self._credentials)
|
|
1260
|
+
return service
|
|
1261
|
+
except Exception as e:
|
|
1262
|
+
raise ValueError(f"Failed to build Gmail service: {e}") from e
|
|
1263
|
+
|
|
1264
|
+
def _get_people_service(self):
|
|
1265
|
+
r"""Get People service object."""
|
|
1266
|
+
from googleapiclient.discovery import build
|
|
1267
|
+
|
|
1268
|
+
try:
|
|
1269
|
+
# Build service with optional timeout
|
|
1270
|
+
if self.timeout is not None:
|
|
1271
|
+
import httplib2
|
|
1272
|
+
|
|
1273
|
+
http = httplib2.Http(timeout=self.timeout)
|
|
1274
|
+
http = self._credentials.authorize(http)
|
|
1275
|
+
service = build('people', 'v1', http=http)
|
|
1276
|
+
else:
|
|
1277
|
+
service = build('people', 'v1', credentials=self._credentials)
|
|
1278
|
+
return service
|
|
1279
|
+
except Exception as e:
|
|
1280
|
+
raise ValueError(f"Failed to build People service: {e}") from e
|
|
1281
|
+
|
|
1282
|
+
def _authenticate(self):
|
|
1283
|
+
r"""Authenticate with Google APIs using OAuth2.
|
|
1284
|
+
|
|
1285
|
+
Automatically saves and loads credentials from
|
|
1286
|
+
~/.camel/gmail_token.json to avoid repeated
|
|
1287
|
+
browser logins.
|
|
1288
|
+
"""
|
|
1289
|
+
import json
|
|
1290
|
+
from pathlib import Path
|
|
1291
|
+
|
|
1292
|
+
from dotenv import load_dotenv
|
|
1293
|
+
from google.auth.transport.requests import Request
|
|
1294
|
+
from google.oauth2.credentials import Credentials
|
|
1295
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
1296
|
+
|
|
1297
|
+
# Look for .env file in the project root (camel/)
|
|
1298
|
+
env_file = Path(__file__).parent.parent.parent / '.env'
|
|
1299
|
+
load_dotenv(env_file)
|
|
1300
|
+
|
|
1301
|
+
client_id = os.environ.get('GOOGLE_CLIENT_ID')
|
|
1302
|
+
client_secret = os.environ.get('GOOGLE_CLIENT_SECRET')
|
|
1303
|
+
|
|
1304
|
+
if not client_id or not client_secret:
|
|
1305
|
+
missing_vars = []
|
|
1306
|
+
if not client_id:
|
|
1307
|
+
missing_vars.append('GOOGLE_CLIENT_ID')
|
|
1308
|
+
if not client_secret:
|
|
1309
|
+
missing_vars.append('GOOGLE_CLIENT_SECRET')
|
|
1310
|
+
raise ValueError(
|
|
1311
|
+
f"Missing required environment variables: "
|
|
1312
|
+
f"{', '.join(missing_vars)}. "
|
|
1313
|
+
"Please set these in your .env file or environment variables."
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
token_file = Path.home() / '.camel' / 'gmail_token.json'
|
|
1317
|
+
creds = None
|
|
1318
|
+
|
|
1319
|
+
# COMPONENT 1: Load saved credentials
|
|
1320
|
+
if token_file.exists():
|
|
1321
|
+
try:
|
|
1322
|
+
with open(token_file, 'r') as f:
|
|
1323
|
+
data = json.load(f)
|
|
1324
|
+
creds = Credentials(
|
|
1325
|
+
token=data.get('token'),
|
|
1326
|
+
refresh_token=data.get('refresh_token'),
|
|
1327
|
+
token_uri=data.get(
|
|
1328
|
+
'token_uri', 'https://oauth2.googleapis.com/token'
|
|
1329
|
+
),
|
|
1330
|
+
client_id=client_id,
|
|
1331
|
+
client_secret=client_secret,
|
|
1332
|
+
scopes=SCOPES,
|
|
1333
|
+
)
|
|
1334
|
+
except Exception as e:
|
|
1335
|
+
logger.warning(f"Failed to load saved token: {e}")
|
|
1336
|
+
creds = None
|
|
1337
|
+
|
|
1338
|
+
# COMPONENT 2: Refresh if expired
|
|
1339
|
+
if creds and creds.expired and creds.refresh_token:
|
|
1340
|
+
try:
|
|
1341
|
+
creds.refresh(Request())
|
|
1342
|
+
logger.info("Access token refreshed")
|
|
1343
|
+
|
|
1344
|
+
# Save refreshed credentials to disk
|
|
1345
|
+
token_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1346
|
+
try:
|
|
1347
|
+
with open(token_file, 'w') as f:
|
|
1348
|
+
json.dump(
|
|
1349
|
+
{
|
|
1350
|
+
'token': creds.token,
|
|
1351
|
+
'refresh_token': creds.refresh_token,
|
|
1352
|
+
'token_uri': creds.token_uri,
|
|
1353
|
+
'scopes': creds.scopes,
|
|
1354
|
+
},
|
|
1355
|
+
f,
|
|
1356
|
+
)
|
|
1357
|
+
os.chmod(token_file, 0o600)
|
|
1358
|
+
logger.info(f"Refreshed credentials saved to {token_file}")
|
|
1359
|
+
except Exception as e:
|
|
1360
|
+
logger.warning(
|
|
1361
|
+
f"Failed to save refreshed credentials to "
|
|
1362
|
+
f"{token_file}: {e}. "
|
|
1363
|
+
"Token refreshed but not persisted."
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
return creds
|
|
1367
|
+
except Exception as e:
|
|
1368
|
+
logger.warning(f"Token refresh failed: {e}")
|
|
1369
|
+
creds = None
|
|
1370
|
+
|
|
1371
|
+
# COMPONENT 3: Return if valid
|
|
1372
|
+
if creds and creds.valid:
|
|
1373
|
+
return creds
|
|
1374
|
+
|
|
1375
|
+
# COMPONENT 4: Browser OAuth (first-time or invalid credentials)
|
|
1376
|
+
client_config = {
|
|
1377
|
+
"installed": {
|
|
1378
|
+
"client_id": client_id,
|
|
1379
|
+
"client_secret": client_secret,
|
|
1380
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
1381
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
1382
|
+
"redirect_uris": ["http://localhost"],
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
|
|
1387
|
+
creds = flow.run_local_server(port=0)
|
|
1388
|
+
|
|
1389
|
+
# Save new credentials
|
|
1390
|
+
token_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1391
|
+
try:
|
|
1392
|
+
with open(token_file, 'w') as f:
|
|
1393
|
+
json.dump(
|
|
1394
|
+
{
|
|
1395
|
+
'token': creds.token,
|
|
1396
|
+
'refresh_token': creds.refresh_token,
|
|
1397
|
+
'token_uri': creds.token_uri,
|
|
1398
|
+
'scopes': creds.scopes,
|
|
1399
|
+
},
|
|
1400
|
+
f,
|
|
1401
|
+
)
|
|
1402
|
+
os.chmod(token_file, 0o600)
|
|
1403
|
+
logger.info(f"Credentials saved to {token_file}")
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
logger.warning(
|
|
1406
|
+
f"Failed to save credentials to {token_file}: {e}. "
|
|
1407
|
+
"You may need to re-authenticate next time."
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
return creds
|
|
1411
|
+
|
|
1412
|
+
def _create_message(
|
|
1413
|
+
self,
|
|
1414
|
+
to_list: List[str],
|
|
1415
|
+
subject: str,
|
|
1416
|
+
body: str,
|
|
1417
|
+
cc_list: Optional[List[str]] = None,
|
|
1418
|
+
bcc_list: Optional[List[str]] = None,
|
|
1419
|
+
attachments: Optional[List[str]] = None,
|
|
1420
|
+
is_html: bool = False,
|
|
1421
|
+
in_reply_to: Optional[str] = None,
|
|
1422
|
+
references: Optional[List[str]] = None,
|
|
1423
|
+
) -> Dict[str, str]:
|
|
1424
|
+
r"""Create a message object for sending."""
|
|
1425
|
+
|
|
1426
|
+
import base64
|
|
1427
|
+
from email import encoders
|
|
1428
|
+
from email.mime.base import MIMEBase
|
|
1429
|
+
from email.mime.multipart import MIMEMultipart
|
|
1430
|
+
from email.mime.text import MIMEText
|
|
1431
|
+
|
|
1432
|
+
message = MIMEMultipart()
|
|
1433
|
+
message['to'] = ', '.join(to_list)
|
|
1434
|
+
message['subject'] = subject
|
|
1435
|
+
|
|
1436
|
+
if cc_list:
|
|
1437
|
+
message['cc'] = ', '.join(cc_list)
|
|
1438
|
+
if bcc_list:
|
|
1439
|
+
message['bcc'] = ', '.join(bcc_list)
|
|
1440
|
+
|
|
1441
|
+
# Set reply headers when provided
|
|
1442
|
+
if in_reply_to:
|
|
1443
|
+
message['In-Reply-To'] = in_reply_to
|
|
1444
|
+
if references:
|
|
1445
|
+
message['References'] = ' '.join(references)
|
|
1446
|
+
|
|
1447
|
+
# Add body
|
|
1448
|
+
if is_html:
|
|
1449
|
+
message.attach(MIMEText(body, 'html'))
|
|
1450
|
+
else:
|
|
1451
|
+
message.attach(MIMEText(body, 'plain'))
|
|
1452
|
+
|
|
1453
|
+
# Add attachments
|
|
1454
|
+
if attachments:
|
|
1455
|
+
for file_path in attachments:
|
|
1456
|
+
if os.path.isfile(file_path):
|
|
1457
|
+
with open(file_path, "rb") as attachment:
|
|
1458
|
+
part = MIMEBase('application', 'octet-stream')
|
|
1459
|
+
part.set_payload(attachment.read())
|
|
1460
|
+
encoders.encode_base64(part)
|
|
1461
|
+
part.add_header(
|
|
1462
|
+
'Content-Disposition',
|
|
1463
|
+
f'attachment; filename= '
|
|
1464
|
+
f'{os.path.basename(file_path)}',
|
|
1465
|
+
)
|
|
1466
|
+
message.attach(part)
|
|
1467
|
+
|
|
1468
|
+
# Encode message
|
|
1469
|
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode(
|
|
1470
|
+
'utf-8'
|
|
1471
|
+
)
|
|
1472
|
+
return {'raw': raw_message}
|
|
1473
|
+
|
|
1474
|
+
def _get_message_details(
|
|
1475
|
+
self, message_id: str
|
|
1476
|
+
) -> Optional[Dict[str, Any]]:
|
|
1477
|
+
r"""Get detailed information about a message."""
|
|
1478
|
+
try:
|
|
1479
|
+
message = (
|
|
1480
|
+
self.gmail_service.users()
|
|
1481
|
+
.messages()
|
|
1482
|
+
.get(userId='me', id=message_id)
|
|
1483
|
+
.execute()
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
headers = message['payload'].get('headers', [])
|
|
1487
|
+
# Build a name->value map in one pass (case-insensitive)
|
|
1488
|
+
header_map = {}
|
|
1489
|
+
for header in headers:
|
|
1490
|
+
name = header.get('name')
|
|
1491
|
+
if name:
|
|
1492
|
+
header_map[name.lower()] = header.get('value', '')
|
|
1493
|
+
|
|
1494
|
+
return {
|
|
1495
|
+
"message_id": message['id'],
|
|
1496
|
+
"thread_id": message['threadId'],
|
|
1497
|
+
"snippet": message.get('snippet', ''),
|
|
1498
|
+
"subject": header_map.get('subject', ''),
|
|
1499
|
+
"from": header_map.get('from', ''),
|
|
1500
|
+
"to": header_map.get('to', ''),
|
|
1501
|
+
"cc": header_map.get('cc', ''),
|
|
1502
|
+
"bcc": header_map.get('bcc', ''),
|
|
1503
|
+
"date": header_map.get('date', ''),
|
|
1504
|
+
"body": self._extract_message_body(message),
|
|
1505
|
+
"attachments": self._extract_attachments(
|
|
1506
|
+
message, include_inline=True
|
|
1507
|
+
),
|
|
1508
|
+
"label_ids": message.get('labelIds', []),
|
|
1509
|
+
"size_estimate": message.get('sizeEstimate', 0),
|
|
1510
|
+
}
|
|
1511
|
+
except Exception as e:
|
|
1512
|
+
logger.error("Failed to get message details: %s", e)
|
|
1513
|
+
return None
|
|
1514
|
+
|
|
1515
|
+
def _get_header_value(
|
|
1516
|
+
self, headers: List[Dict[str, str]], name: str
|
|
1517
|
+
) -> str:
|
|
1518
|
+
r"""Get header value by name."""
|
|
1519
|
+
for header in headers:
|
|
1520
|
+
if header['name'].lower() == name.lower():
|
|
1521
|
+
return header['value']
|
|
1522
|
+
return ""
|
|
1523
|
+
|
|
1524
|
+
def _extract_message_body(self, message: Dict[str, Any]) -> str:
|
|
1525
|
+
r"""Extract message body from message payload.
|
|
1526
|
+
|
|
1527
|
+
Recursively traverses the entire message tree and collects all text
|
|
1528
|
+
content from text/plain and text/html parts. Special handling for
|
|
1529
|
+
multipart/alternative containers: recursively searches for one format
|
|
1530
|
+
(preferring plain text) to avoid duplication when both formats contain
|
|
1531
|
+
the same content. All other text parts are collected to ensure no
|
|
1532
|
+
information is lost.
|
|
1533
|
+
|
|
1534
|
+
Args:
|
|
1535
|
+
message (Dict[str, Any]): The Gmail message dictionary containing
|
|
1536
|
+
the payload to extract text from.
|
|
1537
|
+
|
|
1538
|
+
Returns:
|
|
1539
|
+
str: The extracted message body text with multiple parts separated
|
|
1540
|
+
by double newlines, or an empty string if no text content is
|
|
1541
|
+
found.
|
|
1542
|
+
"""
|
|
1543
|
+
import base64
|
|
1544
|
+
import re
|
|
1545
|
+
|
|
1546
|
+
text_parts = []
|
|
1547
|
+
|
|
1548
|
+
def decode_text_data(data: str, mime_type: str) -> Optional[str]:
|
|
1549
|
+
"""Helper to decode base64 text data.
|
|
1550
|
+
|
|
1551
|
+
Args:
|
|
1552
|
+
data: Base64 encoded text data.
|
|
1553
|
+
mime_type: MIME type for logging purposes.
|
|
1554
|
+
|
|
1555
|
+
Returns:
|
|
1556
|
+
Decoded text string, or None if decoding fails or text
|
|
1557
|
+
is empty.
|
|
1558
|
+
"""
|
|
1559
|
+
if not data:
|
|
1560
|
+
return None
|
|
1561
|
+
try:
|
|
1562
|
+
text = base64.urlsafe_b64decode(data).decode('utf-8')
|
|
1563
|
+
return text if text.strip() else None
|
|
1564
|
+
except Exception as e:
|
|
1565
|
+
logger.warning(f"Failed to decode {mime_type}: {e}")
|
|
1566
|
+
return None
|
|
1567
|
+
|
|
1568
|
+
def strip_html_tags(html_content: str) -> str:
|
|
1569
|
+
"""Strip HTML tags and convert to readable plain text.
|
|
1570
|
+
|
|
1571
|
+
Uses regex to remove tags and clean up formatting while preserving
|
|
1572
|
+
basic document structure.
|
|
1573
|
+
|
|
1574
|
+
Args:
|
|
1575
|
+
html_content: HTML content to strip.
|
|
1576
|
+
|
|
1577
|
+
Returns:
|
|
1578
|
+
Plain text version of HTML content.
|
|
1579
|
+
"""
|
|
1580
|
+
if not html_content or not html_content.strip():
|
|
1581
|
+
return ""
|
|
1582
|
+
|
|
1583
|
+
text = html_content
|
|
1584
|
+
|
|
1585
|
+
# Remove script and style elements completely
|
|
1586
|
+
text = re.sub(
|
|
1587
|
+
r'<script[^>]*>.*?</script>',
|
|
1588
|
+
'',
|
|
1589
|
+
text,
|
|
1590
|
+
flags=re.DOTALL | re.IGNORECASE,
|
|
1591
|
+
)
|
|
1592
|
+
text = re.sub(
|
|
1593
|
+
r'<style[^>]*>.*?</style>',
|
|
1594
|
+
'',
|
|
1595
|
+
text,
|
|
1596
|
+
flags=re.DOTALL | re.IGNORECASE,
|
|
1597
|
+
)
|
|
1598
|
+
|
|
1599
|
+
# Convert common HTML entities
|
|
1600
|
+
text = text.replace(' ', ' ')
|
|
1601
|
+
text = text.replace('&', '&')
|
|
1602
|
+
text = text.replace('<', '<')
|
|
1603
|
+
text = text.replace('>', '>')
|
|
1604
|
+
text = text.replace('"', '"')
|
|
1605
|
+
text = text.replace(''', "'")
|
|
1606
|
+
text = text.replace('’', "'")
|
|
1607
|
+
text = text.replace('‘', "'")
|
|
1608
|
+
text = text.replace('”', '"')
|
|
1609
|
+
text = text.replace('“', '"')
|
|
1610
|
+
text = text.replace('—', '—')
|
|
1611
|
+
text = text.replace('–', '-')
|
|
1612
|
+
|
|
1613
|
+
# Convert <br> and <br/> to newlines
|
|
1614
|
+
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
|
1615
|
+
|
|
1616
|
+
# Convert block-level closing tags to newlines
|
|
1617
|
+
text = re.sub(
|
|
1618
|
+
r'</(p|div|h[1-6]|tr|li)>', '\n', text, flags=re.IGNORECASE
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
# Convert <hr> to separator
|
|
1622
|
+
text = re.sub(r'<hr\s*/?>', '\n---\n', text, flags=re.IGNORECASE)
|
|
1623
|
+
|
|
1624
|
+
# Remove all remaining HTML tags
|
|
1625
|
+
text = re.sub(r'<[^>]+>', '', text)
|
|
1626
|
+
|
|
1627
|
+
# Clean up whitespace
|
|
1628
|
+
text = re.sub(
|
|
1629
|
+
r'\n\s*\n\s*\n+', '\n\n', text
|
|
1630
|
+
) # Multiple blank lines to double newline
|
|
1631
|
+
text = re.sub(r' +', ' ', text) # Multiple spaces to single space
|
|
1632
|
+
text = re.sub(r'\n ', '\n', text) # Remove leading spaces on lines
|
|
1633
|
+
text = re.sub(
|
|
1634
|
+
r' \n', '\n', text
|
|
1635
|
+
) # Remove trailing spaces on lines
|
|
1636
|
+
|
|
1637
|
+
return text.strip()
|
|
1638
|
+
|
|
1639
|
+
def find_text_recursive(
|
|
1640
|
+
part: Dict[str, Any], target_mime: str
|
|
1641
|
+
) -> Optional[str]:
|
|
1642
|
+
"""Recursively search for text content of a specific MIME type.
|
|
1643
|
+
|
|
1644
|
+
Args:
|
|
1645
|
+
part: Message part to search in.
|
|
1646
|
+
target_mime: Target MIME type ('text/plain' or 'text/html').
|
|
1647
|
+
|
|
1648
|
+
Returns:
|
|
1649
|
+
Decoded text string if found, None otherwise.
|
|
1650
|
+
"""
|
|
1651
|
+
mime = part.get('mimeType', '')
|
|
1652
|
+
|
|
1653
|
+
# Found the target type at this level
|
|
1654
|
+
if mime == target_mime:
|
|
1655
|
+
data = part.get('body', {}).get('data', '')
|
|
1656
|
+
decoded = decode_text_data(data, target_mime)
|
|
1657
|
+
# Strip HTML tags if this is HTML content
|
|
1658
|
+
if decoded and target_mime == 'text/html':
|
|
1659
|
+
return strip_html_tags(decoded)
|
|
1660
|
+
return decoded
|
|
1661
|
+
|
|
1662
|
+
# Not found, but has nested parts? Search recursively
|
|
1663
|
+
if 'parts' in part:
|
|
1664
|
+
for nested_part in part['parts']:
|
|
1665
|
+
result = find_text_recursive(nested_part, target_mime)
|
|
1666
|
+
if result:
|
|
1667
|
+
return result
|
|
1668
|
+
|
|
1669
|
+
return None
|
|
1670
|
+
|
|
1671
|
+
def extract_from_part(part: Dict[str, Any]):
|
|
1672
|
+
"""Recursively collect all text from message parts."""
|
|
1673
|
+
mime_type = part.get('mimeType', '')
|
|
1674
|
+
|
|
1675
|
+
# Special handling for multipart/alternative
|
|
1676
|
+
if mime_type == 'multipart/alternative' and 'parts' in part:
|
|
1677
|
+
# Recursively search for one format (prefer plain text)
|
|
1678
|
+
plain_text = None
|
|
1679
|
+
html_text = None
|
|
1680
|
+
|
|
1681
|
+
# Search each alternative branch recursively
|
|
1682
|
+
for nested_part in part['parts']:
|
|
1683
|
+
if not plain_text:
|
|
1684
|
+
plain_text = find_text_recursive(
|
|
1685
|
+
nested_part, 'text/plain'
|
|
1686
|
+
)
|
|
1687
|
+
if not html_text:
|
|
1688
|
+
html_text = find_text_recursive(
|
|
1689
|
+
nested_part, 'text/html'
|
|
1690
|
+
)
|
|
1691
|
+
|
|
1692
|
+
# Prefer plain text, fall back to HTML
|
|
1693
|
+
chosen_text = plain_text if plain_text else html_text
|
|
1694
|
+
if chosen_text:
|
|
1695
|
+
text_parts.append(chosen_text)
|
|
1696
|
+
|
|
1697
|
+
# If this part has nested parts (but not multipart/alternative)
|
|
1698
|
+
elif 'parts' in part:
|
|
1699
|
+
for nested_part in part['parts']:
|
|
1700
|
+
extract_from_part(nested_part)
|
|
1701
|
+
|
|
1702
|
+
# If this is a text leaf, extract and collect it
|
|
1703
|
+
elif mime_type == 'text/plain':
|
|
1704
|
+
data = part.get('body', {}).get('data', '')
|
|
1705
|
+
text = decode_text_data(data, 'plain text body')
|
|
1706
|
+
if text:
|
|
1707
|
+
text_parts.append(text)
|
|
1708
|
+
|
|
1709
|
+
elif mime_type == 'text/html':
|
|
1710
|
+
data = part.get('body', {}).get('data', '')
|
|
1711
|
+
html_text = decode_text_data(data, 'HTML body')
|
|
1712
|
+
if html_text:
|
|
1713
|
+
text = strip_html_tags(html_text)
|
|
1714
|
+
if text:
|
|
1715
|
+
text_parts.append(text)
|
|
1716
|
+
|
|
1717
|
+
# Traverse the entire tree and collect all text parts
|
|
1718
|
+
payload = message.get('payload', {})
|
|
1719
|
+
extract_from_part(payload)
|
|
1720
|
+
|
|
1721
|
+
if not text_parts:
|
|
1722
|
+
return ""
|
|
1723
|
+
|
|
1724
|
+
# Return all text parts combined
|
|
1725
|
+
return '\n\n'.join(text_parts)
|
|
1726
|
+
|
|
1727
|
+
def _extract_attachments(
|
|
1728
|
+
self, message: Dict[str, Any], include_inline: bool = False
|
|
1729
|
+
) -> List[Dict[str, Any]]:
|
|
1730
|
+
r"""Extract attachment information from message payload.
|
|
1731
|
+
|
|
1732
|
+
Recursively traverses the message tree to find all attachments
|
|
1733
|
+
and extracts their metadata. Distinguishes between regular attachments
|
|
1734
|
+
and inline images embedded in HTML content.
|
|
1735
|
+
|
|
1736
|
+
Args:
|
|
1737
|
+
message (Dict[str, Any]): The Gmail message dictionary containing
|
|
1738
|
+
the payload to extract attachments from.
|
|
1739
|
+
|
|
1740
|
+
Returns:
|
|
1741
|
+
List[Dict[str, Any]]: List of attachment dictionaries, each
|
|
1742
|
+
containing:
|
|
1743
|
+
- attachment_id: Gmail's unique identifier for the attachment
|
|
1744
|
+
- filename: Name of the attached file
|
|
1745
|
+
- mime_type: MIME type of the attachment
|
|
1746
|
+
- size: Size of the attachment in bytes
|
|
1747
|
+
- is_inline: Whether this is an inline image (embedded in HTML)
|
|
1748
|
+
"""
|
|
1749
|
+
attachments = []
|
|
1750
|
+
|
|
1751
|
+
def is_inline_image(part: Dict[str, Any]) -> bool:
|
|
1752
|
+
"""Check if this part is an inline image."""
|
|
1753
|
+
headers = part.get('headers', [])
|
|
1754
|
+
for header in headers:
|
|
1755
|
+
name = header.get('name', '').lower()
|
|
1756
|
+
value = header.get('value', '').lower()
|
|
1757
|
+
# Check for Content-Disposition: inline
|
|
1758
|
+
if name == 'content-disposition' and 'inline' in value:
|
|
1759
|
+
return True
|
|
1760
|
+
# Check for Content-ID (usually indicates inline)
|
|
1761
|
+
if name == 'content-id':
|
|
1762
|
+
return True
|
|
1763
|
+
return False
|
|
1764
|
+
|
|
1765
|
+
def find_attachments(part: Dict[str, Any]):
|
|
1766
|
+
"""Recursively find attachments in message parts."""
|
|
1767
|
+
# Check if this part has an attachmentId (indicates it's an
|
|
1768
|
+
# attachment)
|
|
1769
|
+
if 'body' in part and 'attachmentId' in part['body']:
|
|
1770
|
+
attachment_info = {
|
|
1771
|
+
'attachment_id': part['body']['attachmentId'],
|
|
1772
|
+
'filename': part.get('filename', 'unnamed'),
|
|
1773
|
+
'mime_type': part.get(
|
|
1774
|
+
'mimeType', 'application/octet-stream'
|
|
1775
|
+
),
|
|
1776
|
+
'size': part['body'].get('size', 0),
|
|
1777
|
+
'is_inline': is_inline_image(part),
|
|
1778
|
+
}
|
|
1779
|
+
attachments.append(attachment_info)
|
|
1780
|
+
|
|
1781
|
+
# Recurse into nested parts
|
|
1782
|
+
if 'parts' in part:
|
|
1783
|
+
for nested_part in part['parts']:
|
|
1784
|
+
find_attachments(nested_part)
|
|
1785
|
+
|
|
1786
|
+
# Start traversal from the message payload
|
|
1787
|
+
payload = message.get('payload', {})
|
|
1788
|
+
if payload:
|
|
1789
|
+
find_attachments(payload)
|
|
1790
|
+
|
|
1791
|
+
# Return based on include_inline toggle
|
|
1792
|
+
if include_inline:
|
|
1793
|
+
return attachments
|
|
1794
|
+
return [att for att in attachments if not att['is_inline']]
|
|
1795
|
+
|
|
1796
|
+
def _is_valid_email(self, email: str) -> bool:
|
|
1797
|
+
r"""Validate email address format.
|
|
1798
|
+
|
|
1799
|
+
Supports both formats:
|
|
1800
|
+
- Plain email: john@example.com
|
|
1801
|
+
- Named email: John Doe <john@example.com>
|
|
1802
|
+
"""
|
|
1803
|
+
# Extract email from "Name <email>" format if present
|
|
1804
|
+
match = re.search(r'<([^>]+)>$', email.strip())
|
|
1805
|
+
email_to_check = match.group(1) if match else email.strip()
|
|
1806
|
+
|
|
1807
|
+
# Validate the email address
|
|
1808
|
+
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
1809
|
+
return re.match(pattern, email_to_check) is not None
|
|
1810
|
+
|
|
1811
|
+
def get_tools(self) -> List[FunctionTool]:
|
|
1812
|
+
r"""Returns a list of FunctionTool objects representing the
|
|
1813
|
+
functions in the toolkit.
|
|
1814
|
+
|
|
1815
|
+
Returns:
|
|
1816
|
+
List[FunctionTool]: A list of FunctionTool objects
|
|
1817
|
+
representing the functions in the toolkit.
|
|
1818
|
+
"""
|
|
1819
|
+
return [
|
|
1820
|
+
FunctionTool(self.send_email),
|
|
1821
|
+
FunctionTool(self.reply_to_email),
|
|
1822
|
+
FunctionTool(self.forward_email),
|
|
1823
|
+
FunctionTool(self.create_email_draft),
|
|
1824
|
+
FunctionTool(self.send_draft),
|
|
1825
|
+
FunctionTool(self.fetch_emails),
|
|
1826
|
+
FunctionTool(self.fetch_thread_by_id),
|
|
1827
|
+
FunctionTool(self.modify_email_labels),
|
|
1828
|
+
FunctionTool(self.move_to_trash),
|
|
1829
|
+
FunctionTool(self.get_attachment),
|
|
1830
|
+
FunctionTool(self.list_threads),
|
|
1831
|
+
FunctionTool(self.list_drafts),
|
|
1832
|
+
FunctionTool(self.list_gmail_labels),
|
|
1833
|
+
FunctionTool(self.create_label),
|
|
1834
|
+
FunctionTool(self.delete_label),
|
|
1835
|
+
FunctionTool(self.modify_thread_labels),
|
|
1836
|
+
FunctionTool(self.get_profile),
|
|
1837
|
+
FunctionTool(self.get_contacts),
|
|
1838
|
+
FunctionTool(self.search_people),
|
|
1839
|
+
]
|