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.
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/PKG-INFO +1 -1
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/setup.py +1 -1
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/microsoft365_tools.py +24 -1
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/smtp_email_tools.py +11 -2
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/test_connect.py +66 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/PKG-INFO +1 -1
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/SOURCES.txt +1 -0
- dhisana-0.0.1.dev312/tests/test_reply_html_format.py +256 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/README.md +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/pyproject.toml +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/setup.cfg +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/__init__.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/__init__.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/cli.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/datasets.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/models.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/cli/predictions.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/schemas/__init__.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/schemas/common.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/schemas/sales.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/ui/__init__.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/ui/components.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/__init__.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/add_mapping.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/agent_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/apollo_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/assistant_tool_tag.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/built_with_api_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/cache_output_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/cache_output_tools_local.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_email_validity_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_for_intent_signal.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/clay_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/clean_properties.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/company_utils.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/compose_salesnav_query.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/compose_search_query.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/composite_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/dataframe_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/domain_parser.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/email_body_utils.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/email_parse_helpers.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/email_provider.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/enrich_lead_information.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/fetch_openai_config.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/field_validators.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/g2_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_content.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_custom_message.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_email.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_email_response.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_flow.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_sms_whatsapp.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/google_custom_search.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/google_oauth_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/google_workspace_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/hubspot_clearbit.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/instantly_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/linkedin_crawler.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/lusha_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/mailgun_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/mailreach_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openai_helpers.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/profile.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/proxy_curl_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/python_function_to_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/research_lead.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/search_router.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/search_router_jobs.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/sendgrid_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serarch_router_local_business.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_google_search.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_search_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serperdev_local_business.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serperdev_search.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/trasform_json.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/web_download_parse_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/workflow_code_model.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/zoominfo_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/__init__.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/agent.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/flow.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/task.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/workflow/test.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/dependency_links.txt +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/entry_points.txt +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/requires.txt +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana.egg-info/top_level.txt +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_agent_tools.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_apollo_company_search.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_apollo_json_error_handling.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_apollo_lead_search.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_connectivity.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_email_body_utils.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_generate_email.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_generate_sms_whatsapp.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_google_document.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_hubspot_call_logs.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_linkedin_serper.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_mailreach.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_mcp_connectivity.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_normalize_graph_datetime.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_proxycurl_get_company_search_id.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_proxycurl_job_count.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_reply_thread_fallback.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_send_email_recipients.py +0 -0
- {dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/tests/test_structured_output_with_mcp.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
730
|
-
|
|
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]] = {}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_email_validity_tools.py
RENAMED
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/check_linkedin_url_validity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/compose_three_step_workflow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/extract_email_content_for_llm.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/generate_linkedin_connect_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openai_assistant_and_file_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/openapi_tool/openapi_tool.py
RENAMED
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/parse_linkedin_messages_txt.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serarch_router_local_business.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev310 → dhisana-0.0.1.dev312}/src/dhisana/utils/serpapi_local_business_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|