dhisana 0.0.1.dev279__tar.gz → 0.0.1.dev280__tar.gz

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 (121) hide show
  1. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/setup.py +1 -1
  3. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/schemas/common.py +14 -1
  4. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/google_oauth_tools.py +179 -22
  5. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/google_workspace_tools.py +182 -26
  6. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/microsoft365_tools.py +190 -42
  7. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/smtp_email_tools.py +190 -20
  8. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/PKG-INFO +1 -1
  9. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/README.md +0 -0
  10. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/pyproject.toml +0 -0
  11. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/setup.cfg +0 -0
  12. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/__init__.py +0 -0
  13. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/__init__.py +0 -0
  14. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/cli.py +0 -0
  15. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/datasets.py +0 -0
  16. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/models.py +0 -0
  17. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/predictions.py +0 -0
  18. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/schemas/__init__.py +0 -0
  19. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/schemas/sales.py +0 -0
  20. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/ui/__init__.py +0 -0
  21. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/ui/components.py +0 -0
  22. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/__init__.py +0 -0
  23. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/add_mapping.py +0 -0
  24. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/agent_tools.py +0 -0
  25. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/apollo_tools.py +0 -0
  26. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  27. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/built_with_api_tools.py +0 -0
  28. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/cache_output_tools.py +0 -0
  29. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  30. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  31. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  32. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  33. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/clay_tools.py +0 -0
  34. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/clean_properties.py +0 -0
  35. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/company_utils.py +0 -0
  36. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  37. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/compose_search_query.py +0 -0
  38. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  39. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/composite_tools.py +0 -0
  40. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/dataframe_tools.py +0 -0
  41. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/domain_parser.py +0 -0
  42. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/email_body_utils.py +0 -0
  43. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/email_parse_helpers.py +0 -0
  44. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/email_provider.py +0 -0
  45. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/enrich_lead_information.py +0 -0
  46. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  47. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/fetch_openai_config.py +0 -0
  48. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/field_validators.py +0 -0
  49. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/g2_tools.py +0 -0
  50. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_content.py +0 -0
  51. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_custom_message.py +0 -0
  52. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_email.py +0 -0
  53. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_email_response.py +0 -0
  54. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_flow.py +0 -0
  55. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  56. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  57. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  58. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
  59. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/google_custom_search.py +0 -0
  60. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  61. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  62. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/instantly_tools.py +0 -0
  63. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/linkedin_crawler.py +0 -0
  64. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/lusha_tools.py +0 -0
  65. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/mailgun_tools.py +0 -0
  66. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/mailreach_tools.py +0 -0
  67. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  68. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openai_helpers.py +0 -0
  69. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  70. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  71. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  72. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  73. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  74. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  75. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/profile.py +0 -0
  76. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  77. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  78. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/python_function_to_tools.py +0 -0
  79. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/research_lead.py +0 -0
  80. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  81. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  82. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/search_router.py +0 -0
  83. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/search_router_jobs.py +0 -0
  84. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/sendgrid_tools.py +0 -0
  85. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  86. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  87. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  88. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_google_search.py +0 -0
  89. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  90. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_search_tools.py +0 -0
  91. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  92. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serperdev_local_business.py +0 -0
  93. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serperdev_search.py +0 -0
  94. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/test_connect.py +0 -0
  95. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/trasform_json.py +0 -0
  96. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  97. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/workflow_code_model.py +0 -0
  98. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/zoominfo_tools.py +0 -0
  99. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/__init__.py +0 -0
  100. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/agent.py +0 -0
  101. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/flow.py +0 -0
  102. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/task.py +0 -0
  103. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/test.py +0 -0
  104. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/SOURCES.txt +0 -0
  105. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/dependency_links.txt +0 -0
  106. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/entry_points.txt +0 -0
  107. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/requires.txt +0 -0
  108. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/top_level.txt +0 -0
  109. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_agent_tools.py +0 -0
  110. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_apollo_company_search.py +0 -0
  111. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_apollo_lead_search.py +0 -0
  112. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_connectivity.py +0 -0
  113. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_email_body_utils.py +0 -0
  114. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_google_document.py +0 -0
  115. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_hubspot_call_logs.py +0 -0
  116. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_linkedin_serper.py +0 -0
  117. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_mailreach.py +0 -0
  118. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_mcp_connectivity.py +0 -0
  119. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_proxycurl_get_company_search_id.py +0 -0
  120. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_proxycurl_job_count.py +0 -0
  121. {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_structured_output_with_mcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev279
3
+ Version: 0.0.1.dev280
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
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='dhisana',
5
- version='0.0.1-dev279',
5
+ version='0.0.1-dev280',
6
6
  description='A Python SDK for Dhisana AI Platform',
7
7
  author='Admin',
8
8
  author_email='contact@dhisana.ai',
@@ -389,13 +389,26 @@ class QueryEmailContext(BaseModel):
389
389
  labels: Optional[List[str]] = None
390
390
 
391
391
 
392
+ class EmailRecipient(BaseModel):
393
+ email: str
394
+ name: Optional[str] = None
395
+
396
+
392
397
  class ReplyEmailContext(BaseModel):
398
+ """Context for replying to or forwarding an email."""
393
399
  message_id: str
394
400
  reply_body: str
395
401
  sender_email: str
396
- sender_name: str
402
+ sender_name: Optional[str] = None
397
403
  headers: Optional[Dict[str, str]] = None
398
404
  fallback_recipient: Optional[str] = None
399
405
  mark_as_read: str = "True"
400
406
  add_labels: Optional[List[str]] = None
401
407
  reply_body_format: BodyFormat = BodyFormat.AUTO
408
+ to_recipients: Optional[List[EmailRecipient]] = None
409
+ cc_recipients: Optional[List[EmailRecipient]] = None
410
+ bcc_recipients: Optional[List[EmailRecipient]] = None
411
+ # Type of reply: "reply", "reply_all", or "forward". Defaults to "reply" if not specified.
412
+ reply_type: Optional[str] = None
413
+ # Custom subject line (primarily used for forward operations to override the default "Fwd: ..." prefix)
414
+ subject: Optional[str] = None
@@ -4,6 +4,7 @@ import logging
4
4
  import re
5
5
  from email.mime.multipart import MIMEMultipart
6
6
  from email.mime.text import MIMEText
7
+ from email.utils import getaddresses
7
8
  from typing import Any, Dict, List, Optional
8
9
 
9
10
  import httpx
@@ -78,6 +79,70 @@ def _rethrow_with_google_message(exc: httpx.HTTPStatusError, context: str) -> No
78
79
  )
