dhisana 0.0.1.dev279__tar.gz → 0.0.1.dev280__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.dev279 → dhisana-0.0.1.dev280}/PKG-INFO +1 -1
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/setup.py +1 -1
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/schemas/common.py +14 -1
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/google_oauth_tools.py +179 -22
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/google_workspace_tools.py +182 -26
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/microsoft365_tools.py +190 -42
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/smtp_email_tools.py +190 -20
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/PKG-INFO +1 -1
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/README.md +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/pyproject.toml +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/setup.cfg +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/__init__.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/__init__.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/cli.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/datasets.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/models.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/cli/predictions.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/schemas/__init__.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/schemas/sales.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/ui/__init__.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/ui/components.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/__init__.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/add_mapping.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/agent_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/apollo_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/assistant_tool_tag.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/built_with_api_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/cache_output_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/cache_output_tools_local.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/check_email_validity_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/check_for_intent_signal.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/check_linkedin_url_validity.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/clay_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/clean_properties.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/company_utils.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/compose_salesnav_query.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/compose_search_query.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/compose_three_step_workflow.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/composite_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/dataframe_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/domain_parser.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/email_body_utils.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/email_parse_helpers.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/email_provider.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/enrich_lead_information.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/extract_email_content_for_llm.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/fetch_openai_config.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/field_validators.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/g2_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_content.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_custom_message.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_email.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_email_response.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_flow.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_leads_salesnav.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_linkedin_connect_message.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_linkedin_response_message.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/generate_structured_output_internal.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/google_custom_search.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/hubspot_clearbit.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/hubspot_crm_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/instantly_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/linkedin_crawler.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/lusha_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/mailgun_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/mailreach_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openai_assistant_and_file_utils.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openai_helpers.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_spec_to_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/__init__.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/api_models.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/openapi_tool/openapi_tool.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/parse_linkedin_messages_txt.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/profile.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/proxy_curl_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/proxycurl_search_leads.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/python_function_to_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/research_lead.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/sales_navigator_crawler.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/salesforce_crm_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/search_router.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/search_router_jobs.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/sendgrid_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serarch_router_local_business.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_additional_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_google_jobs.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_google_search.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_local_business_search.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serpapi_search_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serperdev_google_jobs.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serperdev_local_business.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/serperdev_search.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/test_connect.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/trasform_json.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/web_download_parse_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/workflow_code_model.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/utils/zoominfo_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/__init__.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/agent.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/flow.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/task.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana/workflow/test.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/SOURCES.txt +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/dependency_links.txt +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/entry_points.txt +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/requires.txt +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/src/dhisana.egg-info/top_level.txt +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_agent_tools.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_apollo_company_search.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_apollo_lead_search.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_connectivity.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_email_body_utils.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_google_document.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_hubspot_call_logs.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_linkedin_serper.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_mailreach.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_mcp_connectivity.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_proxycurl_get_company_search_id.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_proxycurl_job_count.py +0 -0
- {dhisana-0.0.1.dev279 → dhisana-0.0.1.dev280}/tests/test_structured_output_with_mcp.py +0 -0
|
@@ -389,13 +389,26 @@ class QueryEmailContext(BaseModel):
|
|
|
389
389
|
labels: Optional[List[str]] = None
|
|
390
390
|
|
|
391
391
|
|
|
392
|
+
class EmailRecipient(BaseModel):
|
|
393
|
+
email: str
|
|
394
|
+
name: Optional[str] = None
|
|
395
|
+
|
|
396
|
+
|
|
392
397
|
class ReplyEmailContext(BaseModel):
|
|
398
|
+
"""Context for replying to or forwarding an email."""
|
|
393
399
|
message_id: str
|
|
394
400
|
reply_body: str
|
|
395
401
|
sender_email: str
|
|
396
|
-
sender_name: str
|
|
402
|
+
sender_name: Optional[str] = None
|
|
397
403
|
headers: Optional[Dict[str, str]] = None
|
|
398
404
|
fallback_recipient: Optional[str] = None
|
|
399
405
|
mark_as_read: str = "True"
|
|
400
406
|
add_labels: Optional[List[str]] = None
|
|
401
407
|
reply_body_format: BodyFormat = BodyFormat.AUTO
|
|
408
|
+
to_recipients: Optional[List[EmailRecipient]] = None
|
|
409
|
+
cc_recipients: Optional[List[EmailRecipient]] = None
|
|
410
|
+
bcc_recipients: Optional[List[EmailRecipient]] = None
|
|
411
|
+
# Type of reply: "reply", "reply_all", or "forward". Defaults to "reply" if not specified.
|
|
412
|
+
reply_type: Optional[str] = None
|
|
413
|
+
# Custom subject line (primarily used for forward operations to override the default "Fwd: ..." prefix)
|
|
414
|
+
subject: Optional[str] = None
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import re
|
|
5
5
|
from email.mime.multipart import MIMEMultipart
|
|
6
6
|
from email.mime.text import MIMEText
|
|
7
|
+
from email.utils import getaddresses
|
|
7
8
|
from typing import Any, Dict, List, Optional
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
@@ -78,6 +79,70 @@ def _rethrow_with_google_message(exc: httpx.HTTPStatusError, context: str) -> No
|
|
|
78
79
|
)
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def _normalize_recipient(recipient: Any) -> Optional[Dict[str, str]]:
|
|
83
|
+
if recipient is None:
|
|
84
|
+
return None
|
|
85
|
+
if isinstance(recipient, dict):
|
|
86
|
+
email = recipient.get("email") or recipient.get("address")
|
|
87
|
+
name = recipient.get("name")
|
|
88
|
+
else:
|
|
89
|
+
email = getattr(recipient, "email", None) or getattr(recipient, "address", None)
|
|
90
|
+
name = getattr(recipient, "name", None)
|
|
91
|
+
if isinstance(recipient, str):
|
|
92
|
+
email = recipient
|
|
93
|
+
name = None
|
|
94
|
+
if not email:
|
|
95
|
+
return None
|
|
96
|
+
return {"email": email, "name": name}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_recipients(recipients: Optional[List[Any]]) -> List[Dict[str, str]]:
|
|
100
|
+
normalized: List[Dict[str, str]] = []
|
|
101
|
+
for recipient in recipients or []:
|
|
102
|
+
item = _normalize_recipient(recipient)
|
|
103
|
+
if item:
|
|
104
|
+
normalized.append(item)
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_header_recipients(header_value: str) -> List[Dict[str, str]]:
|
|
109
|
+
parsed = getaddresses([header_value]) if header_value else []
|
|
110
|
+
return [{"email": address, "name": name} for name, address in parsed if address]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _dedupe_recipients(
|
|
114
|
+
recipients: List[Dict[str, str]],
|
|
115
|
+
exclude_emails: Optional[List[str]] = None,
|
|
116
|
+
) -> List[Dict[str, str]]:
|
|
117
|
+
excluded = {email.lower() for email in (exclude_emails or []) if email}
|
|
118
|
+
seen: set[str] = set()
|
|
119
|
+
result: List[Dict[str, str]] = []
|
|
120
|
+
for recipient in recipients:
|
|
121
|
+
email = (recipient.get("email") or "").strip()
|
|
122
|
+
if not email:
|
|
123
|
+
continue
|
|
124
|
+
email_lc = email.lower()
|
|
125
|
+
if email_lc in seen or email_lc in excluded:
|
|
126
|
+
continue
|
|
127
|
+
seen.add(email_lc)
|
|
128
|
+
result.append({"email": email, "name": recipient.get("name")})
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _recipients_to_header(recipients: List[Dict[str, str]]) -> str:
|
|
133
|
+
parts: List[str] = []
|
|
134
|
+
for recipient in recipients:
|
|
135
|
+
name = recipient.get("name")
|
|
136
|
+
email = recipient.get("email")
|
|
137
|
+
if not email:
|
|
138
|
+
continue
|
|
139
|
+
if name:
|
|
140
|
+
parts.append(f"{name} <{email}>")
|
|
141
|
+
else:
|
|
142
|
+
parts.append(email)
|
|
143
|
+
return ", ".join(parts)
|
|
144
|
+
|
|
145
|
+
|
|
81
146
|
def get_google_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
82
147
|
"""
|
|
83
148
|
Retrieve a Google OAuth2 access token from the 'google' integration config.
|
|
@@ -291,8 +356,6 @@ async def reply_to_email_google_oauth_async(
|
|
|
291
356
|
headers_list = (original.get("payload") or {}).get("headers", [])
|
|
292
357
|
# Use case-insensitive lookups via find_header to avoid missing values on header casing differences.
|
|
293
358
|
subject = find_header(headers_list, "Subject") or ""
|
|
294
|
-
if not subject.startswith("Re:"):
|
|
295
|
-
subject = f"Re: {subject}"
|
|
296
359
|
reply_to_header = find_header(headers_list, "Reply-To") or ""
|
|
297
360
|
from_header = find_header(headers_list, "From") or ""
|
|
298
361
|
to_header = find_header(headers_list, "To") or ""
|
|
@@ -300,31 +363,120 @@ async def reply_to_email_google_oauth_async(
|
|
|
300
363
|
message_id_header = find_header(headers_list, "Message-ID") or ""
|
|
301
364
|
thread_id = original.get("threadId")
|
|
302
365
|
|
|
366
|
+
reply_type = (reply_email_context.reply_type or "reply").lower()
|
|
367
|
+
if reply_type not in {"reply", "reply_all", "forward"}:
|
|
368
|
+
reply_type = "reply"
|
|
369
|
+
|
|
303
370
|
sender_email_lc = (reply_email_context.sender_email or "").lower()
|
|
304
371
|
|
|
305
372
|
def _is_self(addr: str) -> bool:
|
|
306
373
|
return bool(sender_email_lc) and sender_email_lc in addr.lower()
|
|
307
374
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
375
|
+
explicit_to = _normalize_recipients(reply_email_context.to_recipients)
|
|
376
|
+
explicit_cc = _normalize_recipients(reply_email_context.cc_recipients)
|
|
377
|
+
explicit_bcc = _normalize_recipients(reply_email_context.bcc_recipients)
|
|
378
|
+
|
|
379
|
+
to_recipients: List[Dict[str, str]] = []
|
|
380
|
+
cc_recipients: List[Dict[str, str]] = []
|
|
381
|
+
bcc_recipients: List[Dict[str, str]] = []
|
|
382
|
+
|
|
383
|
+
if reply_type == "forward":
|
|
384
|
+
if not explicit_to:
|
|
385
|
+
raise ValueError("Forward requires explicit to_recipients.")
|
|
386
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
387
|
+
cc_recipients = _dedupe_recipients(explicit_cc)
|
|
388
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
389
|
+
|
|
390
|
+
if reply_email_context.subject:
|
|
391
|
+
subject = reply_email_context.subject
|
|
392
|
+
elif subject and not subject.lower().startswith("fwd:"):
|
|
393
|
+
subject = f"Fwd: {subject}"
|
|
394
|
+
else:
|
|
395
|
+
subject = subject or "Fwd:"
|
|
317
396
|
else:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
397
|
+
if not subject.startswith("Re:"):
|
|
398
|
+
subject = f"Re: {subject}"
|
|
399
|
+
|
|
400
|
+
if reply_type == "reply_all":
|
|
401
|
+
base_to = _dedupe_recipients(
|
|
402
|
+
_parse_header_recipients(from_header),
|
|
403
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
404
|
+
)
|
|
405
|
+
if not base_to:
|
|
406
|
+
base_to = _dedupe_recipients(
|
|
407
|
+
_parse_header_recipients(reply_to_header),
|
|
408
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
409
|
+
)
|
|
410
|
+
if not base_to:
|
|
411
|
+
base_to = _dedupe_recipients(
|
|
412
|
+
_parse_header_recipients(to_header),
|
|
413
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
414
|
+
)
|
|
415
|
+
if not base_to and reply_email_context.fallback_recipient:
|
|
416
|
+
base_to = _dedupe_recipients(
|
|
417
|
+
[{"email": reply_email_context.fallback_recipient}],
|
|
418
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
419
|
+
)
|
|
420
|
+
if not base_to:
|
|
421
|
+
raise ValueError(
|
|
422
|
+
"No valid recipient found in the original message; refusing to reply to sender."
|
|
423
|
+
)
|
|
424
|
+
base_to_emails = [recipient.get("email") for recipient in base_to if recipient.get("email")]
|
|
425
|
+
base_cc_candidates = _parse_header_recipients(to_header) + _parse_header_recipients(cc_header)
|
|
426
|
+
base_cc = _dedupe_recipients(
|
|
427
|
+
base_cc_candidates,
|
|
428
|
+
exclude_emails=[reply_email_context.sender_email] + base_to_emails,
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
cc_header_value = cc_header or ""
|
|
432
|
+
# Prefer Reply-To unless it points back to the sender. If the original was SENT mail,
|
|
433
|
+
# From will equal the sender, so we should reply to the original To/CC instead.
|
|
434
|
+
if reply_to_header and not _is_self(reply_to_header):
|
|
435
|
+
to_header_value = reply_to_header
|
|
436
|
+
elif from_header and not _is_self(from_header):
|
|
437
|
+
to_header_value = from_header
|
|
438
|
+
elif to_header and not _is_self(to_header):
|
|
439
|
+
to_header_value = to_header
|
|
440
|
+
else:
|
|
441
|
+
to_header_value = ", ".join([v for v in (to_header, cc_header, from_header) if v])
|
|
442
|
+
cc_header_value = ""
|
|
443
|
+
|
|
444
|
+
base_to = _dedupe_recipients(
|
|
445
|
+
_parse_header_recipients(to_header_value),
|
|
446
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
447
|
+
)
|
|
448
|
+
base_cc = _dedupe_recipients(
|
|
449
|
+
_parse_header_recipients(cc_header_value),
|
|
450
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if not base_to and reply_email_context.fallback_recipient and not _is_self(reply_email_context.fallback_recipient):
|
|
454
|
+
base_to = _dedupe_recipients([{"email": reply_email_context.fallback_recipient}])
|
|
455
|
+
base_cc = []
|
|
456
|
+
|
|
457
|
+
if not base_to:
|
|
458
|
+
raise ValueError(
|
|
459
|
+
"No valid recipient found in the original message; refusing to reply to sender."
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
to_recipients = base_to
|
|
463
|
+
if explicit_to:
|
|
464
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
465
|
+
|
|
466
|
+
cc_recipients = base_cc
|
|
467
|
+
if explicit_cc:
|
|
468
|
+
cc_recipients = _dedupe_recipients(
|
|
469
|
+
base_cc + explicit_cc,
|
|
470
|
+
exclude_emails=[recipient.get("email") for recipient in to_recipients if recipient.get("email")],
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
321
474
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
cc_addresses = ""
|
|
475
|
+
to_addresses = _recipients_to_header(to_recipients)
|
|
476
|
+
cc_addresses = _recipients_to_header(cc_recipients)
|
|
477
|
+
bcc_addresses = _recipients_to_header(bcc_recipients)
|
|
326
478
|
|
|
327
|
-
if not to_addresses
|
|
479
|
+
if not to_addresses:
|
|
328
480
|
raise ValueError(
|
|
329
481
|
"No valid recipient found in the original message; refusing to reply to sender."
|
|
330
482
|
)
|
|
@@ -344,15 +496,18 @@ async def reply_to_email_google_oauth_async(
|
|
|
344
496
|
msg["To"] = to_addresses
|
|
345
497
|
if cc_addresses:
|
|
346
498
|
msg["Cc"] = cc_addresses
|
|
347
|
-
|
|
499
|
+
if bcc_addresses:
|
|
500
|
+
msg["Bcc"] = bcc_addresses
|
|
501
|
+
sender_display = reply_email_context.sender_name or reply_email_context.sender_email
|
|
502
|
+
msg["From"] = f"{sender_display} <{reply_email_context.sender_email}>"
|
|
348
503
|
msg["Subject"] = subject
|
|
349
|
-
if message_id_header:
|
|
504
|
+
if reply_type != "forward" and message_id_header:
|
|
350
505
|
msg["In-Reply-To"] = message_id_header
|
|
351
506
|
msg["References"] = message_id_header
|
|
352
507
|
|
|
353
508
|
raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode()
|
|
354
509
|
payload = {"raw": raw_message}
|
|
355
|
-
if thread_id:
|
|
510
|
+
if thread_id and reply_type != "forward":
|
|
356
511
|
payload["threadId"] = thread_id
|
|
357
512
|
|
|
358
513
|
# 3) Send the reply
|
|
@@ -391,6 +546,8 @@ async def reply_to_email_google_oauth_async(
|
|
|
391
546
|
"email_subject": subject,
|
|
392
547
|
"email_sender": reply_email_context.sender_email,
|
|
393
548
|
"email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
|
|
549
|
+
"to_recipients": to_recipients,
|
|
550
|
+
"cc_recipients": cc_recipients,
|
|
394
551
|
"read_email_status": "READ" if str(reply_email_context.mark_as_read).lower() == "true" else "UNREAD",
|
|
395
552
|
"email_labels": sent.get("labelIds", []),
|
|
396
553
|
}
|
|
@@ -10,6 +10,7 @@ import re
|
|
|
10
10
|
import uuid
|
|
11
11
|
from email.mime.multipart import MIMEMultipart
|
|
12
12
|
from email.mime.text import MIMEText
|
|
13
|
+
from email.utils import getaddresses
|
|
13
14
|
from typing import Any, Dict, List, Optional
|
|
14
15
|
|
|
15
16
|
import httpx
|
|
@@ -33,6 +34,69 @@ from dhisana.schemas.common import (SendEmailContext, QueryEmailContext, ReplyEm
|
|
|
33
34
|
# HELPER FUNCTIONS
|
|
34
35
|
################################################################################
|
|
35
36
|
|
|
37
|
+
def _normalize_recipient(recipient: Any) -> Optional[Dict[str, str]]:
|
|
38
|
+
if recipient is None:
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(recipient, dict):
|
|
41
|
+
email = recipient.get("email") or recipient.get("address")
|
|
42
|
+
name = recipient.get("name")
|
|
43
|
+
else:
|
|
44
|
+
email = getattr(recipient, "email", None) or getattr(recipient, "address", None)
|
|
45
|
+
name = getattr(recipient, "name", None)
|
|
46
|
+
if isinstance(recipient, str):
|
|
47
|
+
email = recipient
|
|
48
|
+
name = None
|
|
49
|
+
if not email:
|
|
50
|
+
return None
|
|
51
|
+
return {"email": email, "name": name}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_recipients(recipients: Optional[List[Any]]) -> List[Dict[str, str]]:
|
|
55
|
+
normalized: List[Dict[str, str]] = []
|
|
56
|
+
for recipient in recipients or []:
|
|
57
|
+
item = _normalize_recipient(recipient)
|
|
58
|
+
if item:
|
|
59
|
+
normalized.append(item)
|
|
60
|
+
return normalized
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_header_recipients(header_value: str) -> List[Dict[str, str]]:
|
|
64
|
+
parsed = getaddresses([header_value]) if header_value else []
|
|
65
|
+
return [{"email": address, "name": name} for name, address in parsed if address]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _dedupe_recipients(
|
|
69
|
+
recipients: List[Dict[str, str]],
|
|
70
|
+
exclude_emails: Optional[List[str]] = None,
|
|
71
|
+
) -> List[Dict[str, str]]:
|
|
72
|
+
excluded = {email.lower() for email in (exclude_emails or []) if email}
|
|
73
|
+
seen: set[str] = set()
|
|
74
|
+
result: List[Dict[str, str]] = []
|
|
75
|
+
for recipient in recipients:
|
|
76
|
+
email = (recipient.get("email") or "").strip()
|
|
77
|
+
if not email:
|
|
78
|
+
continue
|
|
79
|
+
email_lc = email.lower()
|
|
80
|
+
if email_lc in seen or email_lc in excluded:
|
|
81
|
+
continue
|
|
82
|
+
seen.add(email_lc)
|
|
83
|
+
result.append({"email": email, "name": recipient.get("name")})
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _recipients_to_header(recipients: List[Dict[str, str]]) -> str:
|
|
88
|
+
parts: List[str] = []
|
|
89
|
+
for recipient in recipients:
|
|
90
|
+
name = recipient.get("name")
|
|
91
|
+
email = recipient.get("email")
|
|
92
|
+
if not email:
|
|
93
|
+
continue
|
|
94
|
+
if name:
|
|
95
|
+
parts.append(f"{name} <{email}>")
|
|
96
|
+
else:
|
|
97
|
+
parts.append(email)
|
|
98
|
+
return ", ".join(parts)
|
|
99
|
+
|
|
36
100
|
def get_google_workspace_token(tool_config: Optional[List[Dict]] = None) -> Any:
|
|
37
101
|
"""
|
|
38
102
|
Retrieves the GOOGLE_SERVICE_KEY (base64-encoded JSON) from the provided tool configuration or environment.
|
|
@@ -945,8 +1009,6 @@ async def reply_to_email_async(
|
|
|
945
1009
|
headers_list = original_message.get('payload', {}).get('headers', [])
|
|
946
1010
|
# Case-insensitive header lookup and resilient recipient fallback to avoid Gmail 400s.
|
|
947
1011
|
subject = find_header(headers_list, 'Subject') or ''
|
|
948
|
-
if not subject.startswith('Re:'):
|
|
949
|
-
subject = f'Re: {subject}'
|
|
950
1012
|
reply_to_header = find_header(headers_list, 'Reply-To') or ''
|
|
951
1013
|
from_header = find_header(headers_list, 'From') or ''
|
|
952
1014
|
to_header = find_header(headers_list, 'To') or ''
|
|
@@ -954,29 +1016,118 @@ async def reply_to_email_async(
|
|
|
954
1016
|
message_id_header = find_header(headers_list, 'Message-ID') or ''
|
|
955
1017
|
thread_id = original_message.get('threadId')
|
|
956
1018
|
|
|
1019
|
+
reply_type = (reply_email_context.reply_type or "reply").lower()
|
|
1020
|
+
if reply_type not in {"reply", "reply_all", "forward"}:
|
|
1021
|
+
reply_type = "reply"
|
|
1022
|
+
|
|
957
1023
|
sender_email_lc = (reply_email_context.sender_email or '').lower()
|
|
958
1024
|
|
|
959
1025
|
def _is_self(addr: str) -> bool:
|
|
960
1026
|
return bool(sender_email_lc) and sender_email_lc in addr.lower()
|
|
961
1027
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1028
|
+
explicit_to = _normalize_recipients(reply_email_context.to_recipients)
|
|
1029
|
+
explicit_cc = _normalize_recipients(reply_email_context.cc_recipients)
|
|
1030
|
+
explicit_bcc = _normalize_recipients(reply_email_context.bcc_recipients)
|
|
1031
|
+
|
|
1032
|
+
to_recipients: List[Dict[str, str]] = []
|
|
1033
|
+
cc_recipients: List[Dict[str, str]] = []
|
|
1034
|
+
bcc_recipients: List[Dict[str, str]] = []
|
|
1035
|
+
|
|
1036
|
+
if reply_type == "forward":
|
|
1037
|
+
if not explicit_to:
|
|
1038
|
+
raise ValueError("Forward requires explicit to_recipients.")
|
|
1039
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
1040
|
+
cc_recipients = _dedupe_recipients(explicit_cc)
|
|
1041
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
1042
|
+
|
|
1043
|
+
if reply_email_context.subject:
|
|
1044
|
+
subject = reply_email_context.subject
|
|
1045
|
+
elif subject and not subject.lower().startswith("fwd:"):
|
|
1046
|
+
subject = f"Fwd: {subject}"
|
|
1047
|
+
else:
|
|
1048
|
+
subject = subject or "Fwd:"
|
|
969
1049
|
else:
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
cc_addresses = ''
|
|
1050
|
+
if not subject.startswith('Re:'):
|
|
1051
|
+
subject = f'Re: {subject}'
|
|
973
1052
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1053
|
+
if reply_type == "reply_all":
|
|
1054
|
+
base_to = _dedupe_recipients(
|
|
1055
|
+
_parse_header_recipients(from_header),
|
|
1056
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
1057
|
+
)
|
|
1058
|
+
if not base_to:
|
|
1059
|
+
base_to = _dedupe_recipients(
|
|
1060
|
+
_parse_header_recipients(reply_to_header),
|
|
1061
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
1062
|
+
)
|
|
1063
|
+
if not base_to:
|
|
1064
|
+
base_to = _dedupe_recipients(
|
|
1065
|
+
_parse_header_recipients(to_header),
|
|
1066
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
1067
|
+
)
|
|
1068
|
+
if not base_to and reply_email_context.fallback_recipient:
|
|
1069
|
+
base_to = _dedupe_recipients(
|
|
1070
|
+
[{"email": reply_email_context.fallback_recipient}],
|
|
1071
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
1072
|
+
)
|
|
1073
|
+
if not base_to:
|
|
1074
|
+
raise ValueError(
|
|
1075
|
+
"No valid recipient found in the original message; refusing to reply to sender."
|
|
1076
|
+
)
|
|
1077
|
+
base_to_emails = [recipient.get("email") for recipient in base_to if recipient.get("email")]
|
|
1078
|
+
base_cc_candidates = _parse_header_recipients(to_header) + _parse_header_recipients(cc_header)
|
|
1079
|
+
base_cc = _dedupe_recipients(
|
|
1080
|
+
base_cc_candidates,
|
|
1081
|
+
exclude_emails=[reply_email_context.sender_email] + base_to_emails,
|
|
1082
|
+
)
|
|
1083
|
+
else:
|
|
1084
|
+
cc_header_value = cc_header or ''
|
|
1085
|
+
if reply_to_header and not _is_self(reply_to_header):
|
|
1086
|
+
to_header_value = reply_to_header
|
|
1087
|
+
elif from_header and not _is_self(from_header):
|
|
1088
|
+
to_header_value = from_header
|
|
1089
|
+
elif to_header and not _is_self(to_header):
|
|
1090
|
+
to_header_value = to_header
|
|
1091
|
+
else:
|
|
1092
|
+
to_header_value = ", ".join([v for v in (to_header, cc_header, from_header) if v])
|
|
1093
|
+
cc_header_value = ''
|
|
1094
|
+
|
|
1095
|
+
base_to = _dedupe_recipients(
|
|
1096
|
+
_parse_header_recipients(to_header_value),
|
|
1097
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
1098
|
+
)
|
|
1099
|
+
base_cc = _dedupe_recipients(
|
|
1100
|
+
_parse_header_recipients(cc_header_value),
|
|
1101
|
+
exclude_emails=[reply_email_context.sender_email],
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
if not base_to and reply_email_context.fallback_recipient and not _is_self(reply_email_context.fallback_recipient):
|
|
1105
|
+
base_to = _dedupe_recipients([{"email": reply_email_context.fallback_recipient}])
|
|
1106
|
+
base_cc = []
|
|
1107
|
+
|
|
1108
|
+
if not base_to:
|
|
1109
|
+
raise ValueError(
|
|
1110
|
+
"No valid recipient found in the original message; refusing to reply to sender."
|
|
1111
|
+
)
|
|
978
1112
|
|
|
979
|
-
|
|
1113
|
+
to_recipients = base_to
|
|
1114
|
+
if explicit_to:
|
|
1115
|
+
to_recipients = _dedupe_recipients(explicit_to)
|
|
1116
|
+
|
|
1117
|
+
cc_recipients = base_cc
|
|
1118
|
+
if explicit_cc:
|
|
1119
|
+
cc_recipients = _dedupe_recipients(
|
|
1120
|
+
base_cc + explicit_cc,
|
|
1121
|
+
exclude_emails=[recipient.get("email") for recipient in to_recipients if recipient.get("email")],
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
bcc_recipients = _dedupe_recipients(explicit_bcc)
|
|
1125
|
+
|
|
1126
|
+
to_addresses = _recipients_to_header(to_recipients)
|
|
1127
|
+
cc_addresses = _recipients_to_header(cc_recipients)
|
|
1128
|
+
bcc_addresses = _recipients_to_header(bcc_recipients)
|
|
1129
|
+
|
|
1130
|
+
if not to_addresses:
|
|
980
1131
|
raise ValueError(
|
|
981
1132
|
"No valid recipient found in the original message; refusing to reply to sender."
|
|
982
1133
|
)
|
|
@@ -995,16 +1146,19 @@ async def reply_to_email_async(
|
|
|
995
1146
|
msg['To'] = to_addresses
|
|
996
1147
|
if cc_addresses:
|
|
997
1148
|
msg['Cc'] = cc_addresses
|
|
998
|
-
|
|
1149
|
+
if bcc_addresses:
|
|
1150
|
+
msg['Bcc'] = bcc_addresses
|
|
1151
|
+
sender_display = reply_email_context.sender_name or reply_email_context.sender_email
|
|
1152
|
+
msg['From'] = f"{sender_display} <{reply_email_context.sender_email}>"
|
|
999
1153
|
msg['Subject'] = subject
|
|
1000
|
-
|
|
1001
|
-
|
|
1154
|
+
if reply_type != "forward":
|
|
1155
|
+
msg['In-Reply-To'] = message_id_header
|
|
1156
|
+
msg['References'] = message_id_header
|
|
1002
1157
|
|
|
1003
1158
|
raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode()
|
|
1004
|
-
payload = {
|
|
1005
|
-
|
|
1006
|
-
'threadId'
|
|
1007
|
-
}
|
|
1159
|
+
payload = {'raw': raw_message}
|
|
1160
|
+
if thread_id and reply_type != "forward":
|
|
1161
|
+
payload['threadId'] = thread_id
|
|
1008
1162
|
|
|
1009
1163
|
# 4. Send the reply
|
|
1010
1164
|
send_message_url = f'{gmail_api_base_url}/messages/send'
|
|
@@ -1014,7 +1168,7 @@ async def reply_to_email_async(
|
|
|
1014
1168
|
sent_message = response.json()
|
|
1015
1169
|
|
|
1016
1170
|
# 5. (Optional) Mark the thread as read
|
|
1017
|
-
if reply_email_context.mark_as_read.lower() == "true":
|
|
1171
|
+
if str(reply_email_context.mark_as_read).lower() == "true":
|
|
1018
1172
|
modify_thread_url = f'{gmail_api_base_url}/threads/{thread_id}/modify'
|
|
1019
1173
|
modify_payload = {'removeLabelIds': ['UNREAD']}
|
|
1020
1174
|
async with httpx.AsyncClient() as client:
|
|
@@ -1036,7 +1190,9 @@ async def reply_to_email_async(
|
|
|
1036
1190
|
"email_subject": subject,
|
|
1037
1191
|
"email_sender": reply_email_context.sender_email,
|
|
1038
1192
|
"email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
|
|
1039
|
-
"
|
|
1193
|
+
"to_recipients": to_recipients,
|
|
1194
|
+
"cc_recipients": cc_recipients,
|
|
1195
|
+
"read_email_status": 'READ' if str(reply_email_context.mark_as_read).lower() == "true" else 'UNREAD',
|
|
1040
1196
|
"email_labels": sent_message.get('labelIds', [])
|
|
1041
1197
|
}
|
|
1042
1198
|
|