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,582 @@
|
|
|
1
|
+
# dhisana/email_io.py
|
|
2
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
# Standard library
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
import asyncio
|
|
6
|
+
import datetime
|
|
7
|
+
import email
|
|
8
|
+
import email.utils
|
|
9
|
+
import hashlib
|
|
10
|
+
import html as html_lib
|
|
11
|
+
import imaplib
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
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
|
|
18
|
+
from email.mime.text import MIMEText
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
from typing import Any, Dict, List, Optional, Union
|
|
21
|
+
|
|
22
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
# Third-party libraries
|
|
24
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
import aiosmtplib
|
|
26
|
+
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
# Project-internal modules
|
|
29
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
from dhisana.schemas.sales import MessageItem
|
|
31
|
+
from dhisana.schemas.common import ReplyEmailContext
|
|
32
|
+
from dhisana.utils.google_workspace_tools import (
|
|
33
|
+
QueryEmailContext,
|
|
34
|
+
SendEmailContext,
|
|
35
|
+
)
|
|
36
|
+
from dhisana.utils.email_body_utils import body_variants
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
# Helper / Utility
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
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
|
+
|
|
69
|
+
def _imap_date(iso_dt: Union[str, datetime]) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Convert an ISO 8601 datetime or datetime object into IMAP date format: DD-Mmm-YYYY.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
"2025-04-22T00:00:00Z" or datetime -> "22-Apr-2025"
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(iso_dt, datetime):
|
|
77
|
+
dt_obj = iso_dt
|
|
78
|
+
else:
|
|
79
|
+
# handle Zulu‑UTC suffix
|
|
80
|
+
dt_obj = datetime.fromisoformat(iso_dt.replace("Z", "+00:00"))
|
|
81
|
+
return dt_obj.strftime("%d-%b-%Y")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _to_datetime(val: Union[datetime, str]) -> datetime:
|
|
85
|
+
"""
|
|
86
|
+
Accept a datetime or a string and return a timezone-aware datetime.
|
|
87
|
+
Tries ISO-8601 first; falls back to RFC-2822 (email Date header format).
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(val, datetime):
|
|
90
|
+
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
|
|
91
|
+
|
|
92
|
+
# Try ISO-8601 (e.g. 2025-04-24T15:28:00 or 2025-04-24 15:28:00±hh:mm)
|
|
93
|
+
try:
|
|
94
|
+
dt = datetime.fromisoformat(val)
|
|
95
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
# Fall back to RFC-2822 (e.g. "Thu, 24 Apr 2025 15:28:00 -0700")
|
|
100
|
+
try:
|
|
101
|
+
return email.utils.parsedate_to_datetime(val)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
raise TypeError(
|
|
104
|
+
f"start_time/end_time must be datetime or ISO/RFC-2822 string, got {val!r}"
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
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
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
# Outbound -- SMTP
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
|
|
134
|
+
async def send_email_via_smtp_async(
|
|
135
|
+
ctx: SendEmailContext,
|
|
136
|
+
smtp_server: str,
|
|
137
|
+
smtp_port: int,
|
|
138
|
+
username: str,
|
|
139
|
+
password: str,
|
|
140
|
+
*,
|
|
141
|
+
use_starttls: bool = True,
|
|
142
|
+
) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Send a single e-mail over SMTP and return the RFC 5322 Message-ID that
|
|
145
|
+
we set in the outbound message.
|
|
146
|
+
|
|
147
|
+
This is crucial for correlating the sent message with what we see in IMAP
|
|
148
|
+
later. We generate a unique Message-ID, and the IMAP server should preserve it.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
str
|
|
153
|
+
The Message-ID of the sent message (e.g., "<uuid@yourdomain.com>").
|
|
154
|
+
"""
|
|
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
|
+
|
|
168
|
+
msg["From"] = f"{ctx.sender_name} <{ctx.sender_email}>"
|
|
169
|
+
msg["To"] = ctx.recipient
|
|
170
|
+
msg["Subject"] = ctx.subject
|
|
171
|
+
|
|
172
|
+
# Generate a real RFC 5322 Message-ID
|
|
173
|
+
domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
|
|
174
|
+
generated_id = f"<{uuid.uuid4()}@{domain_part}>"
|
|
175
|
+
msg["Message-ID"] = generated_id
|
|
176
|
+
|
|
177
|
+
extra_headers = getattr(ctx, "headers", None) or {}
|
|
178
|
+
for header, value in extra_headers.items():
|
|
179
|
+
if not header or value is None:
|
|
180
|
+
continue
|
|
181
|
+
msg[header] = str(value)
|
|
182
|
+
|
|
183
|
+
smtp_kwargs = dict(
|
|
184
|
+
hostname=smtp_server,
|
|
185
|
+
port=smtp_port,
|
|
186
|
+
username=username,
|
|
187
|
+
password=password,
|
|
188
|
+
)
|
|
189
|
+
# Decide whether to use STARTTLS or TLS
|
|
190
|
+
if use_starttls:
|
|
191
|
+
smtp_kwargs["start_tls"] = True
|
|
192
|
+
else:
|
|
193
|
+
smtp_kwargs["tls"] = True
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
# aiosmtplib.send returns a (code, response) tuple, but no server message ID.
|
|
197
|
+
# We rely on the real Message-ID we have just set.
|
|
198
|
+
await aiosmtplib.send(msg, **smtp_kwargs)
|
|
199
|
+
logging.info("SMTP send OK – msg id %s", generated_id)
|
|
200
|
+
await asyncio.sleep(20)
|
|
201
|
+
return generated_id
|
|
202
|
+
except Exception:
|
|
203
|
+
logging.exception("SMTP send failed")
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
# Inbound -- IMAP
|
|
209
|
+
# --------------------------------------------------------------------------- #
|
|
210
|
+
|
|
211
|
+
def _parse_email_msg(raw_bytes: bytes) -> MessageItem:
|
|
212
|
+
"""
|
|
213
|
+
Convert raw RFC-822 bytes into a MessageItem.
|
|
214
|
+
|
|
215
|
+
We read the real "Message-ID", "In-Reply-To", and "References" headers
|
|
216
|
+
to produce correct message_id and thread_id.
|
|
217
|
+
|
|
218
|
+
If the email lacks a Message-ID, we generate a fallback using SHA-256
|
|
219
|
+
of the body + a UTC timestamp, but normally real emails will have one.
|
|
220
|
+
"""
|
|
221
|
+
msg = email.message_from_bytes(raw_bytes)
|
|
222
|
+
|
|
223
|
+
# Helper for reading headers
|
|
224
|
+
hdr = lambda h: _decode_header_value(msg.get(h))
|
|
225
|
+
|
|
226
|
+
sender_name, sender_email = email.utils.parseaddr(hdr("From"))
|
|
227
|
+
receiver_name, receiver_email = email.utils.parseaddr(hdr("To"))
|
|
228
|
+
|
|
229
|
+
# Body: prefer the first text/plain part
|
|
230
|
+
body: str = ""
|
|
231
|
+
if msg.is_multipart():
|
|
232
|
+
for part in msg.walk():
|
|
233
|
+
if (
|
|
234
|
+
part.get_content_type() == "text/plain"
|
|
235
|
+
and "attachment" not in str(part.get("Content-Disposition", ""))
|
|
236
|
+
):
|
|
237
|
+
payload = part.get_payload(decode=True)
|
|
238
|
+
if payload is not None:
|
|
239
|
+
body = payload.decode(errors="ignore")
|
|
240
|
+
break
|
|
241
|
+
else:
|
|
242
|
+
payload = msg.get_payload(decode=True)
|
|
243
|
+
if payload is not None:
|
|
244
|
+
body = payload.decode(errors="ignore")
|
|
245
|
+
|
|
246
|
+
# Parse the Date header to get a timezone-aware datetime
|
|
247
|
+
try:
|
|
248
|
+
dt = email.utils.parsedate_to_datetime(hdr("Date"))
|
|
249
|
+
dt_utc = dt.astimezone(timezone.utc)
|
|
250
|
+
except Exception:
|
|
251
|
+
dt_utc = datetime.utcnow()
|
|
252
|
+
|
|
253
|
+
sent_iso = dt_utc.isoformat()
|
|
254
|
+
ts_compact = dt_utc.strftime("%m-%d-%y-%H-%M")
|
|
255
|
+
|
|
256
|
+
# Get the real Message-ID, or generate a fallback
|
|
257
|
+
message_id = hdr("Message-ID").strip()
|
|
258
|
+
if not message_id:
|
|
259
|
+
# Fallback if none present
|
|
260
|
+
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
261
|
+
message_id = f"<{body_hash}-{ts_compact}@fallback.local>"
|
|
262
|
+
|
|
263
|
+
# Determine a thread_id from References / In-Reply-To
|
|
264
|
+
references = hdr("References").strip()
|
|
265
|
+
in_reply_to = hdr("In-Reply-To").strip()
|
|
266
|
+
|
|
267
|
+
if references:
|
|
268
|
+
# Typically the first or last entry in References is the root; you can choose
|
|
269
|
+
ref_ids = references.split()
|
|
270
|
+
# Let's pick the *first* as the thread root
|
|
271
|
+
thread_id = ref_ids[0]
|
|
272
|
+
elif in_reply_to:
|
|
273
|
+
# If there's no References but there's In-Reply-To, use that as thread ID
|
|
274
|
+
thread_id = in_reply_to
|
|
275
|
+
else:
|
|
276
|
+
# No references or in-reply-to => this is the start of a new thread
|
|
277
|
+
thread_id = message_id
|
|
278
|
+
|
|
279
|
+
return MessageItem(
|
|
280
|
+
message_id=message_id,
|
|
281
|
+
thread_id=thread_id,
|
|
282
|
+
sender_name=sender_name,
|
|
283
|
+
sender_email=sender_email,
|
|
284
|
+
receiver_name=receiver_name,
|
|
285
|
+
receiver_email=receiver_email,
|
|
286
|
+
iso_datetime=sent_iso,
|
|
287
|
+
subject=hdr("Subject"),
|
|
288
|
+
body=body,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def list_emails_in_time_range_imap_async(
|
|
293
|
+
ctx: QueryEmailContext,
|
|
294
|
+
imap_server: str,
|
|
295
|
+
imap_port: int,
|
|
296
|
+
username: str,
|
|
297
|
+
password: str,
|
|
298
|
+
*,
|
|
299
|
+
mailbox: str = "INBOX",
|
|
300
|
+
use_ssl: bool = True,
|
|
301
|
+
) -> List[MessageItem]:
|
|
302
|
+
"""
|
|
303
|
+
Return all messages whose INTERNALDATE lies in [ctx.start_time, ctx.end_time).
|
|
304
|
+
|
|
305
|
+
Uses `SINCE <date>` and `BEFORE <date+1>` for day-level search, then does
|
|
306
|
+
a second-precision filter in Python to return only the correct messages.
|
|
307
|
+
"""
|
|
308
|
+
start_dt = _to_datetime(ctx.start_time)
|
|
309
|
+
end_dt = _to_datetime(ctx.end_time)
|
|
310
|
+
|
|
311
|
+
def _worker() -> List[MessageItem]:
|
|
312
|
+
conn = (
|
|
313
|
+
imaplib.IMAP4_SSL(imap_server, imap_port)
|
|
314
|
+
if use_ssl
|
|
315
|
+
else imaplib.IMAP4(imap_server, imap_port)
|
|
316
|
+
)
|
|
317
|
+
try:
|
|
318
|
+
conn.login(username, password)
|
|
319
|
+
conn.select(mailbox, readonly=True)
|
|
320
|
+
|
|
321
|
+
# Build coarse search window
|
|
322
|
+
since_str = _imap_date(start_dt)
|
|
323
|
+
before_str = _imap_date(end_dt + timedelta(days=1)) # BEFORE is exclusive
|
|
324
|
+
criteria = ["SINCE", since_str, "BEFORE", before_str]
|
|
325
|
+
if ctx.unread_only:
|
|
326
|
+
criteria.insert(0, "UNSEEN")
|
|
327
|
+
|
|
328
|
+
status, msg_nums = conn.search(None, *criteria)
|
|
329
|
+
if status != "OK":
|
|
330
|
+
logging.warning("IMAP search failed: %s %s", status, criteria)
|
|
331
|
+
return []
|
|
332
|
+
|
|
333
|
+
raw_ids = msg_nums[0]
|
|
334
|
+
if not raw_ids:
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
items: List[MessageItem] = []
|
|
338
|
+
for num in raw_ids.split():
|
|
339
|
+
# Precise filter on INTERNALDATE
|
|
340
|
+
int_status, int_data = conn.fetch(num, "(INTERNALDATE)")
|
|
341
|
+
if int_status != "OK" or not int_data or not int_data[0]:
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
m = re.search(
|
|
345
|
+
r'INTERNALDATE "([^"]+)"', int_data[0].decode(errors="ignore")
|
|
346
|
+
)
|
|
347
|
+
if not m:
|
|
348
|
+
continue
|
|
349
|
+
msg_dt = email.utils.parsedate_to_datetime(m.group(1))
|
|
350
|
+
|
|
351
|
+
if not (start_dt <= msg_dt < end_dt):
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
fetch_status, data = conn.fetch(num, "(RFC822)")
|
|
355
|
+
if fetch_status == "OK" and data and data[0]:
|
|
356
|
+
items.append(_parse_email_msg(data[0][1]))
|
|
357
|
+
|
|
358
|
+
return items
|
|
359
|
+
|
|
360
|
+
finally:
|
|
361
|
+
try:
|
|
362
|
+
conn.close()
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
conn.logout()
|
|
366
|
+
|
|
367
|
+
return await asyncio.to_thread(_worker)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# --------------------------------------------------------------------------- #
|
|
371
|
+
# Reply-All via IMAP (fetch original) + SMTP (send reply)
|
|
372
|
+
# --------------------------------------------------------------------------- #
|
|
373
|
+
|
|
374
|
+
async def reply_to_email_via_smtp_async(
|
|
375
|
+
ctx: ReplyEmailContext,
|
|
376
|
+
*,
|
|
377
|
+
smtp_server: str,
|
|
378
|
+
smtp_port: int,
|
|
379
|
+
imap_server: str,
|
|
380
|
+
imap_port: int,
|
|
381
|
+
username: str,
|
|
382
|
+
password: str,
|
|
383
|
+
mailbox: str = "INBOX",
|
|
384
|
+
use_ssl_imap: bool = True,
|
|
385
|
+
use_starttls_smtp: bool = True,
|
|
386
|
+
) -> Dict[str, Any]:
|
|
387
|
+
"""
|
|
388
|
+
Fetch the original message via IMAP (by Message-ID) and send a Reply-All
|
|
389
|
+
over SMTP. Credentials assumed to be the same USER/PASS for both protocols.
|
|
390
|
+
|
|
391
|
+
Returns dict with keys that mimic your Gmail helper's shape.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
# 1. Locate & pull the original message (blocking -> run in executor)
|
|
395
|
+
def _fetch_original() -> Optional[bytes]:
|
|
396
|
+
conn = (
|
|
397
|
+
imaplib.IMAP4_SSL(imap_server, imap_port)
|
|
398
|
+
if use_ssl_imap
|
|
399
|
+
else imaplib.IMAP4(imap_server, imap_port)
|
|
400
|
+
)
|
|
401
|
+
try:
|
|
402
|
+
conn.login(username, password)
|
|
403
|
+
# Sent messages usually live outside INBOX; build a candidate list
|
|
404
|
+
# from the provided mailbox, common sent folders, and any LISTed
|
|
405
|
+
# mailboxes containing "sent" (case-insensitive).
|
|
406
|
+
candidate_mailboxes = []
|
|
407
|
+
if mailbox:
|
|
408
|
+
candidate_mailboxes.append(mailbox)
|
|
409
|
+
candidate_mailboxes.extend([
|
|
410
|
+
"Sent",
|
|
411
|
+
"Sent Items",
|
|
412
|
+
"Sent Mail",
|
|
413
|
+
"[Gmail]/Sent Mail",
|
|
414
|
+
"[Gmail]/Sent Items",
|
|
415
|
+
"INBOX.Sent",
|
|
416
|
+
"INBOX/Sent",
|
|
417
|
+
])
|
|
418
|
+
try:
|
|
419
|
+
status, mailboxes = conn.list()
|
|
420
|
+
if status == "OK" and mailboxes:
|
|
421
|
+
for mbox in mailboxes:
|
|
422
|
+
try:
|
|
423
|
+
decoded = mbox.decode(errors="ignore")
|
|
424
|
+
except Exception:
|
|
425
|
+
decoded = str(mbox)
|
|
426
|
+
# Parse flags + name from LIST response:
|
|
427
|
+
# e.g., (\\HasNoChildren \\Sent) "/" "Sent Items"
|
|
428
|
+
flags = set()
|
|
429
|
+
name_part = decoded
|
|
430
|
+
if ") " in decoded:
|
|
431
|
+
flags_raw, _, remainder = decoded.partition(") ")
|
|
432
|
+
flags = {f.lower() for f in flags_raw.strip("(").split() if f}
|
|
433
|
+
# remainder is like '"/" "Sent Items"' or '"/" Sent'
|
|
434
|
+
pieces = remainder.split(" ", 1)
|
|
435
|
+
if len(pieces) == 2:
|
|
436
|
+
name_part = pieces[1].strip()
|
|
437
|
+
else:
|
|
438
|
+
name_part = remainder.strip()
|
|
439
|
+
name_part = name_part.strip()
|
|
440
|
+
if name_part.startswith('"') and name_part.endswith('"'):
|
|
441
|
+
name_part = name_part[1:-1]
|
|
442
|
+
|
|
443
|
+
# Prefer provider-marked \Sent flag; otherwise fall back to substring match.
|
|
444
|
+
is_sent_flag = "\\sent" in flags
|
|
445
|
+
is_sent_name = "sent" in name_part.lower()
|
|
446
|
+
if is_sent_flag or is_sent_name:
|
|
447
|
+
candidate_mailboxes.append(name_part)
|
|
448
|
+
except Exception:
|
|
449
|
+
logging.exception("IMAP LIST failed; continuing with default sent folders")
|
|
450
|
+
# Deduplicate while preserving order
|
|
451
|
+
seen = set()
|
|
452
|
+
candidate_mailboxes = [m for m in candidate_mailboxes if not (m in seen or seen.add(m))]
|
|
453
|
+
|
|
454
|
+
msg_data = None
|
|
455
|
+
for mb in candidate_mailboxes:
|
|
456
|
+
def _try_select(name: str) -> bool:
|
|
457
|
+
# Quote mailbox names with spaces or special chars; fall back to raw.
|
|
458
|
+
for candidate in (f'"{name}"', name):
|
|
459
|
+
try:
|
|
460
|
+
status, _ = conn.select(candidate, readonly=False)
|
|
461
|
+
except imaplib.IMAP4.error as exc:
|
|
462
|
+
logging.warning("IMAP select %r failed: %s", candidate, exc)
|
|
463
|
+
continue
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
logging.warning("IMAP select %r failed: %s", candidate, exc)
|
|
466
|
+
continue
|
|
467
|
+
if status == "OK":
|
|
468
|
+
return True
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
if not _try_select(mb):
|
|
472
|
+
continue
|
|
473
|
+
# Search for the Message-ID header. Some servers store IDs without angle
|
|
474
|
+
# brackets or require quoted search terms, so try a few variants.
|
|
475
|
+
candidates = [ctx.message_id]
|
|
476
|
+
trimmed = ctx.message_id.strip()
|
|
477
|
+
if trimmed.startswith("<") and trimmed.endswith(">"):
|
|
478
|
+
candidates.append(trimmed[1:-1])
|
|
479
|
+
for mid in candidates:
|
|
480
|
+
status, nums = conn.search(None, "HEADER", "Message-ID", f'"{mid}"')
|
|
481
|
+
if status == "OK" and nums and nums[0]:
|
|
482
|
+
num = nums[0].split()[0]
|
|
483
|
+
_, data = conn.fetch(num, "(RFC822)")
|
|
484
|
+
if ctx.mark_as_read.lower() == "true":
|
|
485
|
+
conn.store(num, "+FLAGS", "\\Seen")
|
|
486
|
+
msg_data = data[0][1] if data and data[0] else None
|
|
487
|
+
break
|
|
488
|
+
if msg_data:
|
|
489
|
+
break
|
|
490
|
+
|
|
491
|
+
if not msg_data:
|
|
492
|
+
logging.warning("IMAP search for %r returned no matches in any mailbox", ctx.message_id)
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
return msg_data
|
|
496
|
+
finally:
|
|
497
|
+
try:
|
|
498
|
+
conn.close()
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
conn.logout()
|
|
502
|
+
|
|
503
|
+
raw_original = await asyncio.to_thread(_fetch_original)
|
|
504
|
+
if raw_original is None:
|
|
505
|
+
raise RuntimeError(f"Could not locate original message with ID {ctx.message_id!r}")
|
|
506
|
+
|
|
507
|
+
original = email.message_from_bytes(raw_original)
|
|
508
|
+
hdr = lambda h: original.get(h, "")
|
|
509
|
+
|
|
510
|
+
# 2. Derive reply headers
|
|
511
|
+
to_addrs = hdr("Reply-To") or hdr("From")
|
|
512
|
+
cc_addrs = hdr("Cc")
|
|
513
|
+
# If the derived recipient points back to the sender or is missing, fall back to provided recipient.
|
|
514
|
+
sender_email_lc = (ctx.sender_email or "").lower()
|
|
515
|
+
def _is_self(addr: str) -> bool:
|
|
516
|
+
return bool(sender_email_lc) and sender_email_lc in addr.lower()
|
|
517
|
+
if (not to_addrs or _is_self(to_addrs)) and getattr(ctx, "fallback_recipient", None):
|
|
518
|
+
fr = ctx.fallback_recipient
|
|
519
|
+
if fr and not _is_self(fr):
|
|
520
|
+
to_addrs = fr
|
|
521
|
+
cc_addrs = ""
|
|
522
|
+
if not to_addrs or _is_self(to_addrs):
|
|
523
|
+
raise RuntimeError("No valid recipient found in original message; refusing to reply to sender.")
|
|
524
|
+
subject = hdr("Subject")
|
|
525
|
+
if not subject.lower().startswith("re:"):
|
|
526
|
+
subject = f"Re: {subject}"
|
|
527
|
+
orig_msg_id = hdr("Message-ID") # parent's ID
|
|
528
|
+
|
|
529
|
+
# Build the References header by appending the parent's ID
|
|
530
|
+
existing_refs = hdr("References")
|
|
531
|
+
if existing_refs:
|
|
532
|
+
references = existing_refs.strip() + " " + orig_msg_id
|
|
533
|
+
else:
|
|
534
|
+
references = orig_msg_id
|
|
535
|
+
|
|
536
|
+
# 3. Build the MIMEText reply
|
|
537
|
+
msg = MIMEText(ctx.reply_body, _charset="utf-8")
|
|
538
|
+
msg["From"] = f"{ctx.sender_name} <{ctx.sender_email}>"
|
|
539
|
+
msg["To"] = to_addrs
|
|
540
|
+
if cc_addrs:
|
|
541
|
+
msg["Cc"] = cc_addrs
|
|
542
|
+
msg["Subject"] = subject
|
|
543
|
+
msg["In-Reply-To"] = orig_msg_id
|
|
544
|
+
msg["References"] = references
|
|
545
|
+
|
|
546
|
+
# Generate a new Message-ID for this reply
|
|
547
|
+
domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
|
|
548
|
+
reply_msg_id = f"<{uuid.uuid4()}@{domain_part}>"
|
|
549
|
+
msg["Message-ID"] = reply_msg_id
|
|
550
|
+
|
|
551
|
+
# 4. Send via SMTP
|
|
552
|
+
smtp_kwargs = dict(
|
|
553
|
+
hostname=smtp_server,
|
|
554
|
+
port=smtp_port,
|
|
555
|
+
username=username,
|
|
556
|
+
password=password,
|
|
557
|
+
)
|
|
558
|
+
if use_starttls_smtp:
|
|
559
|
+
smtp_kwargs["start_tls"] = True
|
|
560
|
+
else:
|
|
561
|
+
smtp_kwargs["tls"] = True
|
|
562
|
+
|
|
563
|
+
await aiosmtplib.send(msg, **smtp_kwargs)
|
|
564
|
+
|
|
565
|
+
# 5. There's no universal "label" concept in generic IMAP, so ignore add_labels
|
|
566
|
+
if ctx.add_labels:
|
|
567
|
+
logging.warning("add_labels ignored – generic IMAP has no label concept")
|
|
568
|
+
|
|
569
|
+
# 6. Build response dictionary
|
|
570
|
+
recipients: List[str] = [to_addrs]
|
|
571
|
+
if cc_addrs:
|
|
572
|
+
recipients.append(cc_addrs)
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
"mailbox_email_id": reply_msg_id, # the new reply's ID
|
|
576
|
+
"message_id": reply_msg_id, # the new reply's ID
|
|
577
|
+
"email_subject": subject,
|
|
578
|
+
"email_sender": ctx.sender_email,
|
|
579
|
+
"email_recipients": recipients,
|
|
580
|
+
"read_email_status": "READ" if ctx.mark_as_read.lower() == "true" else "UNREAD",
|
|
581
|
+
"email_labels": [], # Not applicable for IMAP
|
|
582
|
+
}
|