79
80
 
80
81
 
82
+ def _normalize_recipient(recipient: Any) -> Optional[Dict[str, str]]:
83
+ if recipient is None:
84
+ return None
85
+ if isinstance(recipient, dict):
86
+ email = recipient.get("email") or recipient.get("address")
87
+ name = recipient.get("name")
88
+ else:
89
+ email = getattr(recipient, "email", None) or getattr(recipient, "address", None)
90
+ name = getattr(recipient, "name", None)
91
+ if isinstance(recipient, str):
92
+ email = recipient
93
+ name = None
94
+ if not email:
95
+ return None
96
+ return {"email": email, "name": name}
97
+
98
+
99
+ def _normalize_recipients(recipients: Optional[List[Any]]) -> List[Dict[str, str]]:
100
+ normalized: List[Dict[str, str]] = []
101
+ for recipient in recipients or []:
102
+ item = _normalize_recipient(recipient)
103
+ if item:
104
+ normalized.append(item)
105
+ return normalized
106
+
107
+
108
+ def _parse_header_recipients(header_value: str) -> List[Dict[str, str]]:
109
+ parsed = getaddresses([header_value]) if header_value else []
110
+ return [{"email": address, "name": name} for name, address in parsed if address]
111
+
112
+
113
+ def _dedupe_recipients(
114
+ recipients: List[Dict[str, str]],
115
+ exclude_emails: Optional[List[str]] = None,
116
+ ) -> List[Dict[str, str]]:
117
+ excluded = {email.lower() for email in (exclude_emails or []) if email}
118
+ seen: set[str] = set()
119
+ result: List[Dict[str, str]] = []
120
+ for recipient in recipients:
121
+ email = (recipient.get("email") or "").strip()
122
+ if not email:
123
+ continue
124
+ email_lc = email.lower()
125
+ if email_lc in seen or email_lc in excluded:
126
+ continue
127
+ seen.add(email_lc)
128
+ result.append({"email": email, "name": recipient.get("name")})
129
+ return result
130
+
131
+
132
+ def _recipients_to_header(recipients: List[Dict[str, str]]) -> str:
133
+ parts: List[str] = []
134
+ for recipient in recipients:
135
+ name = recipient.get("name")
136
+ email = recipient.get("email")
137
+ if not email:
138
+ continue
139
+ if name:
140
+ parts.append(f"{name} <{email}>")
141
+ else:
142
+ parts.append(email)
143
+ return ", ".join(parts)
144
+
145
+
81
146
  def get_google_access_token(tool_config: Optional[List[Dict]] = None) -> str:
