dhisana 0.0.1.dev85__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 (70) hide show
  1. dhisana/schemas/common.py +33 -0
  2. dhisana/schemas/sales.py +224 -23
  3. dhisana/utils/add_mapping.py +72 -63
  4. dhisana/utils/apollo_tools.py +739 -109
  5. dhisana/utils/built_with_api_tools.py +4 -2
  6. dhisana/utils/cache_output_tools.py +23 -23
  7. dhisana/utils/check_email_validity_tools.py +456 -458
  8. dhisana/utils/check_for_intent_signal.py +1 -2
  9. dhisana/utils/check_linkedin_url_validity.py +34 -8
  10. dhisana/utils/clay_tools.py +3 -2
  11. dhisana/utils/clean_properties.py +3 -1
  12. dhisana/utils/compose_salesnav_query.py +0 -1
  13. dhisana/utils/compose_search_query.py +7 -3
  14. dhisana/utils/composite_tools.py +0 -1
  15. dhisana/utils/dataframe_tools.py +2 -2
  16. dhisana/utils/email_body_utils.py +72 -0
  17. dhisana/utils/email_provider.py +375 -0
  18. dhisana/utils/enrich_lead_information.py +585 -85
  19. dhisana/utils/fetch_openai_config.py +129 -0
  20. dhisana/utils/field_validators.py +1 -1
  21. dhisana/utils/g2_tools.py +0 -1
  22. dhisana/utils/generate_content.py +0 -1
  23. dhisana/utils/generate_email.py +69 -16
  24. dhisana/utils/generate_email_response.py +298 -41
  25. dhisana/utils/generate_flow.py +0 -1
  26. dhisana/utils/generate_linkedin_connect_message.py +19 -6
  27. dhisana/utils/generate_linkedin_response_message.py +156 -65
  28. dhisana/utils/generate_structured_output_internal.py +351 -131
  29. dhisana/utils/google_custom_search.py +150 -44
  30. dhisana/utils/google_oauth_tools.py +721 -0
  31. dhisana/utils/google_workspace_tools.py +391 -25
  32. dhisana/utils/hubspot_clearbit.py +3 -1
  33. dhisana/utils/hubspot_crm_tools.py +771 -167
  34. dhisana/utils/instantly_tools.py +3 -1
  35. dhisana/utils/lusha_tools.py +10 -7
  36. dhisana/utils/mailgun_tools.py +150 -0
  37. dhisana/utils/microsoft365_tools.py +447 -0
  38. dhisana/utils/openai_assistant_and_file_utils.py +121 -177
  39. dhisana/utils/openai_helpers.py +19 -16
  40. dhisana/utils/parse_linkedin_messages_txt.py +2 -3
  41. dhisana/utils/profile.py +37 -0
  42. dhisana/utils/proxy_curl_tools.py +507 -206
  43. dhisana/utils/proxycurl_search_leads.py +426 -0
  44. dhisana/utils/research_lead.py +121 -68
  45. dhisana/utils/sales_navigator_crawler.py +1 -6
  46. dhisana/utils/salesforce_crm_tools.py +323 -50
  47. dhisana/utils/search_router.py +131 -0
  48. dhisana/utils/search_router_jobs.py +51 -0
  49. dhisana/utils/sendgrid_tools.py +126 -91
  50. dhisana/utils/serarch_router_local_business.py +75 -0
  51. dhisana/utils/serpapi_additional_tools.py +290 -0
  52. dhisana/utils/serpapi_google_jobs.py +117 -0
  53. dhisana/utils/serpapi_google_search.py +188 -0
  54. dhisana/utils/serpapi_local_business_search.py +129 -0
  55. dhisana/utils/serpapi_search_tools.py +363 -432
  56. dhisana/utils/serperdev_google_jobs.py +125 -0
  57. dhisana/utils/serperdev_local_business.py +154 -0
  58. dhisana/utils/serperdev_search.py +233 -0
  59. dhisana/utils/smtp_email_tools.py +576 -0
  60. dhisana/utils/test_connect.py +1765 -92
  61. dhisana/utils/trasform_json.py +95 -16
  62. dhisana/utils/web_download_parse_tools.py +0 -1
  63. dhisana/utils/zoominfo_tools.py +2 -3
  64. dhisana/workflow/test.py +1 -1
  65. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +5 -2
  66. dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
  67. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
  68. dhisana-0.0.1.dev85.dist-info/RECORD +0 -81
  69. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
  70. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,576 @@
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
+ smtp_kwargs = dict(
178
+ hostname=smtp_server,
179
+ port=smtp_port,
180
+ username=username,
181
+ password=password,
182
+ )
183
+ # Decide whether to use STARTTLS or TLS
184
+ if use_starttls:
185
+ smtp_kwargs["start_tls"] = True
186
+ else:
187
+ smtp_kwargs["tls"] = True
188
+
189
+ try:
190
+ # aiosmtplib.send returns a (code, response) tuple, but no server message ID.
191
+ # We rely on the real Message-ID we have just set.
192
+ await aiosmtplib.send(msg, **smtp_kwargs)
193
+ logging.info("SMTP send OK – msg id %s", generated_id)
194
+ await asyncio.sleep(20)
195
+ return generated_id
196
+ except Exception:
197
+ logging.exception("SMTP send failed")
198
+ raise
199
+
200
+
201
+ # --------------------------------------------------------------------------- #
202
+ # Inbound -- IMAP
203
+ # --------------------------------------------------------------------------- #
204
+
205
+ def _parse_email_msg(raw_bytes: bytes) -> MessageItem:
206
+ """
207
+ Convert raw RFC-822 bytes into a MessageItem.
208
+
209
+ We read the real "Message-ID", "In-Reply-To", and "References" headers
210
+ to produce correct message_id and thread_id.
211
+
212
+ If the email lacks a Message-ID, we generate a fallback using SHA-256
213
+ of the body + a UTC timestamp, but normally real emails will have one.
214
+ """
215
+ msg = email.message_from_bytes(raw_bytes)
216
+
217
+ # Helper for reading headers
218
+ hdr = lambda h: _decode_header_value(msg.get(h))
219
+
220
+ sender_name, sender_email = email.utils.parseaddr(hdr("From"))
221
+ receiver_name, receiver_email = email.utils.parseaddr(hdr("To"))
222
+
223
+ # Body: prefer the first text/plain part
224
+ body: str = ""
225
+ if msg.is_multipart():
226
+ for part in msg.walk():
227
+ if (
228
+ part.get_content_type() == "text/plain"
229
+ and "attachment" not in str(part.get("Content-Disposition", ""))
230
+ ):
231
+ payload = part.get_payload(decode=True)
232
+ if payload is not None:
233
+ body = payload.decode(errors="ignore")
234
+ break
235
+ else:
236
+ payload = msg.get_payload(decode=True)
237
+ if payload is not None:
238
+ body = payload.decode(errors="ignore")
239
+
240
+ # Parse the Date header to get a timezone-aware datetime
241
+ try:
242
+ dt = email.utils.parsedate_to_datetime(hdr("Date"))
243
+ dt_utc = dt.astimezone(timezone.utc)
244
+ except Exception:
245
+ dt_utc = datetime.utcnow()
246
+
247
+ sent_iso = dt_utc.isoformat()
248
+ ts_compact = dt_utc.strftime("%m-%d-%y-%H-%M")
249
+
250
+ # Get the real Message-ID, or generate a fallback
251
+ message_id = hdr("Message-ID").strip()
252
+ if not message_id:
253
+ # Fallback if none present
254
+ body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
255
+ message_id = f"<{body_hash}-{ts_compact}@fallback.local>"
256
+
257
+ # Determine a thread_id from References / In-Reply-To
258
+ references = hdr("References").strip()
259
+ in_reply_to = hdr("In-Reply-To").strip()
260
+
261
+ if references:
262
+ # Typically the first or last entry in References is the root; you can choose
263
+ ref_ids = references.split()
264
+ # Let's pick the *first* as the thread root
265
+ thread_id = ref_ids[0]
266
+ elif in_reply_to:
267
+ # If there's no References but there's In-Reply-To, use that as thread ID
268
+ thread_id = in_reply_to
269
+ else:
270
+ # No references or in-reply-to => this is the start of a new thread
271
+ thread_id = message_id
272
+
273
+ return MessageItem(
274
+ message_id=message_id,
275
+ thread_id=thread_id,
276
+ sender_name=sender_name,
277
+ sender_email=sender_email,
278
+ receiver_name=receiver_name,
279
+ receiver_email=receiver_email,
280
+ iso_datetime=sent_iso,
281
+ subject=hdr("Subject"),
282
+ body=body,
283
+ )
284
+
285
+
286
+ async def list_emails_in_time_range_imap_async(
287
+ ctx: QueryEmailContext,
288
+ imap_server: str,
289
+ imap_port: int,
290
+ username: str,
291
+ password: str,
292
+ *,
293
+ mailbox: str = "INBOX",
294
+ use_ssl: bool = True,
295
+ ) -> List[MessageItem]:
296
+ """
297
+ Return all messages whose INTERNALDATE lies in [ctx.start_time, ctx.end_time).
298
+
299
+ Uses `SINCE <date>` and `BEFORE <date+1>` for day-level search, then does
300
+ a second-precision filter in Python to return only the correct messages.
301
+ """
302
+ start_dt = _to_datetime(ctx.start_time)
303
+ end_dt = _to_datetime(ctx.end_time)
304
+
305
+ def _worker() -> List[MessageItem]:
306
+ conn = (
307
+ imaplib.IMAP4_SSL(imap_server, imap_port)
308
+ if use_ssl
309
+ else imaplib.IMAP4(imap_server, imap_port)
310
+ )
311
+ try:
312
+ conn.login(username, password)
313
+ conn.select(mailbox, readonly=True)
314
+
315
+ # Build coarse search window
316
+ since_str = _imap_date(start_dt)
317
+ before_str = _imap_date(end_dt + timedelta(days=1)) # BEFORE is exclusive
318
+ criteria = ["SINCE", since_str, "BEFORE", before_str]
319
+ if ctx.unread_only:
320
+ criteria.insert(0, "UNSEEN")
321
+
322
+ status, msg_nums = conn.search(None, *criteria)
323
+ if status != "OK":
324
+ logging.warning("IMAP search failed: %s %s", status, criteria)
325
+ return []
326
+
327
+ raw_ids = msg_nums[0]
328
+ if not raw_ids:
329
+ return []
330
+
331
+ items: List[MessageItem] = []
332
+ for num in raw_ids.split():
333
+ # Precise filter on INTERNALDATE
334
+ int_status, int_data = conn.fetch(num, "(INTERNALDATE)")
335
+ if int_status != "OK" or not int_data or not int_data[0]:
336
+ continue
337
+
338
+ m = re.search(
339
+ r'INTERNALDATE "([^"]+)"', int_data[0].decode(errors="ignore")
340
+ )
341
+ if not m:
342
+ continue
343
+ msg_dt = email.utils.parsedate_to_datetime(m.group(1))
344
+
345
+ if not (start_dt <= msg_dt < end_dt):
346
+ continue
347
+
348
+ fetch_status, data = conn.fetch(num, "(RFC822)")
349
+ if fetch_status == "OK" and data and data[0]:
350
+ items.append(_parse_email_msg(data[0][1]))
351
+
352
+ return items
353
+
354
+ finally:
355
+ try:
356
+ conn.close()
357
+ except Exception:
358
+ pass
359
+ conn.logout()
360
+
361
+ return await asyncio.to_thread(_worker)
362
+
363
+
364
+ # --------------------------------------------------------------------------- #
365
+ # Reply-All via IMAP (fetch original) + SMTP (send reply)
366
+ # --------------------------------------------------------------------------- #
367
+
368
+ async def reply_to_email_via_smtp_async(
369
+ ctx: ReplyEmailContext,
370
+ *,
371
+ smtp_server: str,
372
+ smtp_port: int,
373
+ imap_server: str,
374
+ imap_port: int,
375
+ username: str,
376
+ password: str,
377
+ mailbox: str = "INBOX",
378
+ use_ssl_imap: bool = True,
379
+ use_starttls_smtp: bool = True,
380
+ ) -> Dict[str, Any]:
381
+ """
382
+ Fetch the original message via IMAP (by Message-ID) and send a Reply-All
383
+ over SMTP. Credentials assumed to be the same USER/PASS for both protocols.
384
+
385
+ Returns dict with keys that mimic your Gmail helper's shape.
386
+ """
387
+
388
+ # 1. Locate & pull the original message (blocking -> run in executor)
389
+ def _fetch_original() -> Optional[bytes]:
390
+ conn = (
391
+ imaplib.IMAP4_SSL(imap_server, imap_port)
392
+ if use_ssl_imap
393
+ else imaplib.IMAP4(imap_server, imap_port)
394
+ )
395
+ try:
396
+ conn.login(username, password)
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)
487
+ return None
488
+
489
+ return msg_data
490
+ finally:
491
+ try:
492
+ conn.close()
493
+ except Exception:
494
+ pass
495
+ conn.logout()
496
+
497
+ raw_original = await asyncio.to_thread(_fetch_original)
498
+ if raw_original is None:
499
+ raise RuntimeError(f"Could not locate original message with ID {ctx.message_id!r}")
500
+
501
+ original = email.message_from_bytes(raw_original)
502
+ hdr = lambda h: original.get(h, "")
503
+
504
+ # 2. Derive reply headers
505
+ to_addrs = hdr("Reply-To") or hdr("From")
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.")
518
+ subject = hdr("Subject")
519
+ if not subject.lower().startswith("re:"):
520
+ subject = f"Re: {subject}"
521
+ orig_msg_id = hdr("Message-ID") # parent's ID
522
+
523
+ # Build the References header by appending the parent's ID
524
+ existing_refs = hdr("References")
525
+ if existing_refs:
526
+ references = existing_refs.strip() + " " + orig_msg_id
527
+ else:
528
+ references = orig_msg_id
529
+
530
+ # 3. Build the MIMEText reply
531
+ msg = MIMEText(ctx.reply_body, _charset="utf-8")
532
+ msg["From"] = f"{ctx.sender_name} <{ctx.sender_email}>"
533
+ msg["To"] = to_addrs
534
+ if cc_addrs:
535
+ msg["Cc"] = cc_addrs
536
+ msg["Subject"] = subject
537
+ msg["In-Reply-To"] = orig_msg_id
538
+ msg["References"] = references
539
+
540
+ # Generate a new Message-ID for this reply
541
+ domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
542
+ reply_msg_id = f"<{uuid.uuid4()}@{domain_part}>"
543
+ msg["Message-ID"] = reply_msg_id
544
+
545
+ # 4. Send via SMTP
546
+ smtp_kwargs = dict(
547
+ hostname=smtp_server,
548
+ port=smtp_port,
549
+ username=username,
550
+ password=password,
551
+ )
552
+ if use_starttls_smtp:
553
+ smtp_kwargs["start_tls"] = True
554
+ else:
555
+ smtp_kwargs["tls"] = True
556
+
557
+ await aiosmtplib.send(msg, **smtp_kwargs)
558
+
559
+ # 5. There's no universal "label" concept in generic IMAP, so ignore add_labels
560
+ if ctx.add_labels:
561
+ logging.warning("add_labels ignored – generic IMAP has no label concept")
562
+
563
+ # 6. Build response dictionary
564
+ recipients: List[str] = [to_addrs]
565
+ if cc_addrs:
566
+ recipients.append(cc_addrs)
567
+
568
+ return {
569
+ "mailbox_email_id": reply_msg_id, # the new reply's ID
570
+ "message_id": reply_msg_id, # the new reply's ID
571
+ "email_subject": subject,
572
+ "email_sender": ctx.sender_email,
573
+ "email_recipients": recipients,
574
+ "read_email_status": "READ" if ctx.mark_as_read.lower() == "true" else "UNREAD",
575
+ "email_labels": [], # Not applicable for IMAP
576
+ }