dhisana 0.0.1.dev310__tar.gz → 0.0.1.dev312__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 (129) hide show
  1. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/setup.py +1 -1
  3. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/microsoft365_tools.py +24 -1
  4. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/smtp_email_tools.py +11 -2
  5. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/test_connect.py +66 -0
  6. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/PKG-INFO +1 -1
  7. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/SOURCES.txt +1 -0
  8. dhisana-0.0.1.dev312/tests/test_reply_html_format.py +256 -0
  9. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/README.md +0 -0
  10. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/pyproject.toml +0 -0
  11. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/setup.cfg +0 -0
  12. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/__init__.py +0 -0
  13. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/__init__.py +0 -0
  14. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/cli.py +0 -0
  15. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/datasets.py +0 -0
  16. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/models.py +0 -0
  17. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/predictions.py +0 -0
  18. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/schemas/__init__.py +0 -0
  19. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/schemas/common.py +0 -0
  20. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/schemas/sales.py +0 -0
  21. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/ui/__init__.py +0 -0
  22. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/ui/components.py +0 -0
  23. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/__init__.py +0 -0
  24. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/add_mapping.py +0 -0
  25. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/agent_tools.py +0 -0
  26. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/apollo_tools.py +0 -0
  27. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  28. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/built_with_api_tools.py +0 -0
  29. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/cache_output_tools.py +0 -0
  30. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  31. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  32. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  33. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  34. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/clay_tools.py +0 -0
  35. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/clean_properties.py +0 -0
  36. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/company_utils.py +0 -0
  37. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  38. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/compose_search_query.py +0 -0
  39. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  40. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/composite_tools.py +0 -0
  41. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/dataframe_tools.py +0 -0
  42. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/domain_parser.py +0 -0
  43. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/email_body_utils.py +0 -0
  44. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/email_parse_helpers.py +0 -0
  45. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/email_provider.py +0 -0
  46. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/enrich_lead_information.py +0 -0
  47. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  48. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/fetch_openai_config.py +0 -0
  49. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/field_validators.py +0 -0
  50. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/g2_tools.py +0 -0
  51. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_content.py +0 -0
  52. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_custom_message.py +0 -0
  53. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_email.py +0 -0
  54. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_email_response.py +0 -0
  55. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_flow.py +0 -0
  56. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  57. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  58. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  59. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_sms_whatsapp.py +0 -0
  60. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
  61. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/google_custom_search.py +0 -0
  62. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/google_oauth_tools.py +0 -0
  63. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/google_workspace_tools.py +0 -0
  64. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  65. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  66. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/instantly_tools.py +0 -0
  67. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/linkedin_crawler.py +0 -0
  68. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/lusha_tools.py +0 -0
  69. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/mailgun_tools.py +0 -0
  70. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/mailreach_tools.py +0 -0
  71. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  72. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openai_helpers.py +0 -0
  73. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  74. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  75. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  76. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  77. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  78. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  79. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/profile.py +0 -0
  80. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  81. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  82. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/python_function_to_tools.py +0 -0
  83. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/research_lead.py +0 -0
  84. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  85. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  86. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/search_router.py +0 -0
  87. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/search_router_jobs.py +0 -0
  88. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/sendgrid_tools.py +0 -0
  89. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  90. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  91. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  92. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_google_search.py +0 -0
  93. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  94. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_search_tools.py +0 -0
  95. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  96. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serperdev_local_business.py +0 -0
  97. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serperdev_search.py +0 -0
  98. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/trasform_json.py +0 -0
  99. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  100. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/workflow_code_model.py +0 -0
  101. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/zoominfo_tools.py +0 -0
  102. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/__init__.py +0 -0
  103. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/agent.py +0 -0
  104. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/flow.py +0 -0
  105. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/task.py +0 -0
  106. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/test.py +0 -0
  107. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/dependency_links.txt +0 -0
  108. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/entry_points.txt +0 -0
  109. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/requires.txt +0 -0
  110. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/top_level.txt +0 -0
  111. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_agent_tools.py +0 -0
  112. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_apollo_company_search.py +0 -0
  113. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_apollo_json_error_handling.py +0 -0
  114. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_apollo_lead_search.py +0 -0
  115. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_connectivity.py +0 -0
  116. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_email_body_utils.py +0 -0
  117. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_generate_email.py +0 -0
  118. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_generate_sms_whatsapp.py +0 -0
  119. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_google_document.py +0 -0
  120. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_hubspot_call_logs.py +0 -0
  121. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_linkedin_serper.py +0 -0
  122. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_mailreach.py +0 -0
  123. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_mcp_connectivity.py +0 -0
  124. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_normalize_graph_datetime.py +0 -0
  125. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_proxycurl_get_company_search_id.py +0 -0
  126. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_proxycurl_job_count.py +0 -0
  127. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_reply_thread_fallback.py +0 -0
  128. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_send_email_recipients.py +0 -0
  129. {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/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.dev310
3
+ Version: 0.0.1.dev312
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-dev310',
5
+ version='0.0.1-dev312',
6
6
  description='A Python SDK for Dhisana AI Platform',
7
7
  author='Admin',
8
8
  author_email='contact@dhisana.ai',
@@ -598,12 +598,35 @@ async def reply_to_email_m365_async(
598
598
  create_reply_url = (
599
599
  f"{base_url}{base_res}/messages/{reply_email_context.message_id}/createReplyAll"
600
600
  )
601
- create_payload = {"comment": reply_email_context.reply_body}
601
+ # Resolve plain-text and HTML variants of the reply body.
602
+ # We always create the draft with {"comment": ""} so that Graph
603
+ # auto-generates the quoted conversation thread, then patch the
604
+ # body for HTML replies to preserve that quoted content.
605
+ plain_reply, html_reply, resolved_fmt = body_variants(
606
+ reply_email_context.reply_body,
607
+ reply_email_context.reply_body_format,
608
+ )
609
+
610
+ if resolved_fmt == "html":
611
+ # Create draft with empty comment so Graph generates quoted thread
612
+ create_payload: Dict[str, Any] = {"comment": ""}
613
+ else:
614
+ create_payload = {"comment": plain_reply}
602
615
  create_resp = await client.post(create_reply_url, headers=headers, json=create_payload)
603
616
  create_resp.raise_for_status()
604
617
  reply_msg = create_resp.json()
605
618
  reply_id = reply_msg.get("id")
606
619
 
620
+ if resolved_fmt == "html":
621
+ # Prepend the HTML reply above the Graph-generated quoted thread
622
+ existing_body = reply_msg.get("body", {}).get("content", "")
623
+ merged_body = html_reply + existing_body
624
+ patch_body_url = f"{base_url}{base_res}/messages/{reply_id}"
625
+ patch_body_resp = await client.patch(patch_body_url, headers=headers, json={
626
+ "body": {"contentType": "html", "content": merged_body}
627
+ })
628
+ patch_body_resp.raise_for_status()
629
+
607
630
  # 3) Optionally update recipients/subject and add categories (labels) to the draft
608
631
  patch_payload: Dict[str, Any] = {}
609
632
  if to_recipients:
@@ -726,8 +726,17 @@ async def reply_to_email_via_smtp_async(
726
726
  else:
727
727
  references = orig_msg_id
728
728
 
729
- # 3. Build the MIMEText reply
730
- msg = MIMEText(ctx.reply_body, _charset="utf-8")
729
+ # 3. Build the reply MIME (honor reply_body_format for HTML emails)
730
+ plain_reply, html_reply, resolved_reply_fmt = body_variants(
731
+ ctx.reply_body,
732
+ ctx.reply_body_format,
733
+ )
734
+ if resolved_reply_fmt == "text":
735
+ msg = MIMEText(plain_reply, "plain", _charset="utf-8")
736
+ else:
737
+ msg = MIMEMultipart("alternative")
738
+ msg.attach(MIMEText(plain_reply, "plain", _charset="utf-8"))
739
+ msg.attach(MIMEText(html_reply, "html", _charset="utf-8"))
731
740
  sender_display = ctx.sender_name or ctx.sender_email
732
741
  msg["From"] = f"{sender_display} <{ctx.sender_email}>"
733
742
  msg["To"] = to_addrs
@@ -2234,6 +2234,71 @@ async def test_gemini(
2234
2234
  return {"success": False, "status_code": 0, "error_message": err_msg}
2235
2235
 
2236
2236
 
2237
+ ###############################################################################
2238
+ # BUILTWITH CONNECTIVITY
2239
+ ###############################################################################
2240
+
2241
+ async def test_builtwith(api_key: str) -> Dict[str, Any]:
2242
+ """
2243
+ Connectivity test for BuiltWith using the Domain API v22.
2244
+
2245
+ Performs a lightweight lookup of ``example.com`` to verify the API key is
2246
+ valid and the account has remaining credits.
2247
+ """
2248
+ url = "https://api.builtwith.com/v22/api.json"
2249
+ headers = {
2250
+ "Accept": "application/json",
2251
+ }
2252
+ params = {
2253
+ "KEY": api_key,
2254
+ "LOOKUP": "example.com",
2255
+ "HIDEDL": "yes",
2256
+ "NOPII": "yes",
2257
+ "LIVEONLY": "yes",
2258
+ }
2259
+
2260
+ try:
2261
+ timeout = aiohttp.ClientTimeout(total=15)
2262
+ async with aiohttp.ClientSession(timeout=timeout) as session:
2263
+ async with session.get(url, headers=headers, params=params) as resp:
2264
+ status = resp.status
2265
+ data = await safe_json(resp)
2266
+
2267
+ if status == 200:
2268
+ # A valid key returns a JSON object; check for error key
2269
+ if isinstance(data, dict):
2270
+ errors = data.get("Errors")
2271
+ if errors:
2272
+ return {
2273
+ "success": False,
2274
+ "status_code": status,
2275
+ "error_message": str(errors),
2276
+ }
2277
+ return {"success": True, "status_code": status, "error_message": None}
2278
+
2279
+ if status in (401, 403):
2280
+ msg = None
2281
+ if isinstance(data, dict):
2282
+ msg = data.get("Errors") or data.get("message") or data.get("error")
2283
+ return {
2284
+ "success": False,
2285
+ "status_code": status,
2286
+ "error_message": str(msg) if msg else "Unauthorized \u2013 check BuiltWith API key",
2287
+ }
2288
+
2289
+ msg = None
2290
+ if isinstance(data, dict):
2291
+ msg = data.get("Errors") or data.get("message") or data.get("error")
2292
+ return {
2293
+ "success": False,
2294
+ "status_code": status,
2295
+ "error_message": str(msg) if msg else f"BuiltWith responded with {status}",
2296
+ }
2297
+ except Exception as e:
2298
+ logger.error(f"BuiltWith connectivity test failed: {e}")
2299
+ return {"success": False, "status_code": 0, "error_message": str(e)}
2300
+
2301
+
2237
2302
  ###############################################################################
2238
2303
  # MAIN CONNECTIVITY FUNCTION
2239
2304
  ###############################################################################
@@ -2293,6 +2358,7 @@ async def test_connectivity(tool_config: List[Dict[str, Any]]) -> Dict[str, Dict
2293
2358
  "samgov": test_samgov,
2294
2359
  "scraperapi": test_scraperapi,
2295
2360
  "gemini": test_gemini,
2361
+ "builtwith": test_builtwith,
2296
2362
  }
2297
2363
 
2298
2364
  results: Dict[str, Dict[str, Any]] = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev310
3
+ Version: 0.0.1.dev312
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
@@ -121,6 +121,7 @@ tests/test_mcp_connectivity.py
121
121
  tests/test_normalize_graph_datetime.py
122
122
  tests/test_proxycurl_get_company_search_id.py
123
123
  tests/test_proxycurl_job_count.py
124
+ tests/test_reply_html_format.py
124
125
  tests/test_reply_thread_fallback.py
125
126
  tests/test_send_email_recipients.py
126
127
  tests/test_structured_output_with_mcp.py
@@ -0,0 +1,256 @@
1
+ """Tests for HTML reply body formatting across SMTP and M365 providers.
2
+
3
+ Validates that reply_body_format=HTML is honoured so that follow-up
4
+ emails in a campaign are sent as HTML rather than plain text.
5
+ """
6
+
7
+ import asyncio
8
+ import base64
9
+ import email as email_lib
10
+ import json
11
+ from email.mime.multipart import MIMEMultipart
12
+ from email.mime.text import MIMEText
13
+ from typing import Any, Dict
14
+ from unittest.mock import AsyncMock, MagicMock, patch
15
+
16
+ import httpx
17
+ import pytest
18
+
19
+ from dhisana.schemas.common import BodyFormat, ReplyEmailContext
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Helpers
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _make_response(status_code: int, json_body: Any = None) -> httpx.Response:
27
+ resp = httpx.Response(
28
+ status_code=status_code,
29
+ request=httpx.Request("GET", "https://example.com"),
30
+ content=json.dumps(json_body or {}).encode(),
31
+ )
32
+ return resp
33
+
34
+
35
+ HTML_BODY = "<p>Hello <strong>World</strong></p>"
36
+ PLAIN_BODY = "Hello World"
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # SMTP reply tests
41
+ # ---------------------------------------------------------------------------
42
+
43
+ class TestSmtpReplyHtmlFormat:
44
+ """reply_to_email_via_smtp_async must build a multipart/alternative MIME
45
+ message when reply_body_format is HTML."""
46
+
47
+ @pytest.fixture
48
+ def smtp_ctx_html(self):
49
+ return ReplyEmailContext(
50
+ message_id="<orig@example.com>",
51
+ reply_body=HTML_BODY,
52
+ sender_email="sender@example.com",
53
+ sender_name="Sender",
54
+ fallback_recipient="recipient@example.com",
55
+ reply_body_format=BodyFormat.HTML,
56
+ )
57
+
58
+ @pytest.fixture
59
+ def smtp_ctx_text(self):
60
+ return ReplyEmailContext(
61
+ message_id="<orig@example.com>",
62
+ reply_body=PLAIN_BODY,
63
+ sender_email="sender@example.com",
64
+ sender_name="Sender",
65
+ fallback_recipient="recipient@example.com",
66
+ reply_body_format=BodyFormat.TEXT,
67
+ )
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_html_reply_produces_multipart(self, smtp_ctx_html):
71
+ """When reply_body_format=HTML, the MIME should be multipart/alternative
72
+ with both plain and HTML parts."""
73
+ from dhisana.utils.smtp_email_tools import reply_to_email_via_smtp_async
74
+
75
+ raw_original = self._build_raw_original()
76
+
77
+ with patch("dhisana.utils.smtp_email_tools.asyncio") as mock_asyncio, \
78
+ patch("dhisana.utils.smtp_email_tools.aiosmtplib") as mock_aiosmtp:
79
+
80
+ mock_asyncio.to_thread = AsyncMock(return_value=raw_original)
81
+ mock_aiosmtp.send = AsyncMock(return_value=None)
82
+
83
+ result = await reply_to_email_via_smtp_async(
84
+ smtp_ctx_html,
85
+ smtp_server="smtp.example.com",
86
+ smtp_port=587,
87
+ imap_server="imap.example.com",
88
+ imap_port=993,
89
+ username="user",
90
+ password="pass",
91
+ )
92
+
93
+ # Verify aiosmtplib.send was called
94
+ mock_aiosmtp.send.assert_awaited_once()
95
+ sent_msg = mock_aiosmtp.send.call_args[0][0]
96
+
97
+ # The message should be multipart/alternative
98
+ assert sent_msg.get_content_type() == "multipart/alternative"
99
+ parts = sent_msg.get_payload()
100
+ assert len(parts) == 2
101
+
102
+ plain_part = parts[0]
103
+ html_part = parts[1]
104
+ assert plain_part.get_content_type() == "text/plain"
105
+ assert html_part.get_content_type() == "text/html"
106
+ assert HTML_BODY in html_part.get_payload(decode=True).decode()
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_text_reply_produces_plain(self, smtp_ctx_text):
110
+ """When reply_body_format=TEXT, the MIME should be text/plain."""
111
+ from dhisana.utils.smtp_email_tools import reply_to_email_via_smtp_async
112
+
113
+ raw_original = self._build_raw_original()
114
+
115
+ with patch("dhisana.utils.smtp_email_tools.asyncio") as mock_asyncio, \
116
+ patch("dhisana.utils.smtp_email_tools.aiosmtplib") as mock_aiosmtp:
117
+
118
+ mock_asyncio.to_thread = AsyncMock(return_value=raw_original)
119
+ mock_aiosmtp.send = AsyncMock(return_value=None)
120
+
121
+ result = await reply_to_email_via_smtp_async(
122
+ smtp_ctx_text,
123
+ smtp_server="smtp.example.com",
124
+ smtp_port=587,
125
+ imap_server="imap.example.com",
126
+ imap_port=993,
127
+ username="user",
128
+ password="pass",
129
+ )
130
+
131
+ mock_aiosmtp.send.assert_awaited_once()
132
+ sent_msg = mock_aiosmtp.send.call_args[0][0]
133
+
134
+ assert sent_msg.get_content_type() == "text/plain"
135
+
136
+ def _build_raw_original(self) -> bytes:
137
+ """Build a minimal raw email message for IMAP fetch simulation."""
138
+ msg = MIMEText("Original body", "plain", "utf-8")
139
+ msg["From"] = "recipient@example.com"
140
+ msg["To"] = "sender@example.com"
141
+ msg["Subject"] = "Test"
142
+ msg["Message-ID"] = "<orig@example.com>"
143
+ return msg.as_bytes()
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Microsoft 365 reply tests
148
+ # ---------------------------------------------------------------------------
149
+
150
+ class TestM365ReplyHtmlFormat:
151
+ """reply_to_email_m365_async must create an empty-comment draft, then PATCH
152
+ the body with HTML content when reply_body_format is HTML."""
153
+
154
+ def _m365_original_message(self) -> Dict[str, Any]:
155
+ return {
156
+ "id": "msg_123",
157
+ "subject": "Test Subject",
158
+ "conversationId": "conv_abc",
159
+ "from": {
160
+ "emailAddress": {
161
+ "address": "recipient@example.com",
162
+ "name": "Recipient",
163
+ }
164
+ },
165
+ "toRecipients": [
166
+ {"emailAddress": {"address": "sender@example.com", "name": "Sender"}}
167
+ ],
168
+ "ccRecipients": [],
169
+ }
170
+
171
+ def _build_mock_client(self, original_msg: Dict[str, Any]):
172
+ mock_client = AsyncMock()
173
+ mock_client.get = AsyncMock(
174
+ return_value=_make_response(200, original_msg)
175
+ )
176
+ mock_client.post = AsyncMock(
177
+ return_value=_make_response(201, {"id": "reply_draft_id", "categories": []})
178
+ )
179
+ mock_client.patch = AsyncMock(
180
+ return_value=_make_response(200, {})
181
+ )
182
+ return mock_client
183
+
184
+ @pytest.mark.asyncio
185
+ async def test_html_reply_uses_comment_then_patch(self):
186
+ """HTML reply should create draft with empty comment (to preserve the
187
+ quoted thread) and then PATCH the body with the HTML content."""
188
+ from dhisana.utils.microsoft365_tools import reply_to_email_m365_async
189
+
190
+ ctx = ReplyEmailContext(
191
+ message_id="msg_123",
192
+ reply_body=HTML_BODY,
193
+ sender_email="sender@example.com",
194
+ sender_name="Sender",
195
+ reply_body_format=BodyFormat.HTML,
196
+ )
197
+ original = self._m365_original_message()
198
+ mock_client = self._build_mock_client(original)
199
+
200
+ mock_cm = AsyncMock()
201
+ mock_cm.__aenter__ = AsyncMock(return_value=mock_client)
202
+ mock_cm.__aexit__ = AsyncMock(return_value=False)
203
+
204
+ with patch("dhisana.utils.microsoft365_tools.get_microsoft365_access_token", return_value="fake_token"), \
205
+ patch("dhisana.utils.microsoft365_tools.httpx.AsyncClient", return_value=mock_cm):
206
+
207
+ result = await reply_to_email_m365_async(ctx, tool_config=[{"name": "microsoft365"}])
208
+
209
+ # 1) The createReply POST should use an empty comment to let Graph
210
+ # generate the quoted thread in the draft.
211
+ post_calls = mock_client.post.call_args_list
212
+ create_reply_call = [c for c in post_calls if "createReply" in str(c)]
213
+ assert len(create_reply_call) >= 1
214
+
215
+ payload = create_reply_call[0].kwargs.get("json") or create_reply_call[0][1].get("json")
216
+ assert payload == {"comment": ""}, "HTML reply should create draft with empty comment"
217
+
218
+ # 2) A PATCH call should set the HTML body on the draft.
219
+ patch_calls = mock_client.patch.call_args_list
220
+ body_patch = [c for c in patch_calls if "body" in json.dumps(c.kwargs.get("json", {}))]
221
+ assert len(body_patch) >= 1, "Expected a PATCH to set the HTML body on the draft"
222
+ patch_json = body_patch[0].kwargs.get("json")
223
+ assert patch_json["body"]["contentType"] == "html"
224
+ assert HTML_BODY in patch_json["body"]["content"]
225
+
226
+ @pytest.mark.asyncio
227
+ async def test_text_reply_uses_comment(self):
228
+ """Plain text reply should continue to use the comment field."""
229
+ from dhisana.utils.microsoft365_tools import reply_to_email_m365_async
230
+
231
+ ctx = ReplyEmailContext(
232
+ message_id="msg_123",
233
+ reply_body=PLAIN_BODY,
234
+ sender_email="sender@example.com",
235
+ sender_name="Sender",
236
+ reply_body_format=BodyFormat.TEXT,
237
+ )
238
+ original = self._m365_original_message()
239
+ mock_client = self._build_mock_client(original)
240
+
241
+ mock_cm = AsyncMock()
242
+ mock_cm.__aenter__ = AsyncMock(return_value=mock_client)
243
+ mock_cm.__aexit__ = AsyncMock(return_value=False)
244
+
245
+ with patch("dhisana.utils.microsoft365_tools.get_microsoft365_access_token", return_value="fake_token"), \
246
+ patch("dhisana.utils.microsoft365_tools.httpx.AsyncClient", return_value=mock_cm):
247
+
248
+ result = await reply_to_email_m365_async(ctx, tool_config=[{"name": "microsoft365"}])
249
+
250
+ post_calls = mock_client.post.call_args_list
251
+ create_reply_call = [c for c in post_calls if "createReply" in str(c)]
252
+ assert len(create_reply_call) >= 1
253
+
254
+ payload = create_reply_call[0].kwargs.get("json") or create_reply_call[0][1].get("json")
255
+ assert "comment" in payload
256
+ assert "message" not in payload
File without changes
File without changes