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.
- dhisana/schemas/common.py +10 -1
- dhisana/schemas/sales.py +203 -22
- dhisana/utils/add_mapping.py +0 -2
- dhisana/utils/apollo_tools.py +739 -119
- dhisana/utils/built_with_api_tools.py +4 -2
- dhisana/utils/check_email_validity_tools.py +35 -18
- dhisana/utils/check_for_intent_signal.py +1 -2
- dhisana/utils/check_linkedin_url_validity.py +34 -8
- dhisana/utils/clay_tools.py +3 -2
- dhisana/utils/clean_properties.py +1 -4
- dhisana/utils/compose_salesnav_query.py +0 -1
- dhisana/utils/compose_search_query.py +7 -3
- dhisana/utils/composite_tools.py +0 -1
- dhisana/utils/dataframe_tools.py +2 -2
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_provider.py +174 -35
- dhisana/utils/enrich_lead_information.py +183 -53
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +1 -1
- dhisana/utils/g2_tools.py +0 -1
- dhisana/utils/generate_content.py +0 -1
- dhisana/utils/generate_email.py +68 -23
- dhisana/utils/generate_email_response.py +294 -46
- dhisana/utils/generate_flow.py +0 -1
- dhisana/utils/generate_linkedin_connect_message.py +9 -2
- dhisana/utils/generate_linkedin_response_message.py +137 -66
- dhisana/utils/generate_structured_output_internal.py +317 -164
- dhisana/utils/google_custom_search.py +150 -44
- dhisana/utils/google_oauth_tools.py +721 -0
- dhisana/utils/google_workspace_tools.py +278 -54
- dhisana/utils/hubspot_clearbit.py +3 -1
- dhisana/utils/hubspot_crm_tools.py +718 -272
- dhisana/utils/instantly_tools.py +3 -1
- dhisana/utils/lusha_tools.py +10 -7
- dhisana/utils/mailgun_tools.py +150 -0
- dhisana/utils/microsoft365_tools.py +447 -0
- dhisana/utils/openai_assistant_and_file_utils.py +121 -177
- dhisana/utils/openai_helpers.py +8 -6
- dhisana/utils/parse_linkedin_messages_txt.py +1 -3
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +377 -76
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/research_lead.py +3 -3
- dhisana/utils/sales_navigator_crawler.py +1 -6
- dhisana/utils/salesforce_crm_tools.py +323 -50
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +126 -91
- 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 +360 -432
- 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 +178 -18
- dhisana/utils/test_connect.py +1603 -130
- dhisana/utils/trasform_json.py +3 -3
- dhisana/utils/web_download_parse_tools.py +0 -1
- dhisana/utils/zoominfo_tools.py +2 -3
- dhisana/workflow/test.py +1 -1
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +1 -1
- dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
- dhisana-0.0.1.dev116.dist-info/RECORD +0 -83
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
- {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,
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
#
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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}"
|