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,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")