dhisana 0.0.1.dev226__tar.gz → 0.0.1.dev228__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 (118) hide show
  1. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/PKG-INFO +1 -1
  2. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/setup.py +1 -1
  3. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/schemas/common.py +9 -1
  4. dhisana-0.0.1.dev228/src/dhisana/utils/email_body_utils.py +72 -0
  5. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/google_oauth_tools.py +25 -2
  6. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/google_workspace_tools.py +61 -6
  7. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/mailgun_tools.py +14 -2
  8. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/microsoft365_tools.py +10 -2
  9. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/sendgrid_tools.py +14 -3
  10. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/smtp_email_tools.py +38 -1
  11. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana.egg-info/PKG-INFO +1 -1
  12. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana.egg-info/SOURCES.txt +2 -0
  13. dhisana-0.0.1.dev228/tests/test_email_body_utils.py +23 -0
  14. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/README.md +0 -0
  15. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/pyproject.toml +0 -0
  16. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/setup.cfg +0 -0
  17. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/__init__.py +0 -0
  18. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/cli/__init__.py +0 -0
  19. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/cli/cli.py +0 -0
  20. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/cli/datasets.py +0 -0
  21. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/cli/models.py +0 -0
  22. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/cli/predictions.py +0 -0
  23. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/schemas/__init__.py +0 -0
  24. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/schemas/sales.py +0 -0
  25. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/ui/__init__.py +0 -0
  26. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/ui/components.py +0 -0
  27. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/__init__.py +0 -0
  28. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/add_mapping.py +0 -0
  29. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/agent_tools.py +0 -0
  30. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/apollo_tools.py +0 -0
  31. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/assistant_tool_tag.py +0 -0
  32. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/built_with_api_tools.py +0 -0
  33. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/cache_output_tools.py +0 -0
  34. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/cache_output_tools_local.py +0 -0
  35. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/check_email_validity_tools.py +0 -0
  36. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/check_for_intent_signal.py +0 -0
  37. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
  38. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/clay_tools.py +0 -0
  39. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/clean_properties.py +0 -0
  40. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/company_utils.py +0 -0
  41. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/compose_salesnav_query.py +0 -0
  42. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/compose_search_query.py +0 -0
  43. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
  44. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/composite_tools.py +0 -0
  45. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/dataframe_tools.py +0 -0
  46. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/domain_parser.py +0 -0
  47. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/email_parse_helpers.py +0 -0
  48. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/email_provider.py +0 -0
  49. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/enrich_lead_information.py +0 -0
  50. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
  51. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/fetch_openai_config.py +0 -0
  52. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/field_validators.py +0 -0
  53. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/g2_tools.py +0 -0
  54. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_content.py +0 -0
  55. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_email.py +0 -0
  56. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_email_response.py +0 -0
  57. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_flow.py +0 -0
  58. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
  59. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
  60. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
  61. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
  62. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/google_custom_search.py +0 -0
  63. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/hubspot_clearbit.py +0 -0
  64. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
  65. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/instantly_tools.py +0 -0
  66. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/linkedin_crawler.py +0 -0
  67. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/lusha_tools.py +0 -0
  68. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
  69. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/openai_helpers.py +0 -0
  70. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
  71. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
  72. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
  73. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
  74. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
  75. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
  76. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/profile.py +0 -0
  77. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/proxy_curl_tools.py +0 -0
  78. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
  79. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/python_function_to_tools.py +0 -0
  80. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/research_lead.py +0 -0
  81. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
  82. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
  83. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/search_router.py +0 -0
  84. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/search_router_jobs.py +0 -0
  85. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serarch_router_local_business.py +0 -0
  86. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
  87. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
  88. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serpapi_google_search.py +0 -0
  89. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
  90. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serpapi_search_tools.py +0 -0
  91. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
  92. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serperdev_local_business.py +0 -0
  93. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/serperdev_search.py +0 -0
  94. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/test_connect.py +0 -0
  95. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/trasform_json.py +0 -0
  96. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/web_download_parse_tools.py +0 -0
  97. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/workflow_code_model.py +0 -0
  98. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/utils/zoominfo_tools.py +0 -0
  99. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/workflow/__init__.py +0 -0
  100. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/workflow/agent.py +0 -0
  101. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/workflow/flow.py +0 -0
  102. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/workflow/task.py +0 -0
  103. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana/workflow/test.py +0 -0
  104. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana.egg-info/dependency_links.txt +0 -0
  105. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana.egg-info/entry_points.txt +0 -0
  106. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana.egg-info/requires.txt +0 -0
  107. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/src/dhisana.egg-info/top_level.txt +0 -0
  108. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_agent_tools.py +0 -0
  109. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_apollo_company_search.py +0 -0
  110. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_apollo_lead_search.py +0 -0
  111. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_connectivity.py +0 -0
  112. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_google_document.py +0 -0
  113. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_hubspot_call_logs.py +0 -0
  114. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_linkedin_serper.py +0 -0
  115. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_mcp_connectivity.py +0 -0
  116. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_proxycurl_get_company_search_id.py +0 -0
  117. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/tests/test_proxycurl_job_count.py +0 -0
  118. {dhisana-0.0.1.dev226 → dhisana-0.0.1.dev228}/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.dev226
