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,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,132 @@
1
+ import base64
2
+ import email.utils
3
+ from email.utils import parseaddr
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+ from bs4 import BeautifulSoup
6
+
7
+ def decode_base64_url(data: str) -> str:
8
+ """
9
+ Decodes a Base64-url-encoded string (Gmail API uses URL-safe Base64).
10
+ """
11
+ data = data.replace('-', '+').replace('_', '/')
12
+ # Fix padding
13
+ missing_padding = len(data) % 4
14
+ if missing_padding:
15
+ data += '=' * (4 - missing_padding)
16
+ return base64.b64decode(data).decode('utf-8', errors='ignore')
17
+
18
+
19
+ def parse_plain_text_from_parts(parts: List[Dict[str, Any]]) -> str:
20
+ """
21
+ Recursively parse payload parts to extract all text (plain or HTML).
22
+ HTML is converted to plain text.
23
+ """
24
+ text_chunks: List[str] = []
25
+ for part in parts:
26
+ if 'parts' in part:
27
+ # Recursively parse nested parts
28
+ text_chunks.append(parse_plain_text_from_parts(part['parts']))
29
+ else:
30
+ mime_type = part.get('mimeType', '')
31
+ data = part.get('body', {}).get('data', '')
32
+ if data:
33
+ decoded_data = decode_base64_url(data)
34
+ if 'text/plain' in mime_type:
35
+ text_chunks.append(decoded_data)
36
+ elif 'text/html' in mime_type:
37
+ soup = BeautifulSoup(decoded_data, 'html.parser')
38
+ text_chunks.append(soup.get_text())
39
+ return "\n".join(chunk for chunk in text_chunks if chunk)
40
+
41
+
42
+ def extract_email_body_in_plain_text(message_data: Dict[str, Any]) -> str:
43
+ """
44
+ Extract the email body from the Gmail message_data in plain text.
45
+ Converts any HTML to plain text.
46
+ Combines multiple parts if necessary.
47
+ """
48
+ payload = message_data.get('payload', {})
49
+ # If top-level body has data (i.e. single-part message)
50
+ if payload.get('body', {}).get('data'):
51
+ raw_data = payload['body']['data']
52
+ decoded_data = decode_base64_url(raw_data)
53
+ # Check if it might be HTML
54
+ mime_type = payload.get('mimeType', '')
55
+ if 'text/html' in mime_type:
56
+ soup = BeautifulSoup(decoded_data, 'html.parser')
57
+ return soup.get_text()
58
+ return decoded_data
59
+
60
+ # If multiple parts exist
61
+ if 'parts' in payload:
62
+ return parse_plain_text_from_parts(payload['parts'])
63
+
64
+ return ""
65
+
66
+
67
+ def convert_date_to_iso(date_str: str) -> str:
68
+ """
69
+ Convert a date string (RFC 2822/5322) to an ISO 8601 formatted string.
70
+ Example: "Wed, 07 Apr 2021 16:30:00 -0700" -> "2021-04-07T16:30:00-07:00"
71
+ """
72
+ dt = email.utils.parsedate_to_datetime(date_str)
73
+ if not dt:
74
+ return ""
75
+ return dt.isoformat()
76
+
77
+
78
+ def find_header(headers_list: List[Dict[str, str]], header_name: str) -> Optional[str]:
79
+ """
80
+ Return the first matching header value for header_name, or None if not found.
81
+ """
82
+ for h in headers_list:
83
+ if h['name'].lower() == header_name.lower():
84
+ return h['value']
85
+ return None
86
+
87
+
88
+ def parse_single_address(display_str: str) -> (str, str):
89
+ """
90
+ Parses a single display string like "Alice <alice@example.com>"
91
+ returning (name, email).
92
+ """
93
+ name, email = parseaddr(display_str)
94
+ # If no name is given, might be email only
95
+ return (name.strip() or "", email.strip() or "")
96
+
97
+
98
+ def parse_address_list(display_str: str) -> List[Tuple[str, str]]:
99
+ """
100
+ Split a header string with possibly multiple addresses into a list
101
+ of (name, email) tuples.
102
+
103
+ Example input:
104
+ "John Doe <john@example.com>, Jane Roe <jane@example.com>"
105
+ returns:
106
+ [("John Doe","john@example.com"), ("Jane Roe","jane@example.com")]
107
+ """
108
+ # The standard library doesn't have a direct "splitall addresses"
109
+ # but we can rely on email.utils.getaddresses
110
+ # We'll break them into a list of (name, email).
111
+ addresses = email.utils.getaddresses([display_str])
112
+ return addresses
113
+
114
+
115
+ def find_all_recipients_in_headers(headers_list: List[Dict[str, str]]) -> (str, str):
116
+ """
117
+ Collect 'To', 'Cc', 'Bcc' headers, parse each address, and return:
118
+ (comma-separated receiver names, comma-separated receiver emails)
119
+ """
120
+ full_str = []
121
+ for h in headers_list:
122
+ if h['name'].lower() in ['to', 'cc', 'bcc']:
123
+ full_str.append(h['value'])
124
+ if not full_str:
125
+ return ("", "") # No recipients found
126
+
127
+ combined_str = ", ".join(full_str)
128
+ addresses = parse_address_list(combined_str)
129
+ names = [addr[0] for addr in addresses if addr[0] or addr[1]]
130
+ emails = [addr[1] for addr in addresses if addr[0] or addr[1]]
131
+
132
+ return (", ".join(names), ", ".join(emails))
@@ -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.")