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.
- dhisana/__init__.py +1 -0
- dhisana/cli/__init__.py +1 -0
- dhisana/cli/cli.py +20 -0
- dhisana/cli/datasets.py +27 -0
- dhisana/cli/models.py +26 -0
- dhisana/cli/predictions.py +20 -0
- dhisana/schemas/__init__.py +1 -0
- dhisana/schemas/common.py +399 -0
- dhisana/schemas/sales.py +965 -0
- dhisana/ui/__init__.py +1 -0
- dhisana/ui/components.py +472 -0
- dhisana/utils/__init__.py +1 -0
- dhisana/utils/add_mapping.py +352 -0
- dhisana/utils/agent_tools.py +51 -0
- dhisana/utils/apollo_tools.py +1597 -0
- dhisana/utils/assistant_tool_tag.py +4 -0
- dhisana/utils/built_with_api_tools.py +282 -0
- dhisana/utils/cache_output_tools.py +98 -0
- dhisana/utils/cache_output_tools_local.py +78 -0
- dhisana/utils/check_email_validity_tools.py +717 -0
- dhisana/utils/check_for_intent_signal.py +107 -0
- dhisana/utils/check_linkedin_url_validity.py +209 -0
- dhisana/utils/clay_tools.py +43 -0
- dhisana/utils/clean_properties.py +135 -0
- dhisana/utils/company_utils.py +60 -0
- dhisana/utils/compose_salesnav_query.py +259 -0
- dhisana/utils/compose_search_query.py +759 -0
- dhisana/utils/compose_three_step_workflow.py +234 -0
- dhisana/utils/composite_tools.py +137 -0
- dhisana/utils/dataframe_tools.py +237 -0
- dhisana/utils/domain_parser.py +45 -0
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_parse_helpers.py +132 -0
- dhisana/utils/email_provider.py +375 -0
- dhisana/utils/enrich_lead_information.py +933 -0
- dhisana/utils/extract_email_content_for_llm.py +101 -0
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +426 -0
- dhisana/utils/g2_tools.py +104 -0
- dhisana/utils/generate_content.py +41 -0
- dhisana/utils/generate_custom_message.py +271 -0
- dhisana/utils/generate_email.py +278 -0
- dhisana/utils/generate_email_response.py +465 -0
- dhisana/utils/generate_flow.py +102 -0
- dhisana/utils/generate_leads_salesnav.py +303 -0
- dhisana/utils/generate_linkedin_connect_message.py +224 -0
- dhisana/utils/generate_linkedin_response_message.py +317 -0
- dhisana/utils/generate_structured_output_internal.py +462 -0
- dhisana/utils/google_custom_search.py +267 -0
- dhisana/utils/google_oauth_tools.py +727 -0
- dhisana/utils/google_workspace_tools.py +1294 -0
- dhisana/utils/hubspot_clearbit.py +96 -0
- dhisana/utils/hubspot_crm_tools.py +2440 -0
- dhisana/utils/instantly_tools.py +149 -0
- dhisana/utils/linkedin_crawler.py +168 -0
- dhisana/utils/lusha_tools.py +333 -0
- dhisana/utils/mailgun_tools.py +156 -0
- dhisana/utils/mailreach_tools.py +123 -0
- dhisana/utils/microsoft365_tools.py +455 -0
- dhisana/utils/openai_assistant_and_file_utils.py +267 -0
- dhisana/utils/openai_helpers.py +977 -0
- dhisana/utils/openapi_spec_to_tools.py +45 -0
- dhisana/utils/openapi_tool/__init__.py +1 -0
- dhisana/utils/openapi_tool/api_models.py +633 -0
- dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
- dhisana/utils/openapi_tool/openapi_tool.py +319 -0
- dhisana/utils/parse_linkedin_messages_txt.py +100 -0
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +1226 -0
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/python_function_to_tools.py +83 -0
- dhisana/utils/research_lead.py +176 -0
- dhisana/utils/sales_navigator_crawler.py +1103 -0
- dhisana/utils/salesforce_crm_tools.py +477 -0
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +162 -0
- 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 +852 -0
- 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 +582 -0
- dhisana/utils/test_connect.py +2087 -0
- dhisana/utils/trasform_json.py +173 -0
- dhisana/utils/web_download_parse_tools.py +189 -0
- dhisana/utils/workflow_code_model.py +5 -0
- dhisana/utils/zoominfo_tools.py +357 -0
- dhisana/workflow/__init__.py +1 -0
- dhisana/workflow/agent.py +18 -0
- dhisana/workflow/flow.py +44 -0
- dhisana/workflow/task.py +43 -0
- dhisana/workflow/test.py +90 -0
- dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
- dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
- dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
- dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
- 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.")
|