3
+ Version: 0.0.1.dev228
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-dev226',
5
+ version='0.0.1-dev228',
6
6
  description='A Python SDK for Dhisana AI Platform',
7
7
  author='Admin',
8
8
  author_email='contact@dhisana.ai',
@@ -364,6 +364,12 @@ class Integration(IntegrationBase):
364
364
  Integration.model_rebuild()
365
365
  IntegrationUpdate.model_rebuild()
366
366
 
367
+ class BodyFormat(str, Enum):
368
+ AUTO = "auto"
369
+ HTML = "html"
370
+ TEXT = "text"
371
+
372
+
367
373
  class SendEmailContext(BaseModel):
368
374
  recipient: str
369
375
  subject: str
@@ -371,6 +377,7 @@ class SendEmailContext(BaseModel):
371
377
  sender_name: str
372
378
  sender_email: str
373
379
  labels: Optional[List[str]]
380
+ body_format: BodyFormat = BodyFormat.AUTO
374
381
 
375
382
  class QueryEmailContext(BaseModel):
376
383
  start_time: str
@@ -386,4 +393,5 @@ class ReplyEmailContext(BaseModel):
386
393
  sender_email: str
387
394
  sender_name: str
388
395
  mark_as_read: str = "True"
389
- add_labels: Optional[List[str]] = None
396
+ add_labels: Optional[List[str]] = None
397
+ reply_body_format: BodyFormat = BodyFormat.AUTO
@@ -0,0 +1,72 @@
1
+ """Small helpers for handling e-mail bodies across providers."""
2
+
3
+ from typing import Optional, Tuple
4
+ import html as html_lib
5
+ import re
6
+
7
+
8
+ def looks_like_html(text: str) -> bool:
9
+ """Heuristically determine whether the body contains HTML markup."""
10
+ return bool(text and re.search(r"<[a-zA-Z][^>]*>", text))
11
+
12
+
13
+ def _normalize_format_hint(format_hint: Optional[str]) -> str:
14
+ """
15
+ Normalize a user-supplied format hint into html/text/auto.
16
+
17
+ Accepts variations like "plain" or "plaintext" as text.
18
+ """
19
+ if not format_hint:
20
+ return "auto"
21
+ fmt_raw = getattr(format_hint, "value", format_hint)
22
+ fmt = str(fmt_raw).strip().lower()
23
+ if fmt in ("html",):
24
+ return "html"
25
+ if fmt in ("text", "plain", "plain_text", "plaintext"):
26
+ return "text"
27
+ return "auto"
28
+
29
+
30
+ def html_to_plain_text(html: str) -> str:
31
+ """
32
+ Produce a very lightweight plain-text version of an HTML fragment.
33
+ This keeps newlines on block boundaries and strips tags.
34
+ """
35
+ if not html:
36
+ return ""
37
+ text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", html)
38
+ text = re.sub(r"(?i)<br\s*/?>", "\n", text)
39
+ text = re.sub(r"(?i)</(p|div|li|h[1-6])\s*>", "\n", text)
40
+ text = re.sub(r"(?is)<.*?>", "", text)
41
+ text = html_lib.unescape(text)
42
+ text = re.sub(r"\s+\n", "\n", text)
43
+ text = re.sub(r"\n{3,}", "\n\n", text)
44
+ return text.strip()
45
+
46
+
47
+ def plain_text_to_html(text: str) -> str:
48
+ """Wrap plain text in a minimal HTML container that preserves newlines."""
49
+ if text is None:
50
+ return ""
51
+ escaped = html_lib.escape(text)
52
+ return f'<div style="white-space: pre-wrap">{escaped}</div>'
53
+
54
+
55
+ def body_variants(body: Optional[str], format_hint: Optional[str]) -> Tuple[str, str, str]:
56
+ """
57
+ Return (plain, html, resolved_format) honoring an optional format hint.
58
+
59
+ resolved_format is "html" or "text" after applying auto-detection.
60
+ """
61
+ content = body or ""
62
+ fmt = _normalize_format_hint(format_hint)
63
+
64
+ if fmt == "html":
65
+ return html_to_plain_text(content), content, "html"
66
+ if fmt == "text":
67
+ return content, plain_text_to_html(content), "text"
68
+
69
+ if looks_like_html(content):
70
+ return html_to_plain_text(content), content, "html"
71
+
72
+ return content, plain_text_to_html(content), "text"
@@ -2,6 +2,7 @@ import base64
2
2
  import json
