Skip to content

Commit 6bf877a

Browse files
enhance: improve OutlookMailToolkit with run_async and explicit redirect… PR#3419 (#3492)
Co-authored-by: Tanuj Taneja <[email protected]>
1 parent 32cbe44 commit 6bf877a

File tree

2 files changed

+149
-39
lines changed

2 files changed

+149
-39
lines changed

camel/toolkits/microsoft_outlook_mail_toolkit.py

Lines changed: 53 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from camel.toolkits import FunctionTool
2828
from camel.toolkits.base import BaseToolkit
2929
from camel.utils import MCPServer, api_keys_required
30+
from camel.utils.commons import run_async
3031

3132
load_dotenv()
3233
logger = get_logger(__name__)
@@ -73,8 +74,8 @@ class CustomAzureCredential:
7374
tenant_id (str): The Microsoft tenant ID.
7475
refresh_token (str): The refresh token from OAuth flow.
7576
scopes (List[str]): List of OAuth permission scopes.
76-
token_file_path (Optional[Path]): Path to persist the refresh token.
77-
If None, token persistence is disabled.
77+
refresh_token_file_path (Optional[Path]): File path of json file
78+
with refresh token.
7879
"""
7980

8081
def __init__(
@@ -84,14 +85,14 @@ def __init__(
8485
tenant_id: str,
8586
refresh_token: str,
8687
scopes: List[str],
87-
token_file_path: Optional[Path],
88+
refresh_token_file_path: Optional[Path],
8889
):
8990
self.client_id = client_id
9091
self.client_secret = client_secret
9192
self.tenant_id = tenant_id
9293
self.refresh_token = refresh_token
9394
self.scopes = scopes
94-
self.token_file_path = token_file_path
95+
self.refresh_token_file_path = refresh_token_file_path
9596

9697
self._access_token = None
9798
self._expires_at = 0
@@ -144,18 +145,20 @@ def _save_refresh_token(self, refresh_token: str):
144145
Args:
145146
refresh_token (str): The refresh token to save.
146147
"""
147-
if not self.token_file_path:
148+
if not self.refresh_token_file_path:
148149
logger.info("Token file path not set, skipping token save")
149150
return
150151

151152
token_data = {"refresh_token": refresh_token}
152153

153154
try:
154155
# Create parent directories if they don't exist
155-
self.token_file_path.parent.mkdir(parents=True, exist_ok=True)
156+
self.refresh_token_file_path.parent.mkdir(
157+
parents=True, exist_ok=True
158+
)
156159

157160
# Write new refresh token to file
158-
with open(self.token_file_path, 'w') as f:
161+
with open(self.refresh_token_file_path, 'w') as f:
159162
json.dump(token_data, f, indent=2)
160163
except Exception as e:
161164
logger.warning(f"Failed to save refresh token: {e!s}")
@@ -191,35 +194,38 @@ def get_token(self, *args, **kwargs):
191194

