dhisana 0.0.1.dev85__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 +33 -0
- dhisana/schemas/sales.py +224 -23
- dhisana/utils/add_mapping.py +72 -63
- dhisana/utils/apollo_tools.py +739 -109
- dhisana/utils/built_with_api_tools.py +4 -2
- dhisana/utils/cache_output_tools.py +23 -23
- dhisana/utils/check_email_validity_tools.py +456 -458
- 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 +3 -1
- 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 +375 -0
- dhisana/utils/enrich_lead_information.py +585 -85
- 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 +69 -16
- dhisana/utils/generate_email_response.py +298 -41
- dhisana/utils/generate_flow.py +0 -1
- dhisana/utils/generate_linkedin_connect_message.py +19 -6
- dhisana/utils/generate_linkedin_response_message.py +156 -65
- dhisana/utils/generate_structured_output_internal.py +351 -131
- dhisana/utils/google_custom_search.py +150 -44
- dhisana/utils/google_oauth_tools.py +721 -0
- dhisana/utils/google_workspace_tools.py +391 -25
- dhisana/utils/hubspot_clearbit.py +3 -1
- dhisana/utils/hubspot_crm_tools.py +771 -167
- 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 +19 -16
- dhisana/utils/parse_linkedin_messages_txt.py +2 -3
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +507 -206
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/research_lead.py +121 -68
- 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 +363 -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 +576 -0
- dhisana/utils/test_connect.py +1765 -92
- dhisana/utils/trasform_json.py +95 -16
- 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.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +5 -2
- dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
- {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
- dhisana-0.0.1.dev85.dist-info/RECORD +0 -81
- {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
- {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
|
@@ -5,7 +5,6 @@ from typing import Any, Dict, List, Optional, cast
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
6
|
from dhisana.utils.generate_structured_output_internal import get_structured_output_internal
|
|
7
7
|
from dhisana.utils.compose_search_query import (
|
|
8
|
-
generate_google_search_queries,
|
|
9
8
|
get_search_results_for_insights
|
|
10
9
|
)
|
|
11
10
|
|
|
@@ -49,7 +48,7 @@ async def check_for_intent_signal(
|
|
|
49
48
|
logger.info("Search query: %s", query_str)
|
|
50
49
|
logger.info("Search results snippet: %s", results_str[:100]) # Show partial snippet
|
|
51
50
|
search_results_text += f"Query: {query_str}\nResults: {results_str}\n\n"
|
|
52
|
-
|
|
51
|
+
datetime.datetime.now().isoformat()
|
|
53
52
|
user_prompt = f"""
|
|
54
53
|
Hi AI Assistant,
|
|
55
54
|
You are an expert in scoring leads based on intent signals.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import re
|
|
2
2
|
from typing import Dict, List, Optional, Any
|
|
3
|
-
import aiohttp
|
|
4
3
|
from pydantic import BaseModel
|
|
5
4
|
from dhisana.utils.apollo_tools import enrich_person_info_from_apollo
|
|
6
5
|
from dhisana.utils.assistant_tool_tag import assistant_tool
|
|
@@ -28,6 +27,7 @@ def compare_field(
|
|
|
28
27
|
person_key: str
|
|
29
28
|
) -> bool:
|
|
30
29
|
if not lead_properties.get(lead_key):
|
|
30
|
+
# If the lead doesn't have the field at all, let's consider it "matched" by default
|
|
31
31
|
return True
|
|
32
32
|
|
|
33
33
|
lead_value = lead_properties.get(lead_key, "")
|
|
@@ -72,8 +72,7 @@ async def validate_linkedin_url_with_apollo(
|
|
|
72
72
|
linkedin_url=linkedin_url,
|
|
73
73
|
tool_config=tool_config
|
|
74
74
|
)
|
|
75
|
-
# If no data is returned from Apollo, return defaults
|
|
76
|
-
# the logic in compare_field where no input -> True).
|
|
75
|
+
# If no data is returned from Apollo, return defaults
|
|
77
76
|
if not linkedin_data:
|
|
78
77
|
return match_result.model_dump()
|
|
79
78
|
|
|
@@ -120,8 +119,7 @@ async def validate_linkedin_url_with_proxy_curl(
|
|
|
120
119
|
linkedin_url=linkedin_url,
|
|
121
120
|
tool_config=tool_config
|
|
122
121
|
)
|
|
123
|
-
# If no data is returned from
|
|
124
|
-
# the logic in compare_field where no input -> True).
|
|
122
|
+
# If no data is returned from Proxycurl, return defaults
|
|
125
123
|
if not linkedin_data:
|
|
126
124
|
return match_result.model_dump()
|
|
127
125
|
|
|
@@ -148,6 +146,18 @@ LINKEDIN_VALIDATE_TOOL_NAME_TO_FUNCTION_MAP = {
|
|
|
148
146
|
"proxycurl": validate_linkedin_url_with_proxy_curl
|
|
149
147
|
}
|
|
150
148
|
|
|
149
|
+
def is_proxy_linkedin_url(url: str) -> bool:
|
|
150
|
+
"""
|
|
151
|
+
Determines if a LinkedIn URL is "proxy-like":
|
|
152
|
+
specifically, if /in/<profile_id> starts with 'acw' and is > 10 chars total.
|
|
153
|
+
"""
|
|
154
|
+
match = re.search(r"linkedin\.com/in/([^/]+)", url, re.IGNORECASE)
|
|
155
|
+
if match:
|
|
156
|
+
profile_id = match.group(1).strip()
|
|
157
|
+
if profile_id.startswith("acw") and len(profile_id) > 10:
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
151
161
|
@assistant_tool
|
|
152
162
|
async def check_linkedin_url_validity(
|
|
153
163
|
lead_properties: Dict[str, Any],
|
|
@@ -155,10 +165,12 @@ async def check_linkedin_url_validity(
|
|
|
155
165
|
) -> Dict[str, bool]:
|
|
156
166
|
"""
|
|
157
167
|
Validates LinkedIn URL (and related fields) by choosing the appropriate tool
|
|
158
|
-
from the tool_config.
|
|
168
|
+
from the tool_config. If the LinkedIn URL is detected as a "proxy" URL,
|
|
169
|
+
we skip calling any external tool and directly return 'linkedin_url_valid' = True.
|
|
159
170
|
|
|
160
171
|
Args:
|
|
161
|
-
lead_properties (dict): Lead info (e.g. first_name, last_name, job_title,
|
|
172
|
+
lead_properties (dict): Lead info (e.g. first_name, last_name, job_title,
|
|
173
|
+
lead_location, user_linkedin_url).
|
|
162
174
|
tool_config (Optional[List[Dict]]): Configuration to identify which tool is available.
|
|
163
175
|
|
|
164
176
|
Returns:
|
|
@@ -170,6 +182,20 @@ async def check_linkedin_url_validity(
|
|
|
170
182
|
if not tool_config:
|
|
171
183
|
raise ValueError("No tool configuration found.")
|
|
172
184
|
|
|
185
|
+
# ---------------------------------------------------------
|
|
186
|
+
# 1) If it’s a "proxy" LinkedIn URL, just return valid = True
|
|
187
|
+
# ---------------------------------------------------------
|
|
188
|
+
linkedin_url = lead_properties.get("user_linkedin_url", "")
|
|
189
|
+
if is_proxy_linkedin_url(linkedin_url):
|
|
190
|
+
match_result = LeadLinkedInMatch()
|
|
191
|
+
match_result.linkedin_url_valid = True
|
|
192
|
+
# The other fields remain their default (False) unless
|
|
193
|
+
# you want to set them otherwise. For now, we just do:
|
|
194
|
+
return match_result.model_dump()
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------
|
|
197
|
+
# 2) Otherwise, pick the correct tool and validate normally
|
|
198
|
+
# ---------------------------------------------------------
|
|
173
199
|
chosen_tool_func = None
|
|
174
200
|
for item in tool_config:
|
|
175
201
|
tool_name = item.get("name")
|
dhisana/utils/clay_tools.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import aiohttp
|
|
3
2
|
import logging
|
|
4
3
|
from typing import Optional
|
|
@@ -22,7 +21,9 @@ async def push_to_clay_table(
|
|
|
22
21
|
- **dict**: Response message or error.
|
|
23
22
|
"""
|
|
24
23
|
if not api_key:
|
|
25
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
'error': "Clay integration is not configured. Please configure the connection to Clay in Integrations."
|
|
26
|
+
}
|
|
26
27
|
|
|
27
28
|
if not webhook:
|
|
28
29
|
return {'error': "Webhook URL not provided"}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import datetime
|
|
2
1
|
import logging
|
|
3
2
|
import os
|
|
4
3
|
import json
|
|
@@ -352,8 +351,11 @@ async def get_search_results_for_insights(
|
|
|
352
351
|
|
|
353
352
|
def get_serp_api_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
354
353
|
"""
|
|
355
|
-
Retrieves the SERPAPI_KEY access token from the provided tool configuration
|
|
354
|
+
Retrieves the SERPAPI_KEY access token from the provided tool configuration
|
|
356
355
|
or from the environment variable SERPAPI_KEY.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
ValueError: If the SerpAPI integration has not been configured.
|
|
357
359
|
"""
|
|
358
360
|
serpapi_key = None
|
|
359
361
|
if tool_config:
|
|
@@ -373,7 +375,7 @@ def get_serp_api_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
|
373
375
|
serpapi_key = serpapi_key or os.getenv("SERPAPI_KEY")
|
|
374
376
|
if not serpapi_key:
|
|
375
377
|
raise ValueError(
|
|
376
|
-
"
|
|
378
|
+
"SerpAPI integration is not configured. Please configure the connection to SerpAPI in Integrations."
|
|
377
379
|
)
|
|
378
380
|
return serpapi_key
|
|
379
381
|
|
|
@@ -470,6 +472,7 @@ Output must be valid JSON, e.g.:
|
|
|
470
472
|
prompt=prompt,
|
|
471
473
|
response_format=TechnologyUsedCheck,
|
|
472
474
|
effort="high",
|
|
475
|
+
model="gpt-5.1-chat",
|
|
473
476
|
tool_config=tool_config
|
|
474
477
|
)
|
|
475
478
|
|
|
@@ -531,6 +534,7 @@ Output must be valid JSON, e.g.:
|
|
|
531
534
|
prompt=prompt,
|
|
532
535
|
response_format=TechnologyAndRoleCheck,
|
|
533
536
|
effort="high",
|
|
537
|
+
model="gpt-5.1-chat",
|
|
534
538
|
tool_config=tool_config
|
|
535
539
|
)
|
|
536
540
|
|
dhisana/utils/composite_tools.py
CHANGED
|
@@ -7,7 +7,6 @@ from dhisana.utils.built_with_api_tools import (
|
|
|
7
7
|
)
|
|
8
8
|
from dhisana.utils.dataframe_tools import get_structured_output
|
|
9
9
|
from dhisana.utils.google_custom_search import search_google_custom
|
|
10
|
-
from dhisana.utils.serpapi_search_tools import search_google
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
class QualifyCompanyBasedOnTechUsage(BaseModel):
|
dhisana/utils/dataframe_tools.py
CHANGED
|
@@ -33,13 +33,13 @@ class PandasQuery(BaseModel):
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
@assistant_tool
|
|
36
|
-
async def get_structured_output(message: str, response_type, model: str = "
|
|
36
|
+
async def get_structured_output(message: str, response_type, model: str = "gpt-5.1-chat"):
|
|
37
37
|
"""
|
|
38
38
|
Asynchronously retrieves structured output from the OpenAI API based on the input message.
|
|
39
39
|
|
|
40
40
|
:param message: The input message to be processed by the OpenAI API.
|
|
41
41
|
:param response_type: The expected format of the response (e.g., JSON).
|
|
42
|
-
:param model: The model to be used for processing the input message. Defaults to "
|
|
42
|
+
:param model: The model to be used for processing the input message. Defaults to "gpt-5.1-chat".
|
|
43
43
|
:return: A tuple containing the parsed response and a status string ('SUCCESS' or 'FAIL').
|
|
44
44
|
"""
|
|
45
45
|
try:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Small helpers for handling e-mail bodies across providers."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Tuple
|
|
4
|
+
import html as html_lib
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def looks_like_html(text: str) -> bool:
|
|
9
|
+
"""Heuristically determine whether the body contains HTML markup."""
|
|
10
|
+
return bool(text and re.search(r"<[a-zA-Z][^>]*>", text))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize_format_hint(format_hint: Optional[str]) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Normalize a user-supplied format hint into html/text/auto.
|
|
16
|
+
|
|
17
|
+
Accepts variations like "plain" or "plaintext" as text.
|
|
18
|
+
"""
|
|
19
|
+
if not format_hint:
|
|
20
|
+
return "auto"
|
|
21
|
+
fmt_raw = getattr(format_hint, "value", format_hint)
|
|
22
|
+
fmt = str(fmt_raw).strip().lower()
|
|
23
|
+
if fmt in ("html",):
|
|
24
|
+
return "html"
|
|
25
|
+
if fmt in ("text", "plain", "plain_text", "plaintext"):
|
|
26
|
+
return "text"
|
|
27
|
+
return "auto"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def html_to_plain_text(html: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Produce a very lightweight plain-text version of an HTML fragment.
|
|
33
|
+
This keeps newlines on block boundaries and strips tags.
|
|
34
|
+
"""
|
|
35
|
+
if not html:
|
|
36
|
+
return ""
|
|
37
|
+
text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", html)
|
|
38
|
+
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
|
|
39
|
+
text = re.sub(r"(?i)</(p|div|li|h[1-6])\s*>", "\n", text)
|
|
40
|
+
text = re.sub(r"(?is)<.*?>", "", text)
|
|
41
|
+
text = html_lib.unescape(text)
|
|
42
|
+
text = re.sub(r"\s+\n", "\n", text)
|
|
43
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
44
|
+
return text.strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def plain_text_to_html(text: str) -> str:
|
|
48
|
+
"""Wrap plain text in a minimal HTML container that preserves newlines."""
|
|
49
|
+
if text is None:
|
|
50
|
+
return ""
|
|
51
|
+
escaped = html_lib.escape(text)
|
|
52
|
+
return f'<div style="white-space: pre-wrap">{escaped}</div>'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def body_variants(body: Optional[str], format_hint: Optional[str]) -> Tuple[str, str, str]:
|
|
56
|
+
"""
|
|
57
|
+
Return (plain, html, resolved_format) honoring an optional format hint.
|
|
58
|
+
|
|
59
|
+
resolved_format is "html" or "text" after applying auto-detection.
|
|
60
|
+
"""
|
|
61
|
+
content = body or ""
|
|
62
|
+
fmt = _normalize_format_hint(format_hint)
|
|
63
|
+
|
|
64
|
+
if fmt == "html":
|
|
65
|
+
return html_to_plain_text(content), content, "html"
|
|
66
|
+
if fmt == "text":
|
|
67
|
+
return content, plain_text_to_html(content), "text"
|
|
68
|
+
|
|
69
|
+
if looks_like_html(content):
|
|
70
|
+
return html_to_plain_text(content), content, "html"
|
|
71
|
+
|
|
72
|
+
return content, plain_text_to_html(content), "text"
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# dhisana/email_providers.py
|
|
2
|
+
#
|
|
3
|
+
# Generic e-mail wrapper helpers for Dhisana.
|
|
4
|
+
# ---------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
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.google_workspace_tools import (
|
|
16
|
+
send_email_using_service_account_async,
|
|
17
|
+
list_emails_in_time_range_async,
|
|
18
|
+
reply_to_email_async as gw_reply_to_email_async,
|
|
19
|
+
)
|
|
20
|
+
from dhisana.utils.google_oauth_tools import (
|
|
21
|
+
send_email_using_google_oauth_async,
|
|
22
|
+
list_emails_in_time_range_google_oauth_async,
|
|
23
|
+
reply_to_email_google_oauth_async,
|
|
24
|
+
)
|
|
25
|
+
from dhisana.utils.microsoft365_tools import (
|
|
26
|
+
send_email_using_microsoft_graph_async,
|
|
27
|
+
list_emails_in_time_range_m365_async,
|
|
28
|
+
reply_to_email_m365_async,
|
|
29
|
+
)
|
|
30
|
+
from dhisana.utils.smtp_email_tools import (
|
|
31
|
+
send_email_via_smtp_async,
|
|
32
|
+
list_emails_in_time_range_imap_async,
|
|
33
|
+
reply_to_email_via_smtp_async,
|
|
34
|
+
)
|
|
35
|
+
from dhisana.utils.mailgun_tools import send_email_using_mailgun_async
|
|
36
|
+
from dhisana.utils.sendgrid_tools import send_email_using_sendgrid_async
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# Provider-selection helpers
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_provider_cfg(
|
|
44
|
+
tool_cfg: Optional[Sequence[Dict]], provider_name: str
|
|
45
|
+
) -> Optional[Dict]:
|
|
46
|
+
"""
|
|
47
|
+
Return the *first* config-dict whose ``name`` matches *provider_name*.
|
|
48
|
+
"""
|
|
49
|
+
if not tool_cfg:
|
|
50
|
+
return None
|
|
51
|
+
return next((c for c in tool_cfg if c.get("name") == provider_name), None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _smtp_creds_for_sender(smtp_cfg: Dict, sender_email: str) -> Optional[Dict[str, str]]:
|
|
55
|
+
"""
|
|
56
|
+
Given an SMTP provider config and a sender address, return the matching
|
|
57
|
+
``username`` / ``password`` plus server settings, or ``None``.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
usernames = [
|
|
61
|
+
u.strip()
|
|
62
|
+
for u in next(f for f in smtp_cfg["configuration"] if f["name"] == "usernames")[
|
|
63
|
+
"value"
|
|
64
|
+
].split(",")
|
|
65
|
+
if u.strip()
|
|
66
|
+
]
|
|
67
|
+
passwords = [
|
|
68
|
+
p.strip()
|
|
69
|
+
for p in next(f for f in smtp_cfg["configuration"] if f["name"] == "passwords")[
|
|
70
|
+
"value"
|
|
71
|
+
].split(",")
|
|
72
|
+
]
|
|
73
|
+
if len(usernames) != len(passwords):
|
|
74
|
+
logging.warning(
|
|
75
|
+
"smtpEmail config: usernames/passwords length mismatch – skipping"
|
|
76
|
+
)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
if sender_email not in usernames:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
idx = usernames.index(sender_email)
|
|
83
|
+
|
|
84
|
+
def _field(name: str, default):
|
|
85
|
+
try:
|
|
86
|
+
return next(f for f in smtp_cfg["configuration"] if f["name"] == name)[
|
|
87
|
+
"value"
|
|
88
|
+
]
|
|
89
|
+
except StopIteration:
|
|
90
|
+
return default
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"username": usernames[idx],
|
|
94
|
+
"password": passwords[idx],
|
|
95
|
+
"smtp_host": _field("smtpEndpoint", "smtp.gmail.com"),
|
|
96
|
+
"smtp_port": int(_field("smtpPort", 587)),
|
|
97
|
+
"imap_host": _field("imapEndpoint", "imap.gmail.com"),
|
|
98
|
+
"imap_port": int(_field("imapPort", 993)),
|
|
99
|
+
}
|
|
100
|
+
except Exception:
|
|
101
|
+
logging.exception("Failed to parse smtpEmail config")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
# Public wrapper APIs
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
|
|
109
|
+
async def send_email_async(
|
|
110
|
+
send_email_context: SendEmailContext,
|
|
111
|
+
tool_config: Optional[List[Dict]] = None,
|
|
112
|
+
*,
|
|
113
|
+
provider_order: Sequence[str] = (
|
|
114
|
+
"mailgun",
|
|
115
|
+
"sendgrid",
|
|
116
|
+
"google", # Google OAuth (per-user token)
|
|
117
|
+
"smtpEmail",
|
|
118
|
+
"googleworkspace", # Google Workspace service account (DWD)
|
|
119
|
+
"microsoft365",
|
|
120
|
+
),
|
|
121
|
+
):
|
|
122
|
+
"""
|
|
123
|
+
Send an e-mail using the first *configured* provider in *provider_order*.
|
|
124
|
+
|
|
125
|
+
Returns whatever the underlying provider helper returns:
|
|
126
|
+
|
|
127
|
+
* SMTP → str (Message-ID)
|
|
128
|
+
* Microsoft 365 → str (message-id)
|
|
129
|
+
* Google Workspace → str (message-id)
|
|
130
|
+
"""
|
|
131
|
+
# ------------------------------------------------------------------ #
|
|
132
|
+
# 1) Try the preferred providers in order
|
|
133
|
+
# ------------------------------------------------------------------ #
|
|
134
|
+
for provider in provider_order:
|
|
135
|
+
# 1a) SMTP
|
|
136
|
+
if provider == "smtpEmail":
|
|
137
|
+
smtp_cfg = _find_provider_cfg(tool_config, "smtpEmail")
|
|
138
|
+
if not smtp_cfg:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
creds = _smtp_creds_for_sender(smtp_cfg, send_email_context.sender_email)
|
|
142
|
+
if not creds:
|
|
143
|
+
# No creds for this sender – fall through.
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
return await send_email_via_smtp_async(
|
|
147
|
+
send_email_context,
|
|
148
|
+
smtp_server=creds["smtp_host"],
|
|
149
|
+
smtp_port=creds["smtp_port"],
|
|
150
|
+
username=creds["username"],
|
|
151
|
+
password=creds["password"],
|
|
152
|
+
use_starttls=(creds["smtp_port"] == 587),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# 1b) Mailgun
|
|
156
|
+
elif provider == "mailgun":
|
|
157
|
+
mg_cfg = _find_provider_cfg(tool_config, "mailgun")
|
|
158
|
+
if not mg_cfg:
|
|
159
|
+
continue
|
|
160
|
+
return await send_email_using_mailgun_async(send_email_context, tool_config)
|
|
161
|
+
|
|
162
|
+
# 1c) SendGrid
|
|
163
|
+
elif provider == "sendgrid":
|
|
164
|
+
sg_cfg = _find_provider_cfg(tool_config, "sendgrid")
|
|
165
|
+
if not sg_cfg:
|
|
166
|
+
continue
|
|
167
|
+
return await send_email_using_sendgrid_async(send_email_context, tool_config)
|
|
168
|
+
|
|
169
|
+
# 1d) Google (Gmail API via per-user OAuth)
|
|
170
|
+
elif provider == "google":
|
|
171
|
+
g_cfg = _find_provider_cfg(tool_config, "google")
|
|
172
|
+
if not g_cfg:
|
|
173
|
+
continue
|
|
174
|
+
return await send_email_using_google_oauth_async(send_email_context, tool_config)
|
|
175
|
+
|
|
176
|
+
# 1e) Google Workspace
|
|
177
|
+
elif provider == "googleworkspace":
|
|
178
|
+
gw_cfg = _find_provider_cfg(tool_config, "googleworkspace")
|
|
179
|
+
if not gw_cfg:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
return await send_email_using_service_account_async(
|
|
183
|
+
send_email_context, tool_config
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# 1f) Microsoft 365 (Graph API)
|
|
187
|
+
elif provider == "microsoft365":
|
|
188
|
+
ms_cfg = _find_provider_cfg(tool_config, "microsoft365")
|
|
189
|
+
if not ms_cfg:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
return await send_email_using_microsoft_graph_async(
|
|
193
|
+
send_email_context, tool_config
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# -- future providers slot --------------------------------------
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------ #
|
|
199
|
+
# 2) FINAL FALLBACK — use *first* SMTP credentials if available
|
|
200
|
+
# ------------------------------------------------------------------ #
|
|
201
|
+
smtp_cfg = _find_provider_cfg(tool_config, "smtpEmail")
|
|
202
|
+
if smtp_cfg:
|
|
203
|
+
try:
|
|
204
|
+
usernames = [
|
|
205
|
+
u.strip()
|
|
206
|
+
for u in next(
|
|
207
|
+
f for f in smtp_cfg["configuration"] if f["name"] == "usernames"
|
|
208
|
+
)["value"].split(",")
|
|
209
|
+
if u.strip()
|
|
210
|
+
]
|
|
211
|
+
passwords = [
|
|
212
|
+
p.strip()
|
|
213
|
+
for p in next(
|
|
214
|
+
f for f in smtp_cfg["configuration"] if f["name"] == "passwords"
|
|
215
|
+
)["value"].split(",")
|
|
216
|
+
]
|
|
217
|
+
if usernames and len(usernames) == len(passwords):
|
|
218
|
+
# Build a fake SendEmailContext for the fallback user, so that
|
|
219
|
+
# the underlying SMTP helper still sends the intended message
|
|
220
|
+
# but authenticates with the first available mailbox.
|
|
221
|
+
fallback_sender = usernames[0]
|
|
222
|
+
creds = _smtp_creds_for_sender(smtp_cfg, fallback_sender)
|
|
223
|
+
|
|
224
|
+
if creds:
|
|
225
|
+
logging.info(
|
|
226
|
+
"Fallback: no provider matched – using first SMTP creds (%s).",
|
|
227
|
+
creds["username"],
|
|
228
|
+
)
|
|
229
|
+
return await send_email_via_smtp_async(
|
|
230
|
+
send_email_context,
|
|
231
|
+
smtp_server=creds["smtp_host"],
|
|
232
|
+
smtp_port=creds["smtp_port"],
|
|
233
|
+
username=creds["username"],
|
|
234
|
+
password=creds["password"],
|
|
235
|
+
use_starttls=(creds["smtp_port"] == 587),
|
|
236
|
+
)
|
|
237
|
+
except Exception:
|
|
238
|
+
logging.exception("SMTP fallback failed")
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------ #
|
|
241
|
+
# 3) Nothing worked
|
|
242
|
+
# ------------------------------------------------------------------ #
|
|
243
|
+
raise RuntimeError("No suitable e-mail provider configured for this sender.")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def list_emails_async(
|
|
249
|
+
query_email_context: QueryEmailContext,
|
|
250
|
+
tool_config: Optional[List[Dict]] = None,
|
|
251
|
+
*,
|
|
252
|
+
provider_order: Sequence[str] = ("google", "smtpEmail", "googleworkspace", "microsoft365"),
|
|
253
|
+
) -> List[MessageItem]:
|
|
254
|
+
"""
|
|
255
|
+
List e-mails (see ``QueryEmailContext``) using the first configured provider.
|
|
256
|
+
|
|
257
|
+
Always returns ``List[MessageItem]``.
|
|
258
|
+
"""
|
|
259
|
+
for provider in provider_order:
|
|
260
|
+
if provider == "smtpEmail":
|
|
261
|
+
smtp_cfg = _find_provider_cfg(tool_config, "smtpEmail")
|
|
262
|
+
if not smtp_cfg:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
creds = _smtp_creds_for_sender(smtp_cfg, query_email_context.sender_email)
|
|
266
|
+
if not creds:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
return await list_emails_in_time_range_imap_async(
|
|
270
|
+
query_email_context,
|
|
271
|
+
imap_server=creds["imap_host"],
|
|
272
|
+
imap_port=creds["imap_port"],
|
|
273
|
+
username=creds["username"],
|
|
274
|
+
password=creds["password"],
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
elif provider == "google":
|
|
278
|
+
g_cfg = _find_provider_cfg(tool_config, "google")
|
|
279
|
+
if not g_cfg:
|
|
280
|
+
continue
|
|
281
|
+
return await list_emails_in_time_range_google_oauth_async(query_email_context, tool_config)
|
|
282
|
+
|
|
283
|
+
elif provider == "googleworkspace":
|
|
284
|
+
gw_cfg = _find_provider_cfg(tool_config, "googleworkspace")
|
|
285
|
+
if not gw_cfg:
|
|
286
|
+
continue
|
|
287
|
+
return await list_emails_in_time_range_async(query_email_context, tool_config)
|
|
288
|
+
|
|
289
|
+
elif provider == "microsoft365":
|
|
290
|
+
ms_cfg = _find_provider_cfg(tool_config, "microsoft365")
|
|
291
|
+
if not ms_cfg:
|
|
292
|
+
continue
|
|
293
|
+
return await list_emails_in_time_range_m365_async(query_email_context, tool_config)
|
|
294
|
+
|
|
295
|
+
# --- future providers go here ---
|
|
296
|
+
|
|
297
|
+
logging.warning(
|
|
298
|
+
"No suitable inbox provider configured for sender %s; returning empty list.",
|
|
299
|
+
query_email_context.sender_email,
|
|
300
|
+
)
|
|
301
|
+
return []
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
# New public helper: reply_email_async
|
|
306
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
307
|
+
async def reply_email_async(
|
|
308
|
+
reply_email_context: ReplyEmailContext,
|
|
309
|
+
tool_config: Optional[List[Dict]] = None,
|
|
310
|
+
*,
|
|
311
|
+
provider_order: Sequence[str] = ("google", "smtpEmail", "googleworkspace", "microsoft365"),
|
|
312
|
+
) -> Dict[str, Any]:
|
|
313
|
+
"""
|
|
314
|
+
Reply (reply-all) to an e-mail using the first *configured* provider
|
|
315
|
+
in *provider_order*.
|
|
316
|
+
|
|
317
|
+
Returns the provider’s reply-metadata dictionary.
|
|
318
|
+
"""
|
|
319
|
+
for provider in provider_order:
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
# 1) SMTP
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
if provider == "smtpEmail":
|
|
324
|
+
smtp_cfg = _find_provider_cfg(tool_config, "smtpEmail")
|
|
325
|
+
if not smtp_cfg:
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
creds = _smtp_creds_for_sender(smtp_cfg, reply_email_context.sender_email)
|
|
329
|
+
if not creds:
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
return await reply_to_email_via_smtp_async(
|
|
333
|
+
reply_email_context,
|
|
334
|
+
smtp_server=creds["smtp_host"],
|
|
335
|
+
smtp_port=creds["smtp_port"],
|
|
336
|
+
imap_server=creds["imap_host"],
|
|
337
|
+
imap_port=creds["imap_port"],
|
|
338
|
+
username=creds["username"],
|
|
339
|
+
password=creds["password"],
|
|
340
|
+
use_starttls_smtp=(creds["smtp_port"] == 587),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
# 2) Google OAuth (per-user)
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
elif provider == "google":
|
|
347
|
+
g_cfg = _find_provider_cfg(tool_config, "google")
|
|
348
|
+
if not g_cfg:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
return await reply_to_email_google_oauth_async(reply_email_context, tool_config)
|
|
352
|
+
|
|
353
|
+
# ------------------------------------------------------------------
|
|
354
|
+
# 3) Google Workspace service-account
|
|
355
|
+
# ------------------------------------------------------------------
|
|
356
|
+
elif provider == "googleworkspace":
|
|
357
|
+
gw_cfg = _find_provider_cfg(tool_config, "googleworkspace")
|
|
358
|
+
if not gw_cfg:
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
return await gw_reply_to_email_async(reply_email_context, tool_config)
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# 4) Microsoft 365 (Graph)
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
elif provider == "microsoft365":
|
|
367
|
+
ms_cfg = _find_provider_cfg(tool_config, "microsoft365")
|
|
368
|
+
if not ms_cfg:
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
return await reply_to_email_m365_async(reply_email_context, tool_config)
|
|
372
|
+
|
|
373
|
+
# -- future providers slot -----------------------------------------
|
|
374
|
+
|
|
375
|
+
raise RuntimeError("No suitable reply-capable e-mail provider configured.")
|