dhisana 0.0.1.dev116__py3-none-any.whl → 0.0.1.dev236__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 (69) hide show
  1. dhisana/schemas/common.py +10 -1
  2. dhisana/schemas/sales.py +203 -22
  3. dhisana/utils/add_mapping.py +0 -2
  4. dhisana/utils/apollo_tools.py +739 -119
  5. dhisana/utils/built_with_api_tools.py +4 -2
  6. dhisana/utils/check_email_validity_tools.py +35 -18
  7. dhisana/utils/check_for_intent_signal.py +1 -2
  8. dhisana/utils/check_linkedin_url_validity.py +34 -8
  9. dhisana/utils/clay_tools.py +3 -2
  10. dhisana/utils/clean_properties.py +1 -4
  11. dhisana/utils/compose_salesnav_query.py +0 -1
  12. dhisana/utils/compose_search_query.py +7 -3
  13. dhisana/utils/composite_tools.py +0 -1
  14. dhisana/utils/dataframe_tools.py +2 -2
  15. dhisana/utils/email_body_utils.py +72 -0
  16. dhisana/utils/email_provider.py +174 -35
  17. dhisana/utils/enrich_lead_information.py +183 -53
  18. dhisana/utils/fetch_openai_config.py +129 -0
  19. dhisana/utils/field_validators.py +1 -1
  20. dhisana/utils/g2_tools.py +0 -1
  21. dhisana/utils/generate_content.py +0 -1
  22. dhisana/utils/generate_email.py +68 -23
  23. dhisana/utils/generate_email_response.py +294 -46
  24. dhisana/utils/generate_flow.py +0 -1
  25. dhisana/utils/generate_linkedin_connect_message.py +9 -2
  26. dhisana/utils/generate_linkedin_response_message.py +137 -66
  27. dhisana/utils/generate_structured_output_internal.py +317 -164
  28. dhisana/utils/google_custom_search.py +150 -44
  29. dhisana/utils/google_oauth_tools.py +721 -0
  30. dhisana/utils/google_workspace_tools.py +278 -54
  31. dhisana/utils/hubspot_clearbit.py +3 -1
  32. dhisana/utils/hubspot_crm_tools.py +718 -272
  33. dhisana/utils/instantly_tools.py +3 -1
  34. dhisana/utils/lusha_tools.py +10 -7
  35. dhisana/utils/mailgun_tools.py +150 -0
  36. dhisana/utils/microsoft365_tools.py +447 -0
  37. dhisana/utils/openai_assistant_and_file_utils.py +121 -177
  38. dhisana/utils/openai_helpers.py +8 -6
  39. dhisana/utils/parse_linkedin_messages_txt.py +1 -3
  40. dhisana/utils/profile.py +37 -0
  41. dhisana/utils/proxy_curl_tools.py +377 -76
  42. dhisana/utils/proxycurl_search_leads.py +426 -0
  43. dhisana/utils/research_lead.py +3 -3
  44. dhisana/utils/sales_navigator_crawler.py +1 -6
  45. dhisana/utils/salesforce_crm_tools.py +323 -50
  46. dhisana/utils/search_router.py +131 -0
  47. dhisana/utils/search_router_jobs.py +51 -0
  48. dhisana/utils/sendgrid_tools.py +126 -91
  49. dhisana/utils/serarch_router_local_business.py +75 -0
  50. dhisana/utils/serpapi_additional_tools.py +290 -0
  51. dhisana/utils/serpapi_google_jobs.py +117 -0
  52. dhisana/utils/serpapi_google_search.py +188 -0
  53. dhisana/utils/serpapi_local_business_search.py +129 -0
  54. dhisana/utils/serpapi_search_tools.py +360 -432
  55. dhisana/utils/serperdev_google_jobs.py +125 -0
  56. dhisana/utils/serperdev_local_business.py +154 -0
  57. dhisana/utils/serperdev_search.py +233 -0
  58. dhisana/utils/smtp_email_tools.py +178 -18
  59. dhisana/utils/test_connect.py +1603 -130
  60. dhisana/utils/trasform_json.py +3 -3
  61. dhisana/utils/web_download_parse_tools.py +0 -1
  62. dhisana/utils/zoominfo_tools.py +2 -3
  63. dhisana/workflow/test.py +1 -1
  64. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +1 -1
  65. dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
  66. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
  67. dhisana-0.0.1.dev116.dist-info/RECORD +0 -83
  68. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
  69. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
