dhisana 0.0.1.dev304__tar.gz → 0.0.1.dev305__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 (125) hide show
  1. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/setup.py +1 -1
  3. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/schemas/common.py +7 -2
  4. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_oauth_tools.py +21 -0
  5. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/microsoft365_tools.py +29 -0
  6. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/smtp_email_tools.py +36 -1
  7. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/PKG-INFO +1 -1
  8. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/SOURCES.txt +1 -0
  9. dhisana-0.0.1.dev305/tests/test_send_email_recipients.py +403 -0
  10. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/README.md +0 -0
  11. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/pyproject.toml +0 -0
  12. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/setup.cfg +0 -0
  13. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/__init__.py +0 -0
  14. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/__init__.py +0 -0
  15. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/cli.py +0 -0
  16. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/datasets.py +0 -0
  17. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/models.py +0 -0
  18. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/predictions.py +0 -0
  19. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/schemas/__init__.py +0 -0
  20. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/schemas/sales.py +0 -0
  21. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/ui/__init__.py +0 -0
  22. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/ui/components.py +0 -0
  23. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/__init__.py +0 -0
  24. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/add_mapping.py +0 -0
  25. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/agent_tools.py +0 -0
  26. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/apollo_tools.py +0 -0
  27. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  28. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/built_with_api_tools.py +0 -0
  29. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools.py +0 -0
  30. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  31. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  32. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  33. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  34. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/clay_tools.py +0 -0
  35. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/clean_properties.py +0 -0
  36. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/company_utils.py +0 -0
  37. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  38. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_search_query.py +0 -0
  39. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  40. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/composite_tools.py +0 -0
  41. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/dataframe_tools.py +0 -0
  42. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/domain_parser.py +0 -0
  43. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_body_utils.py +0 -0
  44. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_parse_helpers.py +0 -0
  45. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_provider.py +0 -0
  46. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/enrich_lead_information.py +0 -0
  47. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  48. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/fetch_openai_config.py +0 -0
  49. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/field_validators.py +0 -0
  50. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/g2_tools.py +0 -0
  51. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_content.py +0 -0
  52. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_custom_message.py +0 -0
  53. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email.py +0 -0
  54. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email_response.py +0 -0
  55. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_flow.py +0 -0
  56. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  57. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  58. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  59. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
  60. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_custom_search.py +0 -0
  61. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_workspace_tools.py +0 -0
  62. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  63. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  64. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/instantly_tools.py +0 -0
  65. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/linkedin_crawler.py +0 -0
  66. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/lusha_tools.py +0 -0
  67. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailgun_tools.py +0 -0
  68. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailreach_tools.py +0 -0
  69. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  70. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_helpers.py +0 -0
  71. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  72. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  73. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  74. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  75. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  76. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  77. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/profile.py +0 -0
  78. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  79. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  80. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/python_function_to_tools.py +0 -0
  81. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/research_lead.py +0 -0
  82. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  83. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  84. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router.py +0 -0
  85. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router_jobs.py +0 -0
  86. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/sendgrid_tools.py +0 -0
  87. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  88. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  89. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  90. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_search.py +0 -0
  91. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  92. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_search_tools.py +0 -0
  93. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  94. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_local_business.py +0 -0
  95. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_search.py +0 -0
  96. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/test_connect.py +0 -0
  97. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/trasform_json.py +0 -0
  98. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  99. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/workflow_code_model.py +0 -0
  100. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/zoominfo_tools.py +0 -0
  101. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/__init__.py +0 -0
  102. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/agent.py +0 -0
  103. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/flow.py +0 -0
  104. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/task.py +0 -0
  105. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/test.py +0 -0
  106. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/dependency_links.txt +0 -0
  107. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/entry_points.txt +0 -0
  108. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/requires.txt +0 -0
  109. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/top_level.txt +0 -0
  110. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_agent_tools.py +0 -0
  111. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_apollo_company_search.py +0 -0
  112. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_apollo_lead_search.py +0 -0
  113. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_connectivity.py +0 -0
  114. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_email_body_utils.py +0 -0
  115. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_generate_email.py +0 -0
  116. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_google_document.py +0 -0
  117. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_hubspot_call_logs.py +0 -0
  118. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_linkedin_serper.py +0 -0
  119. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_mailreach.py +0 -0
  120. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_mcp_connectivity.py +0 -0
  121. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_normalize_graph_datetime.py +0 -0
  122. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_proxycurl_get_company_search_id.py +0 -0
  123. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_proxycurl_job_count.py +0 -0
  124. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_reply_thread_fallback.py +0 -0
  125. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/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.dev304