82
147
  """
83
148
  Retrieve a Google OAuth2 access token from the 'google' integration config.
@@ -291,8 +356,6 @@ async def reply_to_email_google_oauth_async(
291
356
  headers_list = (original.get("payload") or {}).get("headers", [])
292
357
  # Use case-insensitive lookups via find_header to avoid missing values on header casing differences.
293
358
  subject = find_header(headers_list, "Subject") or ""
294
- if not subject.startswith("Re:"):
295
- subject = f"Re: {subject}"
296
359
  reply_to_header = find_header(headers_list, "Reply-To") or ""
297
360
  from_header = find_header(headers_list, "From") or ""
298
361
  to_header = find_header(headers_list, "To") or ""
@@ -300,31 +363,120 @@ async def reply_to_email_google_oauth_async(
300
363
  message_id_header = find_header(headers_list, "Message-ID") or ""
301
364
  thread_id = original.get("threadId")
302
365
 
366
+ reply_type = (reply_email_context.reply_type or "reply").lower()
367
+ if reply_type not in {"reply", "reply_all", "forward"}:
368
+ reply_type = "reply"
369
+
303
370
  sender_email_lc = (reply_email_context.sender_email or "").lower()
304
371
 
305
372
  def _is_self(addr: str) -> bool:
306
373
  return bool(sender_email_lc) and sender_email_lc in addr.lower()
307
374
 
308
- cc_addresses = cc_header or ""
309
- # Prefer Reply-To unless it points back to the sender. If the original was SENT mail,
310
- # From will equal the sender, so we should reply to the original To/CC instead.
311
- if reply_to_header and not _is_self(reply_to_header):
312
- to_addresses = reply_to_header
313
- elif from_header and not _is_self(from_header):
314
- to_addresses = from_header
315
- elif to_header and not _is_self(to_header):
316
- to_addresses = to_header
375
+ explicit_to = _normalize_recipients(reply_email_context.to_recipients)
376
+ explicit_cc = _normalize_recipients(reply_email_context.cc_recipients)
377
+ explicit_bcc = _normalize_recipients(reply_email_context.bcc_recipients)
378
+
379
+ to_recipients: List[Dict[str, str]] = []
380
+ cc_recipients: List[Dict[str, str]] = []
381
+ bcc_recipients: List[Dict[str, str]] = []
382
+
383
+ if reply_type == "forward":
384
+ if not explicit_to:
385
+ raise ValueError("Forward requires explicit to_recipients.")
386
+ to_recipients = _dedupe_recipients(explicit_to)
387
+ cc_recipients = _dedupe_recipients(explicit_cc)
388
+ bcc_recipients = _dedupe_recipients(explicit_bcc)
389
+
390
+ if reply_email_context.subject:
391
+ subject = reply_email_context.subject
392
+ elif subject and not subject.lower().startswith("fwd:"):
393
+ subject = f"Fwd: {subject}"
394
+ else:
395
+ subject = subject or "Fwd:"
317
396
  else:
318
- combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
319
- to_addresses = combined
320
- cc_addresses = ""
397
+ if not subject.startswith("Re:"):
398
+ subject = f"Re: {subject}"
399
+
400
+ if reply_type == "reply_all":
401
+ base_to = _dedupe_recipients(
402
+ _parse_header_recipients(from_header),
403
+ exclude_emails=[reply_email_context.sender_email],
404
+ )
405
+ if not base_to:
406
+ base_to = _dedupe_recipients(
407
+ _parse_header_recipients(reply_to_header),
408
+ exclude_emails=[reply_email_context.sender_email],
409
+ )
410
+ if not base_to:
411
+ base_to = _dedupe_recipients(
412
+ _parse_header_recipients(to_header),
413
+ exclude_emails=[reply_email_context.sender_email],
414
+ )
415
+ if not base_to and reply_email_context.fallback_recipient:
416
+ base_to = _dedupe_recipients(
417
+ [{"email": reply_email_context.fallback_recipient}],
418
+ exclude_emails=[reply_email_context.sender_email],
419
+ )
420
+ if not base_to:
421
+ raise ValueError(
422
+ "No valid recipient found in the original message; refusing to reply to sender."
423
+ )
424
+ base_to_emails = [recipient.get("email") for recipient in base_to if recipient.get("email")]
425
+ base_cc_candidates = _parse_header_recipients(to_header) + _parse_header_recipients(cc_header)
426
+ base_cc = _dedupe_recipients(
427
+ base_cc_candidates,
428
+ exclude_emails=[reply_email_context.sender_email] + base_to_emails,
429
+ )
430
+ else:
431
+ cc_header_value = cc_header or ""
432
+ # Prefer Reply-To unless it points back to the sender. If the original was SENT mail,
433
+ # From will equal the sender, so we should reply to the original To/CC instead.
434
+ if reply_to_header and not _is_self(reply_to_header):
435
+ to_header_value = reply_to_header
436
+ elif from_header and not _is_self(from_header):
437
+ to_header_value = from_header
438
+ elif to_header and not _is_self(to_header):
439
+ to_header_value = to_header
440
+ else:
441
+ to_header_value = ", ".join([v for v in (to_header, cc_header, from_header) if v])
442
+ cc_header_value = ""
443
+
444
+ base_to = _dedupe_recipients(
445
+ _parse_header_recipients(to_header_value),
446
+ exclude_emails=[reply_email_context.sender_email],
447
+ )
448
+ base_cc = _dedupe_recipients(
449
+ _parse_header_recipients(cc_header_value),
450
+ exclude_emails=[reply_email_context.sender_email],
451
+ )
452
+
453
+ if not base_to and reply_email_context.fallback_recipient and not _is_self(reply_email_context.fallback_recipient):
454
+ base_to = _dedupe_recipients([{"email": reply_email_context.fallback_recipient}])
455
+ base_cc = []
456
+
457
+ if not base_to:
458
+ raise ValueError(
459
+ "No valid recipient found in the original message; refusing to reply to sender."
460
+ )
461
+
462
+ to_recipients = base_to
463
+ if explicit_to:
464
+ to_recipients = _dedupe_recipients(explicit_to)
465
+
466
+ cc_recipients = base_cc
467
+ if explicit_cc:
468
+ cc_recipients = _dedupe_recipients(
469
+ base_cc + explicit_cc,
470
+ exclude_emails=[recipient.get("email") for recipient in to_recipients if recipient.get("email")],
471
+ )
472
+
473
+ bcc_recipients = _dedupe_recipients(explicit_bcc)
321
474
 
322
- if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
323
- if not _is_self(reply_email_context.fallback_recipient):
324
- to_addresses = reply_email_context.fallback_recipient
325
- cc_addresses = ""
475
+ to_addresses = _recipients_to_header(to_recipients)
476
+ cc_addresses = _recipients_to_header(cc_recipients)
477
+ bcc_addresses = _recipients_to_header(bcc_recipients)
326
478
 
327
- if not to_addresses or _is_self(to_addresses):
479
+ if not to_addresses:
328
480
  raise ValueError(
329
481
  "No valid recipient found in the original message; refusing to reply to sender."
330
482
  )
@@ -344,15 +496,18 @@ async def reply_to_email_google_oauth_async(
344
496
  msg["To"] = to_addresses
345
497
  if cc_addresses:
346
498
  msg["Cc"] = cc_addresses
347
- msg["From"] = f"{reply_email_context.sender_name} <{reply_email_context.sender_email}>"
499
+ if bcc_addresses:
500
+ msg["Bcc"] = bcc_addresses
501
+ sender_display = reply_email_context.sender_name or reply_email_context.sender_email
502
+ msg["From"] = f"{sender_display} <{reply_email_context.sender_email}>"
348
503
  msg["Subject"] = subject
349
- if message_id_header:
504
+ if reply_type != "forward" and message_id_header:
350
505
  msg["In-Reply-To"] = message_id_header
351
506
  msg["References"] = message_id_header
352
507
 
353
508
  raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode()
354
509
  payload = {"raw": raw_message}
355
- if thread_id:
510
+ if thread_id and reply_type != "forward":
356
511
  payload["threadId"] = thread_id
357
512
 
358
513
  # 3) Send the reply
@@ -391,6 +546,8 @@ async def reply_to_email_google_oauth_async(
391
546
  "email_subject": subject,
392
547
  "email_sender": reply_email_context.sender_email,
393
548
  "email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
549
+ "to_recipients": to_recipients,
550
+ "cc_recipients": cc_recipients,
394
551
  "read_email_status": "READ" if str(reply_email_context.mark_as_read).lower() == "true" else "UNREAD",
395
552
  "email_labels": sent.get("labelIds", []),
396
553
  }
@@ -10,6 +10,7 @@ import re
10
10
  import uuid
11
11
  from email.mime.multipart import MIMEMultipart
12
12
  from email.mime.text import MIMEText
13
+ from email.utils import getaddresses
13
14
  from typing import Any, Dict, List, Optional
14
15
 
15
16
  import httpx
@@ -33,6 +34,69 @@ from dhisana.schemas.common import (SendEmailContext, QueryEmailContext, ReplyEm
33
34
  # HELPER FUNCTIONS
34
35
  ################################################################################
35
36
 
37
+ def _normalize_recipient(recipient: Any) -> Optional[Dict[str, str]]:
38
+ if recipient is None:
39
+ return None
40
+ if isinstance(recipient, dict):
41
+ email = recipient.get("email") or recipient.get("address")
42
+ name = recipient.get("name")
43
+ else:
44
+ email = getattr(recipient, "email", None) or getattr(recipient, "address", None)
45
+ name = getattr(recipient, "name", None)
46
+ if isinstance(recipient, str):
47
+ email = recipient
48
+ name = None
49
+ if not email:
50
+ return None
51
+ return {"email": email, "name": name}
52
+
53
+
54
+ def _normalize_recipients(recipients: Optional[List[Any]]) -> List[Dict[str, str]]:
55
+ normalized: List[Dict[str, str]] = []
56
+ for recipient in recipients or []:
57
+ item = _normalize_recipient(recipient)
58
+ if item:
59
+ normalized.append(item)
60
+ return normalized
61
+
62
+
63
+ def _parse_header_recipients(header_value: str) -> List[Dict[str, str]]:
64
+ parsed = getaddresses([header_value]) if header_value else []
65
+ return [{"email": address, "name": name} for name, address in parsed if address]
66
+
67
+
68
+ def _dedupe_recipients(
69
+ recipients: List[Dict[str, str]],
70
+ exclude_emails: Optional[List[str]] = None,
71
+ ) -> List[Dict[str, str]]:
72
+ excluded = {email.lower() for email in (exclude_emails or []) if email}
73
+ seen: set[str] = set()
74
+ result: List[Dict[str, str]] = []
75
+ for recipient in recipients:
76
+ email = (recipient.get("email") or "").strip()
77
+ if not email:
78
+ continue
79
+ email_lc = email.lower()
80
+ if email_lc in seen or email_lc in excluded:
81
+ continue
82
+ seen.add(email_lc)
83
+ result.append({"email": email, "name": recipient.get("name")})
84
+ return result
85
+
86
+
87
+ def _recipients_to_header(recipients: List[Dict[str, str]]) -> str:
88
+ parts: List[str] = []
89
+ for recipient in recipients:
90
+ name = recipient.get("name")
91
+ email = recipient.get("email")
92
+ if not email:
93
+ continue
94
+ if name:
95
+ parts.append(f"{name} <{email}>")
96
+ else:
97
+ parts.append(email)
98
+ return ", ".join(parts)
99
+
36
100
  def get_google_workspace_token(tool_config: Optional[List[Dict]] = None) -> Any:
37
101
  """