@@ -12,7 +12,9 @@ base_url = 'https://api.instantly.ai/v1'
12
12
  def get_api_key_and_headers() -> Dict[str, str]:
13
13
  api_key = os.environ.get('INSTANTLY_API_KEY')
14
14
  if not api_key:
15
- raise ValueError("Instantly API key not found in environment variables")
15
+ raise ValueError(
16
+ "Instantly integration is not configured. Please configure the connection to Instantly in Integrations."
17
+ )
16
18
  headers = {
17
19
  "Authorization": f"Bearer {api_key}",
18
20
  "Content-Type": "application/json"
@@ -1,8 +1,7 @@
1
- import asyncio
2
1
  import os
3
2
  import json
4
3
  import logging
5
- from typing import Dict, List, Optional, Tuple
4
+ from typing import Dict, List, Optional
6
5
 
7
6
  import aiohttp
8
7
  import backoff
@@ -26,7 +25,6 @@ def get_lusha_credentials_from_config(
26
25
  str: Lusha API key from tool_config or environment variables
27
26
  """
28
27
  lusha_api_key = None
29
- lusha_api_secret = None
30
28
 
31
29
  if tool_config:
32
30
  lusha_config = next(
@@ -41,10 +39,14 @@ def get_lusha_credentials_from_config(
41
39
  if cfg
42
40
  }
43
41
  lusha_api_key = config_map.get("apiKey")
44
- lusha_api_secret = config_map.get("apiSecret")
42
+ config_map.get("apiSecret")
45
43
 
46
44
  # Fallback to environment variables if not found in tool_config
47
45
  lusha_api_key = lusha_api_key or os.environ.get("LUSHA_API_KEY")
46
+ if not lusha_api_key:
47
+ raise ValueError(
48
+ "Lusha integration is not configured. Please configure the connection to Lusha in Integrations."
49
+ )
48
50
  return lusha_api_key
49
51
 
50
52
 
@@ -74,9 +76,10 @@ async def enrich_person_info_from_lusha(
74
76
  Returns:
75
77
  dict: JSON response containing person information, or an error message.
76
78
  """
77
- access_token = get_lusha_credentials_from_config(tool_config)
78
- if not access_token:
79
- return {"error": "Failed to obtain Lusha access token"}
79
+ try:
80
+ access_token = get_lusha_credentials_from_config(tool_config)
81
+ except ValueError as e:
82
+ return {"error": str(e)}
80
83
 
81
84
  if not linkedin_url and not email and not phone:
82
85
  return {"error": "At least one of linkedin_url, email, or phone must be provided"}
@@ -0,0 +1,150 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ from typing import Optional, List, Dict
5
+
6
+ import aiohttp
7
+
8
+ from dhisana.utils.assistant_tool_tag import assistant_tool
9
+ from dhisana.schemas.common import SendEmailContext
10
+ from dhisana.utils.email_body_utils import body_variants
11
+
12
+
13
+ def get_mailgun_notify_key(tool_config: Optional[List[Dict]] = None) -> str:
14
+ """
15
+ Retrieve the Mailgun API key from tool_config or environment.
16
+
17
+ Looks for an integration named "mailgun" and reads configuration item
18
+ with name "apiKey". Falls back to env var MAILGUN_NOTIFY_KEY.
19
+ """
20
+ key = None
21
+ if tool_config:
22
+ cfg = next((item for item in tool_config if item.get("name") == "mailgun"), None)
23
+ if cfg:
24
+ cfg_map = {i["name"]: i["value"] for i in cfg.get("configuration", []) if i}
25
+ key = cfg_map.get("apiKey")
26
+ key = key or os.getenv("MAILGUN_NOTIFY_KEY")
27
+ if not key:
28
+ raise ValueError(
29
+ "Mailgun integration is not configured. Please configure the connection to Mailgun in Integrations."
30
+ )
31
+ return key
32
+
33
+
34
+ def get_mailgun_notify_domain(tool_config: Optional[List[Dict]] = None) -> str:
35
+ """
36
+ Retrieve the Mailgun domain from tool_config or environment.
37
+
38
+ Looks for an integration named "mailgun" and reads configuration item
39
+ with name "domain" (preferred) or legacy "notifyDomain".
40
+ Falls back to env var MAILGUN_DOMAIN, then MAILGUN_NOTIFY_DOMAIN.
41
+ """
42
+ domain = None
43
+ if tool_config:
44
+ cfg = next((item for item in tool_config if item.get("name") == "mailgun"), None)
45
+ if cfg:
46
+ cfg_map = {i["name"]: i["value"] for i in cfg.get("configuration", []) if i}
47
+ domain = cfg_map.get("domain") or cfg_map.get("notifyDomain")
48
+ domain = domain or os.getenv("MAILGUN_DOMAIN") or os.getenv("MAILGUN_NOTIFY_DOMAIN")
49
+ if not domain:
50
+ raise ValueError(
51
+ "Mailgun integration is not configured. Please configure the connection to Mailgun in Integrations."
52
+ )
53
+ return domain
54
+
55
+
56
+ @assistant_tool
57
+ async def send_email_with_mailgun(
58
+ sender: str,
59
+ recipients: List[str],
60
+ subject: str,
61
+ message: str,
62
+ tool_config: Optional[List[Dict]] = None,
63
+ body_format: Optional[str] = None,
64
+ ):
65
+ """
66
+ Send an email using the Mailgun API.
67
+
68
+ Parameters:
69
+ - sender: Email address string, e.g. "Alice <alice@example.com>" or just address.
70
+ - recipients: List of recipient email addresses.
71
+ - subject: Subject string.
72
+ - message: HTML content body.
73
+ - tool_config: Optional integrations config list.
74
+ """
75
+ try:
76
+ api_key = get_mailgun_notify_key(tool_config)
77
+ domain = get_mailgun_notify_domain(tool_config)
78
+
79
+ body = message or ""
80
+ data = {
81
+ "from": sender,
82
+ "to": recipients,
83
+ "subject": subject,
84
+ }
85
+
86
+ plain_body, html_body, _ = body_variants(body, body_format)
87
+ data["text"] = plain_body
88
+ data["html"] = html_body
89
+
90
+ async with aiohttp.ClientSession() as session:
91
+ async with session.post(
92
+ f"https://api.mailgun.net/v3/{domain}/messages",
93
+ auth=aiohttp.BasicAuth("api", api_key),
94
+ data=data,
95
+ ) as response:
96
+ # Try to return JSON payload if available
97
+ try:
98
+ return await response.json()
99
+ except Exception:
100
+ return await response.text()
101
+ except Exception as ex:
102
+ logging.warning(f"Error sending email via Mailgun: {ex}")
103
+ return {"error": str(ex)}
104
+
105
+
106
+ async def send_email_using_mailgun_async(
107
+ send_email_context: SendEmailContext,
108
+ tool_config: Optional[List[Dict]] = None,
109
+ ) -> str:
110
+ """
111
+ Provider-style wrapper for Mailgun that accepts SendEmailContext and returns an id string.
112
+ """
113
+ api_key = get_mailgun_notify_key(tool_config)
114
+ domain = get_mailgun_notify_domain(tool_config)
115
+
116
+ plain_body, html_body, _ = body_variants(
117
+ send_email_context.body,
118
+ getattr(send_email_context, "body_format", None),
119
+ )
120
+
121
+ data = {
122
+ "from": f"{send_email_context.sender_name} <{send_email_context.sender_email}>",
123
+ "to": [send_email_context.recipient],
124
+ "subject": send_email_context.subject,
125
+ "text": plain_body,
126
+ "html": html_body,
127
+ }
128
+
129
+ async with aiohttp.ClientSession() as session:
130
+ async with session.post(
131
+ f"https://api.mailgun.net/v3/{domain}/messages",
132
+ auth=aiohttp.BasicAuth("api", api_key),
133
+ data=data,
134
+ ) as response:
135
+ # Raise if not 2xx to match other providers' behavior
136
+ if response.status < 200 or response.status >= 300:
137
+ try:
138
+ detail = await response.text()
139
+ except Exception:
140
+ detail = f"status={response.status}"
141
+ raise RuntimeError(f"Mailgun send failed: {detail}")
142
+ try:
143
+ payload = await response.json()
144
+ except Exception:
145
+ payload = {"message": await response.text()}
146
+
147
+ # Normalise return value akin to other providers
148
+ msg_id = payload.get("id") if isinstance(payload, dict) else None
149
+ await asyncio.sleep(20)
150
+ return msg_id or str(payload)
@@ -0,0 +1,447 @@
1
+ import asyncio
2
+ import logging
3
+ from datetime import datetime, timedelta, timezone
4
+ import json
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import httpx
8
+
9
+ from dhisana.schemas.common import (
10
+ SendEmailContext,
11
+ QueryEmailContext,
12
+ ReplyEmailContext,
13
+ )
14
+ from dhisana.schemas.sales import MessageItem
15
+ from dhisana.utils.email_body_utils import body_variants
16
+
17
+
18
+ def get_microsoft365_access_token(tool_config: Optional[List[Dict]] = None) -> str:
19
+ """
20
+ Retrieve a Microsoft Graph OAuth2 access token from tool_config or env.
21
+
22
+ Expected tool_config shape (similar to HubSpot):
23
+ {
24
+ "name": "microsoft365",
25
+ "configuration": [
26
+ {"name": "oauth_tokens", "value": {"access_token": "..."} }
27
+ # or {"name": "access_token", "value": "..."}
28
+ ]
29
+ }
30
+
31
+ This helper no longer reads environment variables; the token must be supplied
32
+ via the microsoft365 integration's configuration.
33
+ """
34
+ access_token: Optional[str] = None
35
+
36
+ if tool_config:
37
+ ms_cfg = next((c for c in tool_config if c.get("name") == "microsoft365"), None)
38
+ if ms_cfg:
39
+ cfg_map = {f["name"]: f.get("value") for f in ms_cfg.get("configuration", []) if f}
40
+ raw_oauth = cfg_map.get("oauth_tokens")
41
+ # If oauth_tokens is a JSON string, parse; if dict, read directly
42
+ if isinstance(raw_oauth, str):
43
+ try:
44
+ raw_oauth = json.loads(raw_oauth)
45
+ except Exception:
46
+ raw_oauth = None
47
+ if isinstance(raw_oauth, dict):
48
+ access_token = raw_oauth.get("access_token") or raw_oauth.get("token")
49
+ if not access_token:
50
+ access_token = cfg_map.get("access_token") or cfg_map.get("apiKey")
51
+
52
+ if not access_token:
53
+ raise ValueError(
54
+ "Microsoft 365 integration is not configured. Please connect Microsoft 365 in Integrations and provide an OAuth access token."
55
+ )
56
+ return access_token
57
+
58
+
59
+ def _get_m365_auth_mode(tool_config: Optional[List[Dict]] = None) -> str:
60
+ """
61
+ Determine auth mode: 'delegated' (default) or 'application'.
62
+ Looks for configuration fields in the microsoft365 integration:
63
+ - auth_mode: 'delegated' | 'application'
64
+ - use_application_permissions: true/false (string or bool)
65
+ """
66
+ mode = "delegated"
67
+ if tool_config:
68
+ ms_cfg = next((c for c in tool_config if c.get("name") == "microsoft365"), None)
69
+ if ms_cfg:
70
+ cfg_map = {f["name"]: f.get("value") for f in ms_cfg.get("configuration", []) if f}
71
+ raw_mode = (
72
+ cfg_map.get("auth_mode")
73
+ or cfg_map.get("authMode")
74
+ or cfg_map.get("mode")
75
+ )
76
+ if isinstance(raw_mode, str) and raw_mode:
77
+ val = raw_mode.strip().lower()
78
+ if val in ("application", "app", "service", "service_account"):
79
+ return "application"
80
+ if val in ("delegated", "user"):
81
+ return "delegated"
82
+ uap = cfg_map.get("use_application_permissions") or cfg_map.get("applicationPermissions")
83
+ if isinstance(uap, str):
84
+ if uap.strip().lower() in ("true", "1", "yes", "y"): # truthy
85
+ return "application"
86
+ elif isinstance(uap, bool) and uap:
87
+ return "application"
88
+ return mode
89
+
90
+
91
+ def _base_resource(sender_email: Optional[str], tool_config: Optional[List[Dict]], auth_mode: Optional[str] = None) -> str:
92
+ mode = (auth_mode or _get_m365_auth_mode(tool_config)).lower()
93
+ if mode == "application":
94
+ if not sender_email:
95
+ raise ValueError("sender_email is required when using application permissions.")
96
+ return f"/users/{sender_email}"
97
+ # Delegated (per-user) uses /me
98
+ return "/me"
99
+
100
+
101
+ def _token_has_mail_read_scope(token: str) -> bool:
102
+ """
103
+ Best-effort check if the OAuth token includes Mail.Read or Mail.ReadWrite.
104
+ Works for both delegated ("scp") and app-only ("roles"). No signature verification.
105
+ """
106
+ try:
107
+ parts = token.split(".")
108
+ if len(parts) < 2:
109
+ return False
110
+ import base64
111
+
112
+ def _b64url_decode(segment: str) -> bytes:
113
+ pad = "=" * (-len(segment) % 4)
114
+ return base64.urlsafe_b64decode(segment + pad)
115
+
116
+ payload_bytes = _b64url_decode(parts[1])
117
+ payload = json.loads(payload_bytes.decode("utf-8"))
118
+
119
+ scopes = set()
120
+ scp = payload.get("scp")
121
+ if isinstance(scp, str):
122
+ scopes.update(s.strip() for s in scp.split(" ") if s.strip())
123
+
124
+ roles = payload.get("roles")
125
+ if isinstance(roles, list):
126
+ scopes.update(r for r in roles if isinstance(r, str))
127
+
128
+ return any(s in scopes for s in ("Mail.ReadWrite", "Mail.Read"))
129
+ except Exception:
130
+ return False
131
+
132
+
133
+ async def send_email_using_microsoft_graph_async(
134
+ send_email_context: SendEmailContext,
135
+ tool_config: Optional[List[Dict]] = None,
136
+ auth_mode: Optional[str] = None,
137
+ ) -> str:
138
+ """
139
+ Send an email via Microsoft Graph API using an OAuth2 access token.
140
+
141
+ Uses the /sendMail endpoint which only requires Mail.Send permission.
142
+ Returns a best-effort string identifier (not a Graph message ID) since
143
+ /sendMail responds 202 with no body. If higher privileges are present
144
+ (Mail.Read*), callers should locate the actual message ID from Sent Items
145
+ separately if they need it.
146
+ """
147
+ token = get_microsoft365_access_token(tool_config)
148
+ sender_email = send_email_context.sender_email
149
+
150
+ base_url = "https://graph.microsoft.com/v1.0"
151
+ base_res = _base_resource(sender_email, tool_config, auth_mode)
152
+
153
+ plain_body, html_body, resolved_fmt = body_variants(
154
+ send_email_context.body,
155
+ getattr(send_email_context, "body_format", None),
156
+ )
157
+ content_type = "Text" if resolved_fmt == "text" else "HTML"
158
+ content_body = plain_body if resolved_fmt == "text" else html_body
159
+
160
+ message_payload: Dict[str, Any] = {
161
+ "subject": send_email_context.subject,
162
+ "body": {
163
+ "contentType": content_type,
164
+ "content": content_body,
165
+ },
166
+ "toRecipients": [
167
+ {"emailAddress": {"address": send_email_context.recipient}}
168
+ ],
169
+ }
170
+
171
+ headers = {
172
+ "Authorization": f"Bearer {token}",
173
+ "Content-Type": "application/json",
174
+ }
175
+
176
+ async with httpx.AsyncClient(timeout=30) as client:
177
+ send_url = f"{base_url}{base_res}/sendMail"
178
+ send_body = {
179
+ "message": message_payload,
180
+ "saveToSentItems": True,
181
+ }
182
+ send_resp = await client.post(send_url, headers=headers, json=send_body)
183
+ send_resp.raise_for_status() # expect 202 Accepted
184
+
185
+ # Attempt to fetch the just-sent message from Sent Items regardless of scopes.
186
+ # If the token lacks read scopes, a 401/403 is expected; log and continue.
187
+ try:
188
+ async with httpx.AsyncClient(timeout=30) as client:
189
+ since = (datetime.now(timezone.utc) - timedelta(minutes=15)).isoformat()
190
+ # Normalize to Z suffix
191
+ since = since.replace("+00:00", "Z")
192
+ params = {
193
+ "$select": "id,subject,toRecipients,ccRecipients,sentDateTime,createdDateTime",
194
+ "$orderby": "sentDateTime desc",
195
+ "$top": "25",
196
+ "$filter": f"sentDateTime ge {since}",
197
+ }
198
+ list_url = f"{base_url}{base_res}/mailFolders/SentItems/messages"
199
+ resp = await client.get(list_url, headers=headers, params=params)
200
+ resp.raise_for_status()
201
+ data = resp.json() or {}
202
+ target_subject = (send_email_context.subject or "").strip()
203
+ target_to = (send_email_context.recipient or "").strip().lower()
204
+ for m in data.get("value", []):
205
+ subj = (m.get("subject") or "").strip()
206
+ if subj != target_subject:
207
+ continue
208
+ # Gather recipient addresses
209
+ recips: List[str] = []
210
+ for r in m.get("toRecipients", []) or []:
211
+ addr = ((r.get("emailAddress") or {}).get("address") or "").strip().lower()
212
+ if addr:
213
+ recips.append(addr)
214
+ if target_to and target_to in recips:
215
+ msg_id = m.get("id")
216
+ if msg_id:
217
+ return msg_id
218
+ except httpx.HTTPStatusError as exc:
219
+ status = getattr(getattr(exc, "response", None), "status_code", None)
220
+ if status in (401, 403):
221
+ logging.warning(
222
+ "Microsoft Graph: insufficient read scope (status %s) while fetching sent message id; skipping.",
223
+ status,
224
+ )
225
+ else:
226
+ logging.exception(
227
+ "Microsoft Graph: unable to retrieve sent message id; continuing without it"
228
+ )
229
+ except Exception:
230
+ logging.exception(
231
+ "Microsoft Graph: unable to retrieve sent message id; continuing without it"
232
+ )
233
+
234
+ # Fall back: we cannot reliably return the Graph message ID without a lookup
235
+ # or if we didn't find it. Return a best‑effort opaque token for correlation.
236
+ return f"sent:{send_email_context.sender_email}:{send_email_context.recipient}:{send_email_context.subject}"
237
+
238
+
239
+ def _join_people(emails: List[Dict[str, Any]]) -> tuple[str, str]:
240
+ names: List[str] = []
241
+ addrs: List[str] = []
242
+ for entry in emails or []:
243
+ addr = entry.get("emailAddress", {})
244
+ names.append(addr.get("name") or "")
245
+ addrs.append(addr.get("address") or "")
246
+ return ", ".join([n for n in names if n]), ", ".join([a for a in addrs if a])
247
+
248
+
249
+ async def list_emails_in_time_range_m365_async(
250
+ context: QueryEmailContext,
251
+ tool_config: Optional[List[Dict]] = None,
252
+ auth_mode: Optional[str] = None,
253
+ ) -> List[MessageItem]:
254
+ """
255
+ List messages in a time range using Microsoft Graph.
256
+
257
+ Interprets labels as Outlook categories when provided.
258
+ """
259
+ if context.labels is None:
260
+ context.labels = []
261
+
262
+ token = get_microsoft365_access_token(tool_config)
263
+ base_url = "https://graph.microsoft.com/v1.0"
264
+ base_res = _base_resource(context.sender_email, tool_config, auth_mode)
265
+ headers = {"Authorization": f"Bearer {token}"}
266
+
267
+ # Build $filter
268
+ filters: List[str] = [
269
+ f"receivedDateTime ge {context.start_time}",
270
+ f"receivedDateTime le {context.end_time}",
271
+ ]
272
+ if context.unread_only:
273
+ filters.append("isRead eq false")
274
+ if context.labels:
275
+ cats = [f"categories/any(c:c eq '{lbl}')" for lbl in context.labels]
276
+ filters.append("( " + " or ".join(cats) + " )")
277
+ filter_q = " and ".join(filters)
278
+
279
+ # Select minimal fields and sort newest first
280
+ select = (
281
+ "id,conversationId,subject,from,toRecipients,ccRecipients,receivedDateTime,"
282
+ "bodyPreview,internetMessageId,categories"
283
+ )
284
+ top = 50
285
+ url = (
286
+ f"{base_url}{base_res}/messages"
287
+ f"?$select={select}&$orderby=receivedDateTime desc&$top={top}&$filter={httpx.QueryParams({'f': filter_q})['f']}"
288
+ )
289
+
290
+ items: List[MessageItem] = []
291
+ async with httpx.AsyncClient(timeout=30) as client:
292
+ next_url = url
293
+ fetched = 0
294
+ max_fetch = 200
295
+ while next_url and fetched < max_fetch:
296
+ resp = await client.get(next_url, headers=headers)
297
+ resp.raise_for_status()
298
+ data = resp.json()
299
+ for m in data.get("value", []):
300
+ s_name = (m.get("from", {}).get("emailAddress", {}) or {}).get("name") or ""
301
+ s_email = (m.get("from", {}).get("emailAddress", {}) or {}).get("address") or ""
302
+ to_names, to_emails = _join_people(m.get("toRecipients", []))
303
+ cc_names, cc_emails = _join_people(m.get("ccRecipients", []))
304
+ receiver_name = ", ".join([v for v in [to_names, cc_names] if v])
305
+ receiver_email = ", ".join([v for v in [to_emails, cc_emails] if v])
306
+
307
+ items.append(
308
+ MessageItem(
309
+ message_id=m.get("id", ""),
310
+ thread_id=m.get("conversationId", ""),
311
+ sender_name=s_name,
312
+ sender_email=s_email,
313
+ receiver_name=receiver_name,
314
+ receiver_email=receiver_email,
315
+ iso_datetime=m.get("receivedDateTime", ""),
316
+ subject=m.get("subject", ""),
317
+ body=m.get("bodyPreview", ""),
318
+ )
319
+ )
320
+ fetched += 1
321
+ next_url = data.get("@odata.nextLink")
322
+ if next_url and fetched >= max_fetch:
323
+ break
324
+
325
+ return items
326
+
327
+
328
+ async def reply_to_email_m365_async(
329
+ reply_email_context: ReplyEmailContext,
330
+ tool_config: Optional[List[Dict]] = None,
331
+ auth_mode: Optional[str] = None,
332
+ ) -> Dict[str, Any]:
333
+ """
334
+ Reply-all to a message using Microsoft Graph. Returns basic metadata similar to GW helper.
335
+ """
336
+ if reply_email_context.add_labels is None:
337
+ reply_email_context.add_labels = []
338
+
339
+ token = get_microsoft365_access_token(tool_config)
340
+ base_url = "https://graph.microsoft.com/v1.0"
341
+ base_res = _base_resource(reply_email_context.sender_email, tool_config, auth_mode)
342
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
343
+
344
+ # 1) Fetch original message for context (subject, recipients, thread)
345
+ async with httpx.AsyncClient(timeout=30) as client:
346
+ get_url = f"{base_url}{base_res}/messages/{reply_email_context.message_id}"
347
+ get_resp = await client.get(get_url, headers=headers)
348
+ get_resp.raise_for_status()
349
+ orig = get_resp.json()
350
+
351
+ orig_subject = orig.get("subject", "")
352
+ subject = orig_subject if orig_subject.startswith("Re:") else f"Re: {orig_subject}"
353
+ thread_id = orig.get("conversationId", "")
354
+ cc_list = orig.get("ccRecipients", [])
355
+ to_list = orig.get("toRecipients", [])
356
+ sender_email_lc = (reply_email_context.sender_email or "").lower()
357
+
358
+ def _is_self(addr: str) -> bool:
359
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
360
+
361
+ def _addresses(recipients: List[Dict[str, Any]]) -> List[str]:
362
+ return [
363
+ (recipient.get("emailAddress", {}) or {}).get("address", "")
364
+ for recipient in recipients
365
+ if recipient
366
+ ]
367
+
368
+ to_addresses = ", ".join(
369
+ [addr for addr in _addresses(to_list) if addr and not _is_self(addr)]
370
+ )
371
+ cc_addresses = ", ".join(
372
+ [addr for addr in _addresses(cc_list) if addr and not _is_self(addr)]
373
+ )
374
+
375
+ all_recipients = [addr for addr in _addresses(to_list + cc_list) if addr]
376
+ if not any(all_recipients):
377
+ from_addr = orig.get("from", {}).get("emailAddress", {})
378
+ from_address = from_addr.get("address", "")
379
+ if from_address:
380
+ all_recipients.append(from_address)
381
+
382
+ non_self_recipients = [addr for addr in all_recipients if not _is_self(addr)]
383
+ if not non_self_recipients and reply_email_context.fallback_recipient:
384
+ fr = reply_email_context.fallback_recipient
385
+ if fr and not _is_self(fr):
386
+ non_self_recipients.append(fr)
387
+
388
+ if not to_addresses and non_self_recipients:
389
+ to_addresses = ", ".join(non_self_recipients)
390
+ cc_addresses = ""
391
+
392
+ if not non_self_recipients:
393
+ raise httpx.HTTPStatusError(
394
+ "No valid recipient found in the original message; refusing to reply to sender.",
395
+ request=get_resp.request,
396
+ response=get_resp,
397
+ )
398
+
399
+ # 2) Create reply-all draft with comment
400
+ create_reply_url = (
401
+ f"{base_url}{base_res}/messages/{reply_email_context.message_id}/createReplyAll"
402
+ )
403
+ create_payload = {"comment": reply_email_context.reply_body}
404
+ create_resp = await client.post(create_reply_url, headers=headers, json=create_payload)
405
+ create_resp.raise_for_status()
406
+ reply_msg = create_resp.json()
407
+ reply_id = reply_msg.get("id")
408
+
409
+ # 3) Optionally add categories (labels) to the reply draft
410
+ if reply_email_context.add_labels:
411
+ patch_url = f"{base_url}{base_res}/messages/{reply_id}"
412
+ categories = list(set((reply_msg.get("categories") or []) + reply_email_context.add_labels))
413
+ await client.patch(patch_url, headers=headers, json={"categories": categories})
414
+
415
+ # 4) Send the reply
416
+ send_url = f"{base_url}{base_res}/messages/{reply_id}/send"
417
+ send_resp = await client.post(send_url, headers=headers)
418
+ send_resp.raise_for_status()
419
+
420
+ # 5) Optionally mark original as read
421
+ if str(reply_email_context.mark_as_read).lower() == "true":
422
+ mark_url = f"{base_url}{base_res}/messages/{reply_email_context.message_id}"
423
+ await client.patch(mark_url, headers=headers, json={"isRead": True})
424
+
425
+ # Attempt to fetch the sent message to get final details (best effort)
426
+ email_labels: List[str] = reply_email_context.add_labels or []
427
+ try:
428
+ async with httpx.AsyncClient(timeout=30) as client:
429
+ fetch_url = f"{base_url}{base_res}/messages/{reply_id}?$select=id,categories"
430
+ fetch_resp = await client.get(fetch_url, headers=headers)
431
+ if fetch_resp.status_code == 200:
432
+ sent_obj = fetch_resp.json()
433
+ email_labels = sent_obj.get("categories", email_labels)
434
+ except Exception:
435
+ pass
436
+
437
+ sent_message_details = {
438
+ "mailbox_email_id": reply_id,
439
+ "message_id": thread_id,
440
+ "email_subject": subject,
441
+ "email_sender": reply_email_context.sender_email,
442
+ "email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
443
+ "read_email_status": "READ" if str(reply_email_context.mark_as_read).lower() == "true" else "UNREAD",
444
+ "email_labels": email_labels,
445
+ }
446
+
447
+ return sent_message_details