dhisana 0.0.1.dev243__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.
- dhisana/__init__.py +1 -0
- dhisana/cli/__init__.py +1 -0
- dhisana/cli/cli.py +20 -0
- dhisana/cli/datasets.py +27 -0
- dhisana/cli/models.py +26 -0
- dhisana/cli/predictions.py +20 -0
- dhisana/schemas/__init__.py +1 -0
- dhisana/schemas/common.py +399 -0
- dhisana/schemas/sales.py +965 -0
- dhisana/ui/__init__.py +1 -0
- dhisana/ui/components.py +472 -0
- dhisana/utils/__init__.py +1 -0
- dhisana/utils/add_mapping.py +352 -0
- dhisana/utils/agent_tools.py +51 -0
- dhisana/utils/apollo_tools.py +1597 -0
- dhisana/utils/assistant_tool_tag.py +4 -0
- dhisana/utils/built_with_api_tools.py +282 -0
- dhisana/utils/cache_output_tools.py +98 -0
- dhisana/utils/cache_output_tools_local.py +78 -0
- dhisana/utils/check_email_validity_tools.py +717 -0
- dhisana/utils/check_for_intent_signal.py +107 -0
- dhisana/utils/check_linkedin_url_validity.py +209 -0
- dhisana/utils/clay_tools.py +43 -0
- dhisana/utils/clean_properties.py +135 -0
- dhisana/utils/company_utils.py +60 -0
- dhisana/utils/compose_salesnav_query.py +259 -0
- dhisana/utils/compose_search_query.py +759 -0
- dhisana/utils/compose_three_step_workflow.py +234 -0
- dhisana/utils/composite_tools.py +137 -0
- dhisana/utils/dataframe_tools.py +237 -0
- dhisana/utils/domain_parser.py +45 -0
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_parse_helpers.py +132 -0
- dhisana/utils/email_provider.py +375 -0
- dhisana/utils/enrich_lead_information.py +933 -0
- dhisana/utils/extract_email_content_for_llm.py +101 -0
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +426 -0
- dhisana/utils/g2_tools.py +104 -0
- dhisana/utils/generate_content.py +41 -0
- dhisana/utils/generate_custom_message.py +271 -0
- dhisana/utils/generate_email.py +278 -0
- dhisana/utils/generate_email_response.py +465 -0
- dhisana/utils/generate_flow.py +102 -0
- dhisana/utils/generate_leads_salesnav.py +303 -0
- dhisana/utils/generate_linkedin_connect_message.py +224 -0
- dhisana/utils/generate_linkedin_response_message.py +317 -0
- dhisana/utils/generate_structured_output_internal.py +462 -0
- dhisana/utils/google_custom_search.py +267 -0
- dhisana/utils/google_oauth_tools.py +727 -0
- dhisana/utils/google_workspace_tools.py +1294 -0
- dhisana/utils/hubspot_clearbit.py +96 -0
- dhisana/utils/hubspot_crm_tools.py +2440 -0
- dhisana/utils/instantly_tools.py +149 -0
- dhisana/utils/linkedin_crawler.py +168 -0
- dhisana/utils/lusha_tools.py +333 -0
- dhisana/utils/mailgun_tools.py +156 -0
- dhisana/utils/mailreach_tools.py +123 -0
- dhisana/utils/microsoft365_tools.py +455 -0
- dhisana/utils/openai_assistant_and_file_utils.py +267 -0
- dhisana/utils/openai_helpers.py +977 -0
- dhisana/utils/openapi_spec_to_tools.py +45 -0
- dhisana/utils/openapi_tool/__init__.py +1 -0
- dhisana/utils/openapi_tool/api_models.py +633 -0
- dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
- dhisana/utils/openapi_tool/openapi_tool.py +319 -0
- dhisana/utils/parse_linkedin_messages_txt.py +100 -0
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +1226 -0
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/python_function_to_tools.py +83 -0
- dhisana/utils/research_lead.py +176 -0
- dhisana/utils/sales_navigator_crawler.py +1103 -0
- dhisana/utils/salesforce_crm_tools.py +477 -0
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +162 -0
- dhisana/utils/serarch_router_local_business.py +75 -0
- dhisana/utils/serpapi_additional_tools.py +290 -0
- dhisana/utils/serpapi_google_jobs.py +117 -0
- dhisana/utils/serpapi_google_search.py +188 -0
- dhisana/utils/serpapi_local_business_search.py +129 -0
- dhisana/utils/serpapi_search_tools.py +852 -0
- dhisana/utils/serperdev_google_jobs.py +125 -0
- dhisana/utils/serperdev_local_business.py +154 -0
- dhisana/utils/serperdev_search.py +233 -0
- dhisana/utils/smtp_email_tools.py +582 -0
- dhisana/utils/test_connect.py +2087 -0
- dhisana/utils/trasform_json.py +173 -0
- dhisana/utils/web_download_parse_tools.py +189 -0
- dhisana/utils/workflow_code_model.py +5 -0
- dhisana/utils/zoominfo_tools.py +357 -0
- dhisana/workflow/__init__.py +1 -0
- dhisana/workflow/agent.py +18 -0
- dhisana/workflow/flow.py +44 -0
- dhisana/workflow/task.py +43 -0
- dhisana/workflow/test.py +90 -0
- dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
- dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
- dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
- dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
- dhisana-0.0.1.dev243.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,455 @@
|
|
|
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
|
+
extra_headers = getattr(send_email_context, "headers", None) or {}
|
|
172
|
+
if extra_headers:
|
|
173
|
+
message_payload["internetMessageHeaders"] = [
|
|
174
|
+
{"name": header, "value": str(value)}
|
|
175
|
+
for header, value in extra_headers.items()
|
|
176
|
+
if header and value is not None
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
headers = {
|
|
180
|
+
"Authorization": f"Bearer {token}",
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
185
|
+
send_url = f"{base_url}{base_res}/sendMail"
|
|
186
|
+
send_body = {
|
|
187
|
+
"message": message_payload,
|
|
188
|
+
"saveToSentItems": True,
|
|
189
|
+
}
|
|
190
|
+
send_resp = await client.post(send_url, headers=headers, json=send_body)
|
|
191
|
+
send_resp.raise_for_status() # expect 202 Accepted
|
|
192
|
+
|
|
193
|
+
# Attempt to fetch the just-sent message from Sent Items regardless of scopes.
|
|
194
|
+
# If the token lacks read scopes, a 401/403 is expected; log and continue.
|
|
195
|
+
try:
|
|
196
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
197
|
+
since = (datetime.now(timezone.utc) - timedelta(minutes=15)).isoformat()
|
|
198
|
+
# Normalize to Z suffix
|
|
199
|
+
since = since.replace("+00:00", "Z")
|
|
200
|
+
params = {
|
|
201
|
+
"$select": "id,subject,toRecipients,ccRecipients,sentDateTime,createdDateTime",
|
|
202
|
+
"$orderby": "sentDateTime desc",
|
|
203
|
+
"$top": "25",
|
|
204
|
+
"$filter": f"sentDateTime ge {since}",
|
|
205
|
+
}
|
|
206
|
+
list_url = f"{base_url}{base_res}/mailFolders/SentItems/messages"
|
|
207
|
+
resp = await client.get(list_url, headers=headers, params=params)
|
|
208
|
+
resp.raise_for_status()
|
|
209
|
+
data = resp.json() or {}
|
|
210
|
+
target_subject = (send_email_context.subject or "").strip()
|
|
211
|
+
target_to = (send_email_context.recipient or "").strip().lower()
|
|
212
|
+
for m in data.get("value", []):
|
|
213
|
+
subj = (m.get("subject") or "").strip()
|
|
214
|
+
if subj != target_subject:
|
|
215
|
+
continue
|
|
216
|
+
# Gather recipient addresses
|
|
217
|
+
recips: List[str] = []
|
|
218
|
+
for r in m.get("toRecipients", []) or []:
|
|
219
|
+
addr = ((r.get("emailAddress") or {}).get("address") or "").strip().lower()
|
|
220
|
+
if addr:
|
|
221
|
+
recips.append(addr)
|
|
222
|
+
if target_to and target_to in recips:
|
|
223
|
+
msg_id = m.get("id")
|
|
224
|
+
if msg_id:
|
|
225
|
+
return msg_id
|
|
226
|
+
except httpx.HTTPStatusError as exc:
|
|
227
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
228
|
+
if status in (401, 403):
|
|
229
|
+
logging.warning(
|
|
230
|
+
"Microsoft Graph: insufficient read scope (status %s) while fetching sent message id; skipping.",
|
|
231
|
+
status,
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
logging.exception(
|
|
235
|
+
"Microsoft Graph: unable to retrieve sent message id; continuing without it"
|
|
236
|
+
)
|
|
237
|
+
except Exception:
|
|
238
|
+
logging.exception(
|
|
239
|
+
"Microsoft Graph: unable to retrieve sent message id; continuing without it"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Fall back: we cannot reliably return the Graph message ID without a lookup
|
|
243
|
+
# or if we didn't find it. Return a best‑effort opaque token for correlation.
|
|
244
|
+
return f"sent:{send_email_context.sender_email}:{send_email_context.recipient}:{send_email_context.subject}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _join_people(emails: List[Dict[str, Any]]) -> tuple[str, str]:
|
|
248
|
+
names: List[str] = []
|
|
249
|
+
addrs: List[str] = []
|
|
250
|
+
for entry in emails or []:
|
|
251
|
+
addr = entry.get("emailAddress", {})
|
|
252
|
+
names.append(addr.get("name") or "")
|
|
253
|
+
addrs.append(addr.get("address") or "")
|
|
254
|
+
return ", ".join([n for n in names if n]), ", ".join([a for a in addrs if a])
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def list_emails_in_time_range_m365_async(
|
|
258
|
+
context: QueryEmailContext,
|
|
259
|
+
tool_config: Optional[List[Dict]] = None,
|
|
260
|
+
auth_mode: Optional[str] = None,
|
|
261
|
+
) -> List[MessageItem]:
|
|
262
|
+
"""
|
|
263
|
+
List messages in a time range using Microsoft Graph.
|
|
264
|
+
|
|
265
|
+
Interprets labels as Outlook categories when provided.
|
|
266
|
+
"""
|
|
267
|
+
if context.labels is None:
|
|
268
|
+
context.labels = []
|
|
269
|
+
|
|
270
|
+
token = get_microsoft365_access_token(tool_config)
|
|
271
|
+
base_url = "https://graph.microsoft.com/v1.0"
|
|
272
|
+
base_res = _base_resource(context.sender_email, tool_config, auth_mode)
|
|
273
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
274
|
+
|
|
275
|
+
# Build $filter
|
|
276
|
+
filters: List[str] = [
|
|
277
|
+
f"receivedDateTime ge {context.start_time}",
|
|
278
|
+
f"receivedDateTime le {context.end_time}",
|
|
279
|
+
]
|
|
280
|
+
if context.unread_only:
|
|
281
|
+
filters.append("isRead eq false")
|
|
282
|
+
if context.labels:
|
|
283
|
+
cats = [f"categories/any(c:c eq '{lbl}')" for lbl in context.labels]
|
|
284
|
+
filters.append("( " + " or ".join(cats) + " )")
|
|
285
|
+
filter_q = " and ".join(filters)
|
|
286
|
+
|
|
287
|
+
# Select minimal fields and sort newest first
|
|
288
|
+
select = (
|
|
289
|
+
"id,conversationId,subject,from,toRecipients,ccRecipients,receivedDateTime,"
|
|
290
|
+
"bodyPreview,internetMessageId,categories"
|
|
291
|
+
)
|
|
292
|
+
top = 50
|
|
293
|
+
url = (
|
|
294
|
+
f"{base_url}{base_res}/messages"
|
|
295
|
+
f"?$select={select}&$orderby=receivedDateTime desc&$top={top}&$filter={httpx.QueryParams({'f': filter_q})['f']}"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
items: List[MessageItem] = []
|
|
299
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
300
|
+
next_url = url
|
|
301
|
+
fetched = 0
|
|
302
|
+
max_fetch = 200
|
|
303
|
+
while next_url and fetched < max_fetch:
|
|
304
|
+
resp = await client.get(next_url, headers=headers)
|
|
305
|
+
resp.raise_for_status()
|
|
306
|
+
data = resp.json()
|
|
307
|
+
for m in data.get("value", []):
|
|
308
|
+
s_name = (m.get("from", {}).get("emailAddress", {}) or {}).get("name") or ""
|
|
309
|
+
s_email = (m.get("from", {}).get("emailAddress", {}) or {}).get("address") or ""
|
|
310
|
+
to_names, to_emails = _join_people(m.get("toRecipients", []))
|
|
311
|
+
cc_names, cc_emails = _join_people(m.get("ccRecipients", []))
|
|
312
|
+
receiver_name = ", ".join([v for v in [to_names, cc_names] if v])
|
|
313
|
+
receiver_email = ", ".join([v for v in [to_emails, cc_emails] if v])
|
|
314
|
+
|
|
315
|
+
items.append(
|
|
316
|
+
MessageItem(
|
|
317
|
+
message_id=m.get("id", ""),
|
|
318
|
+
thread_id=m.get("conversationId", ""),
|
|
319
|
+
sender_name=s_name,
|
|
320
|
+
sender_email=s_email,
|
|
321
|
+
receiver_name=receiver_name,
|
|
322
|
+
receiver_email=receiver_email,
|
|
323
|
+
iso_datetime=m.get("receivedDateTime", ""),
|
|
324
|
+
subject=m.get("subject", ""),
|
|
325
|
+
body=m.get("bodyPreview", ""),
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
fetched += 1
|
|
329
|
+
next_url = data.get("@odata.nextLink")
|
|
330
|
+
if next_url and fetched >= max_fetch:
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
return items
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def reply_to_email_m365_async(
|
|
337
|
+
reply_email_context: ReplyEmailContext,
|
|
338
|
+
tool_config: Optional[List[Dict]] = None,
|
|
339
|
+
auth_mode: Optional[str] = None,
|
|
340
|
+
) -> Dict[str, Any]:
|
|
341
|
+
"""
|
|
342
|
+
Reply-all to a message using Microsoft Graph. Returns basic metadata similar to GW helper.
|
|
343
|
+
"""
|
|
344
|
+
if reply_email_context.add_labels is None:
|
|
345
|
+
reply_email_context.add_labels = []
|
|
346
|
+
|
|
347
|
+
token = get_microsoft365_access_token(tool_config)
|
|
348
|
+
base_url = "https://graph.microsoft.com/v1.0"
|
|
349
|
+
base_res = _base_resource(reply_email_context.sender_email, tool_config, auth_mode)
|
|
350
|
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
351
|
+
|
|
352
|
+
# 1) Fetch original message for context (subject, recipients, thread)
|
|
353
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
354
|
+
get_url = f"{base_url}{base_res}/messages/{reply_email_context.message_id}"
|
|
355
|
+
get_resp = await client.get(get_url, headers=headers)
|
|
356
|
+
get_resp.raise_for_status()
|
|
357
|
+
orig = get_resp.json()
|
|
358
|
+
|
|
359
|
+
orig_subject = orig.get("subject", "")
|
|
360
|
+
subject = orig_subject if orig_subject.startswith("Re:") else f"Re: {orig_subject}"
|
|
361
|
+
thread_id = orig.get("conversationId", "")
|
|
362
|
+
cc_list = orig.get("ccRecipients", [])
|
|
363
|
+
to_list = orig.get("toRecipients", [])
|
|
364
|
+
sender_email_lc = (reply_email_context.sender_email or "").lower()
|
|
365
|
+
|
|
366
|
+
def _is_self(addr: str) -> bool:
|
|
367
|
+
return bool(sender_email_lc) and sender_email_lc in addr.lower()
|
|
368
|
+
|
|
369
|
+
def _addresses(recipients: List[Dict[str, Any]]) -> List[str]:
|
|
370
|
+
return [
|
|
371
|
+
(recipient.get("emailAddress", {}) or {}).get("address", "")
|
|
372
|
+
for recipient in recipients
|
|
373
|
+
if recipient
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
to_addresses = ", ".join(
|
|
377
|
+
[addr for addr in _addresses(to_list) if addr and not _is_self(addr)]
|
|
378
|
+
)
|
|
379
|
+
cc_addresses = ", ".join(
|
|
380
|
+
[addr for addr in _addresses(cc_list) if addr and not _is_self(addr)]
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
all_recipients = [addr for addr in _addresses(to_list + cc_list) if addr]
|
|
384
|
+
if not any(all_recipients):
|
|
385
|
+
from_addr = orig.get("from", {}).get("emailAddress", {})
|
|
386
|
+
from_address = from_addr.get("address", "")
|
|
387
|
+
if from_address:
|
|
388
|
+
all_recipients.append(from_address)
|
|
389
|
+
|
|
390
|
+
non_self_recipients = [addr for addr in all_recipients if not _is_self(addr)]
|
|
391
|
+
if not non_self_recipients and reply_email_context.fallback_recipient:
|
|
392
|
+
fr = reply_email_context.fallback_recipient
|
|
393
|
+
if fr and not _is_self(fr):
|
|
394
|
+
non_self_recipients.append(fr)
|
|
395
|
+
|
|
396
|
+
if not to_addresses and non_self_recipients:
|
|
397
|
+
to_addresses = ", ".join(non_self_recipients)
|
|
398
|
+
cc_addresses = ""
|
|
399
|
+
|
|
400
|
+
if not non_self_recipients:
|
|
401
|
+
raise httpx.HTTPStatusError(
|
|
402
|
+
"No valid recipient found in the original message; refusing to reply to sender.",
|
|
403
|
+
request=get_resp.request,
|
|
404
|
+
response=get_resp,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# 2) Create reply-all draft with comment
|
|
408
|
+
create_reply_url = (
|
|
409
|
+
f"{base_url}{base_res}/messages/{reply_email_context.message_id}/createReplyAll"
|
|
410
|
+
)
|
|
411
|
+
create_payload = {"comment": reply_email_context.reply_body}
|
|
412
|
+
create_resp = await client.post(create_reply_url, headers=headers, json=create_payload)
|
|
413
|
+
create_resp.raise_for_status()
|
|
414
|
+
reply_msg = create_resp.json()
|
|
415
|
+
reply_id = reply_msg.get("id")
|
|
416
|
+
|
|
417
|
+
# 3) Optionally add categories (labels) to the reply draft
|
|
418
|
+
if reply_email_context.add_labels:
|
|
419
|
+
patch_url = f"{base_url}{base_res}/messages/{reply_id}"
|
|
420
|
+
categories = list(set((reply_msg.get("categories") or []) + reply_email_context.add_labels))
|
|
421
|
+
await client.patch(patch_url, headers=headers, json={"categories": categories})
|
|
422
|
+
|
|
423
|
+
# 4) Send the reply
|
|
424
|
+
send_url = f"{base_url}{base_res}/messages/{reply_id}/send"
|
|
425
|
+
send_resp = await client.post(send_url, headers=headers)
|
|
426
|
+
send_resp.raise_for_status()
|
|
427
|
+
|
|
428
|
+
# 5) Optionally mark original as read
|
|
429
|
+
if str(reply_email_context.mark_as_read).lower() == "true":
|
|
430
|
+
mark_url = f"{base_url}{base_res}/messages/{reply_email_context.message_id}"
|
|
431
|
+
await client.patch(mark_url, headers=headers, json={"isRead": True})
|
|
432
|
+
|
|
433
|
+
# Attempt to fetch the sent message to get final details (best effort)
|
|
434
|
+
email_labels: List[str] = reply_email_context.add_labels or []
|
|
435
|
+
try:
|
|
436
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
437
|
+
fetch_url = f"{base_url}{base_res}/messages/{reply_id}?$select=id,categories"
|
|
438
|
+
fetch_resp = await client.get(fetch_url, headers=headers)
|
|
439
|
+
if fetch_resp.status_code == 200:
|
|
440
|
+
sent_obj = fetch_resp.json()
|
|
441
|
+
email_labels = sent_obj.get("categories", email_labels)
|
|
442
|
+
except Exception:
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
sent_message_details = {
|
|
446
|
+
"mailbox_email_id": reply_id,
|
|
447
|
+
"message_id": thread_id,
|
|
448
|
+
"email_subject": subject,
|
|
449
|
+
"email_sender": reply_email_context.sender_email,
|
|
450
|
+
"email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
|
|
451
|
+
"read_email_status": "READ" if str(reply_email_context.mark_as_read).lower() == "true" else "UNREAD",
|
|
452
|
+
"email_labels": email_labels,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return sent_message_details
|