dhisana 0.0.1.dev234__py3-none-any.whl → 0.0.1.dev235__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 CHANGED
@@ -392,6 +392,7 @@ class ReplyEmailContext(BaseModel):
392
392
  reply_body: str
393
393
  sender_email: str
394
394
  sender_name: str
395
+ fallback_recipient: Optional[str] = None
395
396
  mark_as_read: str = "True"
396
397
  add_labels: Optional[List[str]] = None
397
398
  reply_body_format: BodyFormat = BodyFormat.AUTO
@@ -267,15 +267,45 @@ async def reply_to_email_google_oauth_async(
267
267
  _rethrow_with_google_message(exc, "Gmail Fetch Message OAuth")
268
268
 
269
269
  headers_list = (original.get("payload") or {}).get("headers", [])
270
- headers_map = {h.get("name"): h.get("value") for h in headers_list if isinstance(h, dict)}
271
- thread_id = original.get("threadId")
272
-
273
- subject = headers_map.get("Subject", "") or ""
270
+ # Use case-insensitive lookups via find_header to avoid missing values on header casing differences.
271
+ subject = find_header(headers_list, "Subject") or ""
274
272
  if not subject.startswith("Re:"):
275
273
  subject = f"Re: {subject}"
276
- to_addresses = headers_map.get("From", "") or ""
277
- cc_addresses = headers_map.get("Cc", "") or ""
278
- message_id_header = headers_map.get("Message-ID", "") or ""
274
+ reply_to_header = find_header(headers_list, "Reply-To") or ""
275
+ from_header = find_header(headers_list, "From") or ""
276
+ to_header = find_header(headers_list, "To") or ""
277
+ cc_header = find_header(headers_list, "Cc") or ""
278
+ message_id_header = find_header(headers_list, "Message-ID") or ""
279
+ thread_id = original.get("threadId")
280
+
281
+ sender_email_lc = (reply_email_context.sender_email or "").lower()
282
+
283
+ def _is_self(addr: str) -> bool:
284
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
285
+
286
+ cc_addresses = cc_header or ""
287
+ # Prefer Reply-To unless it points back to the sender. If the original was SENT mail,
288
+ # From will equal the sender, so we should reply to the original To/CC instead.
289
+ if reply_to_header and not _is_self(reply_to_header):
290
+ to_addresses = reply_to_header
291
+ elif from_header and not _is_self(from_header):
292
+ to_addresses = from_header
293
+ elif to_header and not _is_self(to_header):
294
+ to_addresses = to_header
295
+ else:
296
+ combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
297
+ to_addresses = combined
298
+ cc_addresses = ""
299
+
300
+ if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
301
+ if not _is_self(reply_email_context.fallback_recipient):
302
+ to_addresses = reply_email_context.fallback_recipient
303
+ cc_addresses = ""
304
+
305
+ if not to_addresses or _is_self(to_addresses):
306
+ raise ValueError(
307
+ "No valid recipient found in the original message; refusing to reply to sender."
308
+ )
279
309
 
280
310
  # 2) Build reply MIME
281
311
  plain_reply, html_reply, resolved_reply_fmt = body_variants(
@@ -895,17 +895,43 @@ async def reply_to_email_async(
895
895
  original_message = response.json()
896
896
 
897
897
  headers_list = original_message.get('payload', {}).get('headers', [])
898
- headers_dict = {h['name']: h['value'] for h in headers_list}
899
- thread_id = original_message.get('threadId')
900
-
901
- # 2. Prepare reply headers
902
- subject = headers_dict.get('Subject', '')
898
+ # Case-insensitive header lookup and resilient recipient fallback to avoid Gmail 400s.
899
+ subject = find_header(headers_list, 'Subject') or ''
903
900
  if not subject.startswith('Re:'):
904
901
  subject = f'Re: {subject}'
902
+ reply_to_header = find_header(headers_list, 'Reply-To') or ''
903
+ from_header = find_header(headers_list, 'From') or ''
904
+ to_header = find_header(headers_list, 'To') or ''
905
+ cc_header = find_header(headers_list, 'Cc') or ''
906
+ message_id_header = find_header(headers_list, 'Message-ID') or ''
907
+ thread_id = original_message.get('threadId')
908
+
909
+ sender_email_lc = (reply_email_context.sender_email or '').lower()
910
+
911
+ def _is_self(addr: str) -> bool:
912
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
905
913
 
906
- to_addresses = headers_dict.get('From', '')
907
- cc_addresses = headers_dict.get('Cc', '')
908
- message_id_header = headers_dict.get('Message-ID', '')
914
+ cc_addresses = cc_header or ''
915
+ if reply_to_header and not _is_self(reply_to_header):
916
+ to_addresses = reply_to_header
917
+ elif from_header and not _is_self(from_header):
918
+ to_addresses = from_header
919
+ elif to_header and not _is_self(to_header):
920
+ to_addresses = to_header
921
+ else:
922
+ combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
923
+ to_addresses = combined
924
+ cc_addresses = ''
925
+
926
+ if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
927
+ if not _is_self(reply_email_context.fallback_recipient):
928
+ to_addresses = reply_email_context.fallback_recipient
929
+ cc_addresses = ''
930
+
931
+ if not to_addresses or _is_self(to_addresses):
932
+ raise ValueError(
933
+ "No valid recipient found in the original message; refusing to reply to sender."
934
+ )
909
935
 
910
936
  # 3. Create the reply email message
911
937
  plain_reply, html_reply, resolved_reply_fmt = body_variants(
@@ -351,10 +351,50 @@ async def reply_to_email_m365_async(
351
351
  orig_subject = orig.get("subject", "")
352
352
  subject = orig_subject if orig_subject.startswith("Re:") else f"Re: {orig_subject}"
353
353
  thread_id = orig.get("conversationId", "")
354
- from_addr = orig.get("from", {}).get("emailAddress", {})
355
- to_addresses = from_addr.get("address", "")
356
354
  cc_list = orig.get("ccRecipients", [])
357
- cc_addresses = ", ".join([(r.get("emailAddress", {}) or {}).get("address", "") for r in cc_list if r])
355
+ to_list = orig.get("toRecipients", [])
356
+ sender_email_lc = (reply_email_context.sender_email or "").lower()
357
+
358
+ def _is_self(addr: str) -> bool:
359
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
360
+
361
+ def _addresses(recipients: List[Dict[str, Any]]) -> List[str]:
362
+ return [
363
+ (recipient.get("emailAddress", {}) or {}).get("address", "")
364
+ for recipient in recipients
365
+ if recipient
366
+ ]
367
+
368
+ to_addresses = ", ".join(
369
+ [addr for addr in _addresses(to_list) if addr and not _is_self(addr)]
370
+ )
371
+ cc_addresses = ", ".join(
372
+ [addr for addr in _addresses(cc_list) if addr and not _is_self(addr)]
373
+ )
374
+
375
+ all_recipients = [addr for addr in _addresses(to_list + cc_list) if addr]
376
+ if not any(all_recipients):
377
+ from_addr = orig.get("from", {}).get("emailAddress", {})
378
+ from_address = from_addr.get("address", "")
379
+ if from_address:
380
+ all_recipients.append(from_address)
381
+
382
+ non_self_recipients = [addr for addr in all_recipients if not _is_self(addr)]
383
+ if not non_self_recipients and reply_email_context.fallback_recipient:
384
+ fr = reply_email_context.fallback_recipient
385
+ if fr and not _is_self(fr):
386
+ non_self_recipients.append(fr)
387
+
388
+ if not to_addresses and non_self_recipients:
389
+ to_addresses = ", ".join(non_self_recipients)
390
+ cc_addresses = ""
391
+
392
+ if not non_self_recipients:
393
+ raise httpx.HTTPStatusError(
394
+ "No valid recipient found in the original message; refusing to reply to sender.",
395
+ request=get_resp.request,
396
+ response=get_resp,
397
+ )
358
398
 
359
399
  # 2) Create reply-all draft with comment
360
400
  create_reply_url = (
@@ -394,17 +394,99 @@ async def reply_to_email_via_smtp_async(
394
394
  )
395
395
  try:
396
396
  conn.login(username, password)
397
- conn.select(mailbox, readonly=False) # can set flags if needed
398
- # Search for the exact Message-ID header
399
- status, nums = conn.search(None, "HEADER", "Message-ID", ctx.message_id)
400
- if status != "OK" or not nums[0]:
401
- logging.warning("IMAP search for %r returned %s", ctx.message_id, nums)
397
+ # Sent messages usually live outside INBOX; build a candidate list
398
+ # from the provided mailbox, common sent folders, and any LISTed
399
+ # mailboxes containing "sent" (case-insensitive).
400
+ candidate_mailboxes = []
401
+ if mailbox:
402
+ candidate_mailboxes.append(mailbox)
403
+ candidate_mailboxes.extend([
404
+ "Sent",
405
+ "Sent Items",
406
+ "Sent Mail",
407
+ "[Gmail]/Sent Mail",
408
+ "[Gmail]/Sent Items",
409
+ "INBOX.Sent",
410
+ "INBOX/Sent",
411
+ ])
412
+ try:
413
+ status, mailboxes = conn.list()
414
+ if status == "OK" and mailboxes:
415
+ for mbox in mailboxes:
416
+ try:
417
+ decoded = mbox.decode(errors="ignore")
418
+ except Exception:
419
+ decoded = str(mbox)
420
+ # Parse flags + name from LIST response:
421
+ # e.g., (\\HasNoChildren \\Sent) "/" "Sent Items"
422
+ flags = set()
423
+ name_part = decoded
424
+ if ") " in decoded:
425
+ flags_raw, _, remainder = decoded.partition(") ")
426
+ flags = {f.lower() for f in flags_raw.strip("(").split() if f}
427
+ # remainder is like '"/" "Sent Items"' or '"/" Sent'
428
+ pieces = remainder.split(" ", 1)
429
+ if len(pieces) == 2:
430
+ name_part = pieces[1].strip()
431
+ else:
432
+ name_part = remainder.strip()
433
+ name_part = name_part.strip()
434
+ if name_part.startswith('"') and name_part.endswith('"'):
435
+ name_part = name_part[1:-1]
436
+
437
+ # Prefer provider-marked \Sent flag; otherwise fall back to substring match.
438
+ is_sent_flag = "\\sent" in flags
439
+ is_sent_name = "sent" in name_part.lower()
440
+ if is_sent_flag or is_sent_name:
441
+ candidate_mailboxes.append(name_part)
442
+ except Exception:
443
+ logging.exception("IMAP LIST failed; continuing with default sent folders")
444
+ # Deduplicate while preserving order
445
+ seen = set()
446
+ candidate_mailboxes = [m for m in candidate_mailboxes if not (m in seen or seen.add(m))]
447
+
448
+ msg_data = None
449
+ for mb in candidate_mailboxes:
450
+ def _try_select(name: str) -> bool:
451
+ # Quote mailbox names with spaces or special chars; fall back to raw.
452
+ for candidate in (f'"{name}"', name):
453
+ try:
454
+ status, _ = conn.select(candidate, readonly=False)
455
+ except imaplib.IMAP4.error as exc:
456
+ logging.warning("IMAP select %r failed: %s", candidate, exc)
457
+ continue
458
+ except Exception as exc:
459
+ logging.warning("IMAP select %r failed: %s", candidate, exc)
460
+ continue
461
+ if status == "OK":
462
+ return True
463
+ return False
464
+
465
+ if not _try_select(mb):
466
+ continue
467
+ # Search for the Message-ID header. Some servers store IDs without angle
468
+ # brackets or require quoted search terms, so try a few variants.
469
+ candidates = [ctx.message_id]
470
+ trimmed = ctx.message_id.strip()
471
+ if trimmed.startswith("<") and trimmed.endswith(">"):
472
+ candidates.append(trimmed[1:-1])
473
+ for mid in candidates:
474
+ status, nums = conn.search(None, "HEADER", "Message-ID", f'"{mid}"')
475
+ if status == "OK" and nums and nums[0]:
476
+ num = nums[0].split()[0]
477
+ _, data = conn.fetch(num, "(RFC822)")
478
+ if ctx.mark_as_read.lower() == "true":
479
+ conn.store(num, "+FLAGS", "\\Seen")
480
+ msg_data = data[0][1] if data and data[0] else None
481
+ break
482
+ if msg_data:
483
+ break
484
+
485
+ if not msg_data:
486
+ logging.warning("IMAP search for %r returned no matches in any mailbox", ctx.message_id)
402
487
  return None
403
- num = nums[0].split()[0]
404
- _, data = conn.fetch(num, "(RFC822)")
405
- if ctx.mark_as_read.lower() == "true":
406
- conn.store(num, "+FLAGS", "\\Seen")
407
- return data[0][1] if data and data[0] else None
488
+
489
+ return msg_data
408
490
  finally:
409
491
  try:
410
492
  conn.close()
@@ -422,6 +504,17 @@ async def reply_to_email_via_smtp_async(
422
504
  # 2. Derive reply headers
423
505
  to_addrs = hdr("Reply-To") or hdr("From")
424
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.")
425
518
  subject = hdr("Subject")
426
519
  if not subject.lower().startswith("re:"):
427
520
  subject = f"Re: {subject}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev234
3
+ Version: 0.0.1.dev235
4
4
  Summary: A Python SDK for Dhisana AI Platform
5
5
  Home-page: https://github.com/dhisana-ai/dhisana-python-sdk
6
6
  Author: Admin
@@ -5,7 +5,7 @@ dhisana/cli/datasets.py,sha256=OwzoCrVQqmh0pKpUAKAg_w9uGYncbWU7ZrAL_QukxAk,839
5
5
  dhisana/cli/models.py,sha256=IzUFZW_X32mL3fpM1_j4q8AF7v5nrxJcxBoqvG-TTgA,706
6
6
  dhisana/cli/predictions.py,sha256=VYgoLK1Ksv6MFImoYZqjQJkds7e5Hso65dHwbxTNNzE,646
7
7
  dhisana/schemas/__init__.py,sha256=jv2YF__bseklT3OWEzlqJ5qE24c4aWd5F4r0TTjOrWQ,65
8
- dhisana/schemas/common.py,sha256=SI6nldyfvZhNhfJwy2Qo1VJ7xwuAlg4QHYGLWPoVvZo,9287
8
+ dhisana/schemas/common.py,sha256=rt1ho4nzVhTwTQ_1Kx5TI-xZSbnyDpYN0fQ8Fgf8z6k,9332
9
9
  dhisana/schemas/sales.py,sha256=k-ZTB-DaQbjvI882L6443H4gspWBFY-VrY2_1xlLn74,33587
10
10
  dhisana/ui/__init__.py,sha256=jv2YF__bseklT3OWEzlqJ5qE24c4aWd5F4r0TTjOrWQ,65
11
11
  dhisana/ui/components.py,sha256=4NXrAyl9tx2wWwoVYyABO-EOGnreGMvql1AkXWajIIo,14316
@@ -46,15 +46,15 @@ dhisana/utils/generate_linkedin_connect_message.py,sha256=WZThEun-DMuAOqlzMI--hG
46
46
  dhisana/utils/generate_linkedin_response_message.py,sha256=-jg-u5Ipf4-cn9q0yjEHsEBe1eJhYLCLrjZDtOXnCyQ,14464
47
47
  dhisana/utils/generate_structured_output_internal.py,sha256=DmZ5QzW-79Jo3JL5nDCZQ-Fjl8Nz7FHK6S0rZxXbKyg,20705
48
48
  dhisana/utils/google_custom_search.py,sha256=5rQ4uAF-hjFpd9ooJkd6CjRvSmhZHhqM0jfHItsbpzk,10071
49
- dhisana/utils/google_oauth_tools.py,sha256=pN5YGkM50OieCFpz9RlmEwfrnGzkh342e0h5XschuuE,26211
50
- dhisana/utils/google_workspace_tools.py,sha256=wyBy5WN3-eUCrKz1HYr_CS0vdsiQgOA-SFb368jSDrY,46957
49
+ dhisana/utils/google_oauth_tools.py,sha256=sxWZLHMfFSF4Wyu-FxQKQiDKDHe0Kl_rRk7D6ejBLYg,27609
50
+ dhisana/utils/google_workspace_tools.py,sha256=pvO1rtDpknHAO9bmBKJ9Zhvrv65Og3U2x20W1ytql08,48185
51
51
  dhisana/utils/hubspot_clearbit.py,sha256=keNX1F_RnDl9AOPxYEOTMdukV_A9g8v9j1fZyT4tuP4,3440
52
52
  dhisana/utils/hubspot_crm_tools.py,sha256=lbXFCeq690_TDLjDG8Gm5E-2f1P5EuDqNf5j8PYpMm8,99298
53
53
  dhisana/utils/instantly_tools.py,sha256=hhqjDPyLE6o0dzzuvryszbK3ipnoGU2eBm6NlsUGJjY,4771
54
54
  dhisana/utils/linkedin_crawler.py,sha256=6fMQTY5lTw2kc65SFHgOAM6YfezAS0Yhg-jkiX8LGHo,6533
55
55
  dhisana/utils/lusha_tools.py,sha256=MdiWlxBBjSNpSKz8rhNOyLPtbeh-YWHgGiUq54vN_gM,12734
56
56
  dhisana/utils/mailgun_tools.py,sha256=qUD-jFMZpmkkkKtyihVSe9tgFzYe-UiiBDHQKtsLq0M,5284
57
- dhisana/utils/microsoft365_tools.py,sha256=AwWSdE-xeHkCx9T_cVgDbzBmsz7Co4KE45rAmt_lnAc,16723
57
+ dhisana/utils/microsoft365_tools.py,sha256=aNIUBBz56HhvnEd0ZMy5EGAtsXcBJ_VOMO5Yy4dyojQ,18289
58
58
  dhisana/utils/openai_assistant_and_file_utils.py,sha256=-eyPcxFvtS-DDtYQGle1SU6C6CuxjulVIojFy27HeWc,8957
59
59
  dhisana/utils/openai_helpers.py,sha256=ZK9S5-jcLCpiiD6XBLkCqYcNz-AGYmO9xh4e2H-FDLo,40155
60
60
  dhisana/utils/openapi_spec_to_tools.py,sha256=oBLVq3WeDWvW9O02NCvY8bxQURQdKwHJHGcX8bC_b2I,1926
@@ -78,7 +78,7 @@ dhisana/utils/serpapi_search_tools.py,sha256=xiiYi6Rd6Mqn94mjSKEs5nNZk1l2-PW_hTL
78
78
  dhisana/utils/serperdev_google_jobs.py,sha256=m5_2f_5y79FOFZz1A_go6m0hIUfbbAoZ0YTjUMO2BSI,4508
79
79
  dhisana/utils/serperdev_local_business.py,sha256=JoZfTg58Hojv61cyuwA2lcnPdLT1lawnWaBNrUYWnuQ,6447
80
80
  dhisana/utils/serperdev_search.py,sha256=_iBKIfHMq4gFv5StYz58eArriygoi1zW6VnLlux8vto,9363
81
- dhisana/utils/smtp_email_tools.py,sha256=tF6GoNqkS9pWP52VTTrYSgL7wPdIp3XTklxrHLdzU5o,17186
81
+ dhisana/utils/smtp_email_tools.py,sha256=_1FoN6e-rgkjAKnCVym_IvihJFKz_dOo-43iM6CVqhA,21855
82
82
  dhisana/utils/test_connect.py,sha256=aQjPIKevMF_c-wd4Te2UtPpaY-dEa9PVp6MsNCjQ7q8,83667
83
83
  dhisana/utils/trasform_json.py,sha256=7V72XNDpuxUX0GHN5D83z4anj_gIf5zabaHeQm7b1_E,6979
84
84
  dhisana/utils/web_download_parse_tools.py,sha256=ouXwH7CmjcRjoBfP5BWat86MvcGO-8rLCmWQe_eZKjc,7810
@@ -93,8 +93,8 @@ dhisana/workflow/agent.py,sha256=esv7_i_XuMkV2j1nz_UlsHov_m6X5WZZiZm_tG4OBHU,565
93
93
  dhisana/workflow/flow.py,sha256=xWE3qQbM7j2B3FH8XnY3zOL_QXX4LbTW4ArndnEYJE0,1638
94
94
  dhisana/workflow/task.py,sha256=HlWz9mtrwLYByoSnePOemBUBrMEcj7KbgNjEE1oF5wo,1830
95
95
  dhisana/workflow/test.py,sha256=E7lRnXK0PguTNzyasHytLzTJdkqIPxG5_4qk4hMEeKc,3399
96
- dhisana-0.0.1.dev234.dist-info/METADATA,sha256=2DPCNFKHW9jDh68a3oNEpad87cQHbmOVxXoEOW2-TrE,1190
97
- dhisana-0.0.1.dev234.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
98
- dhisana-0.0.1.dev234.dist-info/entry_points.txt,sha256=jujxteZmNI9EkEaK-pOCoWuBujU8TCevdkfl9ZcKHek,49
99
- dhisana-0.0.1.dev234.dist-info/top_level.txt,sha256=NETTHt6YifG_P7XtRHbQiXZlgSFk9Qh9aR-ng1XTf4s,8
100
- dhisana-0.0.1.dev234.dist-info/RECORD,,
96
+ dhisana-0.0.1.dev235.dist-info/METADATA,sha256=FUmdIhxgFjRKEpg8NjqFPuJOVkVhuEi0GcEn47hztGU,1190
97
+ dhisana-0.0.1.dev235.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
98
+ dhisana-0.0.1.dev235.dist-info/entry_points.txt,sha256=jujxteZmNI9EkEaK-pOCoWuBujU8TCevdkfl9ZcKHek,49
99
+ dhisana-0.0.1.dev235.dist-info/top_level.txt,sha256=NETTHt6YifG_P7XtRHbQiXZlgSFk9Qh9aR-ng1XTf4s,8
100
+ dhisana-0.0.1.dev235.dist-info/RECORD,,