dhisana 0.0.1.dev304__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.dev304 → dhisana-0.0.1.dev305}/PKG-INFO +1 -1
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/setup.py +1 -1
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/schemas/common.py +7 -2
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_oauth_tools.py +21 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/microsoft365_tools.py +29 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/smtp_email_tools.py +36 -1
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/PKG-INFO +1 -1
- {dhisana-0.0.1.dev304 → 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.dev304 → dhisana-0.0.1.dev305}/README.md +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/pyproject.toml +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/setup.cfg +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/cli.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/datasets.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/models.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/cli/predictions.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/schemas/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/schemas/sales.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/ui/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/ui/components.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/add_mapping.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/agent_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/apollo_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/assistant_tool_tag.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/built_with_api_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/cache_output_tools_local.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_email_validity_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_for_intent_signal.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/clay_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/clean_properties.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/company_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_salesnav_query.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_search_query.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/composite_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/dataframe_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/domain_parser.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_body_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_parse_helpers.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/email_provider.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/enrich_lead_information.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/fetch_openai_config.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/field_validators.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/g2_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_content.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_custom_message.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_email_response.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_flow.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_custom_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/google_workspace_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_clearbit.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/instantly_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/linkedin_crawler.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/lusha_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailgun_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/mailreach_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openai_helpers.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/profile.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxy_curl_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/python_function_to_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/research_lead.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/search_router_jobs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/sendgrid_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serarch_router_local_business.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_google_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serpapi_search_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_local_business.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/serperdev_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/test_connect.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/trasform_json.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/web_download_parse_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/workflow_code_model.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/zoominfo_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/__init__.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/agent.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/flow.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/task.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana/workflow/test.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/dependency_links.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/entry_points.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/requires.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/src/dhisana.egg-info/top_level.txt +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_agent_tools.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_apollo_company_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_apollo_lead_search.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_connectivity.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_email_body_utils.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_generate_email.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_google_document.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_hubspot_call_logs.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_linkedin_serper.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_mailreach.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_mcp_connectivity.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_normalize_graph_datetime.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_proxycurl_get_company_search_id.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_proxycurl_job_count.py +0 -0
- {dhisana-0.0.1.dev304 → dhisana-0.0.1.dev305}/tests/test_reply_thread_fallback.py +0 -0
- {dhisana-0.0.1.dev304 → 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."""
|
|
@@ -210,6 +210,27 @@ async def send_email_using_google_oauth_async(
|
|
|
210
210
|
message["from"] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
|
|
211
211
|
message["subject"] = send_email_context.subject
|
|
212
212
|
|
|
213
|
+
# Add extra To recipients
|
|
214
|
+
extra_to = getattr(send_email_context, "to_recipients", None) or []
|
|
215
|
+
if extra_to:
|
|
216
|
+
additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
|
|
217
|
+
additional = [a for a in additional if a and a.lower() != send_email_context.recipient.strip().lower()]
|
|
218
|
+
if additional:
|
|
219
|
+
del message["to"]
|
|
220
|
+
message["to"] = ", ".join([send_email_context.recipient] + additional)
|
|
221
|
+
|
|
222
|
+
# Add CC recipients
|
|
223
|
+
cc_list = getattr(send_email_context, "cc_recipients", None) or []
|
|
224
|
+
if cc_list:
|
|
225
|
+
cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
|
|
226
|
+
message["cc"] = ", ".join(a for a in cc_addrs if a)
|
|
227
|
+
|
|
228
|
+
# Add BCC recipients (header set for envelope; Gmail strips it before delivery)
|
|
229
|
+
bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
|
|
230
|
+
if bcc_list:
|
|
231
|
+
bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
|
|
232
|
+
message["bcc"] = ", ".join(a for a in bcc_addrs if a)
|
|
233
|
+
|
|
213
234
|
extra_headers = getattr(send_email_context, "headers", None) or {}
|
|
214
235
|
for header, value in extra_headers.items():
|
|
215
236
|
if not header or value is None:
|
|
@@ -227,6 +227,35 @@ async def send_email_using_microsoft_graph_async(
|
|
|
227
227
|
],
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
# Merge additional To recipients from the new field
|
|
231
|
+
extra_to = getattr(send_email_context, "to_recipients", None) or []
|
|
232
|
+
for r in extra_to:
|
|
233
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
234
|
+
if addr and addr.lower() != send_email_context.recipient.strip().lower():
|
|
235
|
+
message_payload["toRecipients"].append({"emailAddress": {"address": addr}})
|
|
236
|
+
|
|
237
|
+
# CC recipients
|
|
238
|
+
cc_list = getattr(send_email_context, "cc_recipients", None) or []
|
|
239
|
+
if cc_list:
|
|
240
|
+
cc_entries = []
|
|
241
|
+
for r in cc_list:
|
|
242
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
243
|
+
if addr:
|
|
244
|
+
cc_entries.append({"emailAddress": {"address": addr}})
|
|
245
|
+
if cc_entries:
|
|
246
|
+
message_payload["ccRecipients"] = cc_entries
|
|
247
|
+
|
|
248
|
+
# BCC recipients
|
|
249
|
+
bcc_list = getattr(send_email_context, "bcc_recipients", None) or []
|
|
250
|
+
if bcc_list:
|
|
251
|
+
bcc_entries = []
|
|
252
|
+
for r in bcc_list:
|
|
253
|
+
addr = r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r))
|
|
254
|
+
if addr:
|
|
255
|
+
bcc_entries.append({"emailAddress": {"address": addr}})
|
|
256
|
+
if bcc_entries:
|
|
257
|
+
message_payload["bccRecipients"] = bcc_entries
|
|
258
|
+
|
|
230
259
|
extra_headers = getattr(send_email_context, "headers", None) or {}
|
|
231
260
|
if extra_headers:
|
|
232
261
|
message_payload["internetMessageHeaders"] = [
|
|
@@ -226,6 +226,35 @@ async def send_email_via_smtp_async(
|
|
|
226
226
|
msg["To"] = ctx.recipient
|
|
227
227
|
msg["Subject"] = ctx.subject
|
|
228
228
|
|
|
229
|
+
# Build envelope recipient list starting with primary
|
|
230
|
+
envelope_recipients = [ctx.recipient]
|
|
231
|
+
|
|
232
|
+
# Add extra To recipients
|
|
233
|
+
extra_to = getattr(ctx, "to_recipients", None) or []
|
|
234
|
+
if extra_to:
|
|
235
|
+
additional = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in extra_to]
|
|
236
|
+
additional = [a for a in additional if a and a.lower() != ctx.recipient.strip().lower()]
|
|
237
|
+
if additional:
|
|
238
|
+
del msg["To"]
|
|
239
|
+
msg["To"] = ", ".join([ctx.recipient] + additional)
|
|
240
|
+
envelope_recipients.extend(additional)
|
|
241
|
+
|
|
242
|
+
# Add CC recipients
|
|
243
|
+
cc_list = getattr(ctx, "cc_recipients", None) or []
|
|
244
|
+
if cc_list:
|
|
245
|
+
cc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in cc_list]
|
|
246
|
+
cc_addrs = [a for a in cc_addrs if a]
|
|
247
|
+
if cc_addrs:
|
|
248
|
+
msg["Cc"] = ", ".join(cc_addrs)
|
|
249
|
+
envelope_recipients.extend(cc_addrs)
|
|
250
|
+
|
|
251
|
+
# Add BCC recipients (envelope only, no header)
|
|
252
|
+
bcc_list = getattr(ctx, "bcc_recipients", None) or []
|
|
253
|
+
if bcc_list:
|
|
254
|
+
bcc_addrs = [r.email if hasattr(r, "email") else (r.get("email") if isinstance(r, dict) else str(r)) for r in bcc_list]
|
|
255
|
+
bcc_addrs = [a for a in bcc_addrs if a]
|
|
256
|
+
envelope_recipients.extend(bcc_addrs)
|
|
257
|
+
|
|
229
258
|
# Generate a real RFC 5322 Message-ID
|
|
230
259
|
domain_part = ctx.sender_email.split("@", 1)[-1] or "local"
|
|
231
260
|
generated_id = f"<{uuid.uuid4()}@{domain_part}>"
|
|
@@ -236,6 +265,12 @@ async def send_email_via_smtp_async(
|
|
|
236
265
|
if not header or value is None:
|
|
237
266
|
continue
|
|
238
267
|
msg[header] = str(value)
|
|
268
|
+
# Preserve envelope delivery for Cc/Bcc/To addresses set via extra headers,
|
|
269
|
+
# since explicit recipients= overrides aiosmtplib's header extraction.
|
|
270
|
+
if header.lower() in ("to", "cc", "bcc"):
|
|
271
|
+
for _, addr in email.utils.getaddresses([str(value)]):
|
|
272
|
+
if addr and addr.lower() not in {a.lower() for a in envelope_recipients}:
|
|
273
|
+
envelope_recipients.append(addr)
|
|
239
274
|
|
|
240
275
|
smtp_kwargs = dict(
|
|
241
276
|
hostname=smtp_server,
|
|
@@ -253,7 +288,7 @@ async def send_email_via_smtp_async(
|
|
|
253
288
|
try:
|
|
254
289
|
# aiosmtplib.send returns a (code, response) tuple, but no server message ID.
|
|
255
290
|
# We rely on the real Message-ID we have just set.
|
|
256
|
-
await aiosmtplib.send(msg, **smtp_kwargs)
|
|
291
|
+
await aiosmtplib.send(msg, recipients=envelope_recipients, **smtp_kwargs)
|
|
257
292
|
logging.info("SMTP send OK – msg id %s", generated_id)
|
|
258
293
|
return generated_id
|
|
259
294
|
except Exception:
|
|
@@ -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.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/check_email_validity_tools.py
RENAMED
|
File without changes
|
|
File without changes
|
{dhisana-0.0.1.dev304 → 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.dev304 → 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
|
|
File without changes
|
{dhisana-0.0.1.dev304 → 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.dev304 → 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.dev304 → 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.dev304 → dhisana-0.0.1.dev305}/src/dhisana/utils/openapi_tool/openapi_tool.py
RENAMED
|
File without changes
|
{dhisana-0.0.1.dev304 → 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.dev304 → 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.dev304 → 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
|
|
File without changes
|