dhisana 0.0.1.dev303__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.dev303 → dhisana-0.0.1.dev305}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/setup.py +1 -1
  3. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/schemas/common.py +7 -2
  4. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/enrich_lead_information.py +51 -3
  5. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_oauth_tools.py +21 -0
  6. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/microsoft365_tools.py +29 -0
  7. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_search_tools.py +10 -40
  8. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/smtp_email_tools.py +36 -1
  9. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/PKG-INFO +1 -1
  10. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/SOURCES.txt +1 -0
  11. dhisana-0.0.1.dev305/tests/test_send_email_recipients.py +403 -0
  12. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/README.md +0 -0
  13. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/pyproject.toml +0 -0
  14. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/setup.cfg +0 -0
  15. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/__init__.py +0 -0
  16. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/__init__.py +0 -0
  17. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/cli.py +0 -0
  18. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/datasets.py +0 -0
  19. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/models.py +0 -0
  20. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/predictions.py +0 -0
  21. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/schemas/__init__.py +0 -0
  22. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/schemas/sales.py +0 -0
  23. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/ui/__init__.py +0 -0
  24. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/ui/components.py +0 -0
  25. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/__init__.py +0 -0
  26. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/add_mapping.py +0 -0
  27. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/agent_tools.py +0 -0
  28. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/apollo_tools.py +0 -0
  29. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  30. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/built_with_api_tools.py +0 -0
  31. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools.py +0 -0
  32. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  33. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  34. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  35. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  36. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/clay_tools.py +0 -0
  37. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/clean_properties.py +0 -0
  38. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/company_utils.py +0 -0
  39. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  40. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_search_query.py +0 -0
  41. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  42. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/composite_tools.py +0 -0
  43. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/dataframe_tools.py +0 -0
  44. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/domain_parser.py +0 -0
  45. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_body_utils.py +0 -0
  46. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_parse_helpers.py +0 -0
  47. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_provider.py +0 -0
  48. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  49. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/fetch_openai_config.py +0 -0
  50. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/field_validators.py +0 -0
  51. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/g2_tools.py +0 -0
  52. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_content.py +0 -0
  53. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_custom_message.py +0 -0
  54. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email.py +0 -0
  55. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email_response.py +0 -0
  56. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_flow.py +0 -0
  57. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  58. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  59. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  60. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
  61. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_custom_search.py +0 -0
  62. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_workspace_tools.py +0 -0
  63. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  64. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  65. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/instantly_tools.py +0 -0
  66. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/linkedin_crawler.py +0 -0
  67. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/lusha_tools.py +0 -0
  68. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailgun_tools.py +0 -0
  69. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailreach_tools.py +0 -0
  70. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  71. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_helpers.py +0 -0
  72. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  73. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  74. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  75. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  76. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  77. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  78. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/profile.py +0 -0
  79. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  80. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  81. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/python_function_to_tools.py +0 -0
  82. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/research_lead.py +0 -0
  83. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  84. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  85. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router.py +0 -0
  86. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router_jobs.py +0 -0
  87. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/sendgrid_tools.py +0 -0
  88. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  89. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  90. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  91. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_search.py +0 -0
  92. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  93. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  94. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_local_business.py +0 -0
  95. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_search.py +0 -0
  96. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/test_connect.py +0 -0
  97. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/trasform_json.py +0 -0
  98. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  99. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/workflow_code_model.py +0 -0
  100. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/zoominfo_tools.py +0 -0
  101. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/__init__.py +0 -0
  102. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/agent.py +0 -0
  103. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/flow.py +0 -0
  104. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/task.py +0 -0
  105. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/test.py +0 -0
  106. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/dependency_links.txt +0 -0
  107. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/entry_points.txt +0 -0
  108. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/requires.txt +0 -0
  109. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/top_level.txt +0 -0
  110. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_agent_tools.py +0 -0
  111. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_apollo_company_search.py +0 -0
  112. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_apollo_lead_search.py +0 -0
  113. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_connectivity.py +0 -0
  114. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_email_body_utils.py +0 -0
  115. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_generate_email.py +0 -0
  116. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_google_document.py +0 -0
  117. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_hubspot_call_logs.py +0 -0
  118. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_linkedin_serper.py +0 -0
  119. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_mailreach.py +0 -0
  120. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_mcp_connectivity.py +0 -0
  121. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_normalize_graph_datetime.py +0 -0
  122. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_proxycurl_get_company_search_id.py +0 -0
  123. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_proxycurl_job_count.py +0 -0
  124. {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_reply_thread_fallback.py +0 -0
  125. {dhisana-0.0.1.dev303 → 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.dev303
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-dev303',
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."""
@@ -658,9 +658,57 @@ async def enrich_user_info(
658
658
  tool_config=tool_config,
659
659
  )
660
660
  if found_linkedin_url:
661
- user_linkedin_url = found_linkedin_url
662
- input_properties["user_linkedin_url"] = user_linkedin_url
663
- if not user_linkedin_url and email:
661
+ should_use_url = True
662
+ if use_strict_check and org_domain:
663
+ try:
664
+ temp_props = {"user_linkedin_url": found_linkedin_url}
665
+ enriched = await enrich_with_provider(temp_props, tool_config)
666
+ enriched_domain = (enriched.get("primary_domain_of_organization") or "").strip().lower()
667
+ # If no domain returned but we have an organization LinkedIn URL,
668
+ # look up the org to get its domain
669
+ if not enriched_domain:
670
+ enriched_org_linkedin = (enriched.get("organization_linkedin_url") or "").strip()
671
+ if enriched_org_linkedin:
672
+ try:
673
+ org_info = await search_organization_by_linkedin_or_domain(
674
+ linkedin_url=enriched_org_linkedin,
675
+ tool_config=tool_config,
676
+ )
677
+ enriched_domain = (org_info.get("domain") or "").strip().lower()
678
+ except Exception:
679
+ logger.debug("Could not look up org domain from LinkedIn URL: %s", enriched_org_linkedin)
680
+ input_org_domain = org_domain.strip().lower()
681
+ if input_org_domain and enriched_domain and enriched_domain != input_org_domain:
682
+ logger.info(
683
+ "Skipping Google-found LinkedIn URL %s: enriched domain '%s' does not match input domain '%s'",
684
+ found_linkedin_url, enriched_domain, input_org_domain,
685
+ )
686
+ should_use_url = False
687
+ elif input_org_domain and not enriched_domain:
688
+ logger.info(
689
+ "Skipping Google-found LinkedIn URL %s: could not determine enriched org domain to verify against input domain '%s'",
690
+ found_linkedin_url, input_org_domain,
691
+ )
692
+ should_use_url = False
693
+ except Exception:
694
+ logger.debug("Could not verify LinkedIn URL domain via enrichment; proceeding with found URL.")
695
+ if should_use_url:
696
+ user_linkedin_url = found_linkedin_url
697
+ input_properties["user_linkedin_url"] = user_linkedin_url
698
+ # Skip email-based LinkedIn search if strict check is on and the email
699
+ # domain doesn't match the expected org domain (would waste API calls
700
+ # searching for a person at a different company).
701
+ skip_email_search = False
702
+ if use_strict_check and org_domain and email:
703
+ email_domain = email.split("@")[-1].strip().lower()
704
+ if email_domain and org_domain.strip().lower() != email_domain:
705
+ skip_email_search = True
706
+ logger.info(
707
+ "Skipping email-based LinkedIn search: email domain '%s' does not match org domain '%s'",
708
+ email_domain, org_domain,
709
+ )
710
+
711
+ if not user_linkedin_url and email and not skip_email_search:
664
712
  # If we have an email but no LinkedIn URL yet, try searching by email via Google
665
713
  email_lookup_result = await find_user_linkedin_url_by_email_google(
666
714
  email=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"] = [
@@ -401,12 +401,6 @@ async def find_user_linkedin_url_by_email_google(
401
401
  if query and query not in queries:
402
402
  queries.append(query)
403
403
 
404
- def add_query_parts(*parts: str) -> None:
405
- tokens = [part.strip() for part in parts if part and part.strip()]
406
- if not tokens:
407
- return
408
- add_query(" ".join(tokens))
409
-
410
404
  enriched_terms = []
411
405
  if user_name:
412
406
  enriched_terms.append(f'"{user_name}"')
@@ -418,47 +412,23 @@ async def find_user_linkedin_url_by_email_google(
418
412
  enriched_terms.append(f'"{user_location}"')
419
413
  base_hint = " ".join(enriched_terms)
420
414
 
421
- # Prioritise the direct email search variants before broader fallbacks.
422
- add_query_parts(normalized_email, "linkedin.com/in", base_hint)
423
- add_query_parts(normalized_email, "linkedin.com", base_hint)
424
- add_query_parts(normalized_email, "linkedin", base_hint)
425
- add_query_parts(normalized_email, base_hint)
426
- add_query(f'"{normalized_email}" "linkedin.com/in" {base_hint}')
427
- add_query(f'"{normalized_email}" "linkedin.com" {base_hint}')
428
- add_query(f'"{normalized_email}" linkedin {base_hint}')
415
+ # 1) Best query: site-scoped with exact email
416
+ add_query(f'site:linkedin.com/in "{normalized_email}" {base_hint}')
429
417
 
430
- if email_local_part and email_local_part != normalized_email:
431
- add_query_parts(email_local_part, "linkedin.com/in", base_hint)
432
- add_query_parts(email_local_part, "linkedin.com", base_hint)
433
- add_query_parts(email_local_part, "linkedin", base_hint)
434
- add_query(f'"{email_local_part}" "linkedin.com/in" {base_hint}')
435
- add_query(f'"{email_local_part}" "linkedin.com" {base_hint}')
418
+ # 2) Exact email with linkedin scope (Google may surface LinkedIn even without site:)
419
+ add_query(f'"{normalized_email}" linkedin.com/in {base_hint}')
436
420
 
437
- if email_local_humanized and email_local_humanized not in {email_local_part, normalized_email}:
438
- add_query_parts(email_local_humanized, "linkedin", base_hint)
439
- add_query(f'"{email_local_humanized}" linkedin {base_hint}')
421
+ # 3) Unquoted email with linkedin scope (catches partial matches)
422
+ add_query(f'{normalized_email} linkedin.com/in {base_hint}')
440
423
 
441
- if normalized_email:
442
- add_query(f'site:linkedin.com/in "{normalized_email}" {base_hint}')
443
-
444
- if email_local_part:
424
+ # 4) Local part fallback with site scope (e.g. "santosh" on linkedin)
425
+ if email_local_part and email_local_part != normalized_email:
445
426
  add_query(f'site:linkedin.com/in "{email_local_part}" {base_hint}')
446
427
 
447
- if email_local_humanized and email_local_humanized != email_local_part:
428
+ # 5) Humanized local part fallback (e.g. "john smith" from "john.smith")
429
+ if email_local_humanized and email_local_humanized not in {email_local_part, normalized_email}:
448
430
  add_query(f'site:linkedin.com/in "{email_local_humanized}" {base_hint}')
449
431
 
450
- if base_hint:
451
- lookup_hint = user_name or email_local_humanized or email_local_part or normalized_email
452
- add_query(
453
- f'site:linkedin.com/in "{normalized_email}" {base_hint} '
454
- f'intitle:"{lookup_hint}" -intitle:"profiles"'
455
- )
456
- if email_local_humanized:
457
- add_query(
458
- f'site:linkedin.com/in "{email_local_humanized}" {base_hint} '
459
- f'intitle:"{lookup_hint}" -intitle:"profiles"'
460
- )
461
-
462
432
  candidate_records: List[Dict[str, str]] = []
463
433
  seen_links: Set[str] = set()
464
434
  best_llm_choice: Optional[LinkedinCandidateChoice] = None
@@ -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.dev303
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