dhisana 0.0.1.dev303__tar.gz → 0.0.1.dev305__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/PKG-INFO +1 -1
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/setup.py +1 -1
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/schemas/common.py +7 -2
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/enrich_lead_information.py +51 -3
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_oauth_tools.py +21 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/microsoft365_tools.py +29 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_search_tools.py +10 -40
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/smtp_email_tools.py +36 -1
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/PKG-INFO +1 -1
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/SOURCES.txt +1 -0
- dhisana-0.0.1.dev305/tests/test_send_email_recipients.py +403 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/README.md +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/pyproject.toml +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/setup.cfg +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/__init__.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/__init__.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/cli.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/datasets.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/models.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/cli/predictions.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/schemas/__init__.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/schemas/sales.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/ui/__init__.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/ui/components.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/__init__.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/add_mapping.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/agent_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/apollo_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/assistant_tool_tag.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/built_with_api_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools_local.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_email_validity_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_for_intent_signal.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/clay_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/clean_properties.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/company_utils.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_salesnav_query.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_search_query.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/composite_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/dataframe_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/domain_parser.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_body_utils.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_parse_helpers.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_provider.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/fetch_openai_config.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/field_validators.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/g2_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_content.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_custom_message.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email_response.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_flow.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_custom_search.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_workspace_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_clearbit.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/instantly_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/linkedin_crawler.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/lusha_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailgun_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailreach_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_helpers.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/profile.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxy_curl_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/python_function_to_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/research_lead.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router_jobs.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/sendgrid_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serarch_router_local_business.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_search.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_local_business.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_search.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/test_connect.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/trasform_json.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/web_download_parse_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/workflow_code_model.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/zoominfo_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/__init__.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/agent.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/flow.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/task.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana/workflow/test.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/dependency_links.txt +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/entry_points.txt +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/requires.txt +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/top_level.txt +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_agent_tools.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_apollo_company_search.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_apollo_lead_search.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_connectivity.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_email_body_utils.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_generate_email.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_google_document.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_hubspot_call_logs.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_linkedin_serper.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_mailreach.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_mcp_connectivity.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_normalize_graph_datetime.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_proxycurl_get_company_search_id.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_proxycurl_job_count.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_reply_thread_fallback.py +0 -0
- {dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/tests/test_structured_output_with_mcp.py +0 -0
|
@@ -380,19 +380,24 @@ class SendEmailContext(BaseModel):
|
|
|
380
380
|
body_format: BodyFormat = BodyFormat.AUTO
|
|
381
381
|
headers: Optional[Dict[str, str]] = None
|
|
382
382
|
email_open_token: Optional[str] = None
|
|
383
|
-
|
|
383
|
+
to_recipients: Optional[List["EmailRecipient"]] = None
|
|
384
|
+
cc_recipients: Optional[List["EmailRecipient"]] = None
|
|
385
|
+
bcc_recipients: Optional[List["EmailRecipient"]] = None
|
|
386
|
+
|
|
384
387
|
class QueryEmailContext(BaseModel):
|
|
385
388
|
start_time: str
|
|
386
389
|
end_time: str
|
|
387
390
|
sender_email: str
|
|
388
391
|
unread_only: bool = True
|
|
389
392
|
labels: Optional[List[str]] = None
|
|
390
|
-
|
|
393
|
+
|
|
391
394
|
|
|
392
395
|
class EmailRecipient(BaseModel):
|
|
393
396
|
email: str
|
|
394
397
|
name: Optional[str] = None
|
|
395
398
|
|
|
399
|
+
SendEmailContext.model_rebuild()
|
|
400
|
+
|
|
396
401
|
|
|
397
402
|
class ReplyEmailContext(BaseModel):
|
|
398
403
|
"""Context for replying to or forwarding an email."""
|
|
@@ -658,9 +658,57 @@ async def enrich_user_info(
|
|
|
658
658
|
tool_config=tool_config,
|
|
659
659
|
)
|
|
660
660
|
if found_linkedin_url:
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
661
|
+
should_use_url = True
|
|
662
|
+
if use_strict_check and org_domain:
|
|
663
|
+
try:
|
|
664
|
+
temp_props = {"user_linkedin_url": found_linkedin_url}
|
|
665
|
+
enriched = await enrich_with_provider(temp_props, tool_config)
|
|
666
|
+
enriched_domain = (enriched.get("primary_domain_of_organization") or "").strip().lower()
|
|
667
|
+
# If no domain returned but we have an organization LinkedIn URL,
|
|
668
|
+
# look up the org to get its domain
|
|
669
|
+
if not enriched_domain:
|
|
670
|
+
enriched_org_linkedin = (enriched.get("organization_linkedin_url") or "").strip()
|
|
671
|
+
if enriched_org_linkedin:
|
|
672
|
+
try:
|
|
673
|
+
org_info = await search_organization_by_linkedin_or_domain(
|
|
674
|
+
linkedin_url=enriched_org_linkedin,
|
|
675
|
+
tool_config=tool_config,
|
|
676
|
+
)
|
|
677
|
+
enriched_domain = (org_info.get("domain") or "").strip().lower()
|
|
678
|
+
except Exception:
|
|
679
|
+
logger.debug("Could not look up org domain from LinkedIn URL: %s", enriched_org_linkedin)
|
|
680
|
+
input_org_domain = org_domain.strip().lower()
|
|
681
|
+
if input_org_domain and enriched_domain and enriched_domain != input_org_domain:
|
|
682
|
+
logger.info(
|
|
683
|
+
"Skipping Google-found LinkedIn URL %s: enriched domain '%s' does not match input domain '%s'",
|
|
684
|
+
found_linkedin_url, enriched_domain, input_org_domain,
|
|
685
|
+
)
|
|
686
|
+
should_use_url = False
|
|
687
|
+
elif input_org_domain and not enriched_domain:
|
|
688
|
+
logger.info(
|
|
689
|
+
"Skipping Google-found LinkedIn URL %s: could not determine enriched org domain to verify against input domain '%s'",
|
|
690
|
+
found_linkedin_url, input_org_domain,
|
|
691
|
+
)
|
|
692
|
+
should_use_url = False
|
|
693
|
+
except Exception:
|
|
694
|
+
logger.debug("Could not verify LinkedIn URL domain via enrichment; proceeding with found URL.")
|
|
695
|
+
if should_use_url:
|
|
696
|
+
user_linkedin_url = found_linkedin_url
|
|
697
|
+
input_properties["user_linkedin_url"] = user_linkedin_url
|
|
698
|
+
# Skip email-based LinkedIn search if strict check is on and the email
|
|
699
|
+
# domain doesn't match the expected org domain (would waste API calls
|
|
700
|
+
# searching for a person at a different company).
|
|
701
|
+
skip_email_search = False
|
|
702
|
+
if use_strict_check and org_domain and email:
|
|
703
|
+
email_domain = email.split("@")[-1].strip().lower()
|
|
704
|
+
if email_domain and org_domain.strip().lower() != email_domain:
|
|
705
|
+
skip_email_search = True
|
|
706
|
+
logger.info(
|
|
707
|
+
"Skipping email-based LinkedIn search: email domain '%s' does not match org domain '%s'",
|
|
708
|
+
email_domain, org_domain,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if not user_linkedin_url and email and not skip_email_search:
|
|
664
712
|
# If we have an email but no LinkedIn URL yet, try searching by email via Google
|
|
665
713
|
email_lookup_result = await find_user_linkedin_url_by_email_google(
|
|
666
714
|
email=email,
|
|
@@ -210,6 +210,27 @@ async def send_email_using_google_oauth_async(
|
|
|
210
210
|
message["from"] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
|
|
211
211
|
message["subject"] = send_email_context.subject
|
|
212
212
|
|
|
213
|
+
# Add extra To recipients
|
|
214
|
+
extra_to = getattr(send_email_context, "to_recipients", None) or []
|
|
215
|
+
if extra_to:
|
|
216
|
+
additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
|
|
217
|
+
additional = [a for a in additional if a and a.lower() != send_email_context.recipient.strip().lower()]
|
|
218
|
+
if additional:
|
|
219
|
+
del message["to"]
|
|
220
|
+
message["to"] = ", ".join([send_email_context.recipient] + additional)
|
|
221
|
+
|
|
222
|
+
# Add CC recipients
|
|
223
|
+
cc_list = getattr(send_email_context, "cc_recipients", None) or []
|
|
224
|
+
if cc_list:
|
|
225
|
+
cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
|
|
226
|
+
message["cc"] = ", ".join(a for a in cc_addrs if a)
|
|
227
|
+
|
|
228
|
+
# Add BCC recipients (header set for envelope; Gmail strips it before delivery)
|
|
229
|
+
bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
|
|
230
|
+
if bcc_list:
|
|
231
|
+
bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
|
|
232
|
+
message["bcc"] = ", ".join(a for a in bcc_addrs if a)
|
|
233
|
+
|
|
213
234
|
extra_headers = getattr(send_email_context, "headers", None) or {}
|
|
214
235
|
for header, value in extra_headers.items():
|
|
215
236
|
if not header or value is None:
|
|
@@ -227,6 +227,35 @@ async def send_email_using_microsoft_graph_async(
|
|
|
227
227
|
],
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
# Merge additional To recipients from the new field
|
|
231
|
+
extra_to = getattr(send_email_context, "to_recipients", None) or []
|
|
232
|
+
for r in extra_to:
|
|
233
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
234
|
+
if addr and addr.lower() != send_email_context.recipient.strip().lower():
|
|
235
|
+
message_payload["toRecipients"].append({"emailAddress": {"address": addr}})
|
|
236
|
+
|
|
237
|
+
# CC recipients
|
|
238
|
+
cc_list = getattr(send_email_context, "cc_recipients", None) or []
|
|
239
|
+
if cc_list:
|
|
240
|
+
cc_entries = []
|
|
241
|
+
for r in cc_list:
|
|
242
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
243
|
+
if addr:
|
|
244
|
+
cc_entries.append({"emailAddress": {"address": addr}})
|
|
245
|
+
if cc_entries:
|
|
246
|
+
message_payload["ccRecipients"] = cc_entries
|
|
247
|
+
|
|
248
|
+
# BCC recipients
|
|
249
|
+
bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
|
|
250
|
+
if bcc_list:
|
|
251
|
+
bcc_entries = []
|
|
252
|
+
for r in bcc_list:
|
|
253
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
254
|
+
if addr:
|
|
255
|
+
bcc_entries.append({"emailAddress": {"address": addr}})
|
|
256
|
+
if bcc_entries:
|
|
257
|
+
message_payload["bccRecipients"] = bcc_entries
|
|
258
|
+
|
|
230
259
|
extra_headers = getattr(send_email_context, "headers", None) or {}
|
|
231
260
|
if extra_headers:
|
|
232
261
|
message_payload["internetMessageHeaders"] = [
|
|
@@ -401,12 +401,6 @@ async def find_user_linkedin_url_by_email_google(
|
|
|
401
401
|
if query and query not in queries:
|
|
402
402
|
queries.append(query)
|
|
403
403
|
|
|
404
|
-
def add_query_parts(*parts: str) -> None:
|
|
405
|
-
tokens = [part.strip() for part in parts if part and part.strip()]
|
|
406
|
-
if not tokens:
|
|
407
|
-
return
|
|
408
|
-
add_query(" ".join(tokens))
|
|
409
|
-
|
|
410
404
|
enriched_terms = []
|
|
411
405
|
if user_name:
|
|
412
406
|
enriched_terms.append(f'"{user_name}"')
|
|
@@ -418,47 +412,23 @@ async def find_user_linkedin_url_by_email_google(
|
|
|
418
412
|
enriched_terms.append(f'"{user_location}"')
|
|
419
413
|
base_hint = " ".join(enriched_terms)
|
|
420
414
|
|
|
421
|
-
#
|
|
422
|
-
|
|
423
|
-
add_query_parts(normalized_email, "linkedin.com", base_hint)
|
|
424
|
-
add_query_parts(normalized_email, "linkedin", base_hint)
|
|
425
|
-
add_query_parts(normalized_email, base_hint)
|
|
426
|
-
add_query(f'"{normalized_email}" "linkedin.com/in" {base_hint}')
|
|
427
|
-
add_query(f'"{normalized_email}" "linkedin.com" {base_hint}')
|
|
428
|
-
add_query(f'"{normalized_email}" linkedin {base_hint}')
|
|
415
|
+
# 1) Best query: site-scoped with exact email
|
|
416
|
+
add_query(f'site:linkedin.com/in "{normalized_email}" {base_hint}')
|
|
429
417
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
add_query_parts(email_local_part, "linkedin.com", base_hint)
|
|
433
|
-
add_query_parts(email_local_part, "linkedin", base_hint)
|
|
434
|
-
add_query(f'"{email_local_part}" "linkedin.com/in" {base_hint}')
|
|
435
|
-
add_query(f'"{email_local_part}" "linkedin.com" {base_hint}')
|
|
418
|
+
# 2) Exact email with linkedin scope (Google may surface LinkedIn even without site:)
|
|
419
|
+
add_query(f'"{normalized_email}" linkedin.com/in {base_hint}')
|
|
436
420
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
add_query(f'"{email_local_humanized}" linkedin {base_hint}')
|
|
421
|
+
# 3) Unquoted email with linkedin scope (catches partial matches)
|
|
422
|
+
add_query(f'{normalized_email} linkedin.com/in {base_hint}')
|
|
440
423
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if email_local_part:
|
|
424
|
+
# 4) Local part fallback with site scope (e.g. "santosh" on linkedin)
|
|
425
|
+
if email_local_part and email_local_part != normalized_email:
|
|
445
426
|
add_query(f'site:linkedin.com/in "{email_local_part}" {base_hint}')
|
|
446
427
|
|
|
447
|
-
|
|
428
|
+
# 5) Humanized local part fallback (e.g. "john smith" from "john.smith")
|
|
429
|
+
if email_local_humanized and email_local_humanized not in {email_local_part, normalized_email}:
|
|
448
430
|
add_query(f'site:linkedin.com/in "{email_local_humanized}" {base_hint}')
|
|
449
431
|
|
|
450
|
-
if base_hint:
|
|
451
|
-
lookup_hint = user_name or email_local_humanized or email_local_part or normalized_email
|
|
452
|
-
add_query(
|
|
453
|
-
f'site:linkedin.com/in "{normalized_email}" {base_hint} '
|
|
454
|
-
f'intitle:"{lookup_hint}" -intitle:"profiles"'
|
|
455
|
-
)
|
|
456
|
-
if email_local_humanized:
|
|
457
|
-
add_query(
|
|
458
|
-
f'site:linkedin.com/in "{email_local_humanized}" {base_hint} '
|
|
459
|
-
f'intitle:"{lookup_hint}" -intitle:"profiles"'
|
|
460
|
-
)
|
|
461
|
-
|
|
462
432
|
candidate_records: List[Dict[str, str]] = []
|
|
463
433
|
seen_links: Set[str] = set()
|
|
464
434
|
best_llm_choice: Optional[LinkedinCandidateChoice] = None
|
|
@@ -226,6 +226,35 @@ async def send_email_via_smtp_async(
|
|
|
226
226
|
msg["To"] = ctx.recipient
|
|
227
227
|
msg["Subject"] = ctx.subject
|
|
228
228
|
|
|
229
|
+
# Build envelope recipient list starting with primary
|
|
230
|
+
envelope_recipients = [ctx.recipient]
|
|
231
|
+
|
|
232
|
+
# Add extra To recipients
|
|
233
|
+
extra_to = getattr(ctx, "to_recipients", None) or []
|
|
234
|
+
if extra_to:
|
|
235
|
+
additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
|
|
236
|
+
additional = [a for a in additional if a and a.lower() != ctx.recipient.strip().lower()]
|
|
237
|
+
if additional:
|
|
238
|
+
del msg["To"]
|
|
239
|
+
msg["To"] = ", ".join([ctx.recipient] + additional)
|
|
240
|
+
envelope_recipients.extend(additional)
|
|
241
|
+
|
|
242
|
+
# Add CC recipients
|
|
243
|
+
cc_list = getattr(ctx, "cc_recipients", None) or []
|
|
244
|
+
if cc_list:
|
|
245
|
+
cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
|
|
246
|
+
cc_addrs = [a for a in cc_addrs if a]
|
|
247
|
+
if cc_addrs:
|
|
248
|
+
msg["Cc"] = ", ".join(cc_addrs)
|
|
249
|
+
envelope_recipients.extend(cc_addrs)
|
|
250
|
+
|
|
251
|
+
# Add BCC recipients (envelope only, no header)
|
|
252
|
+
bcc_list = getattr(ctx, "bcc_recipients", None) or []
|
|
253
|
+
if bcc_list:
|
|
254
|
+
bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
|
|
255
|
+
bcc_addrs = [a for a in bcc_addrs if a]
|
|
256
|
+
envelope_recipients.extend(bcc_addrs)
|
|
257
|
+
|
|
229
258
|
# Generate a real RFC 5322 Message-ID
|
|
230
259
|
domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
|
|
231
260
|
generated_id = f"<{uuid.uuid4()}@{domain_part}>"
|
|
@@ -236,6 +265,12 @@ async def send_email_via_smtp_async(
|
|
|
236
265
|
if not header or value is None:
|
|
237
266
|
continue
|
|
238
267
|
msg[header] = str(value)
|
|
268
|
+
# Preserve envelope delivery for Cc/Bcc/To addresses set via extra headers,
|
|
269
|
+
# since explicit recipients= overrides aiosmtplib's header extraction.
|
|
270
|
+
if header.lower() in ("to", "cc", "bcc"):
|
|
271
|
+
for _, addr in email.utils.getaddresses([str(value)]):
|
|
272
|
+
if addr and addr.lower() not in {a.lower() for a in envelope_recipients}:
|
|
273
|
+
envelope_recipients.append(addr)
|
|
239
274
|
|
|
240
275
|
smtp_kwargs = dict(
|
|
241
276
|
hostname=smtp_server,
|
|
@@ -253,7 +288,7 @@ async def send_email_via_smtp_async(
|
|
|
253
288
|
try:
|
|
254
289
|
# aiosmtplib.send returns a (code, response) tuple, but no server message ID.
|
|
255
290
|
# We rely on the real Message-ID we have just set.
|
|
256
|
-
await aiosmtplib.send(msg, **smtp_kwargs)
|
|
291
|
+
await aiosmtplib.send(msg, recipients=envelope_recipients, **smtp_kwargs)
|
|
257
292
|
logging.info("SMTP send OK – msg id %s", generated_id)
|
|
258
293
|
return generated_id
|
|
259
294
|
except Exception:
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Tests for SendEmailContext multi-recipient support (to/cc/bcc).
|
|
2
|
+
|
|
3
|
+
Validates that each provider function correctly handles the new
|
|
4
|
+
to_recipients, cc_recipients, and bcc_recipients fields on SendEmailContext.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import email
|
|
9
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from dhisana.schemas.common import (
|
|
14
|
+
BodyFormat,
|
|
15
|
+
EmailRecipient,
|
|
16
|
+
SendEmailContext,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Provider imports may fail when the full test suite poisons sys.modules
|
|
20
|
+
# (the stale build/ directory creates a competing dhisana namespace).
|
|
21
|
+
# Use importorskip so these tests are skipped rather than erroring.
|
|
22
|
+
google_oauth_tools = pytest.importorskip("dhisana.utils.google_oauth_tools")
|
|
23
|
+
send_email_using_google_oauth_async = google_oauth_tools.send_email_using_google_oauth_async
|
|
24
|
+
|
|
25
|
+
microsoft365_tools = pytest.importorskip("dhisana.utils.microsoft365_tools")
|
|
26
|
+
send_email_using_microsoft_graph_async = microsoft365_tools.send_email_using_microsoft_graph_async
|
|
27
|
+
|
|
28
|
+
smtp_email_tools = pytest.importorskip("dhisana.utils.smtp_email_tools")
|
|
29
|
+
send_email_via_smtp_async = smtp_email_tools.send_email_via_smtp_async
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Fixtures
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def basic_context():
|
|
38
|
+
"""SendEmailContext with a single primary recipient."""
|
|
39
|
+
return SendEmailContext(
|
|
40
|
+
recipient="primary@example.com",
|
|
41
|
+
subject="Test Subject",
|
|
42
|
+
body="Hello world",
|
|
43
|
+
sender_name="Sender",
|
|
44
|
+
sender_email="sender@example.com",
|
|
45
|
+
labels=None,
|
|
46
|
+
body_format=BodyFormat.TEXT,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def multi_recipient_context():
|
|
52
|
+
"""SendEmailContext with to/cc/bcc recipients."""
|
|
53
|
+
return SendEmailContext(
|
|
54
|
+
recipient="primary@example.com",
|
|
55
|
+
subject="Multi Recipient Test",
|
|
56
|
+
body="Hello everyone",
|
|
57
|
+
sender_name="Sender",
|
|
58
|
+
sender_email="sender@example.com",
|
|
59
|
+
labels=None,
|
|
60
|
+
body_format=BodyFormat.TEXT,
|
|
61
|
+
to_recipients=[
|
|
62
|
+
EmailRecipient(email="extra-to@example.com", name="Extra To"),
|
|
63
|
+
],
|
|
64
|
+
cc_recipients=[
|
|
65
|
+
EmailRecipient(email="cc1@example.com", name="CC One"),
|
|
66
|
+
EmailRecipient(email="cc2@example.com", name="CC Two"),
|
|
67
|
+
],
|
|
68
|
+
bcc_recipients=[
|
|
69
|
+
EmailRecipient(email="bcc@example.com", name="BCC One"),
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Schema tests
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
class TestSendEmailContextSchema:
|
|
79
|
+
"""Model-level tests for the new fields."""
|
|
80
|
+
|
|
81
|
+
def test_new_fields_default_to_none(self, basic_context):
|
|
82
|
+
assert basic_context.to_recipients is None
|
|
83
|
+
assert basic_context.cc_recipients is None
|
|
84
|
+
assert basic_context.bcc_recipients is None
|
|
85
|
+
|
|
86
|
+
def test_accepts_email_recipient_objects(self, multi_recipient_context):
|
|
87
|
+
assert len(multi_recipient_context.to_recipients) == 1
|
|
88
|
+
assert multi_recipient_context.to_recipients[0].email == "extra-to@example.com"
|
|
89
|
+
assert len(multi_recipient_context.cc_recipients) == 2
|
|
90
|
+
assert len(multi_recipient_context.bcc_recipients) == 1
|
|
91
|
+
|
|
92
|
+
def test_backward_compatible_serialization(self, basic_context):
|
|
93
|
+
"""Existing code that doesn't set the new fields should still serialize fine."""
|
|
94
|
+
data = basic_context.model_dump()
|
|
95
|
+
assert data["recipient"] == "primary@example.com"
|
|
96
|
+
assert data["to_recipients"] is None
|
|
97
|
+
assert data["cc_recipients"] is None
|
|
98
|
+
assert data["bcc_recipients"] is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Google OAuth (Gmail API) tests
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
class TestGoogleOAuthRecipients:
|
|
106
|
+
"""Verify Gmail MIME message includes to/cc/bcc headers."""
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_gmail_message_includes_cc_bcc(self, multi_recipient_context):
|
|
110
|
+
captured_raw = {}
|
|
111
|
+
|
|
112
|
+
with patch(
|
|
113
|
+
"dhisana.utils.google_oauth_tools.get_google_access_token",
|
|
114
|
+
return_value="fake-token",
|
|
115
|
+
), patch("httpx.AsyncClient") as mock_client_cls:
|
|
116
|
+
mock_resp = MagicMock()
|
|
117
|
+
mock_resp.status_code = 200
|
|
118
|
+
mock_resp.raise_for_status = MagicMock()
|
|
119
|
+
mock_resp.json.return_value = {"id": "gmail-msg-123"}
|
|
120
|
+
|
|
121
|
+
mock_client = AsyncMock()
|
|
122
|
+
|
|
123
|
+
async def fake_post(url, **kwargs):
|
|
124
|
+
captured_raw.update(kwargs.get("json", {}))
|
|
125
|
+
return mock_resp
|
|
126
|
+
|
|
127
|
+
mock_client.post = fake_post
|
|
128
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
129
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
130
|
+
mock_client_cls.return_value = mock_client
|
|
131
|
+
|
|
132
|
+
result = await send_email_using_google_oauth_async(
|
|
133
|
+
multi_recipient_context,
|
|
134
|
+
tool_config=[{"name": "google", "configuration": []}],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
assert result == "gmail-msg-123"
|
|
138
|
+
|
|
139
|
+
# Decode the raw MIME message
|
|
140
|
+
raw_b64 = captured_raw.get("raw", "")
|
|
141
|
+
mime_bytes = base64.urlsafe_b64decode(raw_b64)
|
|
142
|
+
parsed = email.message_from_bytes(mime_bytes)
|
|
143
|
+
|
|
144
|
+
# To header should include primary + extra-to
|
|
145
|
+
to_header = parsed["to"]
|
|
146
|
+
assert "primary@example.com" in to_header
|
|
147
|
+
assert "extra-to@example.com" in to_header
|
|
148
|
+
|
|
149
|
+
# CC header
|
|
150
|
+
cc_header = parsed.get("cc", "")
|
|
151
|
+
assert "cc1@example.com" in cc_header
|
|
152
|
+
assert "cc2@example.com" in cc_header
|
|
153
|
+
|
|
154
|
+
# BCC header (Gmail strips it before delivery, but it should be in the raw payload)
|
|
155
|
+
bcc_header = parsed.get("bcc", "")
|
|
156
|
+
assert "bcc@example.com" in bcc_header
|
|
157
|
+
|
|
158
|
+
@pytest.mark.asyncio
|
|
159
|
+
async def test_gmail_no_extra_headers_when_none(self, basic_context):
|
|
160
|
+
"""When no extra recipients, only To header should be present."""
|
|
161
|
+
captured_raw = {}
|
|
162
|
+
|
|
163
|
+
with patch(
|
|
164
|
+
"dhisana.utils.google_oauth_tools.get_google_access_token",
|
|
165
|
+
return_value="fake-token",
|
|
166
|
+
), patch("httpx.AsyncClient") as mock_client_cls:
|
|
167
|
+
mock_resp = MagicMock()
|
|
168
|
+
mock_resp.status_code = 200
|
|
169
|
+
mock_resp.raise_for_status = MagicMock()
|
|
170
|
+
mock_resp.json.return_value = {"id": "gmail-msg-456"}
|
|
171
|
+
|
|
172
|
+
mock_client = AsyncMock()
|
|
173
|
+
|
|
174
|
+
async def fake_post(url, **kwargs):
|
|
175
|
+
captured_raw.update(kwargs.get("json", {}))
|
|
176
|
+
return mock_resp
|
|
177
|
+
|
|
178
|
+
mock_client.post = fake_post
|
|
179
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
180
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
181
|
+
mock_client_cls.return_value = mock_client
|
|
182
|
+
|
|
183
|
+
await send_email_using_google_oauth_async(
|
|
184
|
+
basic_context,
|
|
185
|
+
tool_config=[{"name": "google", "configuration": []}],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
raw_b64 = captured_raw.get("raw", "")
|
|
189
|
+
mime_bytes = base64.urlsafe_b64decode(raw_b64)
|
|
190
|
+
parsed = email.message_from_bytes(mime_bytes)
|
|
191
|
+
|
|
192
|
+
assert parsed["to"] == "primary@example.com"
|
|
193
|
+
assert parsed.get("cc") is None
|
|
194
|
+
assert parsed.get("bcc") is None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Microsoft 365 tests
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
class TestMicrosoft365Recipients:
|
|
202
|
+
"""Verify Graph API payload includes cc/bcc/to recipients."""
|
|
203
|
+
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_graph_payload_includes_cc_bcc(self, multi_recipient_context):
|
|
206
|
+
captured_payload = {}
|
|
207
|
+
|
|
208
|
+
with patch(
|
|
209
|
+
"dhisana.utils.microsoft365_tools.get_microsoft365_access_token",
|
|
210
|
+
return_value="fake-token",
|
|
211
|
+
), patch(
|
|
212
|
+
"dhisana.utils.microsoft365_tools._base_resource",
|
|
213
|
+
return_value="/users/sender@example.com",
|
|
214
|
+
), patch("httpx.AsyncClient") as mock_client_cls:
|
|
215
|
+
mock_resp_send = MagicMock()
|
|
216
|
+
mock_resp_send.status_code = 202
|
|
217
|
+
mock_resp_send.raise_for_status = MagicMock()
|
|
218
|
+
|
|
219
|
+
mock_resp_list = MagicMock()
|
|
220
|
+
mock_resp_list.status_code = 200
|
|
221
|
+
mock_resp_list.raise_for_status = MagicMock()
|
|
222
|
+
mock_resp_list.json.return_value = {"value": []}
|
|
223
|
+
|
|
224
|
+
mock_client = AsyncMock()
|
|
225
|
+
|
|
226
|
+
async def fake_post(url, **kwargs):
|
|
227
|
+
captured_payload.update(kwargs.get("json", {}))
|
|
228
|
+
return mock_resp_send
|
|
229
|
+
|
|
230
|
+
mock_client.post = fake_post
|
|
231
|
+
mock_client.get = AsyncMock(return_value=mock_resp_list)
|
|
232
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
233
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
234
|
+
mock_client_cls.return_value = mock_client
|
|
235
|
+
|
|
236
|
+
await send_email_using_microsoft_graph_async(
|
|
237
|
+
multi_recipient_context,
|
|
238
|
+
tool_config=[{"name": "microsoft365", "configuration": []}],
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
msg = captured_payload.get("message", {})
|
|
242
|
+
|
|
243
|
+
# toRecipients should include primary + extra-to
|
|
244
|
+
to_addrs = [r["emailAddress"]["address"] for r in msg.get("toRecipients", [])]
|
|
245
|
+
assert "primary@example.com" in to_addrs
|
|
246
|
+
assert "extra-to@example.com" in to_addrs
|
|
247
|
+
|
|
248
|
+
# ccRecipients
|
|
249
|
+
cc_addrs = [r["emailAddress"]["address"] for r in msg.get("ccRecipients", [])]
|
|
250
|
+
assert "cc1@example.com" in cc_addrs
|
|
251
|
+
assert "cc2@example.com" in cc_addrs
|
|
252
|
+
|
|
253
|
+
# bccRecipients
|
|
254
|
+
bcc_addrs = [r["emailAddress"]["address"] for r in msg.get("bccRecipients", [])]
|
|
255
|
+
assert "bcc@example.com" in bcc_addrs
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_graph_no_extra_fields_when_none(self, basic_context):
|
|
259
|
+
"""When no extra recipients, payload should only have toRecipients."""
|
|
260
|
+
captured_payload = {}
|
|
261
|
+
|
|
262
|
+
with patch(
|
|
263
|
+
"dhisana.utils.microsoft365_tools.get_microsoft365_access_token",
|
|
264
|
+
return_value="fake-token",
|
|
265
|
+
), patch(
|
|
266
|
+
"dhisana.utils.microsoft365_tools._base_resource",
|
|
267
|
+
return_value="/users/sender@example.com",
|
|
268
|
+
), patch("httpx.AsyncClient") as mock_client_cls:
|
|
269
|
+
mock_resp = MagicMock()
|
|
270
|
+
mock_resp.status_code = 202
|
|
271
|
+
mock_resp.raise_for_status = MagicMock()
|
|
272
|
+
|
|
273
|
+
mock_resp_list = MagicMock()
|
|
274
|
+
mock_resp_list.status_code = 200
|
|
275
|
+
mock_resp_list.raise_for_status = MagicMock()
|
|
276
|
+
mock_resp_list.json.return_value = {"value": []}
|
|
277
|
+
|
|
278
|
+
mock_client = AsyncMock()
|
|
279
|
+
|
|
280
|
+
async def fake_post(url, **kwargs):
|
|
281
|
+
captured_payload.update(kwargs.get("json", {}))
|
|
282
|
+
return mock_resp
|
|
283
|
+
|
|
284
|
+
mock_client.post = fake_post
|
|
285
|
+
mock_client.get = AsyncMock(return_value=mock_resp_list)
|
|
286
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
287
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
288
|
+
mock_client_cls.return_value = mock_client
|
|
289
|
+
|
|
290
|
+
await send_email_using_microsoft_graph_async(
|
|
291
|
+
basic_context,
|
|
292
|
+
tool_config=[{"name": "microsoft365", "configuration": []}],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
msg = captured_payload.get("message", {})
|
|
296
|
+
assert len(msg["toRecipients"]) == 1
|
|
297
|
+
assert "ccRecipients" not in msg
|
|
298
|
+
assert "bccRecipients" not in msg
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# SMTP tests
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
class TestSMTPRecipients:
|
|
306
|
+
"""Verify SMTP adds Cc header and includes all envelope recipients."""
|
|
307
|
+
|
|
308
|
+
@pytest.mark.asyncio
|
|
309
|
+
async def test_smtp_envelope_includes_all_recipients(self, multi_recipient_context):
|
|
310
|
+
captured_kwargs = {}
|
|
311
|
+
captured_recipients = []
|
|
312
|
+
|
|
313
|
+
async def fake_send(msg, *, recipients=None, **kwargs):
|
|
314
|
+
captured_kwargs.update(kwargs)
|
|
315
|
+
if recipients:
|
|
316
|
+
captured_recipients.extend(recipients)
|
|
317
|
+
# Parse the MIME message to verify headers
|
|
318
|
+
parsed = email.message_from_string(msg.as_string())
|
|
319
|
+
captured_kwargs["_parsed_to"] = parsed["To"]
|
|
320
|
+
captured_kwargs["_parsed_cc"] = parsed.get("Cc", "")
|
|
321
|
+
# BCC should NOT be in headers
|
|
322
|
+
captured_kwargs["_parsed_bcc"] = parsed.get("Bcc")
|
|
323
|
+
|
|
324
|
+
with patch("dhisana.utils.smtp_email_tools.aiosmtplib.send", side_effect=fake_send):
|
|
325
|
+
result = await send_email_via_smtp_async(
|
|
326
|
+
multi_recipient_context,
|
|
327
|
+
smtp_server="smtp.example.com",
|
|
328
|
+
smtp_port=587,
|
|
329
|
+
username="user",
|
|
330
|
+
password="pass",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
assert result # Should return a message ID
|
|
334
|
+
|
|
335
|
+
# Envelope recipients include all addresses
|
|
336
|
+
assert "primary@example.com" in captured_recipients
|
|
337
|
+
assert "extra-to@example.com" in captured_recipients
|
|
338
|
+
assert "cc1@example.com" in captured_recipients
|
|
339
|
+
assert "cc2@example.com" in captured_recipients
|
|
340
|
+
assert "bcc@example.com" in captured_recipients
|
|
341
|
+
|
|
342
|
+
# Cc header present
|
|
343
|
+
assert "cc1@example.com" in captured_kwargs["_parsed_cc"]
|
|
344
|
+
assert "cc2@example.com" in captured_kwargs["_parsed_cc"]
|
|
345
|
+
|
|
346
|
+
# BCC should NOT appear in headers
|
|
347
|
+
assert captured_kwargs["_parsed_bcc"] is None
|
|
348
|
+
|
|
349
|
+
@pytest.mark.asyncio
|
|
350
|
+
async def test_smtp_basic_send_unchanged(self, basic_context):
|
|
351
|
+
"""Without extra recipients, SMTP sends to single recipient as before."""
|
|
352
|
+
captured_recipients = []
|
|
353
|
+
|
|
354
|
+
async def fake_send(msg, *, recipients=None, **kwargs):
|
|
355
|
+
if recipients:
|
|
356
|
+
captured_recipients.extend(recipients)
|
|
357
|
+
|
|
358
|
+
with patch("dhisana.utils.smtp_email_tools.aiosmtplib.send", side_effect=fake_send):
|
|
359
|
+
await send_email_via_smtp_async(
|
|
360
|
+
basic_context,
|
|
361
|
+
smtp_server="smtp.example.com",
|
|
362
|
+
smtp_port=587,
|
|
363
|
+
username="user",
|
|
364
|
+
password="pass",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
assert captured_recipients == ["primary@example.com"]
|
|
368
|
+
|
|
369
|
+
@pytest.mark.asyncio
|
|
370
|
+
async def test_smtp_headers_cc_bcc_included_in_envelope(self):
|
|
371
|
+
"""Cc/Bcc set via ctx.headers must also appear in envelope recipients."""
|
|
372
|
+
ctx = SendEmailContext(
|
|
373
|
+
recipient="primary@example.com",
|
|
374
|
+
subject="Header CC Test",
|
|
375
|
+
body="body",
|
|
376
|
+
sender_name="Sender",
|
|
377
|
+
sender_email="sender@example.com",
|
|
378
|
+
labels=None,
|
|
379
|
+
body_format=BodyFormat.TEXT,
|
|
380
|
+
headers={
|
|
381
|
+
"Cc": "header-cc@example.com",
|
|
382
|
+
"Bcc": "header-bcc@example.com",
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
captured_recipients = []
|
|
387
|
+
|
|
388
|
+
async def fake_send(msg, *, recipients=None, **kwargs):
|
|
389
|
+
if recipients:
|
|
390
|
+
captured_recipients.extend(recipients)
|
|
391
|
+
|
|
392
|
+
with patch("dhisana.utils.smtp_email_tools.aiosmtplib.send", side_effect=fake_send):
|
|
393
|
+
await send_email_via_smtp_async(
|
|
394
|
+
ctx,
|
|
395
|
+
smtp_server="smtp.example.com",
|
|
396
|
+
smtp_port=587,
|
|
397
|
+
username="user",
|
|
398
|
+
password="pass",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
assert "primary@example.com" in captured_recipients
|
|
402
|
+
assert "header-cc@example.com" in captured_recipients
|
|
403
|
+
assert "header-bcc@example.com" in captured_recipients
|
|
File without changes
|
|
File without changes
|
|
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.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_email_validity_tools.py
RENAMED
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/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.dev303 → dhisana-0.0.1.dev305}/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
|
{dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/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.dev303 → dhisana-0.0.1.dev305}/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
|
{dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/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.dev303 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/openapi_tool.py
RENAMED
|
File without changes
|
{dhisana-0.0.1.dev303 → dhisana-0.0.1.dev305}/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.dev303 → dhisana-0.0.1.dev305}/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.dev303 → dhisana-0.0.1.dev305}/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
|