dhisana 0.0.1.dev116__py3-none-any.whl → 0.0.1.dev236__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. dhisana/schemas/common.py +10 -1
  2. dhisana/schemas/sales.py +203 -22
  3. dhisana/utils/add_mapping.py +0 -2
  4. dhisana/utils/apollo_tools.py +739 -119
  5. dhisana/utils/built_with_api_tools.py +4 -2
  6. dhisana/utils/check_email_validity_tools.py +35 -18
  7. dhisana/utils/check_for_intent_signal.py +1 -2
  8. dhisana/utils/check_linkedin_url_validity.py +34 -8
  9. dhisana/utils/clay_tools.py +3 -2
  10. dhisana/utils/clean_properties.py +1 -4
  11. dhisana/utils/compose_salesnav_query.py +0 -1
  12. dhisana/utils/compose_search_query.py +7 -3
  13. dhisana/utils/composite_tools.py +0 -1
  14. dhisana/utils/dataframe_tools.py +2 -2
  15. dhisana/utils/email_body_utils.py +72 -0
  16. dhisana/utils/email_provider.py +174 -35
  17. dhisana/utils/enrich_lead_information.py +183 -53
  18. dhisana/utils/fetch_openai_config.py +129 -0
  19. dhisana/utils/field_validators.py +1 -1
  20. dhisana/utils/g2_tools.py +0 -1
  21. dhisana/utils/generate_content.py +0 -1
  22. dhisana/utils/generate_email.py +68 -23
  23. dhisana/utils/generate_email_response.py +294 -46
  24. dhisana/utils/generate_flow.py +0 -1
  25. dhisana/utils/generate_linkedin_connect_message.py +9 -2
  26. dhisana/utils/generate_linkedin_response_message.py +137 -66
  27. dhisana/utils/generate_structured_output_internal.py +317 -164
  28. dhisana/utils/google_custom_search.py +150 -44
  29. dhisana/utils/google_oauth_tools.py +721 -0
  30. dhisana/utils/google_workspace_tools.py +278 -54
  31. dhisana/utils/hubspot_clearbit.py +3 -1
  32. dhisana/utils/hubspot_crm_tools.py +718 -272
  33. dhisana/utils/instantly_tools.py +3 -1
  34. dhisana/utils/lusha_tools.py +10 -7
  35. dhisana/utils/mailgun_tools.py +150 -0
  36. dhisana/utils/microsoft365_tools.py +447 -0
  37. dhisana/utils/openai_assistant_and_file_utils.py +121 -177
  38. dhisana/utils/openai_helpers.py +8 -6
  39. dhisana/utils/parse_linkedin_messages_txt.py +1 -3
  40. dhisana/utils/profile.py +37 -0
  41. dhisana/utils/proxy_curl_tools.py +377 -76
  42. dhisana/utils/proxycurl_search_leads.py +426 -0
  43. dhisana/utils/research_lead.py +3 -3
  44. dhisana/utils/sales_navigator_crawler.py +1 -6
  45. dhisana/utils/salesforce_crm_tools.py +323 -50
  46. dhisana/utils/search_router.py +131 -0
  47. dhisana/utils/search_router_jobs.py +51 -0
  48. dhisana/utils/sendgrid_tools.py +126 -91
  49. dhisana/utils/serarch_router_local_business.py +75 -0
  50. dhisana/utils/serpapi_additional_tools.py +290 -0
  51. dhisana/utils/serpapi_google_jobs.py +117 -0
  52. dhisana/utils/serpapi_google_search.py +188 -0
  53. dhisana/utils/serpapi_local_business_search.py +129 -0
  54. dhisana/utils/serpapi_search_tools.py +360 -432
  55. dhisana/utils/serperdev_google_jobs.py +125 -0
  56. dhisana/utils/serperdev_local_business.py +154 -0
  57. dhisana/utils/serperdev_search.py +233 -0
  58. dhisana/utils/smtp_email_tools.py +178 -18
  59. dhisana/utils/test_connect.py +1603 -130
  60. dhisana/utils/trasform_json.py +3 -3
  61. dhisana/utils/web_download_parse_tools.py +0 -1
  62. dhisana/utils/zoominfo_tools.py +2 -3
  63. dhisana/workflow/test.py +1 -1
  64. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +1 -1
  65. dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
  66. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
  67. dhisana-0.0.1.dev116.dist-info/RECORD +0 -83
  68. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
  69. {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
@@ -3,19 +3,21 @@
3
3
  # Standard library
4
4
  # ─────────────────────────────────────────────────────────────────────────────
5
5
  import asyncio
6
- import base64
7
6
  import datetime
8
7
  import email
9
8
  import email.utils
10
9
  import hashlib
10
+ import html as html_lib
11
11
  import imaplib
12
12
  import logging
13
- import os
14
13
  import re
15
14
  import uuid
15
+ from email.errors import HeaderParseError
16
+ from email.header import Header, decode_header, make_header
17
+ from email.mime.multipart import MIMEMultipart
16
18
  from email.mime.text import MIMEText
17
19
  from datetime import datetime, timedelta, timezone
18
- from typing import Any, Dict, List, Optional, Tuple, Union
20
+ from typing import Any, Dict, List, Optional, Union
19
21
 
20
22
  # ─────────────────────────────────────────────────────────────────────────────
21
23
  # Third-party libraries
@@ -31,12 +33,39 @@ from dhisana.utils.google_workspace_tools import (
31
33
  QueryEmailContext,
32
34
  SendEmailContext,
33
35
  )
36
+ from dhisana.utils.email_body_utils import body_variants
34
37
 
35
38
 
36
39
  # --------------------------------------------------------------------------- #
37
40
  # Helper / Utility
38
41
  # --------------------------------------------------------------------------- #
39
42
 
43
+
44
+ def _decode_header_value(value: Any) -> str:
45
+ """Return a unicode string for an e-mail header field."""
46
+
47
+ if value is None:
48
+ return ""
49
+
50
+ if isinstance(value, Header):
51
+ value = str(value)
52
+
53
+ if isinstance(value, bytes):
54
+ try:
55
+ return value.decode("utf-8")
56
+ except UnicodeDecodeError:
57
+ return value.decode("latin-1", errors="replace")
58
+
59
+ if isinstance(value, str):
60
+ try:
61
+ decoded = make_header(decode_header(value))
62
+ return str(decoded)
63
+ except (HeaderParseError, UnicodeDecodeError, LookupError):
64
+ return value
65
+
66
+ return str(value)
67
+
68
+
40
69
  def _imap_date(iso_dt: Union[str, datetime]) -> str:
41
70
  """
42
71
  Convert an ISO 8601 datetime or datetime object into IMAP date format: DD-Mmm-YYYY.
@@ -76,6 +105,28 @@ def _to_datetime(val: Union[datetime, str]) -> datetime:
76
105
  ) from exc
77
106
 
78
107
 
108
+ def _looks_like_html(text: str) -> bool:
109
+ """Heuristically determine whether the body contains HTML markup."""
110
+ return bool(text and re.search(r"<[a-zA-Z][^>]*>", text))
111
+
112
+
113
+ def _html_to_plain_text(html: str) -> str:
114
+ """
115
+ Produce a very lightweight plain-text version of an HTML fragment.
116
+ This keeps newlines on block boundaries and strips tags.
117
+ """
118
+ if not html:
119
+ return ""
120
+ text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", html)
121
+ text = re.sub(r"(?i)<br\s*/?>", "\n", text)
122
+ text = re.sub(r"(?i)</(p|div|li|h[1-6])\s*>", "\n", text)
123
+ text = re.sub(r"(?is)<.*?>", "", text)
124
+ text = html_lib.unescape(text)
125
+ text = re.sub(r"\s+\n", "\n", text)
126
+ text = re.sub(r"\n{3,}", "\n\n", text)
127
+ return text.strip()
128
+
129
+
79
130
  # --------------------------------------------------------------------------- #
80
131
  # Outbound -- SMTP
81
132
  # --------------------------------------------------------------------------- #
@@ -101,7 +152,19 @@ async def send_email_via_smtp_async(
101
152
  str
102
153
  The Message-ID of the sent message (e.g., "<uuid@yourdomain.com>").
103
154
  """
104
- msg = MIMEText(ctx.body, _charset="utf-8")
155
+ plain_body, html_body, resolved_fmt = body_variants(
156
+ ctx.body,
157
+ getattr(ctx, "body_format", None),
158
+ )
159
+
160
+ if resolved_fmt == "text":
161
+ msg = MIMEText(plain_body, _subtype="plain", _charset="utf-8")
162
+ else:
163
+ # Build multipart/alternative so HTML-capable clients see rich content.
164
+ msg = MIMEMultipart("alternative")
165
+ msg.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
166
+ msg.attach(MIMEText(html_body, "html", _charset="utf-8"))
167
+
105
168
  msg["From"] = f"{ctx.sender_name} <{ctx.sender_email}>"
106
169
  msg["To"] = ctx.recipient
107
170
  msg["Subject"] = ctx.subject
@@ -152,7 +215,7 @@ def _parse_email_msg(raw_bytes: bytes) -> MessageItem:
152
215
  msg = email.message_from_bytes(raw_bytes)
153
216
 
154
217
  # Helper for reading headers
155
- hdr = lambda h: msg.get(h, "")
218
+ hdr = lambda h: _decode_header_value(msg.get(h))
156
219
 
157
220
  sender_name, sender_email = email.utils.parseaddr(hdr("From"))
158
221
  receiver_name, receiver_email = email.utils.parseaddr(hdr("To"))
@@ -165,10 +228,14 @@ def _parse_email_msg(raw_bytes: bytes) -> MessageItem:
165
228
  part.get_content_type() == "text/plain"
166
229
  and "attachment" not in str(part.get("Content-Disposition", ""))
167
230
  ):
