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,717 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email enrichment & validation module
|
|
3
|
+
|
|
4
|
+
Adds Findymail support on top of existing ZeroBounce, Hunter and Apollo flows.
|
|
5
|
+
|
|
6
|
+
Providers supported
|
|
7
|
+
-------------------
|
|
8
|
+
* Findymail – email finder (`/search/name`) & verifier (`/verify`)
|
|
9
|
+
* Hunter – email finder (`/email-finder`) & verifier (`/email-verifier`)
|
|
10
|
+
* ZeroBounce – guess format (`/guessformat`) & verifier (`/validate`)
|
|
11
|
+
* Apollo – enrichment fallback (re‑checked with ZeroBounce/Hunter)
|
|
12
|
+
|
|
13
|
+
Priority order
|
|
14
|
+
--------------
|
|
15
|
+
Validation: Findymail → Hunter → ZeroBounce
|
|
16
|
+
Guess/find: Findymail → Hunter → ZeroBounce → Apollo
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
from typing import Dict, List, Optional, Any
|
|
26
|
+
|
|
27
|
+
import aiohttp
|
|
28
|
+
|
|
29
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
# Dhisana utility imports
|
|
31
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
from dhisana.schemas.sales import HubSpotLeadInformation
|
|
33
|
+
from dhisana.utils.field_validators import validate_and_clean_email
|
|
34
|
+
from dhisana.utils.apollo_tools import enrich_user_info_with_apollo
|
|
35
|
+
from dhisana.utils.assistant_tool_tag import assistant_tool
|
|
36
|
+
from dhisana.utils.cache_output_tools import cache_output, retrieve_output
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# ===========================================================================
|
|
41
|
+
# 0. FINDYMAIL HELPERS
|
|
42
|
+
# ===========================================================================
|
|
43
|
+
FINDYMAIL_BASE_URL = "https://app.findymail.com/api"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_findymail_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Retrieve the Findymail API key either from tool_config or environment.
|
|
49
|
+
Tool‑config JSON shape expected:
|
|
50
|
+
{
|
|
51
|
+
"name": "findymail",
|
|
52
|
+
"configuration": [
|
|
53
|
+
{"name": "apiKey", "value": "<API_KEY>"}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
"""
|
|
57
|
+
if tool_config:
|
|
58
|
+
fm_cfg = next(
|
|
59
|
+
(item for item in tool_config if item.get("name") == "findymail"), None
|
|
60
|
+
)
|
|
61
|
+
if fm_cfg:
|
|
62
|
+
cfg_map = {
|
|
63
|
+
c["name"]: c["value"] for c in fm_cfg.get("configuration", []) if c
|
|
64
|
+
}
|
|
65
|
+
api_key = cfg_map.get("apiKey")
|
|
66
|
+
else:
|
|
67
|
+
api_key = None
|
|
68
|
+
else:
|
|
69
|
+
api_key = None
|
|
70
|
+
|
|
71
|
+
api_key = api_key or os.getenv("FINDYMAIL_API_KEY")
|
|
72
|
+
if not api_key:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"Findymail integration is not configured. Please configure the connection to Findymail in Integrations."
|
|
75
|
+
)
|
|
76
|
+
return ""
|
|
77
|
+
return api_key
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ===========================================================================
|
|
81
|
+
# 1. ACCESS‑TOKEN HELPERS FOR EXISTING PROVIDERS
|
|
82
|
+
# ===========================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_zero_bounce_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
86
|
+
"""Retrieve ZeroBounce key from config/env."""
|
|
87
|
+
if tool_config:
|
|
88
|
+
zb_cfg = next(
|
|
89
|
+
(item for item in tool_config if item.get("name") == "zerobounce"), None
|
|
90
|
+
)
|
|
91
|
+
if zb_cfg:
|
|
92
|
+
cfg_map = {
|
|
93
|
+
c["name"]: c["value"] for c in zb_cfg.get("configuration", []) if c
|
|
94
|
+
}
|
|
95
|
+
api_key = cfg_map.get("apiKey")
|
|
96
|
+
else:
|
|
97
|
+
api_key = None
|
|
98
|
+
else:
|
|
99
|
+
api_key = None
|
|
100
|
+
|
|
101
|
+
api_key = api_key or os.getenv("ZERO_BOUNCE_API_KEY")
|
|
102
|
+
if not api_key:
|
|
103
|
+
logger.warning(
|
|
104
|
+
"ZeroBounce integration is not configured. Please configure the connection to ZeroBounce in Integrations."
|
|
105
|
+
)
|
|
106
|
+
return ""
|
|
107
|
+
return api_key
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_hunter_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
111
|
+
"""Retrieve Hunter.io key from config/env."""
|
|
112
|
+
if tool_config:
|
|
113
|
+
h_cfg = next(
|
|
114
|
+
(item for item in tool_config if item.get("name") == "hunter"), None
|
|
115
|
+
)
|
|
116
|
+
if h_cfg:
|
|
117
|
+
cfg_map = {
|
|
118
|
+
c["name"]: c["value"] for c in h_cfg.get("configuration", []) if c
|
|
119
|
+
}
|
|
120
|
+
api_key = cfg_map.get("apiKey")
|
|
121
|
+
else:
|
|
122
|
+
api_key = None
|
|
123
|
+
else:
|
|
124
|
+
api_key = None
|
|
125
|
+
|
|
126
|
+
api_key = api_key or os.getenv("HUNTER_API_KEY")
|
|
127
|
+
if not api_key:
|
|
128
|
+
logger.warning(
|
|
129
|
+
"Hunter integration is not configured. Please configure the connection to Hunter in Integrations."
|
|
130
|
+
)
|
|
131
|
+
return ""
|
|
132
|
+
return api_key
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ===========================================================================
|
|
136
|
+
# 2. VALIDATION FUNCTIONS
|
|
137
|
+
# ===========================================================================
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@assistant_tool
|
|
141
|
+
async def check_email_validity_with_findymail(
|
|
142
|
+
email_id: str,
|
|
143
|
+
tool_config: Optional[List[Dict]] = None,
|
|
144
|
+
) -> Dict[str, Any]:
|
|
145
|
+
"""
|
|
146
|
+
Validate deliverability using Findymail `/verify` endpoint.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
{
|
|
151
|
+
"email": str,
|
|
152
|
+
"confidence": "high" | "low",
|
|
153
|
+
"is_valid": bool
|
|
154
|
+
}
|
|
155
|
+
"""
|
|
156
|
+
logger.info("Entering check_email_validity_with_findymail: %s", email_id)
|
|
157
|
+
|
|
158
|
+
if not email_id or not re.fullmatch(r"[^@]+@[^@]+\.[^@]+", email_id):
|
|
159
|
+
return {"email": email_id, "confidence": "low", "is_valid": False}
|
|
160
|
+
|
|
161
|
+
cache_key = f"findymail:{email_id}"
|
|
162
|
+
cached = retrieve_output("findymail_validate", cache_key)
|
|
163
|
+
if cached:
|
|
164
|
+
return json.loads(cached[0])
|
|
165
|
+
|
|
166
|
+
api_key = get_findymail_access_token(tool_config)
|
|
167
|
+
if not api_key:
|
|
168
|
+
return {"email": email_id, "confidence": "low", "is_valid": False}
|
|
169
|
+
|
|
170
|
+
url = f"{FINDYMAIL_BASE_URL}/verify"
|
|
171
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
async with aiohttp.ClientSession() as session:
|
|
175
|
+
async with session.post(url, json={"email": email_id}, headers=headers) as r:
|
|
176
|
+
if r.status != 200:
|
|
177
|
+
logger.warning("[Findymail] verify non‑200: %s", r.status)
|
|
178
|
+
result = {"email": email_id, "confidence": "low", "is_valid": False}
|
|
179
|
+
else:
|
|
180
|
+
data = await r.json()
|
|
181
|
+
verified = bool(data.get("verified") or data.get("result") == "verified")
|
|
182
|
+
result = {
|
|
183
|
+
"email": email_id,
|
|
184
|
+
"confidence": "high" if verified else "low",
|
|
185
|
+
"is_valid": verified,
|
|
186
|
+
}
|
|
187
|
+
except Exception as ex:
|
|
188
|
+
logger.exception("[Findymail] verify exception: %s", ex)
|
|
189
|
+
result = {"email": email_id, "confidence": "low", "is_valid": False}
|
|
190
|
+
|
|
191
|
+
cache_output("findymail_validate", cache_key, [json.dumps(result)])
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ───── ZeroBounce mapping/validation ───────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _map_zerobounce_status_to_confidence(status: str) -> str:
|
|
199
|
+
status = status.lower()
|
|
200
|
+
if status == "valid":
|
|
201
|
+
return "high"
|
|
202
|
+
if status in ("catch-all", "unknown"):
|
|
203
|
+
return "medium"
|
|
204
|
+
return "low"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@assistant_tool
|
|
208
|
+
async def check_email_validity_with_zero_bounce(
|
|
209
|
+
email_id: str,
|
|
210
|
+
tool_config: Optional[List[Dict]] = None,
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
logger.info("Entering check_email_validity_with_zero_bounce: %s", email_id)
|
|
213
|
+
if not email_id or not re.fullmatch(r"[^@]+@[^@]+\.[^@]+", email_id):
|
|
214
|
+
return {"email": email_id, "confidence": "low", "is_valid": False}
|
|
215
|
+
|
|
216
|
+
cache_key = f"zerobounce:{email_id}"
|
|
217
|
+
cached = retrieve_output("zerobounce_validate", cache_key)
|
|
218
|
+
if cached:
|
|
219
|
+
return json.loads(cached[0])
|
|
220
|
+
|
|
221
|
+
api_key = get_zero_bounce_access_token(tool_config)
|
|
222
|
+
if not api_key:
|
|
223
|
+
return {"email": email_id, "confidence": "low", "is_valid": False}
|
|
224
|
+
|
|
225
|
+
url = f"https://api.zerobounce.net/v2/validate?api_key={api_key}&email={email_id}"
|
|
226
|
+
try:
|
|
227
|
+
async with aiohttp.ClientSession() as session:
|
|
228
|
+
async with session.get(url) as r:
|
|
229
|
+
if r.status != 200:
|
|
230
|
+
logger.warning("[ZeroBounce] non‑200: %s", r.status)
|
|
231
|
+
result = {"email": email_id, "confidence": "low", "is_valid": False}
|
|
232
|
+
else:
|
|
233
|
+
data = await r.json()
|
|
234
|
+
conf = _map_zerobounce_status_to_confidence(data.get("status", ""))
|
|
235
|
+
result = {
|
|
236
|
+
"email": email_id,
|
|
237
|
+
"confidence": conf,
|
|
238
|
+
"is_valid": conf == "high",
|
|
239
|
+
}
|
|
240
|
+
except Exception as ex:
|
|
241
|
+
logger.exception("[ZeroBounce] validate exception: %s", ex)
|
|
242
|
+
result = {"email": email_id, "confidence": "low", "is_valid": False}
|
|
243
|
+
|
|
244
|
+
cache_output("zerobounce_validate", cache_key, [json.dumps(result)])
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ───── Hunter mapping/validation ───────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _map_hunter_status_to_confidence(status: str) -> str:
|
|
252
|
+
status = status.lower()
|
|
253
|
+
if status == "deliverable":
|
|
254
|
+
return "high"
|
|
255
|
+
if status in ("unknown", "accept_all"):
|
|
256
|
+
return "medium"
|
|
257
|
+
return "low"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@assistant_tool
|
|
261
|
+
async def check_email_validity_with_hunter(
|
|
262
|
+
email_id: str,
|
|
263
|
+
tool_config: Optional[List[Dict]] = None,
|
|
264
|
+
) -> Dict[str, Any]:
|
|
265
|
+
logger.info("Entering check_email_validity_with_hunter: %s", email_id)
|
|
266
|
+
if not email_id or not re.fullmatch(r"[^@]+@[^@]+\.[^@]+", email_id):
|
|
267
|
+
return {"email": email_id, "confidence": "low", "is_valid": False}
|
|
268
|
+
|
|
269
|
+
cache_key = f"hunter:{email_id}"
|
|
270
|
+
cached = retrieve_output("hunter_validate", cache_key)
|
|
271
|
+
if cached:
|
|
272
|
+
return json.loads(cached[0])
|
|
273
|
+
|
|
274
|
+
api_key = get_hunter_access_token(tool_config)
|
|
275
|
+
if not api_key:
|
|
276
|
+
return {"email": email_id, "confidence": "low", "is_valid": False}
|
|
277
|
+
|
|
278
|
+
url = f"https://api.hunter.io/v2/email-verifier?email={email_id}&api_key={api_key}"
|
|
279
|
+
try:
|
|
280
|
+
async with aiohttp.ClientSession() as session:
|
|
281
|
+
async with session.get(url) as r:
|
|
282
|
+
if r.status != 200:
|
|
283
|
+
logger.warning("[Hunter] non‑200: %s", r.status)
|
|
284
|
+
result = {"email": email_id, "confidence": "low", "is_valid": False}
|
|
285
|
+
else:
|
|
286
|
+
data = await r.json()
|
|
287
|
+
res = data.get("data", {}).get("result", "")
|
|
288
|
+
conf = _map_hunter_status_to_confidence(res)
|
|
289
|
+
result = {
|
|
290
|
+
"email": email_id,
|
|
291
|
+
"confidence": conf,
|
|
292
|
+
"is_valid": conf == "high",
|
|
293
|
+
}
|
|
294
|
+
except Exception as ex:
|
|
295
|
+
logger.exception("[Hunter] validate exception: %s", ex)
|
|
296
|
+
result = {"email": email_id, "confidence": "low", "is_valid": False}
|
|
297
|
+
|
|
298
|
+
cache_output("hunter_validate", cache_key, [json.dumps(result)])
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ===========================================================================
|
|
303
|
+
# 3. GUESS / FIND FUNCTIONS
|
|
304
|
+
# ===========================================================================
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@assistant_tool
|
|
308
|
+
async def guess_email_with_findymail(
|
|
309
|
+
first_name: str,
|
|
310
|
+
last_name: str,
|
|
311
|
+
domain: str,
|
|
312
|
+
user_linkedin_url: Optional[str] = None,
|
|
313
|
+
middle_name: Optional[str] = None,
|
|
314
|
+
tool_config: Optional[List[Dict]] = None,
|
|
315
|
+
) -> Dict[str, Any]:
|
|
316
|
+
"""Use Findymail to guess an email.
|
|
317
|
+
|
|
318
|
+
If ``user_linkedin_url`` is provided, the function queries ``/search/linkedin``.
|
|
319
|
+
Otherwise it falls back to ``/search/name`` with ``first_name``/``last_name``
|
|
320
|
+
and ``domain``. Only verified emails are returned and therefore considered
|
|
321
|
+
high confidence.
|
|
322
|
+
"""
|
|
323
|
+
logger.info("Entering guess_email_with_findymail")
|
|
324
|
+
|
|
325
|
+
if user_linkedin_url:
|
|
326
|
+
cache_key = f"findymail:{user_linkedin_url}"
|
|
327
|
+
else:
|
|
328
|
+
if not first_name or not last_name or not domain:
|
|
329
|
+
return {"email": "", "email_confidence": "low"}
|
|
330
|
+
cache_key = f"findymail:{first_name}_{last_name}_{domain}"
|
|
331
|
+
|
|
332
|
+
api_key = get_findymail_access_token(tool_config)
|
|
333
|
+
if not api_key:
|
|
334
|
+
return {"email": "", "email_confidence": "low"}
|
|
335
|
+
|
|
336
|
+
cached = retrieve_output("findymail_guess", cache_key)
|
|
337
|
+
if cached:
|
|
338
|
+
return json.loads(cached[0])
|
|
339
|
+
|
|
340
|
+
if user_linkedin_url:
|
|
341
|
+
url = f"{FINDYMAIL_BASE_URL}/search/linkedin"
|
|
342
|
+
payload = {"linkedin_url": user_linkedin_url, "webhook_url": None}
|
|
343
|
+
else:
|
|
344
|
+
url = f"{FINDYMAIL_BASE_URL}/search/name"
|
|
345
|
+
full_name = " ".join(filter(None, [first_name, middle_name, last_name]))
|
|
346
|
+
payload = {"name": full_name, "domain": domain}
|
|
347
|
+
|
|
348
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
async with aiohttp.ClientSession() as session:
|
|
352
|
+
async with session.post(url, headers=headers, json=payload) as r:
|
|
353
|
+
if r.status != 200:
|
|
354
|
+
logger.warning("[Findymail] search non‑200: %s", r.status)
|
|
355
|
+
result = {"email": "", "email_confidence": "low"}
|
|
356
|
+
else:
|
|
357
|
+
data = await r.json()
|
|
358
|
+
contact = data.get("contact")
|
|
359
|
+
found = contact.get("email", "") if contact else ""
|
|
360
|
+
if found:
|
|
361
|
+
result = {
|
|
362
|
+
"email": found,
|
|
363
|
+
"email_confidence": "high",
|
|
364
|
+
"contact_info": json.dumps(contact) if contact else "",
|
|
365
|
+
}
|
|
366
|
+
else:
|
|
367
|
+
result = {"email": "", "email_confidence": "low"}
|
|
368
|
+
except Exception as ex:
|
|
369
|
+
logger.exception("[Findymail] search exception: %s", ex)
|
|
370
|
+
result = {"email": "", "email_confidence": "low"}
|
|
371
|
+
|
|
372
|
+
cache_output("findymail_guess", cache_key, [json.dumps(result)])
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ───── ZeroBounce guess ────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@assistant_tool
|
|
380
|
+
async def guess_email_with_zero_bounce(
|
|
381
|
+
first_name: str,
|
|
382
|
+
last_name: str,
|
|
383
|
+
domain: str,
|
|
384
|
+
user_linkedin_url: Optional[str] = None, # unused
|
|
385
|
+
middle_name: Optional[str] = None,
|
|
386
|
+
tool_config: Optional[List[Dict]] = None,
|
|
387
|
+
) -> Dict[str, Any]:
|
|
388
|
+
logger.info("Entering guess_email_with_zero_bounce")
|
|
389
|
+
if not first_name or not last_name or not domain:
|
|
390
|
+
return {"email": "", "email_confidence": "low"}
|
|
391
|
+
|
|
392
|
+
api_key = get_zero_bounce_access_token(tool_config)
|
|
393
|
+
if not api_key:
|
|
394
|
+
return {"email": "", "email_confidence": "low"}
|
|
395
|
+
|
|
396
|
+
cache_key = f"zerobounce:guess:{first_name}_{last_name}_{domain}_{middle_name or ''}"
|
|
397
|
+
cached = retrieve_output("zerobounce_guess", cache_key)
|
|
398
|
+
if cached:
|
|
399
|
+
return json.loads(cached[0])
|
|
400
|
+
|
|
401
|
+
url = (
|
|
402
|
+
"https://api.zerobounce.net/v2/guessformat"
|
|
403
|
+
f"?api_key={api_key}&domain={domain}"
|
|
404
|
+
f"&first_name={first_name}&middle_name={middle_name or ''}&last_name={last_name}"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
async with aiohttp.ClientSession() as session:
|
|
409
|
+
async with session.get(url) as r:
|
|
410
|
+
if r.status != 200:
|
|
411
|
+
logger.warning("[ZeroBounce] guessformat non‑200: %s", r.status)
|
|
412
|
+
result = {"email": "", "email_confidence": "low"}
|
|
413
|
+
else:
|
|
414
|
+
data = await r.json()
|
|
415
|
+
if "email_confidence" not in data:
|
|
416
|
+
data["email_confidence"] = (
|
|
417
|
+
"high" if data.get("email") else "low"
|
|
418
|
+
)
|
|
419
|
+
result = data
|
|
420
|
+
except Exception as ex:
|
|
421
|
+
logger.exception("[ZeroBounce] guess exception: %s", ex)
|
|
422
|
+
result = {"email": "", "email_confidence": "low"}
|
|
423
|
+
|
|
424
|
+
cache_output("zerobounce_guess", cache_key, [json.dumps(result)])
|
|
425
|
+
return result
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ───── Hunter guess ────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@assistant_tool
|
|
432
|
+
async def guess_email_with_hunter(
|
|
433
|
+
first_name: str,
|
|
434
|
+
last_name: str,
|
|
435
|
+
domain: str,
|
|
436
|
+
user_linkedin_url: Optional[str] = None, # unused
|
|
437
|
+
middle_name: Optional[str] = None,
|
|
438
|
+
tool_config: Optional[List[Dict]] = None,
|
|
439
|
+
) -> Dict[str, Any]:
|
|
440
|
+
logger.info("Entering guess_email_with_hunter")
|
|
441
|
+
if not first_name or not last_name or not domain:
|
|
442
|
+
return {"email": "", "email_confidence": "low"}
|
|
443
|
+
|
|
444
|
+
api_key = get_hunter_access_token(tool_config)
|
|
445
|
+
if not api_key:
|
|
446
|
+
return {"email": "", "email_confidence": "low"}
|
|
447
|
+
|
|
448
|
+
url = (
|
|
449
|
+
"https://api.hunter.io/v2/email-finder"
|
|
450
|
+
f"?domain={domain}&first_name={first_name}&last_name={last_name}"
|
|
451
|
+
f"&api_key={api_key}"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
async with aiohttp.ClientSession() as session:
|
|
456
|
+
async with session.get(url) as r:
|
|
457
|
+
if r.status != 200:
|
|
458
|
+
logger.warning("[Hunter] email-finder non‑200: %s", r.status)
|
|
459
|
+
result = {"email": "", "email_confidence": "low"}
|
|
460
|
+
else:
|
|
461
|
+
data = await r.json()
|
|
462
|
+
email = data.get("data", {}).get("email", "")
|
|
463
|
+
score = float(data.get("data", {}).get("score", 0) or 0)
|
|
464
|
+
if score >= 80:
|
|
465
|
+
conf = "high"
|
|
466
|
+
elif score >= 50:
|
|
467
|
+
conf = "medium"
|
|
468
|
+
else:
|
|
469
|
+
conf = "low"
|
|
470
|
+
result = {"email": email, "email_confidence": conf}
|
|
471
|
+
except Exception as ex:
|
|
472
|
+
logger.exception("[Hunter] guess exception: %s", ex)
|
|
473
|
+
result = {"email": "", "email_confidence": "low"}
|
|
474
|
+
|
|
475
|
+
return result
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ───── Apollo guess (fallback) ─────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@assistant_tool
|
|
482
|
+
async def guess_email_with_apollo(
|
|
483
|
+
first_name: str,
|
|
484
|
+
last_name: str,
|
|
485
|
+
domain: str,
|
|
486
|
+
user_linkedin_url: Optional[str] = None,
|
|
487
|
+
middle_name: Optional[str] = None,
|
|
488
|
+
tool_config: Optional[List[Dict]] = None,
|
|
489
|
+
) -> Dict[str, Any]:
|
|
490
|
+
logger.info("Entering guess_email_with_apollo")
|
|
491
|
+
if not first_name or not last_name or not domain:
|
|
492
|
+
return {"email": "", "email_confidence": "low"}
|
|
493
|
+
|
|
494
|
+
apollo_cfg = next(
|
|
495
|
+
(item for item in tool_config or [] if item.get("name") == "apollo"), None
|
|
496
|
+
)
|
|
497
|
+
if not apollo_cfg:
|
|
498
|
+
return {"email": "", "email_confidence": "low"}
|
|
499
|
+
|
|
500
|
+
input_lead = {
|
|
501
|
+
"first_name": first_name,
|
|
502
|
+
"last_name": last_name,
|
|
503
|
+
"primary_domain_of_organization": domain,
|
|
504
|
+
"user_linkedin_url": user_linkedin_url or "",
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
enriched = await enrich_user_info_with_apollo(input_lead, tool_config)
|
|
509
|
+
except Exception as ex:
|
|
510
|
+
logger.exception("[Apollo] enrich exception: %s", ex)
|
|
511
|
+
enriched = {}
|
|
512
|
+
|
|
513
|
+
apollo_email = enriched.get("email", "")
|
|
514
|
+
if not apollo_email:
|
|
515
|
+
return {"email": "", "email_confidence": "low"}
|
|
516
|
+
|
|
517
|
+
# quick re‑check with Hunter
|
|
518
|
+
validation = await check_email_validity_with_hunter(apollo_email, tool_config)
|
|
519
|
+
conf = validation.get("confidence", "low")
|
|
520
|
+
return {"email": apollo_email, "email_confidence": conf}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ─── Provider map
|
|
524
|
+
GUESS_EMAIL_TOOL_MAP = {
|
|
525
|
+
"findymail": guess_email_with_findymail,
|
|
526
|
+
"hunter": guess_email_with_hunter,
|
|
527
|
+
"zerobounce": guess_email_with_zero_bounce,
|
|
528
|
+
"apollo": guess_email_with_apollo,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
# ===========================================================================
|
|
532
|
+
# 4. AGGREGATORS
|
|
533
|
+
# ===========================================================================
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@assistant_tool
|
|
537
|
+
async def check_email_validity(
|
|
538
|
+
email_id: str,
|
|
539
|
+
tool_config: Optional[List[Dict]] = None,
|
|
540
|
+
) -> Dict[str, Any]:
|
|
541
|
+
"""
|
|
542
|
+
Validate by provider priority:
|
|
543
|
+
1) Findymail 2) Hunter 3) ZeroBounce
|
|
544
|
+
"""
|
|
545
|
+
logger.info("Entering check_email_validity")
|
|
546
|
+
if not tool_config:
|
|
547
|
+
return {"email": email_id, "confidence": "low", "is_valid": False}
|
|
548
|
+
|
|
549
|
+
names = [c.get("name") for c in tool_config if c.get("name")]
|
|
550
|
+
priority = ["findymail", "hunter", "zerobounce"]
|
|
551
|
+
|
|
552
|
+
result: Dict[str, Any] = {"email": email_id, "confidence": "low", "is_valid": False}
|
|
553
|
+
|
|
554
|
+
for provider in priority:
|
|
555
|
+
if provider not in names:
|
|
556
|
+
continue
|
|
557
|
+
if provider == "findymail":
|
|
558
|
+
result = await check_email_validity_with_findymail(email_id, tool_config)
|
|
559
|
+
elif provider == "hunter":
|
|
560
|
+
result = await check_email_validity_with_hunter(email_id, tool_config)
|
|
561
|
+
else:
|
|
562
|
+
result = await check_email_validity_with_zero_bounce(email_id, tool_config)
|
|
563
|
+
|
|
564
|
+
if result["confidence"] in ("high", "low"):
|
|
565
|
+
break
|
|
566
|
+
|
|
567
|
+
logger.info("Exiting check_email_validity with %s", result)
|
|
568
|
+
return result
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@assistant_tool
|
|
572
|
+
async def guess_email(
|
|
573
|
+
first_name: str,
|
|
574
|
+
last_name: str,
|
|
575
|
+
domain: str,
|
|
576
|
+
middle_name: Optional[str] = None,
|
|
577
|
+
user_linkedin_url: Optional[str] = None,
|
|
578
|
+
tool_config: Optional[List[Dict]] = None,
|
|
579
|
+
) -> Dict[str, Any]:
|
|
580
|
+
"""
|
|
581
|
+
Guess by provider priority:
|
|
582
|
+
1) Findymail 2) Hunter 3) ZeroBounce 4) Apollo
|
|
583
|
+
"""
|
|
584
|
+
logger.info("Entering guess_email")
|
|
585
|
+
if not tool_config:
|
|
586
|
+
return {"email": "", "email_confidence": "low"}
|
|
587
|
+
|
|
588
|
+
names = [c.get("name") for c in tool_config if c.get("name")]
|
|
589
|
+
priority = ["findymail", "hunter", "zerobounce", "apollo"]
|
|
590
|
+
|
|
591
|
+
result: Dict[str, Any] = {"email": "", "email_confidence": "low"}
|
|
592
|
+
|
|
593
|
+
for provider in priority:
|
|
594
|
+
if provider not in names:
|
|
595
|
+
continue
|
|
596
|
+
guess_fn = GUESS_EMAIL_TOOL_MAP[provider]
|
|
597
|
+
result = await guess_fn(
|
|
598
|
+
first_name,
|
|
599
|
+
last_name,
|
|
600
|
+
domain,
|
|
601
|
+
user_linkedin_url,
|
|
602
|
+
middle_name,
|
|
603
|
+
tool_config,
|
|
604
|
+
)
|
|
605
|
+
if result.get("email_confidence") == "high":
|
|
606
|
+
break
|
|
607
|
+
|
|
608
|
+
logger.info("Exiting guess_email with %s", result)
|
|
609
|
+
return result
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# ===========================================================================
|
|
613
|
+
# 5. PROCESS EMAIL PROPERTIES (unchanged except provider names usable)
|
|
614
|
+
# ===========================================================================
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@assistant_tool
|
|
618
|
+
async def process_email_properties(
|
|
619
|
+
input_properties: Dict[str, Any],
|
|
620
|
+
tool_config: Optional[List[Dict]] = None,
|
|
621
|
+
) -> Dict[str, Any]:
|
|
622
|
+
"""Central orchestrator used elsewhere in Dhisana."""
|
|
623
|
+
logger.info("Entering process_email_properties")
|
|
624
|
+
|
|
625
|
+
first_name = input_properties.get("first_name", "")
|
|
626
|
+
last_name = input_properties.get("last_name", "")
|
|
627
|
+
email = validate_and_clean_email(input_properties.get("email", ""))
|
|
628
|
+
additional_properties = input_properties.get("additional_properties", {})
|
|
629
|
+
user_linkedin_url = input_properties.get("user_linkedin_url", "")
|
|
630
|
+
domain = input_properties.get("primary_domain_of_organization", "")
|
|
631
|
+
|
|
632
|
+
if email:
|
|
633
|
+
val = await check_email_validity(email, tool_config)
|
|
634
|
+
if val["is_valid"] and val["confidence"] == "high":
|
|
635
|
+
input_properties["email_validation_status"] = "valid"
|
|
636
|
+
else:
|
|
637
|
+
input_properties["email_validation_status"] = "invalid"
|
|
638
|
+
else:
|
|
639
|
+
if not domain:
|
|
640
|
+
input_properties["email_validation_status"] = "invalid"
|
|
641
|
+
input_properties["email"] = ""
|
|
642
|
+
else:
|
|
643
|
+
# Try HubSpot lookup first (disabled by default)
|
|
644
|
+
hubspot_lead_info = None
|
|
645
|
+
# hubspot_lead_info = await lookup_contact_by_name_and_domain(
|
|
646
|
+
# first_name, last_name, domain, tool_config=tool_config
|
|
647
|
+
# )
|
|
648
|
+
if (
|
|
649
|
+
hubspot_lead_info
|
|
650
|
+
and isinstance(hubspot_lead_info, HubSpotLeadInformation)
|
|
651
|
+
and hubspot_lead_info.email
|
|
652
|
+
):
|
|
653
|
+
hubspot_email = hubspot_lead_info.email
|
|
654
|
+
val = await check_email_validity(hubspot_email, tool_config)
|
|
655
|
+
if val["is_valid"] and val["confidence"] == "high":
|
|
656
|
+
input_properties["email"] = hubspot_email
|
|
657
|
+
input_properties["email_validation_status"] = "valid"
|
|
658
|
+
else:
|
|
659
|
+
g = await guess_email(
|
|
660
|
+
first_name,
|
|
661
|
+
last_name,
|
|
662
|
+
domain,
|
|
663
|
+
"",
|
|
664
|
+
user_linkedin_url,
|
|
665
|
+
tool_config,
|
|
666
|
+
)
|
|
667
|
+
if is_guess_usable(g):
|
|
668
|
+
input_properties["email"] = g["email"]
|
|
669
|
+
if g["email_confidence"] == "high":
|
|
670
|
+
input_properties["email_validation_status"] = "valid"
|
|
671
|
+
else:
|
|
672
|
+
input_properties["email_validation_status"] = "invalid"
|
|
673
|
+
additional_properties["guessed_email"] = g["email"]
|
|
674
|
+
else:
|
|
675
|
+
g = await guess_email(
|
|
676
|
+
first_name,
|
|
677
|
+
last_name,
|
|
678
|
+
domain,
|
|
679
|
+
"",
|
|
680
|
+
user_linkedin_url,
|
|
681
|
+
tool_config,
|
|
682
|
+
)
|
|
683
|
+
input_properties["email"] = g["email"]
|
|
684
|
+
if is_guess_usable(g) and g["email_confidence"] == "high":
|
|
685
|
+
input_properties["email_validation_status"] = "valid"
|
|
686
|
+
else:
|
|
687
|
+
input_properties["email_validation_status"] = "invalid"
|
|
688
|
+
additional_properties["guessed_email"] = g["email"]
|
|
689
|
+
|
|
690
|
+
input_properties["additional_properties"] = additional_properties
|
|
691
|
+
logger.info("Exiting process_email_properties")
|
|
692
|
+
return input_properties
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
# ===========================================================================
|
|
696
|
+
# 6. HELPER FUNCTIONS
|
|
697
|
+
# ===========================================================================
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
async def safe_read_json_or_text(response: aiohttp.ClientResponse) -> Any:
|
|
701
|
+
"""Attempt JSON parsing; fallback to text."""
|
|
702
|
+
try:
|
|
703
|
+
return await response.json()
|
|
704
|
+
except Exception: # noqa: BLE001
|
|
705
|
+
return await response.text()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def extract_domain(email: str) -> str:
|
|
709
|
+
"""user@domain.com → domain.com"""
|
|
710
|
+
return email.split("@")[-1].strip() if "@" in email else ""
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def is_guess_usable(guess_result: Dict[str, Any]) -> bool:
|
|
714
|
+
"""Treat high/medium as usable."""
|
|
715
|
+
if not guess_result:
|
|
716
|
+
return False
|
|
717
|
+
return guess_result.get("email_confidence", "").lower() in ("high", "medium")
|