192195
@MCPServer()
193196
class OutlookMailToolkit(BaseToolkit):
194-
"""A class representing a toolkit for Microsoft Outlook operations.
197+
"""A comprehensive toolkit for Microsoft Outlook Mail operations.
195198
196-
This class provides methods for interacting with Microsoft Outlook via
197-
the Microsoft Graph API.
199+
This class provides methods for Outlook Mail operations including sending
200+
emails, managing drafts, replying to mails, deleting mails, fetching
201+
mails and attachments and changing folder of mails.
202+
API keys can be accessed in the Azure portal (https://portal.azure.com/)
198203
"""
199204

200205
def __init__(
201206
self,
202207
timeout: Optional[float] = None,
203-
token_file_path: Optional[str] = None,
208+
refresh_token_file_path: Optional[str] = None,
204209
):
205210
"""Initializes a new instance of the OutlookMailToolkit.
206211
207212
Args:
208213
timeout (Optional[float]): The timeout value for API requests
209214
in seconds. If None, no timeout is applied.
210215
(default: :obj:`None`)
211-
token_file_path (Optional[str]): The path to the file where the
212-
refresh token will be stored. If None, token persistence is
213-
disabled and browser authentication will be required on each
214-
initialization. If provided, the token will be saved to and
215-
loaded from this path. (default: :obj:`None`)
216+
refresh_token_file_path (Optional[str]): The path of json file
217+
where refresh token is stored. If None, authentication using
218+
web browser will be required on each initialization. If
219+
provided, the refresh token is read from the file, used, and
220+
automatically updated when it nears expiry.
221+
(default: :obj:`None`)
216222
"""
217223
super().__init__(timeout=timeout)
218224

219225
self.scopes = ["Mail.Send", "Mail.ReadWrite"]
220226
self.redirect_uri = self._get_dynamic_redirect_uri()
221-
self.token_file_path = (
222-
Path(token_file_path) if token_file_path else None
227+
self.refresh_token_file_path = (
228+
Path(refresh_token_file_path) if refresh_token_file_path else None
223229
)
224230
self.credentials = self._authenticate()
225231
self.client = self._get_graph_client(
@@ -272,20 +278,20 @@ def _load_token_from_file(self) -> Optional[str]:
272278
Returns:
273279
Optional[str]: Refresh token if file exists and valid, else None.
274280
"""
275-
if not self.token_file_path:
281+
if not self.refresh_token_file_path:
276282
return None
277283

278-
if not self.token_file_path.exists():
284+
if not self.refresh_token_file_path.exists():
279285
return None
280286

281287
try:
282-
with open(self.token_file_path, 'r') as f:
288+
with open(self.refresh_token_file_path, 'r') as f:
283289
token_data = json.load(f)
284290

285291
refresh_token = token_data.get('refresh_token')
286292
if refresh_token:
287293
logger.info(
288-
f"Refresh token loaded from {self.token_file_path}"
294+
f"Refresh token loaded from {self.refresh_token_file_path}"
289295
)
290296
return refresh_token
291297

@@ -302,17 +308,21 @@ def _save_token_to_file(self, refresh_token: str):
302308
Args:
303309
refresh_token (str): The refresh token to save.
304310
"""
305-
if not self.token_file_path:
311+
if not self.refresh_token_file_path:
306312
logger.info("Token file path not set, skipping token save")
307313
return
308314

309315
try:
310316
# Create parent directories if they don't exist
311-
self.token_file_path.parent.mkdir(parents=True, exist_ok=True)
317+
self.refresh_token_file_path.parent.mkdir(
318+
parents=True, exist_ok=True
319+
)
312320

313-
with open(self.token_file_path, 'w') as f:
321+
with open(self.refresh_token_file_path, 'w') as f:
314322
json.dump({"refresh_token": refresh_token}, f, indent=2)
315-
logger.info(f"Refresh token saved to {self.token_file_path}")
323+
logger.info(
324+
f"Refresh token saved to {self.refresh_token_file_path}"
325+
)
316326
except Exception as e:
317327
logger.warning(f"Failed to save token to file: {e!s}")
318328

@@ -342,7 +352,7 @@ def _authenticate_using_refresh_token(
342352
tenant_id=self.tenant_id,
343353
refresh_token=refresh_token,
344354
scopes=self.scopes,
345-
token_file_path=self.token_file_path,
355+
refresh_token_file_path=self.refresh_token_file_path,
346356
)
347357

348358
logger.info("Authentication with saved token successful")
@@ -426,7 +436,8 @@ def _authenticate(self):
426436
"""Authenticates and creates credential for Microsoft Graph.
427437
428438
Implements two-stage authentication:
429-
1. Attempts to use saved refresh token if token_file_path is provided
439+
1. Attempts to use saved refresh token if refresh_token_file_path is
440+
provided
430441
2. Falls back to browser OAuth if no token or token invalid
431442
432443
Returns:
@@ -443,7 +454,10 @@ def _authenticate(self):
443454
self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET")
444455

445456
# Try saved refresh token first if token file path is provided
446-
if self.token_file_path and self.token_file_path.exists():
457+
if (
458+
self.refresh_token_file_path
459+
and self.refresh_token_file_path.exists()
460+
):
447461
try:
448462
credentials: CustomAzureCredential = (
449463
self._authenticate_using_refresh_token()
@@ -781,16 +795,16 @@ def get_tools(self) -> List[FunctionTool]:
781795
representing the functions in the toolkit.
782796
"""
783797
return [
784-
FunctionTool(self.send_email),
785-
FunctionTool(self.create_draft_email),
786-
FunctionTool(self.send_draft_email),
787-
FunctionTool(self.delete_email),
788-
FunctionTool(self.move_message_to_folder),
789-
FunctionTool(self.get_attachments),
790-
FunctionTool(self.get_message),
791-
FunctionTool(self.list_messages),
792-
FunctionTool(self.reply_to_email),
793-
FunctionTool(self.update_draft_message),
798+
FunctionTool(run_async(self.send_email)),
799+
FunctionTool(run_async(self.create_draft_email)),
800+
FunctionTool(run_async(self.send_draft_email)),
801+
FunctionTool(run_async(self.delete_email)),
802+
FunctionTool(run_async(self.move_message_to_folder)),
803+
FunctionTool(run_async(self.get_attachments)),
804+
FunctionTool(run_async(self.get_message)),
805+
FunctionTool(run_async(self.list_messages)),
806+
FunctionTool(run_async(self.reply_to_email)),
807+
FunctionTool(run_async(self.update_draft_message)),
794808
]
795809

796810
async def create_draft_email(
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
16+
from camel.agents import ChatAgent
17+
from camel.models import ModelFactory
18+
from camel.toolkits import OutlookMailToolkit
19+
from camel.types import ModelPlatformType
20+
from camel.types.enums import ModelType
21+
22+
# Create a model instance
23+
model = ModelFactory.create(
24+
model_platform=ModelPlatformType.DEFAULT,
25+
model_type=ModelType.DEFAULT,
26+
)
27+
28+
# Define system message for the Outlook assistant
29+
sys_msg = (
30+
"You are a helpful Microsoft Outlook assistant that can help users manage "
31+
"their emails. You have access to all Outlook tools including sending "
32+
"emails, fetching emails, managing drafts, and more."
33+
)
34+
35+
# Initialize the Outlook toolkit
36+
print("Initializing Outlook toolkit (browser may open for authentication)...")
37+
outlook_toolkit = OutlookMailToolkit()
38+
print("Outlook toolkit initialized!")
39+
40+
# Get all Outlook tools
41+
all_tools = outlook_toolkit.get_tools()
42+
print(f"Loaded {len(all_tools)} Outlook tools")
43+
44+
# Initialize a ChatAgent with all Outlook tools
45+
outlook_agent = ChatAgent(
46+
system_message=sys_msg,
47+
model=model,
48+
tools=all_tools,
49+
)
50+
51+
# Example: Send an email
52+
print("\nExample: Sending an email")
53+
print("=" * 50)
54+
55+
user_message = (
56+
"Send an email to [email protected] with subject "
57+
"'Hello from Outlook Toolkit' and body 'This is a test email "
58+
"sent using the CAMEL Outlook toolkit.'"
59+
)
60+
61+
response = outlook_agent.step(user_message)
62+
print("Agent Response:")
63+
print(response.msgs[0].content)
64+
print("\nTool calls:")
65+
print(response.info['tool_calls'])
66+
67+
"""
68+
Example: Sending an email
69+
==================================================
70+
Agent Response:
71+
Done  your message has been sent to [email protected].
72+
73+
Tool calls:
74+
[ToolCallingRecord(
75+
tool_name='send_email',
76+
args={
77+
'to_email': ['[email protected]'],
78+
'subject': 'Hello from Outlook Toolkit',
79+
'content': 'This is a test email sent using the CAMEL toolkit.',
80+
'is_content_html': False,
81+
'attachments': None,
82+
'cc_recipients': None,
83+
'bcc_recipients': None,
84+
'reply_to': None,
85+
'save_to_sent_items': True
86+
},
87+
result={
88+
'status': 'success',
89+
'message': 'Email sent successfully',
90+
'recipients': ['[email protected]'],
91+
'subject': 'Hello from Outlook Toolkit'
92+
},
93+
tool_call_id='call_abc123',
94+
images=None
95+
)]
96+
"""

0 commit comments

Comments
 (0)