168
- body = part.get_payload(decode=True).decode(errors="ignore")
169
- break
231
+ payload = part.get_payload(decode=True)
232
+ if payload is not None:
233
+ body = payload.decode(errors="ignore")
234
+ break
170
235
  else:
171
- body = msg.get_payload(decode=True).decode(errors="ignore")
236
+ payload = msg.get_payload(decode=True)
237
+ if payload is not None:
238
+ body = payload.decode(errors="ignore")
172
239
 
173
240
  # Parse the Date header to get a timezone-aware datetime
174
241
  try:
@@ -327,17 +394,99 @@ async def reply_to_email_via_smtp_async(
327
394
  )
328
395
  try:
329
396
  conn.login(username, password)
330
- conn.select(mailbox, readonly=False) # can set flags if needed
331
- # Search for the exact Message-ID header
332
- status, nums = conn.search(None, "HEADER", "Message-ID", ctx.message_id)
333
- if status != "OK" or not nums[0]:
334
- logging.warning("IMAP search for %r returned %s", ctx.message_id, nums)
397
+ # Sent messages usually live outside INBOX; build a candidate list
398
+ # from the provided mailbox, common sent folders, and any LISTed
399
+ # mailboxes containing "sent" (case-insensitive).
400
+ candidate_mailboxes = []
401
+ if mailbox:
402
+ candidate_mailboxes.append(mailbox)
403
+ candidate_mailboxes.extend([
404
+ "Sent",
405
+ "Sent Items",
406
+ "Sent Mail",
407
+ "[Gmail]/Sent Mail",
408
+ "[Gmail]/Sent Items",
409
+ "INBOX.Sent",
410
+ "INBOX/Sent",
411
+ ])
412
+ try:
413
+ status, mailboxes = conn.list()
414
+ if status == "OK" and mailboxes:
415
+ for mbox in mailboxes:
416
+ try:
417
+ decoded = mbox.decode(errors="ignore")
418
+ except Exception:
419
+ decoded = str(mbox)
420
+ # Parse flags + name from LIST response:
421
+ # e.g., (\\HasNoChildren \\Sent) "/" "Sent Items"
422
+ flags = set()
423
+ name_part = decoded
424
+ if ") " in decoded:
425
+ flags_raw, _, remainder = decoded.partition(") ")
426
+ flags = {f.lower() for f in flags_raw.strip("(").split() if f}
427
+ # remainder is like '"/" "Sent Items"' or '"/" Sent'
428
+ pieces = remainder.split(" ", 1)
429
+ if len(pieces) == 2:
430
+ name_part = pieces[1].strip()
431
+ else:
432
+ name_part = remainder.strip()
433
+ name_part = name_part.strip()
434
+ if name_part.startswith('"') and name_part.endswith('"'):
435
+ name_part = name_part[1:-1]
436
+
437
+ # Prefer provider-marked \Sent flag; otherwise fall back to substring match.
438
+ is_sent_flag = "\\sent" in flags
439
+ is_sent_name = "sent" in name_part.lower()
440
+ if is_sent_flag or is_sent_name:
441
+ candidate_mailboxes.append(name_part)
442
+ except Exception:
443
+ logging.exception("IMAP LIST failed; continuing with default sent folders")
444
+ # Deduplicate while preserving order
445
+ seen = set()
446
+ candidate_mailboxes = [m for m in candidate_mailboxes if not (m in seen or seen.add(m))]
447
+
448
+ msg_data = None
449
+ for mb in candidate_mailboxes:
450
+ def _try_select(name: str) -> bool:
451
+ # Quote mailbox names with spaces or special chars; fall back to raw.
452
+ for candidate in (f'"{name}"', name):
453
+ try:
454
+ status, _ = conn.select(candidate, readonly=False)
455
+ except imaplib.IMAP4.error as exc:
456
+ logging.warning("IMAP select %r failed: %s", candidate, exc)
457
+ continue
458
+ except Exception as exc:
459
+ logging.warning("IMAP select %r failed: %s", candidate, exc)
460
+ continue
461
+ if status == "OK":
462
+ return True
463
+ return False
464
+
465
+ if not _try_select(mb):
466
+ continue
467
+ # Search for the Message-ID header. Some servers store IDs without angle
468
+ # brackets or require quoted search terms, so try a few variants.
469
+ candidates = [ctx.message_id]
470
+ trimmed = ctx.message_id.strip()
471
+ if trimmed.startswith("<") and trimmed.endswith(">"):
472
+ candidates.append(trimmed[1:-1])
473
+ for mid in candidates:
474
+ status, nums = conn.search(None, "HEADER", "Message-ID", f'"{mid}"')
475
+ if status == "OK" and nums and nums[0]:
476
+ num = nums[0].split()[0]
477
+ _, data = conn.fetch(num, "(RFC822)")
478
+ if ctx.mark_as_read.lower() == "true":
479
+ conn.store(num, "+FLAGS", "\\Seen")
480
+ msg_data = data[0][1] if data and data[0] else None
481
+ break
482
+ if msg_data:
483
+ break
484
+
485
+ if not msg_data:
486
+ logging.warning("IMAP search for %r returned no matches in any mailbox", ctx.message_id)
335
487
  return None
