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,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
+ }