3
+ Version: 0.0.1.dev305
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-dev304',
5
+ version='0.0.1-dev305',
6
6
  description='A Python SDK for Dhisana AI Platform',
7
7
  author='Admin',
8
8
  author_email='contact@dhisana.ai',
@@ -380,19 +380,24 @@ class SendEmailContext(BaseModel):
380
380
  body_format: BodyFormat = BodyFormat.AUTO
381
381
  headers: Optional[Dict[str, str]] = None
382
382
  email_open_token: Optional[str] = None
383
-
383
+ to_recipients: Optional[List["EmailRecipient"]] = None
384
+ cc_recipients: Optional[List["EmailRecipient"]] = None
385
+ bcc_recipients: Optional[List["EmailRecipient"]] = None
386
+
384
387
  class QueryEmailContext(BaseModel):
385
388
  start_time: str
386
389
  end_time: str
387
390
  sender_email: str
388
391
  unread_only: bool = True
389
392
  labels: Optional[List[str]] = None
390
-
393
+
391
394
 
392
395
  class EmailRecipient(BaseModel):
393
396
  email: str
394
397
  name: Optional[str] = None
395
398
 
399
+ SendEmailContext.model_rebuild()
400
+
396
401
 
397
402
  class ReplyEmailContext(BaseModel):
398
403
  """Context for replying to or forwarding an email."""
@@ -210,6 +210,27 @@ async def send_email_using_google_oauth_async(
210
210
  message["from"] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
211
211
  message["subject"] = send_email_context.subject
212
212
 
213
+ # Add extra To recipients
214
+ extra_to = getattr(send_email_context, "to_recipients", None) or []
215
+ if extra_to:
216
+ additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
217
+ additional = [a for a in additional if a and a.lower() != send_email_context.recipient.strip().lower()]
218
+ if additional:
219
+ del message["to"]
220
+ message["to"] = ", ".join([send_email_context.recipient] + additional)
221
+
222
+ # Add CC recipients
223
+ cc_list = getattr(send_email_context, "cc_recipients", None) or []
224
+ if cc_list:
225
+ cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
226
+ message["cc"] = ", ".join(a for a in cc_addrs if a)
227
+
228
+ # Add BCC recipients (header set for envelope; Gmail strips it before delivery)
229
+ bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
230
+ if bcc_list:
231
+ bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
232
+ message["bcc"] = ", ".join(a for a in bcc_addrs if a)
233
+
213
234
  extra_headers = getattr(send_email_context, "headers", None) or {}
214
235
  for header, value in extra_headers.items():
215
236
  if not header or value is None:
@@ -227,6 +227,35 @@ async def send_email_using_microsoft_graph_async(
227
227
  ],
228
228
  }
229
229
 
230
+ # Merge additional To recipients from the new field
231
+ extra_to = getattr(send_email_context, "to_recipients", None) or []
232
+ for r in extra_to:
233
+ addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
234
+ if addr and addr.lower() != send_email_context.recipient.strip().lower():
235
+ message_payload["toRecipients"].append({"emailAddress": {"address": addr}})
236
+
237
+ # CC recipients
238
+ cc_list = getattr(send_email_context, "cc_recipients", None) or []
239
+ if cc_list:
240
+ cc_entries = []
241
+ for r in cc_list:
242
+ addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
243
+ if addr:
244
+ cc_entries.append({"emailAddress": {"address": addr}})
245
+ if cc_entries:
246
+ message_payload["ccRecipients"] = cc_entries
247
+
248
+ # BCC recipients
249
+ bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
250
+ if bcc_list:
251
+ bcc_entries = []
252
+ for r in bcc_list:
253
+ addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
254
+ if addr:
255
+ bcc_entries.append({"emailAddress": {"address": addr}})
256
+ if bcc_entries:
257
+ message_payload["bccRecipients"] = bcc_entries
258
+
230
259
  extra_headers = getattr(send_email_context, "headers", None) or {}