336
- num = nums[0].split()[0]
337
- _, data = conn.fetch(num, "(RFC822)")
338
- if ctx.mark_as_read.lower() == "true":
339
- conn.store(num, "+FLAGS", "\\Seen")
340
- return data[0][1] if data and data[0] else None
488
+
489
+ return msg_data
341
490
  finally:
342
491
  try:
343
492
  conn.close()
@@ -355,6 +504,17 @@ async def reply_to_email_via_smtp_async(
355
504
  # 2. Derive reply headers
356
505
  to_addrs = hdr("Reply-To") or hdr("From")
357
506
  cc_addrs = hdr("Cc")
507
+ # If the derived recipient points back to the sender or is missing, fall back to provided recipient.
508
+ sender_email_lc = (ctx.sender_email or "").lower()
509
+ def _is_self(addr: str) -> bool:
510
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
511
+ if (not to_addrs or _is_self(to_addrs)) and getattr(ctx, "fallback_recipient", None):
512
+ fr = ctx.fallback_recipient
513
+ if fr and not _is_self(fr):
514
+ to_addrs = fr
515
+ cc_addrs = ""
516
+ if not to_addrs or _is_self(to_addrs):
517
+ raise RuntimeError("No valid recipient found in original message; refusing to reply to sender.")
358
518
  subject = hdr("Subject")
359
519
  if not subject.lower().startswith("re:"):
360
520
  subject = f"Re: {subject}"