connectonion 0.5.8__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.
- connectonion/__init__.py +78 -0
- connectonion/address.py +320 -0
- connectonion/agent.py +450 -0
- connectonion/announce.py +84 -0
- connectonion/asgi.py +287 -0
- connectonion/auto_debug_exception.py +181 -0
- connectonion/cli/__init__.py +3 -0
- connectonion/cli/browser_agent/__init__.py +5 -0
- connectonion/cli/browser_agent/browser.py +243 -0
- connectonion/cli/browser_agent/prompt.md +107 -0
- connectonion/cli/commands/__init__.py +1 -0
- connectonion/cli/commands/auth_commands.py +527 -0
- connectonion/cli/commands/browser_commands.py +27 -0
- connectonion/cli/commands/create.py +511 -0
- connectonion/cli/commands/deploy_commands.py +220 -0
- connectonion/cli/commands/doctor_commands.py +173 -0
- connectonion/cli/commands/init.py +469 -0
- connectonion/cli/commands/project_cmd_lib.py +828 -0
- connectonion/cli/commands/reset_commands.py +149 -0
- connectonion/cli/commands/status_commands.py +168 -0
- connectonion/cli/docs/co-vibecoding-principles-docs-contexts-all-in-one.md +2010 -0
- connectonion/cli/docs/connectonion.md +1256 -0
- connectonion/cli/docs.md +123 -0
- connectonion/cli/main.py +148 -0
- connectonion/cli/templates/meta-agent/README.md +287 -0
- connectonion/cli/templates/meta-agent/agent.py +196 -0
- connectonion/cli/templates/meta-agent/prompts/answer_prompt.md +9 -0
- connectonion/cli/templates/meta-agent/prompts/docs_retrieve_prompt.md +15 -0
- connectonion/cli/templates/meta-agent/prompts/metagent.md +71 -0
- connectonion/cli/templates/meta-agent/prompts/think_prompt.md +18 -0
- connectonion/cli/templates/minimal/README.md +56 -0
- connectonion/cli/templates/minimal/agent.py +40 -0
- connectonion/cli/templates/playwright/README.md +118 -0
- connectonion/cli/templates/playwright/agent.py +336 -0
- connectonion/cli/templates/playwright/prompt.md +102 -0
- connectonion/cli/templates/playwright/requirements.txt +3 -0
- connectonion/cli/templates/web-research/agent.py +122 -0
- connectonion/connect.py +128 -0
- connectonion/console.py +539 -0
- connectonion/debug_agent/__init__.py +13 -0
- connectonion/debug_agent/agent.py +45 -0
- connectonion/debug_agent/prompts/debug_assistant.md +72 -0
- connectonion/debug_agent/runtime_inspector.py +406 -0
- connectonion/debug_explainer/__init__.py +10 -0
- connectonion/debug_explainer/explain_agent.py +114 -0
- connectonion/debug_explainer/explain_context.py +263 -0
- connectonion/debug_explainer/explainer_prompt.md +29 -0
- connectonion/debug_explainer/root_cause_analysis_prompt.md +43 -0
- connectonion/debugger_ui.py +1039 -0
- connectonion/decorators.py +208 -0
- connectonion/events.py +248 -0
- connectonion/execution_analyzer/__init__.py +9 -0
- connectonion/execution_analyzer/execution_analysis.py +93 -0
- connectonion/execution_analyzer/execution_analysis_prompt.md +47 -0
- connectonion/host.py +579 -0
- connectonion/interactive_debugger.py +342 -0
- connectonion/llm.py +801 -0
- connectonion/llm_do.py +307 -0
- connectonion/logger.py +300 -0
- connectonion/prompt_files/__init__.py +1 -0
- connectonion/prompt_files/analyze_contact.md +62 -0
- connectonion/prompt_files/eval_expected.md +12 -0
- connectonion/prompt_files/react_evaluate.md +11 -0
- connectonion/prompt_files/react_plan.md +16 -0
- connectonion/prompt_files/reflect.md +22 -0
- connectonion/prompts.py +144 -0
- connectonion/relay.py +200 -0
- connectonion/static/docs.html +688 -0
- connectonion/tool_executor.py +279 -0
- connectonion/tool_factory.py +186 -0
- connectonion/tool_registry.py +105 -0
- connectonion/trust.py +166 -0
- connectonion/trust_agents.py +71 -0
- connectonion/trust_functions.py +88 -0
- connectonion/tui/__init__.py +57 -0
- connectonion/tui/divider.py +39 -0
- connectonion/tui/dropdown.py +251 -0
- connectonion/tui/footer.py +31 -0
- connectonion/tui/fuzzy.py +56 -0
- connectonion/tui/input.py +278 -0
- connectonion/tui/keys.py +35 -0
- connectonion/tui/pick.py +130 -0
- connectonion/tui/providers.py +155 -0
- connectonion/tui/status_bar.py +163 -0
- connectonion/usage.py +161 -0
- connectonion/useful_events_handlers/__init__.py +16 -0
- connectonion/useful_events_handlers/reflect.py +116 -0
- connectonion/useful_plugins/__init__.py +20 -0
- connectonion/useful_plugins/calendar_plugin.py +163 -0
- connectonion/useful_plugins/eval.py +139 -0
- connectonion/useful_plugins/gmail_plugin.py +162 -0
- connectonion/useful_plugins/image_result_formatter.py +127 -0
- connectonion/useful_plugins/re_act.py +78 -0
- connectonion/useful_plugins/shell_approval.py +159 -0
- connectonion/useful_tools/__init__.py +44 -0
- connectonion/useful_tools/diff_writer.py +192 -0
- connectonion/useful_tools/get_emails.py +183 -0
- connectonion/useful_tools/gmail.py +1596 -0
- connectonion/useful_tools/google_calendar.py +613 -0
- connectonion/useful_tools/memory.py +380 -0
- connectonion/useful_tools/microsoft_calendar.py +604 -0
- connectonion/useful_tools/outlook.py +488 -0
- connectonion/useful_tools/send_email.py +205 -0
- connectonion/useful_tools/shell.py +97 -0
- connectonion/useful_tools/slash_command.py +201 -0
- connectonion/useful_tools/terminal.py +285 -0
- connectonion/useful_tools/todo_list.py +241 -0
- connectonion/useful_tools/web_fetch.py +216 -0
- connectonion/xray.py +467 -0
- connectonion-0.5.8.dist-info/METADATA +741 -0
- connectonion-0.5.8.dist-info/RECORD +113 -0
- connectonion-0.5.8.dist-info/WHEEL +4 -0
- connectonion-0.5.8.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Outlook integration tool for reading, sending, and managing emails via Microsoft Graph API
|
|
3
|
+
LLM-Note:
|
|
4
|
+
Dependencies: imports from [os, datetime, httpx] | imported by [useful_tools/__init__.py] | requires OAuth tokens from 'co auth microsoft' | tested by [tests/unit/test_outlook.py]
|
|
5
|
+
Data flow: Agent calls Outlook methods → _get_headers() loads MICROSOFT_ACCESS_TOKEN from env → HTTP calls to Graph API (https://graph.microsoft.com/v1.0) → returns formatted results (email summaries, bodies, send confirmations)
|
|
6
|
+
State/Effects: reads MICROSOFT_* env vars for OAuth tokens | makes HTTP calls to Microsoft Graph API | can modify mailbox state (mark read, archive, send emails) | no local file persistence
|
|
7
|
+
Integration: exposes Outlook class with read_inbox(), get_sent_emails(), search_emails(), get_email_body(), send(), reply(), mark_read(), archive_email() | used as agent tool via Agent(tools=[Outlook()])
|
|
8
|
+
Performance: network I/O per API call | batch fetching for list operations | email body fetched separately
|
|
9
|
+
Errors: raises ValueError if OAuth not configured | HTTP errors from Graph API propagate | returns error strings for display to user
|
|
10
|
+
|
|
11
|
+
Outlook tool for reading and managing Outlook emails via Microsoft Graph API.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from connectonion import Agent, Outlook
|
|
15
|
+
|
|
16
|
+
outlook = Outlook()
|
|
17
|
+
agent = Agent("assistant", tools=[outlook])
|
|
18
|
+
|
|
19
|
+
# Agent can now use:
|
|
20
|
+
# - read_inbox(last, unread)
|
|
21
|
+
# - get_sent_emails(max_results)
|
|
22
|
+
# - search_emails(query, max_results)
|
|
23
|
+
# - get_email_body(email_id)
|
|
24
|
+
# - send(to, subject, body, cc, bcc)
|
|
25
|
+
# - reply(email_id, body)
|
|
26
|
+
# - mark_read(email_id)
|
|
27
|
+
# - archive_email(email_id)
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
from connectonion import Agent, Outlook
|
|
31
|
+
|
|
32
|
+
outlook = Outlook()
|
|
33
|
+
agent = Agent(
|
|
34
|
+
name="outlook-assistant",
|
|
35
|
+
system_prompt="You are an Outlook assistant.",
|
|
36
|
+
tools=[outlook]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
agent.input("Show me my recent emails")
|
|
40
|
+
agent.input("Send an email to alice@example.com saying hello")
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
from datetime import datetime, timedelta
|
|
45
|
+
import httpx
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Outlook:
|
|
49
|
+
"""Outlook tool for reading and managing emails via Microsoft Graph API."""
|
|
50
|
+
|
|
51
|
+
GRAPH_API_URL = "https://graph.microsoft.com/v1.0"
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
"""Initialize Outlook tool.
|
|
55
|
+
|
|
56
|
+
Validates that Microsoft OAuth is configured.
|
|
57
|
+
Raises ValueError if credentials are missing.
|
|
58
|
+
"""
|
|
59
|
+
scopes = os.getenv("MICROSOFT_SCOPES", "")
|
|
60
|
+
if not scopes or "Mail" not in scopes:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"Missing Microsoft Mail scopes.\n"
|
|
63
|
+
f"Current scopes: {scopes}\n"
|
|
64
|
+
"Please authorize Microsoft access:\n"
|
|
65
|
+
" co auth microsoft"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self._access_token = None
|
|
69
|
+
|
|
70
|
+
def _get_access_token(self) -> str:
|
|
71
|
+
"""Get Microsoft access token (with auto-refresh)."""
|
|
72
|
+
access_token = os.getenv("MICROSOFT_ACCESS_TOKEN")
|
|
73
|
+
refresh_token = os.getenv("MICROSOFT_REFRESH_TOKEN")
|
|
74
|
+
expires_at_str = os.getenv("MICROSOFT_TOKEN_EXPIRES_AT")
|
|
75
|
+
|
|
76
|
+
if not access_token or not refresh_token:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Microsoft OAuth credentials not found.\n"
|
|
79
|
+
"Run: co auth microsoft"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Check if token is expired or about to expire (within 5 minutes)
|
|
83
|
+
if expires_at_str:
|
|
84
|
+
expires_at = datetime.fromisoformat(expires_at_str.replace('Z', '+00:00'))
|
|
85
|
+
now = datetime.utcnow().replace(tzinfo=expires_at.tzinfo) if expires_at.tzinfo else datetime.utcnow()
|
|
86
|
+
|
|
87
|
+
if now >= expires_at - timedelta(minutes=5):
|
|
88
|
+
# Token expired or about to expire, refresh via backend
|
|
89
|
+
access_token = self._refresh_via_backend(refresh_token)
|
|
90
|
+
self._access_token = None
|
|
91
|
+
|
|
92
|
+
if self._access_token:
|
|
93
|
+
return self._access_token
|
|
94
|
+
|
|
95
|
+
self._access_token = access_token
|
|
96
|
+
return self._access_token
|
|
97
|
+
|
|
98
|
+
def _refresh_via_backend(self, refresh_token: str) -> str:
|
|
99
|
+
"""Refresh access token via backend API.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
refresh_token: The refresh token
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
New access token
|
|
106
|
+
"""
|
|
107
|
+
backend_url = os.getenv("OPENONION_API_URL", "https://oo.openonion.ai")
|
|
108
|
+
api_key = os.getenv("OPENONION_API_KEY")
|
|
109
|
+
|
|
110
|
+
if not api_key:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"OPENONION_API_KEY not found.\n"
|
|
113
|
+
"This is needed to refresh tokens via backend."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
response = httpx.post(
|
|
117
|
+
f"{backend_url}/api/v1/oauth/microsoft/refresh",
|
|
118
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
119
|
+
json={"refresh_token": refresh_token}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if response.status_code != 200:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Failed to refresh Microsoft token via backend: {response.text}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
data = response.json()
|
|
128
|
+
new_access_token = data["access_token"]
|
|
129
|
+
expires_at = data["expires_at"]
|
|
130
|
+
|
|
131
|
+
# Update environment variables for this session
|
|
132
|
+
os.environ["MICROSOFT_ACCESS_TOKEN"] = new_access_token
|
|
133
|
+
os.environ["MICROSOFT_TOKEN_EXPIRES_AT"] = expires_at
|
|
134
|
+
|
|
135
|
+
# Update .env file if it exists
|
|
136
|
+
env_file = os.path.join(os.getenv("AGENT_CONFIG_PATH", os.path.expanduser("~/.co")), "keys.env")
|
|
137
|
+
if os.path.exists(env_file):
|
|
138
|
+
with open(env_file, 'r') as f:
|
|
139
|
+
lines = f.readlines()
|
|
140
|
+
|
|
141
|
+
with open(env_file, 'w') as f:
|
|
142
|
+
for line in lines:
|
|
143
|
+
if line.startswith("MICROSOFT_ACCESS_TOKEN="):
|
|
144
|
+
f.write(f"MICROSOFT_ACCESS_TOKEN={new_access_token}\n")
|
|
145
|
+
elif line.startswith("MICROSOFT_TOKEN_EXPIRES_AT="):
|
|
146
|
+
f.write(f"MICROSOFT_TOKEN_EXPIRES_AT={expires_at}\n")
|
|
147
|
+
else:
|
|
148
|
+
f.write(line)
|
|
149
|
+
|
|
150
|
+
return new_access_token
|
|
151
|
+
|
|
152
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> dict:
|
|
153
|
+
"""Make authenticated request to Microsoft Graph API."""
|
|
154
|
+
token = self._get_access_token()
|
|
155
|
+
headers = {
|
|
156
|
+
"Authorization": f"Bearer {token}",
|
|
157
|
+
"Content-Type": "application/json"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
url = f"{self.GRAPH_API_URL}{endpoint}"
|
|
161
|
+
response = httpx.request(method, url, headers=headers, **kwargs)
|
|
162
|
+
|
|
163
|
+
if response.status_code == 401:
|
|
164
|
+
# Token might have expired, try refreshing
|
|
165
|
+
refresh_token = os.getenv("MICROSOFT_REFRESH_TOKEN")
|
|
166
|
+
if refresh_token:
|
|
167
|
+
self._access_token = None
|
|
168
|
+
token = self._refresh_via_backend(refresh_token)
|
|
169
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
170
|
+
response = httpx.request(method, url, headers=headers, **kwargs)
|
|
171
|
+
|
|
172
|
+
if response.status_code not in [200, 201, 202, 204]:
|
|
173
|
+
raise ValueError(f"Microsoft Graph API error: {response.status_code} - {response.text}")
|
|
174
|
+
|
|
175
|
+
if response.status_code == 204:
|
|
176
|
+
return {}
|
|
177
|
+
return response.json()
|
|
178
|
+
|
|
179
|
+
def _format_emails(self, messages: list, max_results: int = 10) -> str:
|
|
180
|
+
"""Helper to format email list."""
|
|
181
|
+
if not messages:
|
|
182
|
+
return "No emails found."
|
|
183
|
+
|
|
184
|
+
emails = []
|
|
185
|
+
for msg in messages[:max_results]:
|
|
186
|
+
emails.append({
|
|
187
|
+
'id': msg['id'],
|
|
188
|
+
'from': msg.get('from', {}).get('emailAddress', {}).get('address', 'Unknown'),
|
|
189
|
+
'from_name': msg.get('from', {}).get('emailAddress', {}).get('name', ''),
|
|
190
|
+
'subject': msg.get('subject', 'No Subject'),
|
|
191
|
+
'date': msg.get('receivedDateTime', 'Unknown'),
|
|
192
|
+
'snippet': msg.get('bodyPreview', '')[:100],
|
|
193
|
+
'unread': not msg.get('isRead', True)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
output = [f"Found {len(emails)} email(s):\n"]
|
|
197
|
+
for i, email in enumerate(emails, 1):
|
|
198
|
+
status = "[UNREAD]" if email['unread'] else ""
|
|
199
|
+
from_display = f"{email['from_name']} <{email['from']}>" if email['from_name'] else email['from']
|
|
200
|
+
output.append(f"{i}. {status} From: {from_display}")
|
|
201
|
+
output.append(f" Subject: {email['subject']}")
|
|
202
|
+
output.append(f" Date: {email['date']}")
|
|
203
|
+
output.append(f" Preview: {email['snippet']}...")
|
|
204
|
+
output.append(f" ID: {email['id']}\n")
|
|
205
|
+
|
|
206
|
+
return "\n".join(output)
|
|
207
|
+
|
|
208
|
+
# === Reading ===
|
|
209
|
+
|
|
210
|
+
def read_inbox(self, last: int = 10, unread: bool = False) -> str:
|
|
211
|
+
"""Read emails from inbox.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
last: Number of emails to retrieve (default: 10)
|
|
215
|
+
unread: Only get unread emails (default: False)
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Formatted string with email list
|
|
219
|
+
"""
|
|
220
|
+
endpoint = "/me/mailFolders/inbox/messages"
|
|
221
|
+
params = {
|
|
222
|
+
"$top": last,
|
|
223
|
+
"$orderby": "receivedDateTime desc",
|
|
224
|
+
"$select": "id,from,subject,receivedDateTime,bodyPreview,isRead"
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if unread:
|
|
228
|
+
params["$filter"] = "isRead eq false"
|
|
229
|
+
|
|
230
|
+
result = self._request("GET", endpoint, params=params)
|
|
231
|
+
messages = result.get('value', [])
|
|
232
|
+
return self._format_emails(messages, last)
|
|
233
|
+
|
|
234
|
+
def get_sent_emails(self, max_results: int = 10) -> str:
|
|
235
|
+
"""Get emails you sent.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
max_results: Number of emails to retrieve (default: 10)
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Formatted string with sent email list
|
|
242
|
+
"""
|
|
243
|
+
endpoint = "/me/mailFolders/sentitems/messages"
|
|
244
|
+
params = {
|
|
245
|
+
"$top": max_results,
|
|
246
|
+
"$orderby": "sentDateTime desc",
|
|
247
|
+
"$select": "id,toRecipients,subject,sentDateTime,bodyPreview"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
result = self._request("GET", endpoint, params=params)
|
|
251
|
+
messages = result.get('value', [])
|
|
252
|
+
|
|
253
|
+
if not messages:
|
|
254
|
+
return "No sent emails found."
|
|
255
|
+
|
|
256
|
+
output = [f"Found {len(messages)} sent email(s):\n"]
|
|
257
|
+
for i, msg in enumerate(messages[:max_results], 1):
|
|
258
|
+
to_list = msg.get('toRecipients', [])
|
|
259
|
+
to_emails = [r.get('emailAddress', {}).get('address', '') for r in to_list]
|
|
260
|
+
output.append(f"{i}. To: {', '.join(to_emails)}")
|
|
261
|
+
output.append(f" Subject: {msg.get('subject', 'No Subject')}")
|
|
262
|
+
output.append(f" Date: {msg.get('sentDateTime', 'Unknown')}")
|
|
263
|
+
output.append(f" ID: {msg['id']}\n")
|
|
264
|
+
|
|
265
|
+
return "\n".join(output)
|
|
266
|
+
|
|
267
|
+
# === Search ===
|
|
268
|
+
|
|
269
|
+
def search_emails(self, query: str, max_results: int = 10) -> str:
|
|
270
|
+
"""Search emails.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
query: Search query (searches subject and body)
|
|
274
|
+
max_results: Number of results to return (default: 10)
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Formatted string with matching emails
|
|
278
|
+
"""
|
|
279
|
+
endpoint = "/me/messages"
|
|
280
|
+
params = {
|
|
281
|
+
"$top": max_results,
|
|
282
|
+
"$search": f'"{query}"',
|
|
283
|
+
"$select": "id,from,subject,receivedDateTime,bodyPreview,isRead"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
result = self._request("GET", endpoint, params=params)
|
|
287
|
+
messages = result.get('value', [])
|
|
288
|
+
|
|
289
|
+
if not messages:
|
|
290
|
+
return f"No emails found matching query: {query}"
|
|
291
|
+
|
|
292
|
+
return self._format_emails(messages, max_results)
|
|
293
|
+
|
|
294
|
+
# === Content ===
|
|
295
|
+
|
|
296
|
+
def get_email_body(self, email_id: str) -> str:
|
|
297
|
+
"""Get full email body.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
email_id: Outlook message ID
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Full email content with headers
|
|
304
|
+
"""
|
|
305
|
+
endpoint = f"/me/messages/{email_id}"
|
|
306
|
+
params = {
|
|
307
|
+
"$select": "from,toRecipients,subject,receivedDateTime,body"
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
message = self._request("GET", endpoint, params=params)
|
|
311
|
+
|
|
312
|
+
from_email = message.get('from', {}).get('emailAddress', {})
|
|
313
|
+
from_addr = f"{from_email.get('name', '')} <{from_email.get('address', '')}>"
|
|
314
|
+
|
|
315
|
+
to_list = message.get('toRecipients', [])
|
|
316
|
+
to_addrs = ', '.join([r.get('emailAddress', {}).get('address', '') for r in to_list])
|
|
317
|
+
|
|
318
|
+
body_content = message.get('body', {}).get('content', 'No body')
|
|
319
|
+
body_type = message.get('body', {}).get('contentType', 'text')
|
|
320
|
+
|
|
321
|
+
# Strip HTML if present
|
|
322
|
+
if body_type == 'html':
|
|
323
|
+
import re
|
|
324
|
+
from html import unescape
|
|
325
|
+
body_content = re.sub(r'<style[^>]*>.*?</style>', '', body_content, flags=re.DOTALL | re.IGNORECASE)
|
|
326
|
+
body_content = re.sub(r'<[^>]+>', '', body_content)
|
|
327
|
+
body_content = unescape(body_content)
|
|
328
|
+
body_content = re.sub(r'\s+', ' ', body_content).strip()
|
|
329
|
+
|
|
330
|
+
output = [
|
|
331
|
+
f"From: {from_addr}",
|
|
332
|
+
f"To: {to_addrs}",
|
|
333
|
+
f"Subject: {message.get('subject', 'No Subject')}",
|
|
334
|
+
f"Date: {message.get('receivedDateTime', 'Unknown')}",
|
|
335
|
+
"\n--- Email Body ---\n",
|
|
336
|
+
body_content
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
return "\n".join(output)
|
|
340
|
+
|
|
341
|
+
# === Sending ===
|
|
342
|
+
|
|
343
|
+
def send(self, to: str, subject: str, body: str, cc: str = None, bcc: str = None) -> str:
|
|
344
|
+
"""Send email via Microsoft Graph API.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
to: Recipient email address
|
|
348
|
+
subject: Email subject
|
|
349
|
+
body: Email body (plain text)
|
|
350
|
+
cc: Optional CC recipients (comma-separated)
|
|
351
|
+
bcc: Optional BCC recipients (comma-separated)
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Confirmation message
|
|
355
|
+
"""
|
|
356
|
+
message = {
|
|
357
|
+
"message": {
|
|
358
|
+
"subject": subject,
|
|
359
|
+
"body": {
|
|
360
|
+
"contentType": "Text",
|
|
361
|
+
"content": body
|
|
362
|
+
},
|
|
363
|
+
"toRecipients": [
|
|
364
|
+
{"emailAddress": {"address": addr.strip()}}
|
|
365
|
+
for addr in to.split(',')
|
|
366
|
+
]
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if cc:
|
|
371
|
+
message["message"]["ccRecipients"] = [
|
|
372
|
+
{"emailAddress": {"address": addr.strip()}}
|
|
373
|
+
for addr in cc.split(',')
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
if bcc:
|
|
377
|
+
message["message"]["bccRecipients"] = [
|
|
378
|
+
{"emailAddress": {"address": addr.strip()}}
|
|
379
|
+
for addr in bcc.split(',')
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
self._request("POST", "/me/sendMail", json=message)
|
|
383
|
+
|
|
384
|
+
return f"Email sent successfully to {to}"
|
|
385
|
+
|
|
386
|
+
def reply(self, email_id: str, body: str) -> str:
|
|
387
|
+
"""Reply to an email.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
email_id: Outlook message ID to reply to
|
|
391
|
+
body: Reply message body (plain text)
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Confirmation message
|
|
395
|
+
"""
|
|
396
|
+
endpoint = f"/me/messages/{email_id}/reply"
|
|
397
|
+
data = {
|
|
398
|
+
"comment": body
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
self._request("POST", endpoint, json=data)
|
|
402
|
+
|
|
403
|
+
return f"Reply sent successfully"
|
|
404
|
+
|
|
405
|
+
# === Actions ===
|
|
406
|
+
|
|
407
|
+
def mark_read(self, email_id: str) -> str:
|
|
408
|
+
"""Mark email as read.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
email_id: Outlook message ID
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Confirmation message
|
|
415
|
+
"""
|
|
416
|
+
endpoint = f"/me/messages/{email_id}"
|
|
417
|
+
data = {"isRead": True}
|
|
418
|
+
|
|
419
|
+
self._request("PATCH", endpoint, json=data)
|
|
420
|
+
|
|
421
|
+
return f"Marked email as read: {email_id}"
|
|
422
|
+
|
|
423
|
+
def mark_unread(self, email_id: str) -> str:
|
|
424
|
+
"""Mark email as unread.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
email_id: Outlook message ID
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Confirmation message
|
|
431
|
+
"""
|
|
432
|
+
endpoint = f"/me/messages/{email_id}"
|
|
433
|
+
data = {"isRead": False}
|
|
434
|
+
|
|
435
|
+
self._request("PATCH", endpoint, json=data)
|
|
436
|
+
|
|
437
|
+
return f"Marked email as unread: {email_id}"
|
|
438
|
+
|
|
439
|
+
def archive_email(self, email_id: str) -> str:
|
|
440
|
+
"""Archive email (move to archive folder).
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
email_id: Outlook message ID
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Confirmation message
|
|
447
|
+
"""
|
|
448
|
+
endpoint = f"/me/messages/{email_id}/move"
|
|
449
|
+
data = {"destinationId": "archive"}
|
|
450
|
+
|
|
451
|
+
self._request("POST", endpoint, json=data)
|
|
452
|
+
|
|
453
|
+
return f"Archived email: {email_id}"
|
|
454
|
+
|
|
455
|
+
# === Stats ===
|
|
456
|
+
|
|
457
|
+
def count_unread(self) -> str:
|
|
458
|
+
"""Count unread emails in inbox.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Number of unread emails
|
|
462
|
+
"""
|
|
463
|
+
endpoint = "/me/mailFolders/inbox"
|
|
464
|
+
params = {"$select": "unreadItemCount"}
|
|
465
|
+
|
|
466
|
+
result = self._request("GET", endpoint, params=params)
|
|
467
|
+
count = result.get('unreadItemCount', 0)
|
|
468
|
+
|
|
469
|
+
return f"You have {count} unread email(s) in your inbox."
|
|
470
|
+
|
|
471
|
+
def get_my_email(self) -> str:
|
|
472
|
+
"""Get the user's email address.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
User's Microsoft email address
|
|
476
|
+
"""
|
|
477
|
+
email = os.getenv("MICROSOFT_EMAIL", "")
|
|
478
|
+
if email:
|
|
479
|
+
return f"Connected as: {email}"
|
|
480
|
+
|
|
481
|
+
# Fallback: fetch from API
|
|
482
|
+
endpoint = "/me"
|
|
483
|
+
params = {"$select": "mail,userPrincipalName"}
|
|
484
|
+
|
|
485
|
+
result = self._request("GET", endpoint, params=params)
|
|
486
|
+
email = result.get('mail') or result.get('userPrincipalName', 'Unknown')
|
|
487
|
+
|
|
488
|
+
return f"Connected as: {email}"
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Send emails via OpenOnion API using agent's authenticated email address
|
|
3
|
+
LLM-Note:
|
|
4
|
+
Dependencies: imports from [os, json, toml, requests, pathlib, typing, dotenv] | imported by [__init__.py, useful_tools/__init__.py] | tested by [tests/test_email_functions.py, tests/test_real_email.py]
|
|
5
|
+
Data flow: Agent calls send_email(to, subject, message) → searches for .env file (cwd → parent dirs → ~/.co/keys.env) → loads OPENONION_API_KEY and AGENT_EMAIL → validates email format → detects HTML vs plain text → POST to oo.openonion.ai/api/email with auth token → returns {success, message_id, from, error}
|
|
6
|
+
State/Effects: reads .env files from filesystem | loads environment variables via dotenv | makes HTTP POST request to OpenOnion API | no local state persistence
|
|
7
|
+
Integration: exposes send_email(to, subject, message) → returns dict | used as agent tool function | requires prior 'co auth' to set OPENONION_API_KEY and AGENT_EMAIL | API endpoint: POST /api/email with Bearer token
|
|
8
|
+
Performance: file search up to 5 parent dirs | one HTTP request per email | no caching | synchronous (blocks on network)
|
|
9
|
+
Errors: returns {success: False, error: str} for: missing .env, missing keys, invalid email format, API failures | HTTP errors caught and wrapped | validates @ and . in email | let-it-crash pattern (returns errors, doesn't raise)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import json
|
|
14
|
+
import toml
|
|
15
|
+
import requests
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def send_email(to: str, subject: str, message: str) -> Dict:
|
|
21
|
+
"""Send an email using the agent's email address.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
to: Recipient email address
|
|
25
|
+
subject: Email subject line
|
|
26
|
+
message: Email body (plain text or HTML)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
dict: Success status and details
|
|
30
|
+
- success (bool): Whether email was sent
|
|
31
|
+
- message_id (str): ID of sent message
|
|
32
|
+
- from (str): Sender email address
|
|
33
|
+
- error (str): Error message if failed
|
|
34
|
+
"""
|
|
35
|
+
# Find .env file by searching up the directory tree
|
|
36
|
+
env_file = None
|
|
37
|
+
current_dir = Path.cwd()
|
|
38
|
+
|
|
39
|
+
# Search up to 5 levels for .env
|
|
40
|
+
for _ in range(5):
|
|
41
|
+
potential_env = current_dir / ".env"
|
|
42
|
+
if potential_env.exists():
|
|
43
|
+
env_file = potential_env
|
|
44
|
+
break
|
|
45
|
+
if current_dir == current_dir.parent: # Reached root
|
|
46
|
+
break
|
|
47
|
+
current_dir = current_dir.parent
|
|
48
|
+
|
|
49
|
+
# If no local .env found, try global keys.env
|
|
50
|
+
if not env_file:
|
|
51
|
+
global_keys_env = Path.home() / ".co" / "keys.env"
|
|
52
|
+
if global_keys_env.exists():
|
|
53
|
+
env_file = global_keys_env
|
|
54
|
+
|
|
55
|
+
if not env_file:
|
|
56
|
+
return {
|
|
57
|
+
"success": False,
|
|
58
|
+
"error": "No .env file found. Run 'co init' or 'co auth' first."
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Get authentication token and agent email from environment
|
|
62
|
+
token = os.getenv("OPENONION_API_KEY")
|
|
63
|
+
from_email = os.getenv("AGENT_EMAIL")
|
|
64
|
+
|
|
65
|
+
if not token:
|
|
66
|
+
return {
|
|
67
|
+
"success": False,
|
|
68
|
+
"error": "OPENONION_API_KEY not found in .env. Run 'co auth' to authenticate."
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if not from_email:
|
|
72
|
+
return {
|
|
73
|
+
"success": False,
|
|
74
|
+
"error": "AGENT_EMAIL not found in .env. Run 'co auth' to set up email."
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Validate recipient email
|
|
78
|
+
if not "@" in to or not "." in to.split("@")[-1]:
|
|
79
|
+
return {
|
|
80
|
+
"success": False,
|
|
81
|
+
"error": f"Invalid email address: {to}"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Detect if message contains HTML
|
|
85
|
+
is_html = "<" in message and ">" in message
|
|
86
|
+
|
|
87
|
+
# Prepare email payload
|
|
88
|
+
payload = {
|
|
89
|
+
"to": to,
|
|
90
|
+
"subject": subject,
|
|
91
|
+
"body": message # Simple direct body
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Send email via backend API
|
|
95
|
+
backend_url = os.getenv("CONNECTONION_BACKEND_URL", "https://oo.openonion.ai")
|
|
96
|
+
endpoint = f"{backend_url}/api/v1/email/send"
|
|
97
|
+
|
|
98
|
+
headers = {
|
|
99
|
+
"Authorization": f"Bearer {token}",
|
|
100
|
+
"Content-Type": "application/json"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
response = requests.post(
|
|
105
|
+
endpoint,
|
|
106
|
+
json=payload,
|
|
107
|
+
headers=headers,
|
|
108
|
+
timeout=10
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if response.status_code == 200:
|
|
112
|
+
data = response.json()
|
|
113
|
+
return {
|
|
114
|
+
"success": True,
|
|
115
|
+
"message_id": data.get("message_id", "msg_unknown"),
|
|
116
|
+
"from": from_email
|
|
117
|
+
}
|
|
118
|
+
elif response.status_code == 429:
|
|
119
|
+
return {
|
|
120
|
+
"success": False,
|
|
121
|
+
"error": "Rate limit exceeded"
|
|
122
|
+
}
|
|
123
|
+
elif response.status_code == 401:
|
|
124
|
+
return {
|
|
125
|
+
"success": False,
|
|
126
|
+
"error": "Authentication failed. Run 'co auth' to re-authenticate."
|
|
127
|
+
}
|
|
128
|
+
else:
|
|
129
|
+
error_msg = response.json().get("detail", "Unknown error")
|
|
130
|
+
return {
|
|
131
|
+
"success": False,
|
|
132
|
+
"error": error_msg
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
except requests.exceptions.Timeout:
|
|
136
|
+
return {
|
|
137
|
+
"success": False,
|
|
138
|
+
"error": "Request timed out. Please try again."
|
|
139
|
+
}
|
|
140
|
+
except requests.exceptions.ConnectionError:
|
|
141
|
+
return {
|
|
142
|
+
"success": False,
|
|
143
|
+
"error": "Cannot connect to email service. Check your internet connection."
|
|
144
|
+
}
|
|
145
|
+
except Exception as e:
|
|
146
|
+
return {
|
|
147
|
+
"success": False,
|
|
148
|
+
"error": f"Failed to send email: {str(e)}"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_agent_email() -> Optional[str]:
|
|
153
|
+
"""Get the agent's email address from configuration.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
str: Agent's email address or None if not configured
|
|
157
|
+
"""
|
|
158
|
+
co_dir = Path(".co")
|
|
159
|
+
if not co_dir.exists():
|
|
160
|
+
co_dir = Path("../.co")
|
|
161
|
+
if not co_dir.exists():
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
config_path = co_dir / "config.toml"
|
|
165
|
+
if not config_path.exists():
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
config = toml.load(config_path)
|
|
170
|
+
agent_config = config.get("agent", {})
|
|
171
|
+
|
|
172
|
+
# Get email or generate from address
|
|
173
|
+
email = agent_config.get("email")
|
|
174
|
+
if not email:
|
|
175
|
+
address = agent_config.get("address", "")
|
|
176
|
+
if address and address.startswith("0x"):
|
|
177
|
+
email = f"{address[:10]}@mail.openonion.ai"
|
|
178
|
+
|
|
179
|
+
return email
|
|
180
|
+
except Exception:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_email_active() -> bool:
|
|
185
|
+
"""Check if the agent's email is activated.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
bool: True if email is activated, False otherwise
|
|
189
|
+
"""
|
|
190
|
+
co_dir = Path(".co")
|
|
191
|
+
if not co_dir.exists():
|
|
192
|
+
co_dir = Path("../.co")
|
|
193
|
+
if not co_dir.exists():
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
config_path = co_dir / "config.toml"
|
|
197
|
+
if not config_path.exists():
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
config = toml.load(config_path)
|
|
202
|
+
agent_config = config.get("agent", {})
|
|
203
|
+
return agent_config.get("email_active", False)
|
|
204
|
+
except Exception:
|
|
205
|
+
return False
|