3
3
  import logging
4
4
  import re
5
+ from email.mime.multipart import MIMEMultipart
5
6
  from email.mime.text import MIMEText
6
7
  from typing import Any, Dict, List, Optional
7
8
 
@@ -22,6 +23,7 @@ from dhisana.utils.email_parse_helpers import (
22
23
  )
23
24
  from dhisana.utils.assistant_tool_tag import assistant_tool
24
25
  from dhisana.utils.cache_output_tools import retrieve_output, cache_output
26
+ from dhisana.utils.email_body_utils import body_variants
25
27
  from typing import Optional as _Optional # avoid name clash in wrappers
26
28
 
27
29
  def _status_phrase(code: int) -> str:
@@ -127,7 +129,18 @@ async def send_email_using_google_oauth_async(
127
129
  """
128
130
  token = get_google_access_token(tool_config)
129
131
 
130
- message = MIMEText(send_email_context.body, _subtype="html")
132
+ plain_body, html_body, resolved_fmt = body_variants(
133
+ send_email_context.body,
134
+ getattr(send_email_context, "body_format", None),
135
+ )
136
+ # Use multipart/alternative when we have both; fall back to single part for pure text.
137
+ if resolved_fmt == "text":
138
+ message = MIMEText(plain_body, "plain", _charset="utf-8")
139
+ else:
140
+ message = MIMEMultipart("alternative")
141
+ message.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
142
+ message.attach(MIMEText(html_body, "html", _charset="utf-8"))
143
+
131
144
  message["to"] = send_email_context.recipient
132
145
  message["from"] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
133
146
  message["subject"] = send_email_context.subject
@@ -265,7 +278,17 @@ async def reply_to_email_google_oauth_async(
265
278
  message_id_header = headers_map.get("Message-ID", "") or ""
266
279
 
267
280
  # 2) Build reply MIME
268
- msg = MIMEText(reply_email_context.reply_body, _subtype="html")
281
+ plain_reply, html_reply, resolved_reply_fmt = body_variants(
282
+ reply_email_context.reply_body,
283
+ getattr(reply_email_context, "reply_body_format", None),
284
+ )
285
+ if resolved_reply_fmt == "text":
286
+ msg = MIMEText(plain_reply, "plain", _charset="utf-8")
287
+ else:
288
+ msg = MIMEMultipart("alternative")
289
+ msg.attach(MIMEText(plain_reply, "plain", _charset="utf-8"))
290
+ msg.attach(MIMEText(html_reply, "html", _charset="utf-8"))
291
+
269
292
  msg["To"] = to_addresses
270
293
  if cc_addresses:
271
294
  msg["Cc"] = cc_addresses
@@ -1,12 +1,14 @@
1
1
  import base64
2
2
  import csv
3
3
  import datetime
4
+ import html as html_lib
4
5
  import io
5
6
  import json
6
7
  import logging
7
8
  import os
8
9
  import re
9
10
  import uuid
11
+ from email.mime.multipart import MIMEMultipart
10
12
  from email.mime.text import MIMEText
11
13
  from typing import Any, Dict, List, Optional
12
14
 
@@ -22,8 +24,9 @@ from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
22
24
  from dhisana.schemas.sales import MessageItem
23
25
  from dhisana.utils.assistant_tool_tag import assistant_tool
24
26
  from dhisana.utils.email_parse_helpers import *
27
+ from dhisana.utils.email_body_utils import body_variants
25
28
  import asyncio
26
- from dhisana.schemas.common import (SendEmailContext, QueryEmailContext, ReplyEmailContext)
29
+ from dhisana.schemas.common import (SendEmailContext, QueryEmailContext, ReplyEmailContext, BodyFormat)
27
30
 
28
31
 
29
32
  ################################################################################
@@ -109,6 +112,28 @@ def get_google_credentials(
109
112
  return credentials
110
113
 
111
114
 
115
+ def _looks_like_html(text: str) -> bool:
116
+ """Heuristically determine whether the body contains HTML markup."""
117
+ return bool(text and re.search(r"<[a-zA-Z][^>]*>", text))
118
+
119
+
120
+ def _html_to_plain_text(html: str) -> str:
121
+ """
122
+ Produce a very lightweight plain-text version of an HTML fragment.
123
+ This keeps newlines on block boundaries and strips tags.
124
+ """
125
+ if not html:
126
+ return ""
127
+ text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", html)
128
+ text = re.sub(r"(?i)<br\s*/?>", "\n", text)
129
+ text = re.sub(r"(?i)</(p|div|li|h[1-6])\s*>", "\n", text)
130
+ text = re.sub(r"(?is)<.*?>", "", text)
131
+ text = html_lib.unescape(text)
132
+ text = re.sub(r"\s+\n", "\n", text)
133
+ text = re.sub(r"\n{3,}", "\n\n", text)
134
+ return text.strip()
135
+
136
+
112
137
 
113
138
  @assistant_tool
114
139
  async def send_email_using_service_account_async(
@@ -137,8 +162,19 @@ async def send_email_using_service_account_async(
137
162
 
138
163
  gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'
139
164
 
140
- # Construct the MIME text message
141
- message = MIMEText(send_email_context.body)
165
+ plain_body, html_body, resolved_fmt = body_variants(
166
+ send_email_context.body,
167
+ getattr(send_email_context, "body_format", None),
168
+ )
169
+
170
+ if resolved_fmt == "text":
171
+ message = MIMEText(plain_body, _subtype="plain", _charset="utf-8")
172
+ else:
173
+ # Gmail prefers multipart/alternative when HTML is present.
174
+ message = MIMEMultipart("alternative")
175
+ message.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
176
+ message.attach(MIMEText(html_body, "html", _charset="utf-8"))
177
+
142
178
  message['to'] = send_email_context.recipient
143
179
  message['from'] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
144
180
  message['subject'] = send_email_context.subject
@@ -477,6 +513,7 @@ class SendEmailContext(BaseModel):
477
513
  sender_name: str
478
514
  sender_email: str
479
515
  labels: Optional[List[str]]
516
+ body_format: BodyFormat = BodyFormat.AUTO
480
517
 
481
518
  @assistant_tool
482
519
  async def send_email_using_service_account_async(
@@ -505,8 +542,18 @@ async def send_email_using_service_account_async(
505
542
 
506
543
  gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'
507
544
 
545
+ plain_body, html_body, resolved_fmt = body_variants(
546
+ send_email_context.body,
547
+ getattr(send_email_context, "body_format", None),
548
+ )
549
+
508
550
  # Construct the MIME text message
509
- message = MIMEText(send_email_context.body)
551
+ if resolved_fmt == "text":
552
+ message = MIMEText(plain_body, _subtype="plain", _charset="utf-8")
553
+ else:
554
+ message = MIMEMultipart("alternative")
555
+ message.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
556
+ message.attach(MIMEText(html_body, "html", _charset="utf-8"))
510
557
  message['to'] = send_email_context.recipient
511
558
  message['from'] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
512
559
  message['subject'] = send_email_context.subject
@@ -861,7 +908,16 @@ async def reply_to_email_async(
861
908
  message_id_header = headers_dict.get('Message-ID', '')
862
909
 
863
910
  # 3. Create the reply email message
864
- msg = MIMEText(reply_email_context.reply_body)
911
+ plain_reply, html_reply, resolved_reply_fmt = body_variants(
912
+ reply_email_context.reply_body,
913
+ getattr(reply_email_context, "reply_body_format", None),
914
+ )
915
+ if resolved_reply_fmt == "text":
916
+ msg = MIMEText(plain_reply, _subtype="plain", _charset="utf-8")
917
+ else:
918
+ msg = MIMEMultipart("alternative")
919
+ msg.attach(MIMEText(plain_reply, "plain", _charset="utf-8"))
920
+ msg.attach(MIMEText(html_reply, "html", _charset="utf-8"))
865
921
  msg['To'] = to_addresses
866
922
  if cc_addresses:
867
923
  msg['Cc'] = cc_addresses
@@ -1186,4 +1242,3 @@ def save_values_to_csv(values: List[List[str]], output_filename: str) -> str:
1186
1242
  writer.writerows(values)
1187
1243
 
1188
1244
  return local_file_path
1189
-
@@ -7,6 +7,7 @@ import aiohttp
7
7
 
8
8
  from dhisana.utils.assistant_tool_tag import assistant_tool
9
9
  from dhisana.schemas.common import SendEmailContext
10
+ from dhisana.utils.email_body_utils import body_variants
10
11
 
11
12
 
12
13
  def get_mailgun_notify_key(tool_config: Optional[List[Dict]] = None) -> str:
@@ -59,6 +60,7 @@ async def send_email_with_mailgun(
59
60
  subject: str,
60
61
  message: str,
61
62
  tool_config: Optional[List[Dict]] = None,
63
+ body_format: Optional[str] = None,
62
64
  ):
63
65
  """
64
66
  Send an email using the Mailgun API.
@@ -74,13 +76,17 @@ async def send_email_with_mailgun(
74
76
  api_key = get_mailgun_notify_key(tool_config)
75
77
  domain = get_mailgun_notify_domain(tool_config)
76
78
 
79
+ body = message or ""
77
80
  data = {
78
81
  "from": sender,
79
82
  "to": recipients,
80
83
  "subject": subject,
81
- "html": message,
82
84
  }
83
85
 
86
+ plain_body, html_body, _ = body_variants(body, body_format)
87
+ data["text"] = plain_body
88
+ data["html"] = html_body
89
+
84
90
  async with aiohttp.ClientSession() as session:
85
91
  async with session.post(
86
92
  f"https://api.mailgun.net/v3/{domain}/messages",
@@ -107,11 +113,17 @@ async def send_email_using_mailgun_async(
107
113
  api_key = get_mailgun_notify_key(tool_config)
108
114
  domain = get_mailgun_notify_domain(tool_config)
109
115
 
116
+ plain_body, html_body, _ = body_variants(
117
+ send_email_context.body,
118
+ getattr(send_email_context, "body_format", None),
119
+ )
120
+
110
121
  data = {
111
122
  "from": f"{send_email_context.sender_name} <{send_email_context.sender_email}>",
112
123
  "to": [send_email_context.recipient],
113
124
  "subject": send_email_context.subject,
114
- "html": send_email_context.body or "",
125
+ "text": plain_body,
126
+ "html": html_body,
115
127
  }
116
128
 
117
129
  async with aiohttp.ClientSession() as session:
@@ -12,6 +12,7 @@ from dhisana.schemas.common import (
12
12
  ReplyEmailContext,
13
13
  )
14
14
  from dhisana.schemas.sales import MessageItem
15
+ from dhisana.utils.email_body_utils import body_variants
15
16
 
16
17
 
17
18
  def get_microsoft365_access_token(tool_config: Optional[List[Dict]] = None) -> str:
@@ -149,11 +150,18 @@ async def send_email_using_microsoft_graph_async(
149
150
  base_url = "https://graph.microsoft.com/v1.0"
150
151
  base_res = _base_resource(sender_email, tool_config, auth_mode)
151
152
 
153
+ plain_body, html_body, resolved_fmt = body_variants(
154
+ send_email_context.body,
155
+ getattr(send_email_context, "body_format", None),
156
+ )
157
+ content_type = "Text" if resolved_fmt == "text" else "HTML"
158
+ content_body = plain_body if resolved_fmt == "text" else html_body
159
+
152
160
  message_payload: Dict[str, Any] = {
153
161
  "subject": send_email_context.subject,
154
162
  "body": {
155
- "contentType": "HTML",
156
- "content": send_email_context.body or "",
163
+ "contentType": content_type,
164
+ "content": content_body,
157
165
  },
158
166
  "toRecipients": [
159
167
  {"emailAddress": {"address": send_email_context.recipient}}
@@ -14,6 +14,7 @@ import aiohttp
14
14
 
15
15
  from dhisana.utils.assistant_tool_tag import assistant_tool
16
16
  from dhisana.schemas.common import SendEmailContext
17
+ from dhisana.utils.email_body_utils import body_variants
17
18
 
18
19
  # --------------------------------------------------------------------------- #
19
20
  # Mailgun (re-exported from dedicated module for backward compatibility)
@@ -57,6 +58,7 @@ async def send_email_with_sendgrid(
57
58
  subject: str,
58
59
  message: str,
59
60
  tool_config: Optional[List[Dict]] = None,
61
+ body_format: Optional[str] = None,
60
62
  ):
61
63
  """
62
64
  Send an email using SendGrid's v3 Mail Send API.
@@ -79,6 +81,12 @@ async def send_email_with_sendgrid(
79
81
  if not to_list:
80
82
  return {"error": "No recipients provided"}
81
83
 
84
+ plain_body, html_body, _ = body_variants(message, body_format)
85
+ content = [
86
+ {"type": "text/plain", "value": plain_body},
87
+ {"type": "text/html", "value": html_body},
88
+ ]
89
+
82
90
  payload = {
83
91
  "personalizations": [
84
92
  {
@@ -87,9 +95,7 @@ async def send_email_with_sendgrid(
87
95
  }
88
96
  ],
89
97
  "from": from_obj,
90
- "content": [
91
- {"type": "text/html", "value": message or ""}
92
- ],
98
+ "content": content,
93
99
  }
94
100
 
95
101
  headers = {
@@ -126,11 +132,16 @@ async def send_email_using_sendgrid_async(
126
132
  Provider-style wrapper for SendGrid using SendEmailContext.
127
133
  Returns an opaque token since SendGrid does not return a message id.
128
134
  """
135
+ plain_body, html_body, _ = body_variants(
136
+ ctx.body,
137
+ getattr(ctx, "body_format", None),
138
+ )
129
139
  result = await send_email_with_sendgrid(
130
140
  sender=f"{ctx.sender_name} <{ctx.sender_email}>",
131
141
  recipients=[ctx.recipient],
132
142
  subject=ctx.subject,
133
143
  message=ctx.body or "",
144
+ body_format=getattr(ctx, "body_format", None),
134
145
  tool_config=tool_config,
135
146
  )
136
147
  # Normalise output to a string id-like value
@@ -7,12 +7,14 @@ import datetime
7
7
  import email
8
8
  import email.utils
9
9
  import hashlib
10
+ import html as html_lib
10
11
  import imaplib
11
12
  import logging
12
13
  import re
13
14
  import uuid
14
15
  from email.errors import HeaderParseError
15
16
  from email.header import Header, decode_header, make_header
17
+ from email.mime.multipart import MIMEMultipart
16
18
  from email.mime.text import MIMEText
17
19
  from datetime import datetime, timedelta, timezone
18
20
  from typing import Any, Dict, List, Optional, Union
@@ -31,6 +33,7 @@ from dhisana.utils.google_workspace_tools import (
31
33
  QueryEmailContext,
32
34
  SendEmailContext,
33
35
  )
36
+ from dhisana.utils.email_body_utils import body_variants
34
37
 
35
38
 
36
39
  # --------------------------------------------------------------------------- #
@@ -102,6 +105,28 @@ def _to_datetime(val: Union[datetime, str]) -> datetime:
102
105
  ) from exc
103
106
 
104
107
 
108
+ def _looks_like_html(text: str) -> bool:
109
+ """Heuristically determine whether the body contains HTML markup."""
110
+ return bool(text and re.search(r"<[a-zA-Z][^>]*>", text))
111
+
112
+
113
+ def _html_to_plain_text(html: str) -> str:
114
+ """
115
+ Produce a very lightweight plain-text version of an HTML fragment.
116
+ This keeps newlines on block boundaries and strips tags.
117
+ """
118
+ if not html:
119
+ return ""
120
+ text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", html)
121
+ text = re.sub(r"(?i)<br\s*/?>", "\n", text)
122
+ text = re.sub(r"(?i)</(p|div|li|h[1-6])\s*>", "\n", text)
123
+ text = re.sub(r"(?is)<.*?>", "", text)
124
+ text = html_lib.unescape(text)
125
+ text = re.sub(r"\s+\n", "\n", text)
126
+ text = re.sub(r"\n{3,}", "\n\n", text)
127
+ return text.strip()
128
+
129
+
105
130
  # --------------------------------------------------------------------------- #
106
131
  # Outbound -- SMTP
107
132
  # --------------------------------------------------------------------------- #
@@ -127,7 +152,19 @@ async def send_email_via_smtp_async(
127
152
  str
128
153
  The Message-ID of the sent message (e.g., "<uuid@yourdomain.com>").
129
154
  """
130
- msg = MIMEText(ctx.body, _charset="utf-8")
155
+ plain_body, html_body, resolved_fmt = body_variants(
156
+ ctx.body,
157
+ getattr(ctx, "body_format", None),
158
+ )
159
+
160
+ if resolved_fmt == "text":
161
+ msg = MIMEText(plain_body, _subtype="plain", _charset="utf-8")
162
+ else:
163
+ # Build multipart/alternative so HTML-capable clients see rich content.
164
+ msg = MIMEMultipart("alternative")
165
+ msg.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
166
+ msg.attach(MIMEText(html_body, "html", _charset="utf-8"))
167
+
131
168
  msg["From"] = f"{ctx.sender_name} <{ctx.sender_email}>"
132
169
  msg["To"] = ctx.recipient
133
170
  msg["Subject"] = ctx.subject
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev226
3
+ Version: 0.0.1.dev228
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
@@ -38,6 +38,7 @@ src/dhisana/utils/compose_three_step_workflow.py
38
38
  src/dhisana/utils/composite_tools.py
39
39
  src/dhisana/utils/dataframe_tools.py
40
40
  src/dhisana/utils/domain_parser.py
41
+ src/dhisana/utils/email_body_utils.py
41
42
  src/dhisana/utils/email_parse_helpers.py
42
43
  src/dhisana/utils/email_provider.py
43
44
  src/dhisana/utils/enrich_lead_information.py
@@ -105,6 +106,7 @@ tests/test_agent_tools.py
105
106
  tests/test_apollo_company_search.py
106
107
  tests/test_apollo_lead_search.py
107
108
  tests/test_connectivity.py
109
+ tests/test_email_body_utils.py
108
110
  tests/test_google_document.py
109
111
  tests/test_hubspot_call_logs.py
110
112
  tests/test_linkedin_serper.py
@@ -0,0 +1,23 @@
1
+ import pytest
2
+
3
+ from dhisana.schemas.common import BodyFormat
4
+ from dhisana.utils.email_body_utils import body_variants
5
+
6
+
7
+ @pytest.mark.parametrize(
8
+ "body,format_hint,expected_resolved",
9
+ [
10
+ ("<p>Hello</p>", BodyFormat.HTML, "html"),
11
+ ("Hello", BodyFormat.TEXT, "text"),
12
+ ],
13
+ )
14
+ def test_body_variants_honors_body_format_enum(body, format_hint, expected_resolved):
15
+ plain, html, resolved = body_variants(body, format_hint)
16
+
17
+ if expected_resolved == "html":
18
+ assert html == body
19
+ assert plain == "Hello"
20
+ else:
21
+ assert plain == body
22
+ assert html.startswith("<div")
23
+ assert resolved == expected_resolved
File without changes
File without changes