38
102
  Retrieves the GOOGLE_SERVICE_KEY (base64-encoded JSON) from the provided tool configuration or environment.
@@ -945,8 +1009,6 @@ async def reply_to_email_async(
945
1009
  headers_list = original_message.get('payload', {}).get('headers', [])
946
1010
  # Case-insensitive header lookup and resilient recipient fallback to avoid Gmail 400s.
947
1011
  subject = find_header(headers_list, 'Subject') or ''
948
- if not subject.startswith('Re:'):
949
- subject = f'Re: {subject}'
950
1012
  reply_to_header = find_header(headers_list, 'Reply-To') or ''
951
1013
  from_header = find_header(headers_list, 'From') or ''
952
1014
  to_header = find_header(headers_list, 'To') or ''
@@ -954,29 +1016,118 @@ async def reply_to_email_async(
954
1016
  message_id_header = find_header(headers_list, 'Message-ID') or ''
955
1017
  thread_id = original_message.get('threadId')
956
1018
 
1019
+ reply_type = (reply_email_context.reply_type or "reply").lower()
1020
+ if reply_type not in {"reply", "reply_all", "forward"}:
1021
+ reply_type = "reply"
1022
+
957
1023
  sender_email_lc = (reply_email_context.sender_email or '').lower()
958
1024
 
959
1025
  def _is_self(addr: str) -> bool:
960
1026
  return bool(sender_email_lc) and sender_email_lc in addr.lower()
961
1027
 
962
- cc_addresses = cc_header or ''
963
- if reply_to_header and not _is_self(reply_to_header):
964
- to_addresses = reply_to_header
965
- elif from_header and not _is_self(from_header):
966
- to_addresses = from_header
967
- elif to_header and not _is_self(to_header):
968
- to_addresses = to_header
1028
+ explicit_to = _normalize_recipients(reply_email_context.to_recipients)
1029
+ explicit_cc = _normalize_recipients(reply_email_context.cc_recipients)
1030
+ explicit_bcc = _normalize_recipients(reply_email_context.bcc_recipients)
1031
+
1032
+ to_recipients: List[Dict[str, str]] = []
1033
+ cc_recipients: List[Dict[str, str]] = []
1034
+ bcc_recipients: List[Dict[str, str]] = []
1035
+
1036
+ if reply_type == "forward":
1037
+ if not explicit_to:
1038
+ raise ValueError("Forward requires explicit to_recipients.")
1039
+ to_recipients = _dedupe_recipients(explicit_to)
1040
+ cc_recipients = _dedupe_recipients(explicit_cc)
1041
+ bcc_recipients = _dedupe_recipients(explicit_bcc)
1042
+
1043
+ if reply_email_context.subject:
1044
+ subject = reply_email_context.subject
1045
+ elif subject and not subject.lower().startswith("fwd:"):
1046
+ subject = f"Fwd: {subject}"
1047
+ else:
1048
+ subject = subject or "Fwd:"
969
1049
  else:
970
- combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
971
- to_addresses = combined
972
- cc_addresses = ''
1050
+ if not subject.startswith('Re:'):
1051
+ subject = f'Re: {subject}'
973
1052
 
974
- if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
975
- if not _is_self(reply_email_context.fallback_recipient):
976
- to_addresses = reply_email_context.fallback_recipient
977
- cc_addresses = ''
1053
+ if reply_type == "reply_all":
1054
+ base_to = _dedupe_recipients(
1055
+ _parse_header_recipients(from_header),
1056
+ exclude_emails=[reply_email_context.sender_email],
1057
+ )
1058
+ if not base_to:
1059
+ base_to = _dedupe_recipients(
1060
+ _parse_header_recipients(reply_to_header),
1061
+ exclude_emails=[reply_email_context.sender_email],
1062
+ )
1063
+ if not base_to:
1064
+ base_to = _dedupe_recipients(
1065
+ _parse_header_recipients(to_header),
1066
+ exclude_emails=[reply_email_context.sender_email],
1067
+ )
1068
+ if not base_to and reply_email_context.fallback_recipient:
1069
+ base_to = _dedupe_recipients(
1070
+ [{"email": reply_email_context.fallback_recipient}],
1071
+ exclude_emails=[reply_email_context.sender_email],
1072
+ )
1073
+ if not base_to:
1074
+ raise ValueError(
1075
+ "No valid recipient found in the original message; refusing to reply to sender."
1076
+ )
1077
+ base_to_emails = [recipient.get("email") for recipient in base_to if recipient.get("email")]
1078
+ base_cc_candidates = _parse_header_recipients(to_header) + _parse_header_recipients(cc_header)
1079
+ base_cc = _dedupe_recipients(
1080
+ base_cc_candidates,
1081
+ exclude_emails=[reply_email_context.sender_email] + base_to_emails,
1082
+ )
1083
+ else:
1084
+ cc_header_value = cc_header or ''
1085
+ if reply_to_header and not _is_self(reply_to_header):
1086
+ to_header_value = reply_to_header
1087
+ elif from_header and not _is_self(from_header):
1088
+ to_header_value = from_header
1089
+ elif to_header and not _is_self(to_header):
1090
+ to_header_value = to_header
1091
+ else:
1092
+ to_header_value = ", ".join([v for v in (to_header, cc_header, from_header) if v])
1093
+ cc_header_value = ''
1094
+
1095
+ base_to = _dedupe_recipients(
1096
+ _parse_header_recipients(to_header_value),
1097
+ exclude_emails=[reply_email_context.sender_email],
1098
+ )
1099
+ base_cc = _dedupe_recipients(
1100
+ _parse_header_recipients(cc_header_value),
1101
+ exclude_emails=[reply_email_context.sender_email],
1102
+ )
1103
+
1104
+ if not base_to and reply_email_context.fallback_recipient and not _is_self(reply_email_context.fallback_recipient):
1105
+ base_to = _dedupe_recipients([{"email": reply_email_context.fallback_recipient}])
1106
+ base_cc = []
1107
+
1108
+ if not base_to:
1109
+ raise ValueError(
1110
+ "No valid recipient found in the original message; refusing to reply to sender."
1111
+ )
978
1112
 
979
- if not to_addresses or _is_self(to_addresses):
1113
+ to_recipients = base_to
1114
+ if explicit_to:
1115
+ to_recipients = _dedupe_recipients(explicit_to)
1116
+
1117
+ cc_recipients = base_cc
1118
+ if explicit_cc:
1119
+ cc_recipients = _dedupe_recipients(
1120
+ base_cc + explicit_cc,
1121
+ exclude_emails=[recipient.get("email") for recipient in to_recipients if recipient.get("email")],
1122
+ )
1123
+
1124
+ bcc_recipients = _dedupe_recipients(explicit_bcc)
1125
+
1126
+ to_addresses = _recipients_to_header(to_recipients)
1127
+ cc_addresses = _recipients_to_header(cc_recipients)
1128
+ bcc_addresses = _recipients_to_header(bcc_recipients)
1129
+
1130
+ if not to_addresses:
980
1131
  raise ValueError(
981
1132
  "No valid recipient found in the original message; refusing to reply to sender."
982
1133
  )
@@ -995,16 +1146,19 @@ async def reply_to_email_async(
995
1146
  msg['To'] = to_addresses
996
1147
  if cc_addresses:
997
1148
  msg['Cc'] = cc_addresses
998
- msg['From'] = f"{reply_email_context.sender_name} <{reply_email_context.sender_email}>"
1149
+ if bcc_addresses:
1150
+ msg['Bcc'] = bcc_addresses
1151
+ sender_display = reply_email_context.sender_name or reply_email_context.sender_email
1152
+ msg['From'] = f"{sender_display} <{reply_email_context.sender_email}>"
999
1153
  msg['Subject'] = subject
1000
- msg['In-Reply-To'] = message_id_header
1001
- msg['References'] = message_id_header
1154
+ if reply_type != "forward":
1155
+ msg['In-Reply-To'] = message_id_header
1156
+ msg['References'] = message_id_header
1002
1157
 
1003
1158
  raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode()
1004
- payload = {
1005
- 'raw': raw_message,
1006
- 'threadId': thread_id
1007
- }
1159
+ payload = {'raw': raw_message}
1160
+ if thread_id and reply_type != "forward":
1161
+ payload['threadId'] = thread_id
1008
1162
 
1009
1163
  # 4. Send the reply
1010
1164
  send_message_url = f'{gmail_api_base_url}/messages/send'
@@ -1014,7 +1168,7 @@ async def reply_to_email_async(
1014
1168
  sent_message = response.json()
1015
1169
 
1016
1170
  # 5. (Optional) Mark the thread as read
1017
- if reply_email_context.mark_as_read.lower() == "true":
1171
+ if str(reply_email_context.mark_as_read).lower() == "true":
1018
1172
  modify_thread_url = f'{gmail_api_base_url}/threads/{thread_id}/modify'
1019
1173
  modify_payload = {'removeLabelIds': ['UNREAD']}
1020
1174
  async with httpx.AsyncClient() as client:
@@ -1036,7 +1190,9 @@ async def reply_to_email_async(
1036
1190
  "email_subject": subject,
1037
1191
  "email_sender": reply_email_context.sender_email,
1038
1192
  "email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
1039
- "read_email_status": 'READ' if reply_email_context.mark_as_read.lower() == "true" else 'UNREAD',
1193
+ "to_recipients": to_recipients,
1194
+ "cc_recipients": cc_recipients,
1195
+ "read_email_status": 'READ' if str(reply_email_context.mark_as_read).lower() == "true" else 'UNREAD',
1040
1196
  "email_labels": sent_message.get('labelIds', [])
1041
1197
  }
1042
1198