dhisana 0.0.1.dev278__py3-none-any.whl → 0.0.1.dev280__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 +14 -1
- dhisana/utils/google_oauth_tools.py +179 -22
- dhisana/utils/google_workspace_tools.py +182 -26
- dhisana/utils/microsoft365_tools.py +190 -42
- dhisana/utils/smtp_email_tools.py +190 -20
- dhisana/utils/test_connect.py +197 -0
- {dhisana-0.0.1.dev278.dist-info → dhisana-0.0.1.dev280.dist-info}/METADATA +1 -1
- {dhisana-0.0.1.dev278.dist-info → dhisana-0.0.1.dev280.dist-info}/RECORD +11 -11
- {dhisana-0.0.1.dev278.dist-info → dhisana-0.0.1.dev280.dist-info}/WHEEL +0 -0
- {dhisana-0.0.1.dev278.dist-info → dhisana-0.0.1.dev280.dist-info}/entry_points.txt +0 -0
- {dhisana-0.0.1.dev278.dist-info → dhisana-0.0.1.dev280.dist-info}/top_level.txt +0 -0
|
@@ -15,6 +15,64 @@ from dhisana.schemas.sales import MessageItem
|
|
|
15
15
|
from dhisana.utils.email_body_utils import body_variants
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _normalize_recipient(recipient: Any) -> Optional[Dict[str, str]]:
|
|
19
|
+
if recipient is None:
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(recipient, dict):
|
|
22
|
+
email = recipient.get("email") or recipient.get("address")
|
|
23
|
+
name = recipient.get("name")
|
|
24
|
+
else:
|
|
25
|
+
email = getattr(recipient, "email", None) or getattr(recipient, "address", None)
|
|
26
|
+
name = getattr(recipient, "name", None)
|
|
27
|
+
if isinstance(recipient, str):
|
|
28
|
+
email = recipient
|
|
29
|
+
name = None
|
|
30
|
+
if not email:
|
|
31
|
+
return None
|
|
32
|
+
return {"email": email, "name": name}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_recipients(recipients: Optional[List[Any]]) -> List[Dict[str, str]]:
|
|
36
|
+
normalized: List[Dict[str, str]] = []
|
|
37
|
+
for recipient in recipients or []:
|
|
38
|
+
item = _normalize_recipient(recipient)
|
|
39
|
+
if item:
|
|
40
|
+
normalized.append(item)
|
|
41
|
+
return normalized
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _dedupe_recipients(
|
|
45
|
+
recipients: List[Dict[str, str]],
|
|
46
|
+
exclude_emails: Optional[List[str]] = None,
|
|
47
|
+
) -> List[Dict[str, str]]:
|
|
48
|
+
excluded = {email.lower() for email in (exclude_emails or []) if email}
|
|
49
|
+
seen: set[str] = set()
|
|
50
|
+
result: List[Dict[str, str]] = []
|
|
51
|
+
for recipient in recipients:
|
|
52
|
+
email = (recipient.get("email") or "").strip()
|
|
53
|
+
if not email:
|
|
54
|
+
continue
|
|
55
|
+
email_lc = email.lower()
|
|
56
|
+
if email_lc in seen or email_lc in excluded:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(email_lc)
|
|
59
|
+
result.append({"email": email, "name": recipient.get("name")})
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _recipients_to_m365(recipients: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
|
64
|
+
result: List[Dict[str, Any]] = []
|
|
65
|
+
for recipient in recipients:
|
|
66
|
+
email = recipient.get("email")
|
|
67
|
+
if not email:
|
|
68
|
+
continue
|
|
69
|
+
payload = {"emailAddress": {"address": email}}
|
|
70
|
+
name = recipient.get("name")
|
|
71
|
+
if name:
|
|
72
|
+
payload["emailAddress"]["name"] = name
|
|
73
|
+
result.append(payload)
|
|
74
|
+
return result
|
|
75
|
+
|
|
18
76
|
def get_microsoft365_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
19
77
|
"""
|
|
20
78
|
Retrieve a Microsoft Graph OAuth2 access token from tool_config or env.
|
|
@@ -349,6 +407,14 @@ async def reply_to_email_m365_async(
|
|
|
349
407
|
base_res = _base_resource(reply_email_context.sender_email, tool_config, auth_mode)
|
|
350
408
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
351
409
|
|
|
410
|
+
reply_type = (reply_email_context.reply_type or "reply").lower()
|
|
411
|
+
if reply_type not in {"reply", "reply_all", "forward"}:
|
|
412
|
+
reply_type = "reply"
|
|
413
|
+
|
|
414
|
+
explicit_to = _normalize_recipients(reply_email_context.to_recipients)
|
|
415
|
+
explicit_cc = _normalize_recipients(reply_email_context.cc_recipients)
|
|
416
|
+
explicit_bcc = _normalize_recipients(reply_email_context.bcc_recipients)
|
|
417
|
+
|
|
352
418
|
# 1) Fetch original message for context (subject, recipients, thread)
|
|
353
419
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
354
420
|
get_url = f"{base_url}{base_res}/messages/{reply_email_context.message_id}"
|
|
@@ -357,68 +423,148 @@ async def reply_to_email_m365_async(
|
|
|
357
423
|
orig = get_resp.json()
|
|
358
424
|
|
|
359
425
|
orig_subject = orig.get("subject", "")
|
|
360
|
-
subject = orig_subject if orig_subject.startswith("Re:") else f"Re: {orig_subject}"
|
|
361
426
|
thread_id = orig.get("conversationId", "")
|
|
362
427
|
cc_list = orig.get("ccRecipients", [])
|
|
363
428
|
to_list = orig.get("toRecipients", [])
|
|
429
|
+
from_addr = (orig.get("from", {}) or {}).get("emailAddress", {}) or {}
|
|
430
|
+
from_email = from_addr.get("address", "")
|
|
431
|
+
from_name = from_addr.get("name")
|
|
432
|
+
|
|
364
433
|
sender_email_lc = (reply_email_context.sender_email or "").lower()
|
|
365
434
|
|
|
366
435
|
def _is_self(addr: str) -> bool:
|
|
367
436
|
return bool(sender_email_lc) and sender_email_lc in addr.lower()
|
|
368
437
|
|
|
369
|
-
def
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
438
|
+
def _address_dicts(recipients: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
439
|
+
result: List[Dict[str, str]] = []
|
|
440
|
+
for recipient in recipients or []:
|
|
441
|
+
email_address = recipient.get("emailAddress", {}) if recipient else {}
|
|
442
|
+
address = email_address.get("address")
|
|
443
|
+
if not address:
|
|
444
|
+
continue
|
|
445
|
+
result.append({"email": address, "name": email_address.get("name")})
|
|
446
|
+
return result
|
|
447
|
+
|
|
448
|
+
to_recipients: List[Dict[str, str]] = []
|
|
449
|
+
cc_recipients: List[Dict[str, str]] = []
|
|
450
|
+
bcc_recipients: List[Dict[str, str]] = []
|
|
451
|
+
|
|
452
|
+
if reply_type == "forward":
|
|
453
|
+
if not explicit_to:
|
|
454
|
+
raise ValueError("Forward requires explicit to_recipients.")
|
|
455
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
456
|
+
cc_recipients = _dedupe_recipients(explicit_cc)
|
|
457
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
458
|
+
|
|
459
|
+
if reply_email_context.subject:
|
|
460
|
+
subject = reply_email_context.subject
|
|
461
|
+
elif orig_subject and not orig_subject.lower().startswith("fwd:"):
|
|
462
|
+
subject = f"Fwd: {orig_subject}"
|
|
463
|
+
else:
|
|
464
|
+
subject = orig_subject or "Fwd:"
|
|
465
|
+
else:
|
|
466
|
+
subject = orig_subject if orig_subject.startswith("Re:") else f"Re: {orig_subject}"
|
|
375
467
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
468
|
+
if reply_type == "reply_all":
|
|
469
|
+
base_to = _dedupe_recipients(
|
|
470
|
+
[{"email": from_email, "name": from_name}] if from_email and not _is_self(from_email) else [],
|
|
471
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
472
|
+
)
|
|
473
|
+
if not base_to:
|
|
474
|
+
base_to = _dedupe_recipients(
|
|
475
|
+
_address_dicts(to_list),
|
|
476
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
477
|
+
)
|
|
478
|
+
if not base_to and reply_email_context.fallback_recipient and not _is_self(reply_email_context.fallback_recipient):
|
|
479
|
+
base_to = _dedupe_recipients([{"email": reply_email_context.fallback_recipient}])
|
|
480
|
+
if not base_to:
|
|
481
|
+
raise ValueError(
|
|
482
|
+
"No valid recipient found in the original message; refusing to reply to sender."
|
|
483
|
+
)
|
|
484
|
+
base_to_emails = [recipient.get("email") for recipient in base_to if recipient.get("email")]
|
|
485
|
+
base_cc_candidates = _address_dicts(to_list) + _address_dicts(cc_list)
|
|
486
|
+
base_cc = _dedupe_recipients(
|
|
487
|
+
base_cc_candidates,
|
|
488
|
+
exclude_emails=[reply_email_context.sender_email] + base_to_emails,
|
|
489
|
+
)
|
|
490
|
+
else:
|
|
491
|
+
base_to = _dedupe_recipients(
|
|
492
|
+
[{"email": from_email, "name": from_name}] if from_email and not _is_self(from_email) else [],
|
|
493
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
494
|
+
)
|
|
495
|
+
if not base_to:
|
|
496
|
+
base_to = _dedupe_recipients(
|
|
497
|
+
_address_dicts(to_list),
|
|
498
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
499
|
+
)
|
|
500
|
+
base_cc = _dedupe_recipients(
|
|
501
|
+
_address_dicts(cc_list),
|
|
502
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
503
|
+
)
|
|
504
|
+
if not base_to and reply_email_context.fallback_recipient and not _is_self(reply_email_context.fallback_recipient):
|
|
505
|
+
base_to = _dedupe_recipients([{"email": reply_email_context.fallback_recipient}])
|
|
506
|
+
base_cc = []
|
|
507
|
+
if not base_to:
|
|
508
|
+
raise ValueError(
|
|
509
|
+
"No valid recipient found in the original message; refusing to reply to sender."
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
to_recipients = base_to
|
|
513
|
+
if explicit_to:
|
|
514
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
515
|
+
|
|
516
|
+
cc_recipients = base_cc
|
|
517
|
+
if explicit_cc:
|
|
518
|
+
cc_recipients = _dedupe_recipients(
|
|
519
|
+
base_cc + explicit_cc,
|
|
520
|
+
exclude_emails=[recipient.get("email") for recipient in to_recipients if recipient.get("email")],
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
524
|
+
|
|
525
|
+
to_addresses = ", ".join([recipient.get("email") for recipient in to_recipients if recipient.get("email")])
|
|
526
|
+
cc_addresses = ", ".join([recipient.get("email") for recipient in cc_recipients if recipient.get("email")])
|
|
382
527
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
from_address = from_addr.get("address", "")
|
|
387
|
-
if from_address:
|
|
388
|
-
all_recipients.append(from_address)
|
|
389
|
-
|
|
390
|
-
non_self_recipients = [addr for addr in all_recipients if not _is_self(addr)]
|
|
391
|
-
if not non_self_recipients and reply_email_context.fallback_recipient:
|
|
392
|
-
fr = reply_email_context.fallback_recipient
|
|
393
|
-
if fr and not _is_self(fr):
|
|
394
|
-
non_self_recipients.append(fr)
|
|
395
|
-
|
|
396
|
-
if not to_addresses and non_self_recipients:
|
|
397
|
-
to_addresses = ", ".join(non_self_recipients)
|
|
398
|
-
cc_addresses = ""
|
|
399
|
-
|
|
400
|
-
if not non_self_recipients:
|
|
401
|
-
raise httpx.HTTPStatusError(
|
|
402
|
-
"No valid recipient found in the original message; refusing to reply to sender.",
|
|
403
|
-
request=get_resp.request,
|
|
404
|
-
response=get_resp,
|
|
528
|
+
if not to_addresses:
|
|
529
|
+
raise ValueError(
|
|
530
|
+
"No valid recipient found in the original message; refusing to reply to sender."
|
|
405
531
|
)
|
|
406
532
|
|
|
407
|
-
# 2) Create
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
533
|
+
# 2) Create draft with comment
|
|
534
|
+
if reply_type == "forward":
|
|
535
|
+
create_reply_url = (
|
|
536
|
+
f"{base_url}{base_res}/messages/{reply_email_context.message_id}/createForward"
|
|
537
|
+
)
|
|
538
|
+
elif reply_type == "reply":
|
|
539
|
+
create_reply_url = (
|
|
540
|
+
f"{base_url}{base_res}/messages/{reply_email_context.message_id}/createReply"
|
|
541
|
+
)
|
|
542
|
+
else:
|
|
543
|
+
create_reply_url = (
|
|
544
|
+
f"{base_url}{base_res}/messages/{reply_email_context.message_id}/createReplyAll"
|
|
545
|
+
)
|
|
411
546
|
create_payload = {"comment": reply_email_context.reply_body}
|
|
412
547
|
create_resp = await client.post(create_reply_url, headers=headers, json=create_payload)
|
|
413
548
|
create_resp.raise_for_status()
|
|
414
549
|
reply_msg = create_resp.json()
|
|
415
550
|
reply_id = reply_msg.get("id")
|
|
416
551
|
|
|
417
|
-
# 3) Optionally add categories (labels) to the
|
|
552
|
+
# 3) Optionally update recipients/subject and add categories (labels) to the draft
|
|
553
|
+
patch_payload: Dict[str, Any] = {}
|
|
554
|
+
if to_recipients:
|
|
555
|
+
patch_payload["toRecipients"] = _recipients_to_m365(to_recipients)
|
|
556
|
+
if cc_recipients:
|
|
557
|
+
patch_payload["ccRecipients"] = _recipients_to_m365(cc_recipients)
|
|
558
|
+
if bcc_recipients:
|
|
559
|
+
patch_payload["bccRecipients"] = _recipients_to_m365(bcc_recipients)
|
|
560
|
+
if reply_type == "forward":
|
|
561
|
+
patch_payload["subject"] = subject
|
|
418
562
|
if reply_email_context.add_labels:
|
|
419
|
-
patch_url = f"{base_url}{base_res}/messages/{reply_id}"
|
|
420
563
|
categories = list(set((reply_msg.get("categories") or []) + reply_email_context.add_labels))
|
|
421
|
-
|
|
564
|
+
patch_payload["categories"] = categories
|
|
565
|
+
if patch_payload:
|
|
566
|
+
patch_url = f"{base_url}{base_res}/messages/{reply_id}"
|
|
567
|
+
await client.patch(patch_url, headers=headers, json=patch_payload)
|
|
422
568
|
|
|
423
569
|
# 4) Send the reply
|
|
424
570
|
send_url = f"{base_url}{base_res}/messages/{reply_id}/send"
|
|
@@ -448,6 +594,8 @@ async def reply_to_email_m365_async(
|
|
|
448
594
|
"email_subject": subject,
|
|
449
595
|
"email_sender": reply_email_context.sender_email,
|
|
450
596
|
"email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
|
|
597
|
+
"to_recipients": to_recipients,
|
|
598
|
+
"cc_recipients": cc_recipients,
|
|
451
599
|
"read_email_status": "READ" if str(reply_email_context.mark_as_read).lower() == "true" else "UNREAD",
|
|
452
600
|
"email_labels": email_labels,
|
|
453
601
|
}
|
|
@@ -39,6 +39,69 @@ from dhisana.utils.email_body_utils import body_variants
|
|
|
39
39
|
# Helper / Utility
|
|
40
40
|
# --------------------------------------------------------------------------- #
|
|
41
41
|
|
|
42
|
+
def _normalize_recipient(recipient: Any) -> Optional[Dict[str, str]]:
|
|
43
|
+
if recipient is None:
|
|
44
|
+
return None
|
|
45
|
+
if isinstance(recipient, dict):
|
|
46
|
+
email_value = recipient.get("email") or recipient.get("address")
|
|
47
|
+
name = recipient.get("name")
|
|
48
|
+
else:
|
|
49
|
+
email_value = getattr(recipient, "email", None) or getattr(recipient, "address", None)
|
|
50
|
+
name = getattr(recipient, "name", None)
|
|
51
|
+
if isinstance(recipient, str):
|
|
52
|
+
email_value = recipient
|
|
53
|
+
name = None
|
|
54
|
+
if not email_value:
|
|
55
|
+
return None
|
|
56
|
+
return {"email": email_value, "name": name}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_recipients(recipients: Optional[List[Any]]) -> List[Dict[str, str]]:
|
|
60
|
+
normalized: List[Dict[str, str]] = []
|
|
61
|
+
for recipient in recipients or []:
|
|
62
|
+
item = _normalize_recipient(recipient)
|
|
63
|
+
if item:
|
|
64
|
+
normalized.append(item)
|
|
65
|
+
return normalized
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_header_recipients(header_value: str) -> List[Dict[str, str]]:
|
|
69
|
+
parsed = email.utils.getaddresses([header_value]) if header_value else []
|
|
70
|
+
return [{"email": address, "name": name} for name, address in parsed if address]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _dedupe_recipients(
|
|
74
|
+
recipients: List[Dict[str, str]],
|
|
75
|
+
exclude_emails: Optional[List[str]] = None,
|
|
76
|
+
) -> List[Dict[str, str]]:
|
|
77
|
+
excluded = {email_value.lower() for email_value in (exclude_emails or []) if email_value}
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
result: List[Dict[str, str]] = []
|
|
80
|
+
for recipient in recipients:
|
|
81
|
+
email_value = (recipient.get("email") or "").strip()
|
|
82
|
+
if not email_value:
|
|
83
|
+
continue
|
|
84
|
+
email_lc = email_value.lower()
|
|
85
|
+
if email_lc in seen or email_lc in excluded:
|
|
86
|
+
continue
|
|
87
|
+
seen.add(email_lc)
|
|
88
|
+
result.append({"email": email_value, "name": recipient.get("name")})
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _recipients_to_header(recipients: List[Dict[str, str]]) -> str:
|
|
93
|
+
parts: List[str] = []
|
|
94
|
+
for recipient in recipients:
|
|
95
|
+
name = recipient.get("name")
|
|
96
|
+
email_value = recipient.get("email")
|
|
97
|
+
if not email_value:
|
|
98
|
+
continue
|
|
99
|
+
if name:
|
|
100
|
+
parts.append(f"{name} <{email_value}>")
|
|
101
|
+
else:
|
|
102
|
+
parts.append(email_value)
|
|
103
|
+
return ", ".join(parts)
|
|
104
|
+
|
|
42
105
|
|
|
43
106
|
def _decode_header_value(value: Any) -> str:
|
|
44
107
|
"""Return a unicode string for an e-mail header field."""
|
|
@@ -475,7 +538,7 @@ async def reply_to_email_via_smtp_async(
|
|
|
475
538
|
if status == "OK" and nums and nums[0]:
|
|
476
539
|
num = nums[0].split()[0]
|
|
477
540
|
_, data = conn.fetch(num, "(RFC822)")
|
|
478
|
-
if ctx.mark_as_read.lower() == "true":
|
|
541
|
+
if str(ctx.mark_as_read).lower() == "true":
|
|
479
542
|
conn.store(num, "+FLAGS", "\\Seen")
|
|
480
543
|
msg_data = data[0][1] if data and data[0] else None
|
|
481
544
|
break
|
|
@@ -496,28 +559,129 @@ async def reply_to_email_via_smtp_async(
|
|
|
496
559
|
|
|
497
560
|
raw_original = await asyncio.to_thread(_fetch_original)
|
|
498
561
|
if raw_original is None:
|
|
499
|
-
raise
|
|
562
|
+
raise ValueError(f"Could not locate original message with ID {ctx.message_id!r}")
|
|
500
563
|
|
|
501
564
|
original = email.message_from_bytes(raw_original)
|
|
502
565
|
hdr = lambda h: original.get(h, "")
|
|
503
566
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
567
|
+
reply_type = (ctx.reply_type or "reply").lower()
|
|
568
|
+
if reply_type not in {"reply", "reply_all", "forward"}:
|
|
569
|
+
reply_type = "reply"
|
|
570
|
+
|
|
508
571
|
sender_email_lc = (ctx.sender_email or "").lower()
|
|
572
|
+
|
|
509
573
|
def _is_self(addr: str) -> bool:
|
|
510
574
|
return bool(sender_email_lc) and sender_email_lc in addr.lower()
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
575
|
+
|
|
576
|
+
explicit_to = _normalize_recipients(getattr(ctx, "to_recipients", None))
|
|
577
|
+
explicit_cc = _normalize_recipients(getattr(ctx, "cc_recipients", None))
|
|
578
|
+
explicit_bcc = _normalize_recipients(getattr(ctx, "bcc_recipients", None))
|
|
579
|
+
|
|
580
|
+
to_recipients: List[Dict[str, str]] = []
|
|
581
|
+
cc_recipients: List[Dict[str, str]] = []
|
|
582
|
+
bcc_recipients: List[Dict[str, str]] = []
|
|
583
|
+
|
|
584
|
+
from_header = hdr("From")
|
|
585
|
+
reply_to_header = hdr("Reply-To")
|
|
586
|
+
to_header = hdr("To")
|
|
587
|
+
cc_header = hdr("Cc")
|
|
588
|
+
|
|
589
|
+
if reply_type == "forward":
|
|
590
|
+
if not explicit_to:
|
|
591
|
+
raise ValueError("Forward requires explicit to_recipients.")
|
|
592
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
593
|
+
cc_recipients = _dedupe_recipients(explicit_cc)
|
|
594
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
595
|
+
# Respect custom subject if explicitly provided; otherwise derive from original
|
|
596
|
+
if ctx.subject:
|
|
597
|
+
subject = ctx.subject
|
|
598
|
+
else:
|
|
599
|
+
subject = hdr("Subject")
|
|
600
|
+
if subject and not subject.lower().startswith("fwd:"):
|
|
601
|
+
subject = f"Fwd: {subject}"
|
|
602
|
+
elif not subject:
|
|
603
|
+
subject = "Fwd:"
|
|
604
|
+
else:
|
|
605
|
+
if reply_type == "reply_all":
|
|
606
|
+
base_to = _dedupe_recipients(
|
|
607
|
+
_parse_header_recipients(from_header),
|
|
608
|
+
exclude_emails=[ctx.sender_email],
|
|
609
|
+
)
|
|
610
|
+
if not base_to:
|
|
611
|
+
base_to = _dedupe_recipients(
|
|
612
|
+
_parse_header_recipients(reply_to_header),
|
|
613
|
+
exclude_emails=[ctx.sender_email],
|
|
614
|
+
)
|
|
615
|
+
if not base_to:
|
|
616
|
+
base_to = _dedupe_recipients(
|
|
617
|
+
_parse_header_recipients(to_header),
|
|
618
|
+
exclude_emails=[ctx.sender_email],
|
|
619
|
+
)
|
|
620
|
+
if not base_to and getattr(ctx, "fallback_recipient", None):
|
|
621
|
+
fr = ctx.fallback_recipient
|
|
622
|
+
if fr and not _is_self(fr):
|
|
623
|
+
base_to = _dedupe_recipients([{"email": fr}])
|
|
624
|
+
if not base_to:
|
|
625
|
+
raise ValueError("No valid recipient found in original message; refusing to reply to sender.")
|
|
626
|
+
base_to_emails = [recipient.get("email") for recipient in base_to if recipient.get("email")]
|
|
627
|
+
base_cc_candidates = _parse_header_recipients(to_header) + _parse_header_recipients(cc_header)
|
|
628
|
+
base_cc = _dedupe_recipients(
|
|
629
|
+
base_cc_candidates,
|
|
630
|
+
exclude_emails=[ctx.sender_email] + base_to_emails,
|
|
631
|
+
)
|
|
632
|
+
else:
|
|
633
|
+
cc_header_value = cc_header or ""
|
|
634
|
+
if reply_to_header and not _is_self(reply_to_header):
|
|
635
|
+
to_header_value = reply_to_header
|
|
636
|
+
elif from_header and not _is_self(from_header):
|
|
637
|
+
to_header_value = from_header
|
|
638
|
+
elif to_header and not _is_self(to_header):
|
|
639
|
+
to_header_value = to_header
|
|
640
|
+
else:
|
|
641
|
+
to_header_value = ", ".join([v for v in (to_header, cc_header, from_header) if v])
|
|
642
|
+
cc_header_value = ""
|
|
643
|
+
|
|
644
|
+
base_to = _dedupe_recipients(
|
|
645
|
+
_parse_header_recipients(to_header_value),
|
|
646
|
+
exclude_emails=[ctx.sender_email],
|
|
647
|
+
)
|
|
648
|
+
base_cc = _dedupe_recipients(
|
|
649
|
+
_parse_header_recipients(cc_header_value),
|
|
650
|
+
exclude_emails=[ctx.sender_email],
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if not base_to and getattr(ctx, "fallback_recipient", None):
|
|
654
|
+
fr = ctx.fallback_recipient
|
|
655
|
+
if fr and not _is_self(fr):
|
|
656
|
+
base_to = _dedupe_recipients([{"email": fr}])
|
|
657
|
+
base_cc = []
|
|
658
|
+
|
|
659
|
+
if not base_to:
|
|
660
|
+
raise ValueError("No valid recipient found in original message; refusing to reply to sender.")
|
|
661
|
+
|
|
662
|
+
to_recipients = base_to
|
|
663
|
+
if explicit_to:
|
|
664
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
665
|
+
|
|
666
|
+
cc_recipients = base_cc
|
|
667
|
+
if explicit_cc:
|
|
668
|
+
cc_recipients = _dedupe_recipients(
|
|
669
|
+
base_cc + explicit_cc,
|
|
670
|
+
exclude_emails=[recipient.get("email") for recipient in to_recipients if recipient.get("email")],
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
674
|
+
|
|
675
|
+
subject = hdr("Subject")
|
|
676
|
+
if not subject.lower().startswith("re:"):
|
|
677
|
+
subject = f"Re: {subject}"
|
|
678
|
+
|
|
679
|
+
to_addrs = _recipients_to_header(to_recipients)
|
|
680
|
+
cc_addrs = _recipients_to_header(cc_recipients)
|
|
681
|
+
bcc_addrs = _recipients_to_header(bcc_recipients)
|
|
682
|
+
if not to_addrs:
|
|
683
|
+
raise ValueError("No valid recipient found in original message; refusing to reply to sender.")
|
|
684
|
+
|
|
521
685
|
orig_msg_id = hdr("Message-ID") # parent's ID
|
|
522
686
|
|
|
523
687
|
# Build the References header by appending the parent's ID
|
|
@@ -529,13 +693,17 @@ async def reply_to_email_via_smtp_async(
|
|
|
529
693
|
|
|
530
694
|
# 3. Build the MIMEText reply
|
|
531
695
|
msg = MIMEText(ctx.reply_body, _charset="utf-8")
|
|
532
|
-
|
|
696
|
+
sender_display = ctx.sender_name or ctx.sender_email
|
|
697
|
+
msg["From"] = f"{sender_display} <{ctx.sender_email}>"
|
|
533
698
|
msg["To"] = to_addrs
|
|
534
699
|
if cc_addrs:
|
|
535
700
|
msg["Cc"] = cc_addrs
|
|
701
|
+
if bcc_addrs:
|
|
702
|
+
msg["Bcc"] = bcc_addrs
|
|
536
703
|
msg["Subject"] = subject
|
|
537
|
-
|
|
538
|
-
|
|
704
|
+
if reply_type != "forward":
|
|
705
|
+
msg["In-Reply-To"] = orig_msg_id
|
|
706
|
+
msg["References"] = references
|
|
539
707
|
|
|
540
708
|
# Generate a new Message-ID for this reply
|
|
541
709
|
domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
|
|
@@ -571,6 +739,8 @@ async def reply_to_email_via_smtp_async(
|
|
|
571
739
|
"email_subject": subject,
|
|
572
740
|
"email_sender": ctx.sender_email,
|
|
573
741
|
"email_recipients": recipients,
|
|
574
|
-
"
|
|
742
|
+
"to_recipients": to_recipients,
|
|
743
|
+
"cc_recipients": cc_recipients,
|
|
744
|
+
"read_email_status": "READ" if str(ctx.mark_as_read).lower() == "true" else "UNREAD",
|
|
575
745
|
"email_labels": [], # Not applicable for IMAP
|
|
576
746
|
}
|