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.
Files changed (113) hide show
  1. connectonion/__init__.py +78 -0
  2. connectonion/address.py +320 -0
  3. connectonion/agent.py +450 -0
  4. connectonion/announce.py +84 -0
  5. connectonion/asgi.py +287 -0
  6. connectonion/auto_debug_exception.py +181 -0
  7. connectonion/cli/__init__.py +3 -0
  8. connectonion/cli/browser_agent/__init__.py +5 -0
  9. connectonion/cli/browser_agent/browser.py +243 -0
  10. connectonion/cli/browser_agent/prompt.md +107 -0
  11. connectonion/cli/commands/__init__.py +1 -0
  12. connectonion/cli/commands/auth_commands.py +527 -0
  13. connectonion/cli/commands/browser_commands.py +27 -0
  14. connectonion/cli/commands/create.py +511 -0
  15. connectonion/cli/commands/deploy_commands.py +220 -0
  16. connectonion/cli/commands/doctor_commands.py +173 -0
  17. connectonion/cli/commands/init.py +469 -0
  18. connectonion/cli/commands/project_cmd_lib.py +828 -0
  19. connectonion/cli/commands/reset_commands.py +149 -0
  20. connectonion/cli/commands/status_commands.py +168 -0
  21. connectonion/cli/docs/co-vibecoding-principles-docs-contexts-all-in-one.md +2010 -0
  22. connectonion/cli/docs/connectonion.md +1256 -0
  23. connectonion/cli/docs.md +123 -0
  24. connectonion/cli/main.py +148 -0
  25. connectonion/cli/templates/meta-agent/README.md +287 -0
  26. connectonion/cli/templates/meta-agent/agent.py +196 -0
  27. connectonion/cli/templates/meta-agent/prompts/answer_prompt.md +9 -0
  28. connectonion/cli/templates/meta-agent/prompts/docs_retrieve_prompt.md +15 -0
  29. connectonion/cli/templates/meta-agent/prompts/metagent.md +71 -0
  30. connectonion/cli/templates/meta-agent/prompts/think_prompt.md +18 -0
  31. connectonion/cli/templates/minimal/README.md +56 -0
  32. connectonion/cli/templates/minimal/agent.py +40 -0
  33. connectonion/cli/templates/playwright/README.md +118 -0
  34. connectonion/cli/templates/playwright/agent.py +336 -0
  35. connectonion/cli/templates/playwright/prompt.md +102 -0
  36. connectonion/cli/templates/playwright/requirements.txt +3 -0
  37. connectonion/cli/templates/web-research/agent.py +122 -0
  38. connectonion/connect.py +128 -0
  39. connectonion/console.py +539 -0
  40. connectonion/debug_agent/__init__.py +13 -0
  41. connectonion/debug_agent/agent.py +45 -0
  42. connectonion/debug_agent/prompts/debug_assistant.md +72 -0
  43. connectonion/debug_agent/runtime_inspector.py +406 -0
  44. connectonion/debug_explainer/__init__.py +10 -0
  45. connectonion/debug_explainer/explain_agent.py +114 -0
  46. connectonion/debug_explainer/explain_context.py +263 -0
  47. connectonion/debug_explainer/explainer_prompt.md +29 -0
  48. connectonion/debug_explainer/root_cause_analysis_prompt.md +43 -0
  49. connectonion/debugger_ui.py +1039 -0
  50. connectonion/decorators.py +208 -0
  51. connectonion/events.py +248 -0
  52. connectonion/execution_analyzer/__init__.py +9 -0
  53. connectonion/execution_analyzer/execution_analysis.py +93 -0
  54. connectonion/execution_analyzer/execution_analysis_prompt.md +47 -0
  55. connectonion/host.py +579 -0
  56. connectonion/interactive_debugger.py +342 -0
  57. connectonion/llm.py +801 -0
  58. connectonion/llm_do.py +307 -0
  59. connectonion/logger.py +300 -0
  60. connectonion/prompt_files/__init__.py +1 -0
  61. connectonion/prompt_files/analyze_contact.md +62 -0
  62. connectonion/prompt_files/eval_expected.md +12 -0
  63. connectonion/prompt_files/react_evaluate.md +11 -0
  64. connectonion/prompt_files/react_plan.md +16 -0
  65. connectonion/prompt_files/reflect.md +22 -0
  66. connectonion/prompts.py +144 -0
  67. connectonion/relay.py +200 -0
  68. connectonion/static/docs.html +688 -0
  69. connectonion/tool_executor.py +279 -0
  70. connectonion/tool_factory.py +186 -0
  71. connectonion/tool_registry.py +105 -0
  72. connectonion/trust.py +166 -0
  73. connectonion/trust_agents.py +71 -0
  74. connectonion/trust_functions.py +88 -0
  75. connectonion/tui/__init__.py +57 -0
  76. connectonion/tui/divider.py +39 -0
  77. connectonion/tui/dropdown.py +251 -0
  78. connectonion/tui/footer.py +31 -0
  79. connectonion/tui/fuzzy.py +56 -0
  80. connectonion/tui/input.py +278 -0
  81. connectonion/tui/keys.py +35 -0
  82. connectonion/tui/pick.py +130 -0
  83. connectonion/tui/providers.py +155 -0
  84. connectonion/tui/status_bar.py +163 -0
  85. connectonion/usage.py +161 -0
  86. connectonion/useful_events_handlers/__init__.py +16 -0
  87. connectonion/useful_events_handlers/reflect.py +116 -0
  88. connectonion/useful_plugins/__init__.py +20 -0
  89. connectonion/useful_plugins/calendar_plugin.py +163 -0
  90. connectonion/useful_plugins/eval.py +139 -0
  91. connectonion/useful_plugins/gmail_plugin.py +162 -0
  92. connectonion/useful_plugins/image_result_formatter.py +127 -0
  93. connectonion/useful_plugins/re_act.py +78 -0
  94. connectonion/useful_plugins/shell_approval.py +159 -0
  95. connectonion/useful_tools/__init__.py +44 -0
  96. connectonion/useful_tools/diff_writer.py +192 -0
  97. connectonion/useful_tools/get_emails.py +183 -0
  98. connectonion/useful_tools/gmail.py +1596 -0
  99. connectonion/useful_tools/google_calendar.py +613 -0
  100. connectonion/useful_tools/memory.py +380 -0
  101. connectonion/useful_tools/microsoft_calendar.py +604 -0
  102. connectonion/useful_tools/outlook.py +488 -0
  103. connectonion/useful_tools/send_email.py +205 -0
  104. connectonion/useful_tools/shell.py +97 -0
  105. connectonion/useful_tools/slash_command.py +201 -0
  106. connectonion/useful_tools/terminal.py +285 -0
  107. connectonion/useful_tools/todo_list.py +241 -0
  108. connectonion/useful_tools/web_fetch.py +216 -0
  109. connectonion/xray.py +467 -0
  110. connectonion-0.5.8.dist-info/METADATA +741 -0
  111. connectonion-0.5.8.dist-info/RECORD +113 -0
  112. connectonion-0.5.8.dist-info/WHEEL +4 -0
  113. 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