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.
Files changed (70) hide show
  1. dhisana/schemas/common.py +33 -0
  2. dhisana/schemas/sales.py +224 -23
  3. dhisana/utils/add_mapping.py +72 -63
  4. dhisana/utils/apollo_tools.py +739 -109
  5. dhisana/utils/built_with_api_tools.py +4 -2
  6. dhisana/utils/cache_output_tools.py +23 -23
  7. dhisana/utils/check_email_validity_tools.py +456 -458
  8. dhisana/utils/check_for_intent_signal.py +1 -2
  9. dhisana/utils/check_linkedin_url_validity.py +34 -8
  10. dhisana/utils/clay_tools.py +3 -2
  11. dhisana/utils/clean_properties.py +3 -1
  12. dhisana/utils/compose_salesnav_query.py +0 -1
  13. dhisana/utils/compose_search_query.py +7 -3
  14. dhisana/utils/composite_tools.py +0 -1
  15. dhisana/utils/dataframe_tools.py +2 -2
  16. dhisana/utils/email_body_utils.py +72 -0
  17. dhisana/utils/email_provider.py +375 -0
  18. dhisana/utils/enrich_lead_information.py +585 -85
  19. dhisana/utils/fetch_openai_config.py +129 -0
  20. dhisana/utils/field_validators.py +1 -1
  21. dhisana/utils/g2_tools.py +0 -1
  22. dhisana/utils/generate_content.py +0 -1
  23. dhisana/utils/generate_email.py +69 -16
  24. dhisana/utils/generate_email_response.py +298 -41
  25. dhisana/utils/generate_flow.py +0 -1
  26. dhisana/utils/generate_linkedin_connect_message.py +19 -6
  27. dhisana/utils/generate_linkedin_response_message.py +156 -65
  28. dhisana/utils/generate_structured_output_internal.py +351 -131
  29. dhisana/utils/google_custom_search.py +150 -44
  30. dhisana/utils/google_oauth_tools.py +721 -0
  31. dhisana/utils/google_workspace_tools.py +391 -25
  32. dhisana/utils/hubspot_clearbit.py +3 -1
  33. dhisana/utils/hubspot_crm_tools.py +771 -167
  34. dhisana/utils/instantly_tools.py +3 -1
  35. dhisana/utils/lusha_tools.py +10 -7
  36. dhisana/utils/mailgun_tools.py +150 -0
  37. dhisana/utils/microsoft365_tools.py +447 -0
  38. dhisana/utils/openai_assistant_and_file_utils.py +121 -177
  39. dhisana/utils/openai_helpers.py +19 -16
  40. dhisana/utils/parse_linkedin_messages_txt.py +2 -3
  41. dhisana/utils/profile.py +37 -0
  42. dhisana/utils/proxy_curl_tools.py +507 -206
  43. dhisana/utils/proxycurl_search_leads.py +426 -0
  44. dhisana/utils/research_lead.py +121 -68
  45. dhisana/utils/sales_navigator_crawler.py +1 -6
  46. dhisana/utils/salesforce_crm_tools.py +323 -50
  47. dhisana/utils/search_router.py +131 -0
  48. dhisana/utils/search_router_jobs.py +51 -0
  49. dhisana/utils/sendgrid_tools.py +126 -91
  50. dhisana/utils/serarch_router_local_business.py +75 -0
  51. dhisana/utils/serpapi_additional_tools.py +290 -0
  52. dhisana/utils/serpapi_google_jobs.py +117 -0
  53. dhisana/utils/serpapi_google_search.py +188 -0
  54. dhisana/utils/serpapi_local_business_search.py +129 -0
  55. dhisana/utils/serpapi_search_tools.py +363 -432
  56. dhisana/utils/serperdev_google_jobs.py +125 -0
  57. dhisana/utils/serperdev_local_business.py +154 -0
  58. dhisana/utils/serperdev_search.py +233 -0
  59. dhisana/utils/smtp_email_tools.py +576 -0
  60. dhisana/utils/test_connect.py +1765 -92
  61. dhisana/utils/trasform_json.py +95 -16
  62. dhisana/utils/web_download_parse_tools.py +0 -1
  63. dhisana/utils/zoominfo_tools.py +2 -3
  64. dhisana/workflow/test.py +1 -1
  65. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +5 -2
  66. dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
  67. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
  68. dhisana-0.0.1.dev85.dist-info/RECORD +0 -81
  69. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
  70. {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
- current_date_iso = datetime.datetime.now().isoformat()
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 os
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 (all False except
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 Apollo, return defaults (all False except
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, lead_location, user_linkedin_url).
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")
@@ -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 {'error': "API key not provided"}
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,7 +1,9 @@
1
- from typing import Any, Dict, List, Union
1
+ from typing import Any, Dict, List
2
2
  import copy
3
3
  from typing import Any, Dict, List, Optional
4
4
 
5
+
6
+
5
7
  def remove_empty(data: Any) -> Any:
6
8
  """
7
9
  Recursively remove empty or null-like values from JSON/dict data.
@@ -1,5 +1,4 @@
1
1
  import logging
2
- import os
3
2
  from typing import Any, Dict, List, Optional
4
3
 
5
4
  import openai # Remove if not required outside get_structured_output_internal
@@ -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
- "SERPAPI_KEY access token not found in tool_config or environment variable."
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
 
@@ -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):
@@ -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 = "o3-mini"):
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 "o3-mini".
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.")