dhisana 0.0.1.dev304__tar.gz → 0.0.1.dev306__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 (127) hide show
  1. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/setup.py +1 -1
  3. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/schemas/common.py +7 -2
  4. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/schemas/sales.py +2 -0
  5. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_content.py +3 -0
  6. dhisana-0.0.1.dev306/src/dhisana/utils/generate_sms_whatsapp.py +253 -0
  7. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/google_oauth_tools.py +21 -0
  8. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/microsoft365_tools.py +29 -0
  9. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/smtp_email_tools.py +36 -1
  10. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/PKG-INFO +1 -1
  11. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/SOURCES.txt +3 -0
  12. dhisana-0.0.1.dev306/tests/test_generate_sms_whatsapp.py +260 -0
  13. dhisana-0.0.1.dev306/tests/test_send_email_recipients.py +403 -0
  14. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/README.md +0 -0
  15. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/pyproject.toml +0 -0
  16. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/setup.cfg +0 -0
  17. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/__init__.py +0 -0
  18. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/__init__.py +0 -0
  19. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/cli.py +0 -0
  20. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/datasets.py +0 -0
  21. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/models.py +0 -0
  22. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/cli/predictions.py +0 -0
  23. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/schemas/__init__.py +0 -0
  24. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/ui/__init__.py +0 -0
  25. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/ui/components.py +0 -0
  26. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/__init__.py +0 -0
  27. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/add_mapping.py +0 -0
  28. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/agent_tools.py +0 -0
  29. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/apollo_tools.py +0 -0
  30. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  31. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/built_with_api_tools.py +0 -0
  32. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/cache_output_tools.py +0 -0
  33. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  34. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  35. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  36. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  37. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/clay_tools.py +0 -0
  38. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/clean_properties.py +0 -0
  39. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/company_utils.py +0 -0
  40. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  41. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/compose_search_query.py +0 -0
  42. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  43. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/composite_tools.py +0 -0
  44. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/dataframe_tools.py +0 -0
  45. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/domain_parser.py +0 -0
  46. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/email_body_utils.py +0 -0
  47. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/email_parse_helpers.py +0 -0
  48. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/email_provider.py +0 -0
  49. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/enrich_lead_information.py +0 -0
  50. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  51. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/fetch_openai_config.py +0 -0
  52. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/field_validators.py +0 -0
  53. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/g2_tools.py +0 -0
  54. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_custom_message.py +0 -0
  55. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_email.py +0 -0
  56. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_email_response.py +0 -0
  57. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_flow.py +0 -0
  58. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  59. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  60. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  61. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
  62. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/google_custom_search.py +0 -0
  63. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/google_workspace_tools.py +0 -0
  64. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  65. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  66. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/instantly_tools.py +0 -0
  67. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/linkedin_crawler.py +0 -0
  68. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/lusha_tools.py +0 -0
  69. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/mailgun_tools.py +0 -0
  70. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/mailreach_tools.py +0 -0
  71. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  72. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openai_helpers.py +0 -0
  73. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  74. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  75. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  76. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  77. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  78. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  79. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/profile.py +0 -0
  80. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  81. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  82. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/python_function_to_tools.py +0 -0
  83. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/research_lead.py +0 -0
  84. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  85. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  86. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/search_router.py +0 -0
  87. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/search_router_jobs.py +0 -0
  88. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/sendgrid_tools.py +0 -0
  89. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  90. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  91. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  92. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_google_search.py +0 -0
  93. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  94. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serpapi_search_tools.py +0 -0
  95. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  96. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serperdev_local_business.py +0 -0
  97. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/serperdev_search.py +0 -0
  98. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/test_connect.py +0 -0
  99. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/trasform_json.py +0 -0
  100. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  101. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/workflow_code_model.py +0 -0
  102. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/utils/zoominfo_tools.py +0 -0
  103. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/__init__.py +0 -0
  104. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/agent.py +0 -0
  105. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/flow.py +0 -0
  106. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/task.py +0 -0
  107. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana/workflow/test.py +0 -0
  108. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/dependency_links.txt +0 -0
  109. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/entry_points.txt +0 -0
  110. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/requires.txt +0 -0
  111. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/src/dhisana.egg-info/top_level.txt +0 -0
  112. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_agent_tools.py +0 -0
  113. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_apollo_company_search.py +0 -0
  114. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_apollo_lead_search.py +0 -0
  115. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_connectivity.py +0 -0
  116. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_email_body_utils.py +0 -0
  117. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_generate_email.py +0 -0
  118. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_google_document.py +0 -0
  119. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_hubspot_call_logs.py +0 -0
  120. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_linkedin_serper.py +0 -0
  121. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_mailreach.py +0 -0
  122. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_mcp_connectivity.py +0 -0
  123. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_normalize_graph_datetime.py +0 -0
  124. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_proxycurl_get_company_search_id.py +0 -0
  125. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_proxycurl_job_count.py +0 -0
  126. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/tests/test_reply_thread_fallback.py +0 -0
  127. {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev306}/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.dev306
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-dev306',
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."""
@@ -271,6 +271,8 @@ class ChannelType(str, Enum):
271
271
  REPLY_EMAIL = "reply_email"
272
272
  LINKEDIN_USER_MESSAGE = "linkedin_user_message"
273
273
  CUSTOM_MESSAGE = "custom_message"
274
+ SMS = "sms"
275
+ WHATSAPP = "whatsapp"
274
276
 
275
277
  class SenderInfo(BaseModel):
276
278
  """
