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.
- dhisana/schemas/common.py +10 -1
- dhisana/schemas/sales.py +203 -22
- dhisana/utils/add_mapping.py +0 -2
- dhisana/utils/apollo_tools.py +739 -119
- dhisana/utils/built_with_api_tools.py +4 -2
- dhisana/utils/check_email_validity_tools.py +35 -18
- dhisana/utils/check_for_intent_signal.py +1 -2
- dhisana/utils/check_linkedin_url_validity.py +34 -8
- dhisana/utils/clay_tools.py +3 -2
- dhisana/utils/clean_properties.py +1 -4
- dhisana/utils/compose_salesnav_query.py +0 -1
- dhisana/utils/compose_search_query.py +7 -3
- dhisana/utils/composite_tools.py +0 -1
- dhisana/utils/dataframe_tools.py +2 -2
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_provider.py +174 -35
- dhisana/utils/enrich_lead_information.py +183 -53
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +1 -1
- dhisana/utils/g2_tools.py +0 -1
- dhisana/utils/generate_content.py +0 -1
- dhisana/utils/generate_email.py +68 -23
- dhisana/utils/generate_email_response.py +294 -46
- dhisana/utils/generate_flow.py +0 -1
- dhisana/utils/generate_linkedin_connect_message.py +9 -2
- dhisana/utils/generate_linkedin_response_message.py +137 -66
- dhisana/utils/generate_structured_output_internal.py +317 -164
- dhisana/utils/google_custom_search.py +150 -44
- dhisana/utils/google_oauth_tools.py +721 -0
- dhisana/utils/google_workspace_tools.py +278 -54
- dhisana/utils/hubspot_clearbit.py +3 -1
- dhisana/utils/hubspot_crm_tools.py +718 -272
- dhisana/utils/instantly_tools.py +3 -1
- dhisana/utils/lusha_tools.py +10 -7
- dhisana/utils/mailgun_tools.py +150 -0
- dhisana/utils/microsoft365_tools.py +447 -0
- dhisana/utils/openai_assistant_and_file_utils.py +121 -177
- dhisana/utils/openai_helpers.py +8 -6
- dhisana/utils/parse_linkedin_messages_txt.py +1 -3
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +377 -76
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/research_lead.py +3 -3
- dhisana/utils/sales_navigator_crawler.py +1 -6
- dhisana/utils/salesforce_crm_tools.py +323 -50
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +126 -91
- 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 +360 -432
- 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 +178 -18
- dhisana/utils/test_connect.py +1603 -130
- dhisana/utils/trasform_json.py +3 -3
- dhisana/utils/web_download_parse_tools.py +0 -1
- dhisana/utils/zoominfo_tools.py +2 -3
- dhisana/workflow/test.py +1 -1
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +1 -1
- dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
- dhisana-0.0.1.dev116.dist-info/RECORD +0 -83
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
dhisana/utils/instantly_tools.py
CHANGED
|
@@ -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(
|
|
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"
|
dhisana/utils/lusha_tools.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|