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.
Files changed (102) hide show
  1. dhisana/__init__.py +1 -0
  2. dhisana/cli/__init__.py +1 -0
  3. dhisana/cli/cli.py +20 -0
  4. dhisana/cli/datasets.py +27 -0
  5. dhisana/cli/models.py +26 -0
  6. dhisana/cli/predictions.py +20 -0
  7. dhisana/schemas/__init__.py +1 -0
  8. dhisana/schemas/common.py +399 -0
  9. dhisana/schemas/sales.py +965 -0
  10. dhisana/ui/__init__.py +1 -0
  11. dhisana/ui/components.py +472 -0
  12. dhisana/utils/__init__.py +1 -0
  13. dhisana/utils/add_mapping.py +352 -0
  14. dhisana/utils/agent_tools.py +51 -0
  15. dhisana/utils/apollo_tools.py +1597 -0
  16. dhisana/utils/assistant_tool_tag.py +4 -0
  17. dhisana/utils/built_with_api_tools.py +282 -0
  18. dhisana/utils/cache_output_tools.py +98 -0
  19. dhisana/utils/cache_output_tools_local.py +78 -0
  20. dhisana/utils/check_email_validity_tools.py +717 -0
  21. dhisana/utils/check_for_intent_signal.py +107 -0
  22. dhisana/utils/check_linkedin_url_validity.py +209 -0
  23. dhisana/utils/clay_tools.py +43 -0
  24. dhisana/utils/clean_properties.py +135 -0
  25. dhisana/utils/company_utils.py +60 -0
  26. dhisana/utils/compose_salesnav_query.py +259 -0
  27. dhisana/utils/compose_search_query.py +759 -0
  28. dhisana/utils/compose_three_step_workflow.py +234 -0
  29. dhisana/utils/composite_tools.py +137 -0
  30. dhisana/utils/dataframe_tools.py +237 -0
  31. dhisana/utils/domain_parser.py +45 -0
  32. dhisana/utils/email_body_utils.py +72 -0
  33. dhisana/utils/email_parse_helpers.py +132 -0
  34. dhisana/utils/email_provider.py +375 -0
  35. dhisana/utils/enrich_lead_information.py +933 -0
  36. dhisana/utils/extract_email_content_for_llm.py +101 -0
  37. dhisana/utils/fetch_openai_config.py +129 -0
  38. dhisana/utils/field_validators.py +426 -0
  39. dhisana/utils/g2_tools.py +104 -0
  40. dhisana/utils/generate_content.py +41 -0
  41. dhisana/utils/generate_custom_message.py +271 -0
  42. dhisana/utils/generate_email.py +278 -0
  43. dhisana/utils/generate_email_response.py +465 -0
  44. dhisana/utils/generate_flow.py +102 -0
  45. dhisana/utils/generate_leads_salesnav.py +303 -0
  46. dhisana/utils/generate_linkedin_connect_message.py +224 -0
  47. dhisana/utils/generate_linkedin_response_message.py +317 -0
  48. dhisana/utils/generate_structured_output_internal.py +462 -0
  49. dhisana/utils/google_custom_search.py +267 -0
  50. dhisana/utils/google_oauth_tools.py +727 -0
  51. dhisana/utils/google_workspace_tools.py +1294 -0
  52. dhisana/utils/hubspot_clearbit.py +96 -0
  53. dhisana/utils/hubspot_crm_tools.py +2440 -0
  54. dhisana/utils/instantly_tools.py +149 -0
  55. dhisana/utils/linkedin_crawler.py +168 -0
  56. dhisana/utils/lusha_tools.py +333 -0
  57. dhisana/utils/mailgun_tools.py +156 -0
  58. dhisana/utils/mailreach_tools.py +123 -0
  59. dhisana/utils/microsoft365_tools.py +455 -0
  60. dhisana/utils/openai_assistant_and_file_utils.py +267 -0
  61. dhisana/utils/openai_helpers.py +977 -0
  62. dhisana/utils/openapi_spec_to_tools.py +45 -0
  63. dhisana/utils/openapi_tool/__init__.py +1 -0
  64. dhisana/utils/openapi_tool/api_models.py +633 -0
  65. dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
  66. dhisana/utils/openapi_tool/openapi_tool.py +319 -0
  67. dhisana/utils/parse_linkedin_messages_txt.py +100 -0
  68. dhisana/utils/profile.py +37 -0
  69. dhisana/utils/proxy_curl_tools.py +1226 -0
  70. dhisana/utils/proxycurl_search_leads.py +426 -0
  71. dhisana/utils/python_function_to_tools.py +83 -0
  72. dhisana/utils/research_lead.py +176 -0
  73. dhisana/utils/sales_navigator_crawler.py +1103 -0
  74. dhisana/utils/salesforce_crm_tools.py +477 -0
  75. dhisana/utils/search_router.py +131 -0
  76. dhisana/utils/search_router_jobs.py +51 -0
  77. dhisana/utils/sendgrid_tools.py +162 -0
  78. dhisana/utils/serarch_router_local_business.py +75 -0
  79. dhisana/utils/serpapi_additional_tools.py +290 -0
  80. dhisana/utils/serpapi_google_jobs.py +117 -0
  81. dhisana/utils/serpapi_google_search.py +188 -0
  82. dhisana/utils/serpapi_local_business_search.py +129 -0
  83. dhisana/utils/serpapi_search_tools.py +852 -0
  84. dhisana/utils/serperdev_google_jobs.py +125 -0
  85. dhisana/utils/serperdev_local_business.py +154 -0
  86. dhisana/utils/serperdev_search.py +233 -0
  87. dhisana/utils/smtp_email_tools.py +582 -0
  88. dhisana/utils/test_connect.py +2087 -0
  89. dhisana/utils/trasform_json.py +173 -0
  90. dhisana/utils/web_download_parse_tools.py +189 -0
  91. dhisana/utils/workflow_code_model.py +5 -0
  92. dhisana/utils/zoominfo_tools.py +357 -0
  93. dhisana/workflow/__init__.py +1 -0
  94. dhisana/workflow/agent.py +18 -0
  95. dhisana/workflow/flow.py +44 -0
  96. dhisana/workflow/task.py +43 -0
  97. dhisana/workflow/test.py +90 -0
  98. dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
  99. dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
  100. dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
  101. dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
  102. 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