231
260
  if extra_headers:
232
261
  message_payload["internetMessageHeaders"] = [
@@ -226,6 +226,35 @@ async def send_email_via_smtp_async(
226
226
  msg["To"] = ctx.recipient
227
227
  msg["Subject"] = ctx.subject
228
228
 
229
+ # Build envelope recipient list starting with primary
230
+ envelope_recipients = [ctx.recipient]
231
+
232
+ # Add extra To recipients
233
+ extra_to = getattr(ctx, "to_recipients", None) or []
234
+ if extra_to:
235
+ additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
236
+ additional = [a for a in additional if a and a.lower() != ctx.recipient.strip().lower()]
237
+ if additional:
238
+ del msg["To"]
239
+ msg["To"] = ", ".join([ctx.recipient] + additional)
240
+ envelope_recipients.extend(additional)
241
+
242
+ # Add CC recipients
243
+ cc_list = getattr(ctx, "cc_recipients", None) or []
244
+ if cc_list:
245
+ cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
246
+ cc_addrs = [a for a in cc_addrs if a]
247
+ if cc_addrs:
248
+ msg["Cc"] = ", ".join(cc_addrs)
249
+ envelope_recipients.extend(cc_addrs)
250
+
251
+ # Add BCC recipients (envelope only, no header)
252
+ bcc_list = getattr(ctx, "bcc_recipients", None) or []
253
+ if bcc_list:
254
+ bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
255
+ bcc_addrs = [a for a in bcc_addrs if a]
256
+ envelope_recipients.extend(bcc_addrs)
257
+
229
258
  # Generate a real RFC 5322 Message-ID
230
259
  domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
231
260
  generated_id = f"<{uuid.uuid4()}@{domain_part}>"
@@ -236,6 +265,12 @@ async def send_email_via_smtp_async(
236
265
  if not header or value is None:
237
266
  continue
238
267
  msg[header] = str(value)
268
+ # Preserve envelope delivery for Cc/Bcc/To addresses set via extra headers,
269
+ # since explicit recipients= overrides aiosmtplib's header extraction.
270
+ if header.lower() in ("to", "cc", "bcc"):
271
+ for _, addr in email.utils.getaddresses([str(value)]):
272
+ if addr and addr.lower() not in {a.lower() for a in envelope_recipients}:
273
+ envelope_recipients.append(addr)
239
274
 
240
275
  smtp_kwargs = dict(
241
276
  hostname=smtp_server,
@@ -253,7 +288,7 @@ async def send_email_via_smtp_async(
253
288
  try:
254
289
  # aiosmtplib.send returns a (code, response) tuple, but no server message ID.
255
290
  # We rely on the real Message-ID we have just set.
256
- await aiosmtplib.send(msg, **smtp_kwargs)
291
+ await aiosmtplib.send(msg, recipients=envelope_recipients, **smtp_kwargs)
257
292
  logging.info("SMTP send OK – msg id %s", generated_id)
258
293
  return generated_id
259
294
  except Exception:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev304
3
+ Version: 0.0.1.dev305
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
@@ -119,4 +119,5 @@ tests/test_normalize_graph_datetime.py
119
119
  tests/test_proxycurl_get_company_search_id.py
120
120
  tests/test_proxycurl_job_count.py
121
121
  tests/test_reply_thread_fallback.py
122
+ tests/test_send_email_recipients.py
122
123
  tests/test_structured_output_with_mcp.py
@@ -0,0 +1,403 @@
1
+ """Tests for SendEmailContext multi-recipient support (to/cc/bcc).
2
+
3
+ Validates that each provider function correctly handles the new
4
+ to_recipients, cc_recipients, and bcc_recipients fields on SendEmailContext.
5
+ """
6
+
7
+ import base64
8
+ import email
9
+ from unittest.mock import AsyncMock, MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ from dhisana.schemas.common import (
14
+ BodyFormat,
15
+ EmailRecipient,
16
+ SendEmailContext,
17
+ )
18
+
19
+ # Provider imports may fail when the full test suite poisons sys.modules
20
+ # (the stale build/ directory creates a competing dhisana namespace).
21
+ # Use importorskip so these tests are skipped rather than erroring.
22
+ google_oauth_tools = pytest.importorskip("dhisana.utils.google_oauth_tools")
23
+ send_email_using_google_oauth_async = google_oauth_tools.send_email_using_google_oauth_async
24
+
25
+ microsoft365_tools = pytest.importorskip("dhisana.utils.microsoft365_tools")
26
+ send_email_using_microsoft_graph_async = microsoft365_tools.send_email_using_microsoft_graph_async
27
+
28
+ smtp_email_tools = pytest.importorskip("dhisana.utils.smtp_email_tools")
29
+ send_email_via_smtp_async = smtp_email_tools.send_email_via_smtp_async
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Fixtures
34
+ # ---------------------------------------------------------------------------
35
+
36
+ @pytest.fixture
37
+ def basic_context():
38
+ """SendEmailContext with a single primary recipient."""
39
+ return SendEmailContext(
40
+ recipient="primary@example.com",
41
+ subject="Test Subject",
42
+ body="Hello world",
43
+ sender_name="Sender",
44
+ sender_email="sender@example.com",
45
+ labels=None,
46
+ body_format=BodyFormat.TEXT,
47
+ )
48
+
49
+
50
+ @pytest.fixture
51
+ def multi_recipient_context():
52
+ """SendEmailContext with to/cc/bcc recipients."""
53
+ return SendEmailContext(
54
+ recipient="primary@example.com",
55
+ subject="Multi Recipient Test",
56
+ body="Hello everyone",
57
+ sender_name="Sender",
58
+ sender_email="sender@example.com",
59
+ labels=None,
60
+ body_format=BodyFormat.TEXT,
61
+ to_recipients=[
62
+ EmailRecipient(email="extra-to@example.com", name="Extra To"),
63
+ ],
64
+ cc_recipients=[
65
+ EmailRecipient(email="cc1@example.com", name="CC One"),
66
+ EmailRecipient(email="cc2@example.com", name="CC Two"),
67
+ ],
68
+ bcc_recipients=[
69
+ EmailRecipient(email="bcc@example.com", name="BCC One"),
70
+ ],
71
+ )
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Schema tests
76
+ # ---------------------------------------------------------------------------
77
+
78
+ class TestSendEmailContextSchema:
79
+ """Model-level tests for the new fields."""
80
+
81
+ def test_new_fields_default_to_none(self, basic_context):
82
+ assert basic_context.to_recipients is None
83
+ assert basic_context.cc_recipients is None
84
+ assert basic_context.bcc_recipients is None
85
+
86
+ def test_accepts_email_recipient_objects(self, multi_recipient_context):
87
+ assert len(multi_recipient_context.to_recipients) == 1
88
+ assert multi_recipient_context.to_recipients[0].email == "extra-to@example.com"
89
+ assert len(multi_recipient_context.cc_recipients) == 2
90
+ assert len(multi_recipient_context.bcc_recipients) == 1
91
+
92
+ def test_backward_compatible_serialization(self, basic_context):
93
+ """Existing code that doesn't set the new fields should still serialize fine."""
94
+ data = basic_context.model_dump()
95
+ assert data["recipient"] == "primary@example.com"
96
+ assert data["to_recipients"] is None
97
+ assert data["cc_recipients"] is None
98
+ assert data["bcc_recipients"] is None
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Google OAuth (Gmail API) tests
103
+ # ---------------------------------------------------------------------------
104
+
105
+ class TestGoogleOAuthRecipients:
106
+ """Verify Gmail MIME message includes to/cc/bcc headers."""
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_gmail_message_includes_cc_bcc(self, multi_recipient_context):
110
+ captured_raw = {}
111
+
112
+ with patch(
113
+ "dhisana.utils.google_oauth_tools.get_google_access_token",
114
+ return_value="fake-token",
115
+ ), patch("httpx.AsyncClient") as mock_client_cls:
116
+ mock_resp = MagicMock()
117
+ mock_resp.status_code = 200
118
+ mock_resp.raise_for_status = MagicMock()
119
+ mock_resp.json.return_value = {"id": "gmail-msg-123"}
120
+
121
+ mock_client = AsyncMock()
122
+
123
+ async def fake_post(url, **kwargs):
124
+ captured_raw.update(kwargs.get("json", {}))
125
+ return mock_resp
126
+
127
+ mock_client.post = fake_post
128
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
129
+ mock_client.__aexit__ = AsyncMock(return_value=False)
130
+ mock_client_cls.return_value = mock_client
131
+
132
+ result = await send_email_using_google_oauth_async(
133
+ multi_recipient_context,
134
+ tool_config=[{"name": "google", "configuration": []}],
135
+ )
136
+
137
+ assert result == "gmail-msg-123"
138
+
139
+ # Decode the raw MIME message
140
+ raw_b64 = captured_raw.get("raw", "")
141
+ mime_bytes = base64.urlsafe_b64decode(raw_b64)
142
+ parsed = email.message_from_bytes(mime_bytes)
143
+
144
+ # To header should include primary + extra-to
145
+ to_header = parsed["to"]
146
+ assert "primary@example.com" in to_header
147
+ assert "extra-to@example.com" in to_header
148
+
149
+ # CC header
150
+ cc_header = parsed.get("cc", "")
151
+ assert "cc1@example.com" in cc_header
152
+ assert "cc2@example.com" in cc_header
153
+
154
+ # BCC header (Gmail strips it before delivery, but it should be in the raw payload)
155
+ bcc_header = parsed.get("bcc", "")
156
+ assert "bcc@example.com" in bcc_header
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_gmail_no_extra_headers_when_none(self, basic_context):
160
+ """When no extra recipients, only To header should be present."""
161
+ captured_raw = {}
162
+
163
+ with patch(
164
+ "dhisana.utils.google_oauth_tools.get_google_access_token",
165
+ return_value="fake-token",
166
+ ), patch("httpx.AsyncClient") as mock_client_cls:
167
+ mock_resp = MagicMock()
168
+ mock_resp.status_code = 200
169
+ mock_resp.raise_for_status = MagicMock()
170
+ mock_resp.json.return_value = {"id": "gmail-msg-456"}
171
+
172
+ mock_client = AsyncMock()
173
+
174
+ async def fake_post(url, **kwargs):
175
+ captured_raw.update(kwargs.get("json", {}))
176
+ return mock_resp
177
+
178
+ mock_client.post = fake_post
179
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
180
+ mock_client.__aexit__ = AsyncMock(return_value=False)
181
+ mock_client_cls.return_value = mock_client
182
+
183
+ await send_email_using_google_oauth_async(
184
+ basic_context,
185
+ tool_config=[{"name": "google", "configuration": []}],
186
+ )
187
+
188
+ raw_b64 = captured_raw.get("raw", "")
189
+ mime_bytes = base64.urlsafe_b64decode(raw_b64)
190
+ parsed = email.message_from_bytes(mime_bytes)
191
+
192
+ assert parsed["to"] == "primary@example.com"
193
+ assert parsed.get("cc") is None
194
+ assert parsed.get("bcc") is None
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Microsoft 365 tests
199
+ # ---------------------------------------------------------------------------
200
+
201
+ class TestMicrosoft365Recipients:
202
+ """Verify Graph API payload includes cc/bcc/to recipients."""
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_graph_payload_includes_cc_bcc(self, multi_recipient_context):
206
+ captured_payload = {}
207
+
208
+ with patch(
209
+ "dhisana.utils.microsoft365_tools.get_microsoft365_access_token",
210
+ return_value="fake-token",
211
+ ), patch(
212
+ "dhisana.utils.microsoft365_tools._base_resource",
213
+ return_value="/users/sender@example.com",
214
+ ), patch("httpx.AsyncClient") as mock_client_cls:
215
+ mock_resp_send = MagicMock()
216
+ mock_resp_send.status_code = 202
217
+ mock_resp_send.raise_for_status = MagicMock()
218
+
219
+ mock_resp_list = MagicMock()
220
+ mock_resp_list.status_code = 200
221
+ mock_resp_list.raise_for_status = MagicMock()
222
+ mock_resp_list.json.return_value = {"value": []}
223
+
224
+ mock_client = AsyncMock()
225
+
226
+ async def fake_post(url, **kwargs):
227
+ captured_payload.update(kwargs.get("json", {}))
228
+ return mock_resp_send
229
+
230
+ mock_client.post = fake_post
231
+ mock_client.get = AsyncMock(return_value=mock_resp_list)
232
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
233
+ mock_client.__aexit__ = AsyncMock(return_value=False)
234
+ mock_client_cls.return_value = mock_client
235
+
236
+ await send_email_using_microsoft_graph_async(
237
+ multi_recipient_context,
238
+ tool_config=[{"name": "microsoft365", "configuration": []}],
239
+ )
240
+
241
+ msg = captured_payload.get("message", {})
242
+
243
+ # toRecipients should include primary + extra-to
244
+ to_addrs = [r["emailAddress"]["address"] for r in msg.get("toRecipients", [])]
245
+ assert "primary@example.com" in to_addrs
246
+ assert "extra-to@example.com" in to_addrs
247
+
248
+ # ccRecipients
249
+ cc_addrs = [r["emailAddress"]["address"] for r in msg.get("ccRecipients", [])]
250
+ assert "cc1@example.com" in cc_addrs
251
+ assert "cc2@example.com" in cc_addrs
252
+
253
+ # bccRecipients
254
+ bcc_addrs = [r["emailAddress"]["address"] for r in msg.get("bccRecipients", [])]
255
+ assert "bcc@example.com" in bcc_addrs
256
+
257
+ @pytest.mark.asyncio
258
+ async def test_graph_no_extra_fields_when_none(self, basic_context):
259
+ """When no extra recipients, payload should only have toRecipients."""
260
+ captured_payload = {}
261
+
262
+ with patch(
263
+ "dhisana.utils.microsoft365_tools.get_microsoft365_access_token",
264
+ return_value="fake-token",
265
+ ), patch(
266
+ "dhisana.utils.microsoft365_tools._base_resource",
267
+ return_value="/users/sender@example.com",
268
+ ), patch("httpx.AsyncClient") as mock_client_cls:
269
+ mock_resp = MagicMock()
270
+ mock_resp.status_code = 202
271
+ mock_resp.raise_for_status = MagicMock()
272
+
273
+ mock_resp_list = MagicMock()
274
+ mock_resp_list.status_code = 200
275
+ mock_resp_list.raise_for_status = MagicMock()
276
+ mock_resp_list.json.return_value = {"value": []}
277
+
278
+ mock_client = AsyncMock()
279
+
280
+ async def fake_post(url, **kwargs):
281
+ captured_payload.update(kwargs.get("json", {}))
282
+ return mock_resp
283
+
284
+ mock_client.post = fake_post
285
+ mock_client.get = AsyncMock(return_value=mock_resp_list)
286
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
287
+ mock_client.__aexit__ = AsyncMock(return_value=False)
288
+ mock_client_cls.return_value = mock_client
289
+
290
+ await send_email_using_microsoft_graph_async(
291
+ basic_context,
292
+ tool_config=[{"name": "microsoft365", "configuration": []}],
293
+ )
294
+
295
+ msg = captured_payload.get("message", {})
296
+ assert len(msg["toRecipients"]) == 1
297
+ assert "ccRecipients" not in msg
298
+ assert "bccRecipients" not in msg
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # SMTP tests
303
+ # ---------------------------------------------------------------------------
304
+
305
+ class TestSMTPRecipients:
306
+ """Verify SMTP adds Cc header and includes all envelope recipients."""
307
+
308
+ @pytest.mark.asyncio
309
+ async def test_smtp_envelope_includes_all_recipients(self, multi_recipient_context):
310
+ captured_kwargs = {}
311
+ captured_recipients = []
312
+
313
+ async def fake_send(msg, *, recipients=None, **kwargs):
314
+ captured_kwargs.update(kwargs)
315
+ if recipients:
316
+ captured_recipients.extend(recipients)
317
+ # Parse the MIME message to verify headers
318
+ parsed = email.message_from_string(msg.as_string())
319
+ captured_kwargs["_parsed_to"] = parsed["To"]
320
+ captured_kwargs["_parsed_cc"] = parsed.get("Cc", "")
321
+ # BCC should NOT be in headers
322
+ captured_kwargs["_parsed_bcc"] = parsed.get("Bcc")
323
+
324
+ with patch("dhisana.utils.smtp_email_tools.aiosmtplib.send", side_effect=fake_send):
325
+ result = await send_email_via_smtp_async(
326
+ multi_recipient_context,
327
+ smtp_server="smtp.example.com",
328
+ smtp_port=587,
329
+ username="user",
330
+ password="pass",
331
+ )
332
+
333
+ assert result # Should return a message ID
334
+
335
+ # Envelope recipients include all addresses
336
+ assert "primary@example.com" in captured_recipients
337
+ assert "extra-to@example.com" in captured_recipients
338
+ assert "cc1@example.com" in captured_recipients
339
+ assert "cc2@example.com" in captured_recipients
340
+ assert "bcc@example.com" in captured_recipients
341
+
342
+ # Cc header present
343
+ assert "cc1@example.com" in captured_kwargs["_parsed_cc"]
344
+ assert "cc2@example.com" in captured_kwargs["_parsed_cc"]
345
+
346
+ # BCC should NOT appear in headers
347
+ assert captured_kwargs["_parsed_bcc"] is None
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_smtp_basic_send_unchanged(self, basic_context):
351
+ """Without extra recipients, SMTP sends to single recipient as before."""
352
+ captured_recipients = []
353
+
354
+ async def fake_send(msg, *, recipients=None, **kwargs):
355
+ if recipients:
356
+ captured_recipients.extend(recipients)
357
+
358
+ with patch("dhisana.utils.smtp_email_tools.aiosmtplib.send", side_effect=fake_send):
359
+ await send_email_via_smtp_async(
360
+ basic_context,
361
+ smtp_server="smtp.example.com",
362
+ smtp_port=587,
363
+ username="user",
364
+ password="pass",
365
+ )
366
+
367
+ assert captured_recipients == ["primary@example.com"]
368
+
369
+ @pytest.mark.asyncio
370
+ async def test_smtp_headers_cc_bcc_included_in_envelope(self):
371
+ """Cc/Bcc set via ctx.headers must also appear in envelope recipients."""
372
+ ctx = SendEmailContext(
373
+ recipient="primary@example.com",
374
+ subject="Header CC Test",
375
+ body="body",
376
+ sender_name="Sender",
377
+ sender_email="sender@example.com",
378
+ labels=None,
379
+ body_format=BodyFormat.TEXT,
380
+ headers={
381
+ "Cc": "header-cc@example.com",
382
+ "Bcc": "header-bcc@example.com",
383
+ },
384
+ )
385
+
386
+ captured_recipients = []
387
+
388
+ async def fake_send(msg, *, recipients=None, **kwargs):
389
+ if recipients:
390
+ captured_recipients.extend(recipients)
391
+
392
+ with patch("dhisana.utils.smtp_email_tools.aiosmtplib.send", side_effect=fake_send):
393
+ await send_email_via_smtp_async(
394
+ ctx,
395
+ smtp_server="smtp.example.com",
396
+ smtp_port=587,
397
+ username="user",
398
+ password="pass",
399
+ )
400
+
401
+ assert "primary@example.com" in captured_recipients
402
+ assert "header-cc@example.com" in captured_recipients
403
+ assert "header-bcc@example.com" in captured_recipients
File without changes
File without changes