@@ -6,6 +6,7 @@ from dhisana.utils.assistant_tool_tag import assistant_tool
6
6
  from dhisana.utils.generate_email import generate_personalized_email
7
7
  from dhisana.utils.generate_linkedin_response_message import get_linkedin_response_message_variations
8
8
  from dhisana.utils.generate_custom_message import generate_custom_message
9
+ from dhisana.utils.generate_sms_whatsapp import generate_sms_whatsapp_message
9
10
 
10
11
 
11
12
  @assistant_tool
@@ -36,6 +37,8 @@ async def generate_content(
36
37
  return await generate_inbound_email_response_variations(generation_context, number_of_variations, tool_config)
37
38
  elif generation_context.target_channel_type == ChannelType.LINKEDIN_USER_MESSAGE.value:
38
39
  return await get_linkedin_response_message_variations(generation_context, number_of_variations, tool_config)
40
+ elif generation_context.target_channel_type in (ChannelType.SMS.value, ChannelType.WHATSAPP.value):
41
+ return await generate_sms_whatsapp_message(generation_context, number_of_variations, tool_config)
39
42
  else:
40
43
  # Default to CUSTOM_MESSAGE for any unrecognized channel type
41
44
  return await generate_custom_message(generation_context, number_of_variations, tool_config)
@@ -0,0 +1,253 @@
1
+ import re
2
+ from datetime import datetime, timezone
3
+ from typing import Dict, List, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from dhisana.schemas.sales import (
8
+ CampaignContext,
9
+ ChannelType,
10
+ ContentGenerationContext,
11
+ ConversationContext,
12
+ Lead,
13
+ MessageGenerationInstructions,
14
+ MessageItem,
15
+ SenderInfo,
16
+ )
17
+ from dhisana.utils.generate_email import _sanitize_lead_for_prompt
18
+ from dhisana.utils.generate_structured_output_internal import (
19
+ get_structured_output_internal,
20
+ get_structured_output_with_assistant_and_vector_store,
21
+ )
22
+
23
+ DEFAULT_SMS_GEN_MODEL = "gpt-5.4"
24
+
25
+ SMS_CHAR_LIMIT = 160
26
+ WHATSAPP_CHAR_LIMIT = 1000
27
+
28
+ # Standard opt-out suffix appended to every generated message.
29
+ OPT_OUT_SUFFIX_SMS = "\nReply STOP to opt out."
30
+ OPT_OUT_SUFFIX_WHATSAPP = "\nReply STOP to opt out."
31
+
32
+
33
+ class SMSWhatsAppCopy(BaseModel):
34
+ body: str
35
+
36
+ model_config = ConfigDict(extra="forbid")
37
+
38
+
39
+ def _effective_char_limit(channel_type: ChannelType) -> int:
40
+ if channel_type == ChannelType.WHATSAPP:
41
+ return WHATSAPP_CHAR_LIMIT
42
+ return SMS_CHAR_LIMIT
43
+
44
+
45
+ def _opt_out_suffix(channel_type: ChannelType) -> str:
46
+ if channel_type == ChannelType.WHATSAPP:
47
+ return OPT_OUT_SUFFIX_WHATSAPP
48
+ return OPT_OUT_SUFFIX_SMS
49
+
50
+
51
+ # Matches explicit opt-out instructions like "Reply STOP", "Text STOP to …",
52
+ # "opt out", or "unsubscribe" — requires an action verb before "stop" so
53
+ # normal prose like "stop wasting time" does not suppress the footer.
54
+ _OPT_OUT_RE = re.compile(
55
+ r"(?:reply|text|send|message|say)\s+stop\b"
56
+ r"|\bopt[\s\-]?out\b"
57
+ r"|\bunsubscribe\b",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def _ensure_opt_out(body: str, channel_type: ChannelType) -> str:
63
+ suffix = _opt_out_suffix(channel_type)
64
+ if _OPT_OUT_RE.search(body):
65
+ return body
66
+ return body.rstrip() + suffix
67
+
68
+
69
+ def _cleanup_context(
70
+ ctx: ContentGenerationContext,
71
+ ) -> ContentGenerationContext:
72
+ clone = ctx.model_copy(deep=True)
73
+ if clone.external_known_data:
74
+ clone.external_known_data.external_openai_vector_store_id = None
75
+ return clone
76
+
77
+
78
+ async def generate_sms_whatsapp_copy(
79
+ message_context: ContentGenerationContext,
80
+ message_instructions: MessageGenerationInstructions,
81
+ variation_text: str,
82
+ channel_type: ChannelType,
83
+ tool_config: Optional[List[Dict]] = None,
84
+ ) -> dict:
85
+ cleaned = _cleanup_context(message_context)
86
+
87
+ user_instructions = (
88
+ message_instructions.instructions_to_generate_message or ""
89
+ ).strip()
90
+ selected_instructions = user_instructions if user_instructions else variation_text
91
+
92
+ lead_data = cleaned.lead_info or Lead()
93
+ sender_data = cleaned.sender_info or SenderInfo()
94
+ campaign_data = cleaned.campaign_context or CampaignContext()
95
+ conversation_data = (
96
+ cleaned.current_conversation_context or ConversationContext()
97
+ )
98
+
99
+ char_limit = _effective_char_limit(channel_type)
100
+ opt_out_suffix = _opt_out_suffix(channel_type)
101
+ usable_chars = char_limit - len(opt_out_suffix)
102
+ channel_label = "WhatsApp" if channel_type == ChannelType.WHATSAPP else "SMS"
103
+
104
+ prompt = f"""You are an AI sales copywriter. Write exactly one personalized {channel_label} message on behalf of the sender to the lead described below.
105
+
106
+ ## Channel Rules — {channel_label}
107
+
108
+ 1. The message body (excluding the compliance footer) MUST be {usable_chars} characters or fewer.
109
+ 2. Plain text only — no HTML, no markdown, no emojis unless the instructions ask for them.
110
+ 3. No subject line — output only a "body" field.
111
+ 4. Be concise and conversational. Lead with value, not a greeting. Avoid filler.
112
+ 5. Do NOT include any opt-out / STOP language — the system appends it automatically.
113
+ 6. Do NOT include phone numbers, email addresses, or links unless the writing instructions explicitly request a specific provided URL.
114
+ 7. Ground every claim in the provided context. Never fabricate information.
115
+ 8. Do not use em dashes.
116
+
117
+ ## Lead
118
+
119
+ Name: {lead_data.first_name or ''} {lead_data.last_name or ''}
120
+ Organization: {lead_data.organization_name or ''}
121
+ Title: {lead_data.job_title or ''}
122
+
123
+ ## Sender (closed set — use ONLY these facts)
124
+
125
+ - Full Name: {sender_data.sender_full_name or ''}
126
+ - First Name: {sender_data.sender_first_name or ''}
127
+ - Last Name: {sender_data.sender_last_name or ''}
128
+ - Bio: {sender_data.sender_bio or ''}
129
+
130
+ ## Campaign
131
+
132
+ - Product: {campaign_data.product_name or ''}
133
+ - Value Proposition: {campaign_data.value_prop or ''}
134
+ - Call to Action: {campaign_data.call_to_action or ''}
135
+ - Pain Points: {campaign_data.pain_points or []}
136
+ - Proof Points: {campaign_data.proof_points or []}
137
+
138
+ ## Writing Instructions
139
+
140
+ {selected_instructions}
141
+
142
+ ## Conversation History
143
+
144
+ {conversation_data.current_email_thread or ''}
145
+ {conversation_data.current_linkedin_thread or ''}
146
+
147
+ ## Output Format
148
+
149
+ Return JSON with a single field: "body" (string, plain text, max {usable_chars} chars).
150
+
151
+ ## Additional Lead Context (reference only)
152
+
153
+ {_sanitize_lead_for_prompt(lead_data)}
154
+ """
155
+
156
+ vector_store_id = (
157
+ message_context.external_known_data.external_openai_vector_store_id
158
+ if message_context.external_known_data
159
+ else None
160
+ )
161
+ use_cache = (
162
+ message_context.message_instructions.use_cache
163
+ if message_context.message_instructions
164
+ else True
165
+ )
166
+
167
+ if vector_store_id:
168
+ response, status = (
169
+ await get_structured_output_with_assistant_and_vector_store(
170
+ prompt=prompt,
171
+ response_format=SMSWhatsAppCopy,
172
+ vector_store_id=vector_store_id,
173
+ model=DEFAULT_SMS_GEN_MODEL,
174
+ effort="medium",
175
+ tool_config=tool_config,
176
+ use_cache=use_cache,
177
+ )
178
+ )
179
+ else:
180
+ response, status = await get_structured_output_internal(
181
+ prompt=prompt,
182
+ response_format=SMSWhatsAppCopy,
183
+ model=DEFAULT_SMS_GEN_MODEL,
184
+ effort="medium",
185
+ tool_config=tool_config,
186
+ use_cache=use_cache,
187
+ )
188
+
189
+ if status != "SUCCESS":
190
+ raise Exception(
191
+ f"Error: Could not generate {channel_label} message."
192
+ )
193
+
194
+ body = (response.body or "").strip()
195
+ # Truncate if model exceeded the limit
196
+ if len(body) > usable_chars:
197
+ body = body[:usable_chars].rsplit(" ", 1)[0]
198
+ body = _ensure_opt_out(body, channel_type)
199
+
200
+ response_item = MessageItem(
201
+ message_id="",
202
+ thread_id="",
203
+ sender_name=sender_data.sender_full_name or "",
204
+ sender_email=sender_data.sender_email or "",
205
+ receiver_name=(message_context.lead_info.full_name or "") if message_context.lead_info else "",
206
+ receiver_email=(message_context.lead_info.email or "") if message_context.lead_info else "",
207
+ iso_datetime=datetime.now(timezone.utc).isoformat(),
208
+ subject="",
209
+ body=body,
210
+ html_body=None,
211
+ )
212
+ return response_item.model_dump()
213
+
214
+
215
+ FRAMEWORK_VARIATIONS_SMS = [
216
+ "Write a brief, personalized outreach message. Lead with a relevant insight about the prospect's company.",
217
+ "Write a short value-first message — mention one pain point and how the product helps.",
218
+ "Write a concise, curiosity-driven message with a clear call to action.",
219
+ ]
220
+
221
+
222
+ async def generate_sms_whatsapp_message(
223
+ generation_context: ContentGenerationContext,
224
+ number_of_variations: int = 3,
225
+ tool_config: Optional[List[Dict]] = None,
226
+ ) -> List[dict]:
227
+ channel_type = generation_context.target_channel_type or ChannelType.SMS
228
+ message_instructions = generation_context.message_instructions
229
+ user_instructions_exist = bool(
230
+ (message_instructions.instructions_to_generate_message or "").strip()
231
+ )
232
+
233
+ variations: List[dict] = []
234
+ for i in range(number_of_variations):
235
+ if user_instructions_exist:
236
+ variation_text = (
237
+ message_instructions.instructions_to_generate_message or ""
238
+ )
239
+ else:
240
+ variation_text = FRAMEWORK_VARIATIONS_SMS[
241
+ i % len(FRAMEWORK_VARIATIONS_SMS)
242
+ ]
243
+
244
+ copy = await generate_sms_whatsapp_copy(
245
+ message_context=generation_context,
246
+ message_instructions=message_instructions,
247
+ variation_text=variation_text,
248
+ channel_type=channel_type,
249
+ tool_config=tool_config,
250
+ )
251
+ variations.append(copy)
252
+
253
+ return variations
@@ -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.dev306
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
@@ -54,6 +54,7 @@ src/dhisana/utils/generate_flow.py
54
54
  src/dhisana/utils/generate_leads_salesnav.py
55
55
  src/dhisana/utils/generate_linkedin_connect_message.py
56
56
  src/dhisana/utils/generate_linkedin_response_message.py
57
+ src/dhisana/utils/generate_sms_whatsapp.py
57
58
  src/dhisana/utils/generate_structured_output_internal.py
58
59
  src/dhisana/utils/google_custom_search.py
59
60
  src/dhisana/utils/google_oauth_tools.py
@@ -110,6 +111,7 @@ tests/test_apollo_lead_search.py
110
111
  tests/test_connectivity.py
111
112
  tests/test_email_body_utils.py
112
113
  tests/test_generate_email.py
114
+ tests/test_generate_sms_whatsapp.py
113
115
  tests/test_google_document.py
114
116
  tests/test_hubspot_call_logs.py
115
117
  tests/test_linkedin_serper.py
@@ -119,4 +121,5 @@ tests/test_normalize_graph_datetime.py
119
121
  tests/test_proxycurl_get_company_search_id.py
120
122
  tests/test_proxycurl_job_count.py
121
123
  tests/test_reply_thread_fallback.py
124
+ tests/test_send_email_recipients.py
122
125
  tests/test_structured_output_with_mcp.py