dhisana 0.0.1.dev265__py3-none-any.whl → 0.0.1.dev267__py3-none-any.whl
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/utils/email_provider.py +3 -0
- dhisana/utils/generate_structured_output_internal.py +306 -59
- dhisana/utils/mailgun_tools.py +10 -7
- dhisana/utils/sendgrid_tools.py +12 -8
- {dhisana-0.0.1.dev265.dist-info → dhisana-0.0.1.dev267.dist-info}/METADATA +1 -1
- {dhisana-0.0.1.dev265.dist-info → dhisana-0.0.1.dev267.dist-info}/RECORD +9 -9
- {dhisana-0.0.1.dev265.dist-info → dhisana-0.0.1.dev267.dist-info}/WHEEL +0 -0
- {dhisana-0.0.1.dev265.dist-info → dhisana-0.0.1.dev267.dist-info}/entry_points.txt +0 -0
- {dhisana-0.0.1.dev265.dist-info → dhisana-0.0.1.dev267.dist-info}/top_level.txt +0 -0
dhisana/utils/email_provider.py
CHANGED
|
@@ -124,9 +124,12 @@ async def send_email_async(
|
|
|
124
124
|
|
|
125
125
|
Returns whatever the underlying provider helper returns:
|
|
126
126
|
|
|
127
|
+
* Mailgun → str (message-id from Mailgun)
|
|
128
|
+
* SendGrid → str (X-Message-Id from SendGrid)
|
|
127
129
|
* SMTP → str (Message-ID)
|
|
128
130
|
* Microsoft 365 → str (message-id)
|
|
129
131
|
* Google Workspace → str (message-id)
|
|
132
|
+
* Google OAuth → str (message-id)
|
|
130
133
|
"""
|
|
131
134
|
# ------------------------------------------------------------------ #
|
|
132
135
|
# 1) Try the preferred providers in order
|
|
@@ -19,6 +19,126 @@ from dhisana.utils.fetch_openai_config import (
|
|
|
19
19
|
create_async_openai_client,
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
+
# Import search and scrape utilities for web search tools
|
|
23
|
+
try:
|
|
24
|
+
from dhisana.utils.search_router import search_google_with_tools
|
|
25
|
+
except Exception:
|
|
26
|
+
async def search_google_with_tools(*a, **k):
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from dhisana.utils.web_download_parse_tools import get_text_content_from_url
|
|
31
|
+
except Exception:
|
|
32
|
+
async def get_text_content_from_url(url: str) -> str:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
# Web search tool definitions for the Responses API
|
|
38
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
SEARCH_GOOGLE_TOOL = {
|
|
41
|
+
"type": "function",
|
|
42
|
+
"name": "search_google",
|
|
43
|
+
"description": "Search Google for information. Returns a list of search results with titles, links, and snippets.",
|
|
44
|
+
"parameters": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"query": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "The search query to look up on Google"
|
|
50
|
+
},
|
|
51
|
+
"num_results": {
|
|
52
|
+
"type": "integer",
|
|
53
|
+
"description": "Number of results to return (default: 5, max: 10)"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"required": ["query"],
|
|
57
|
+
"additionalProperties": False
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
FETCH_URL_CONTENT_TOOL = {
|
|
62
|
+
"type": "function",
|
|
63
|
+
"name": "fetch_url_content",
|
|
64
|
+
"description": "Fetch and extract text content from a URL. Use this to read the full content of a webpage.",
|
|
65
|
+
"parameters": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"url": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"description": "The URL to fetch content from"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"required": ["url"],
|
|
74
|
+
"additionalProperties": False
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _execute_search_google(
|
|
80
|
+
query: str, num_results: int, tool_config: Optional[List[Dict]]
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Execute Google search and return results as JSON string."""
|
|
83
|
+
try:
|
|
84
|
+
num_results = min(max(num_results, 1), 10)
|
|
85
|
+
raw = await search_google_with_tools(
|
|
86
|
+
query, number_of_results=num_results, offset=0, tool_config=tool_config
|
|
87
|
+
)
|
|
88
|
+
results = []
|
|
89
|
+
if isinstance(raw, list):
|
|
90
|
+
for item in raw:
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(item) if isinstance(item, str) else item
|
|
93
|
+
results.append({
|
|
94
|
+
"title": data.get("title", ""),
|
|
95
|
+
"link": data.get("link", ""),
|
|
96
|
+
"snippet": data.get("snippet", "")
|
|
97
|
+
})
|
|
98
|
+
except Exception:
|
|
99
|
+
continue
|
|
100
|
+
return json.dumps(results, default=str)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logging.warning("search_google tool failed: %s", e)
|
|
103
|
+
return json.dumps({"error": str(e)})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _execute_fetch_url_content(url: str) -> str:
|
|
107
|
+
"""Fetch URL content and return as string."""
|
|
108
|
+
try:
|
|
109
|
+
content = await get_text_content_from_url(url)
|
|
110
|
+
if content:
|
|
111
|
+
max_len = 15000
|
|
112
|
+
if len(content) > max_len:
|
|
113
|
+
content = content[:max_len] + "\n... [content truncated]"
|
|
114
|
+
return content
|
|
115
|
+
return "Failed to fetch content from URL"
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logging.warning("fetch_url_content tool failed for %s: %s", url, e)
|
|
118
|
+
return f"Error fetching URL: {str(e)}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _execute_web_search_tool(
|
|
122
|
+
tool_name: str, args: dict, tool_config: Optional[List[Dict]]
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Execute a web search tool and return the result as a string."""
|
|
125
|
+
if tool_name == "search_google":
|
|
126
|
+
query = args.get("query", "")
|
|
127
|
+
num_results = args.get("num_results", 5)
|
|
128
|
+
if not query:
|
|
129
|
+
return json.dumps({"error": "Missing required parameter: query"})
|
|
130
|
+
return await _execute_search_google(query, num_results, tool_config)
|
|
131
|
+
|
|
132
|
+
elif tool_name == "fetch_url_content":
|
|
133
|
+
url = args.get("url", "")
|
|
134
|
+
if not url:
|
|
135
|
+
return json.dumps({"error": "Missing required parameter: url"})
|
|
136
|
+
return await _execute_fetch_url_content(url)
|
|
137
|
+
|
|
138
|
+
else:
|
|
139
|
+
logging.warning(f"Unknown tool requested: {tool_name}")
|
|
140
|
+
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
|
141
|
+
|
|
22
142
|
|
|
23
143
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
24
144
|
# 1. Helper functions
|
|
@@ -71,6 +191,9 @@ async def get_structured_output_internal(
|
|
|
71
191
|
|
|
72
192
|
On a 429 (rate-limit) error the call is retried once after
|
|
73
193
|
20 s + random exponential back-off.
|
|
194
|
+
|
|
195
|
+
If use_web_search=True, uses Google search and URL scraping tools
|
|
196
|
+
to enable web research (works with both OpenAI and Azure OpenAI).
|
|
74
197
|
"""
|
|
75
198
|
try:
|
|
76
199
|
# ─── caching bookkeeping ────────────────────────────────────────────
|
|
@@ -95,30 +218,24 @@ async def get_structured_output_internal(
|
|
|
95
218
|
"schema": schema["json_schema"]["schema"],
|
|
96
219
|
}
|
|
97
220
|
|
|
98
|
-
# ─── client initialisation
|
|
221
|
+
# ─── client initialisation ──────────────────────────────────────────
|
|
99
222
|
client_async = create_async_openai_client(tool_config)
|
|
100
223
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
224
|
+
# ─── Web search path (uses Google search + URL scraping tools) ──────
|
|
225
|
+
if use_web_search:
|
|
226
|
+
return await _get_structured_output_with_web_search(
|
|
227
|
+
client_async=client_async,
|
|
228
|
+
prompt=prompt,
|
|
229
|
+
response_format=response_format,
|
|
230
|
+
json_schema_format=json_schema_format,
|
|
231
|
+
model=model,
|
|
232
|
+
effort=effort,
|
|
233
|
+
tool_config=tool_config,
|
|
234
|
+
cache_key=cache_key,
|
|
235
|
+
)
|
|
105
236
|
|
|
106
|
-
#
|
|
107
|
-
# Internal helper to perform ONE attempt
|
|
108
|
-
# -------------------------------------------------------------------
|
|
237
|
+
# ─── Standard path (no web search) ──────────────────────────────────
|
|
109
238
|
async def _make_request():
|
|
110
|
-
if use_web_search and model.startswith("gpt-"):
|
|
111
|
-
return await client_async.responses.create(
|
|
112
|
-
input=[
|
|
113
|
-
{"role": "system", "content": "You are a helpful AI. Output JSON only."},
|
|
114
|
-
{"role": "user", "content": prompt},
|
|
115
|
-
],
|
|
116
|
-
model=model,
|
|
117
|
-
text={"format": json_schema_format},
|
|
118
|
-
tool_choice="required",
|
|
119
|
-
tools=[{"type": "web_search_preview"}],
|
|
120
|
-
store=False,
|
|
121
|
-
)
|
|
122
239
|
if model.startswith("o"): # reasoning param only for "o" family
|
|
123
240
|
return await client_async.responses.create(
|
|
124
241
|
input=[
|
|
@@ -175,45 +292,8 @@ async def get_structured_output_internal(
|
|
|
175
292
|
logging.error(f"OpenAI API error: {e}")
|
|
176
293
|
return f"OpenAI API error: {str(e)}", "API_ERROR"
|
|
177
294
|
|
|
178
|
-
# ─── handle model output
|
|
179
|
-
|
|
180
|
-
raw_text = None
|
|
181
|
-
for out in completion.output:
|
|
182
|
-
if out.type == "message" and out.content:
|
|
183
|
-
for content_item in out.content:
|
|
184
|
-
if hasattr(content_item, "text"):
|
|
185
|
-
raw_text = content_item.text
|
|
186
|
-
break
|
|
187
|
-
else:
|
|
188
|
-
logging.warning("request refused: %s", str(content_item))
|
|
189
|
-
return "Request refused.", "FAIL"
|
|
190
|
-
if raw_text:
|
|
191
|
-
break
|
|
192
|
-
|
|
193
|
-
if not raw_text or not raw_text.strip():
|
|
194
|
-
return "No text returned (possibly refusal or empty response)", "FAIL"
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
parsed_obj = response_format.parse_raw(raw_text)
|
|
198
|
-
cache_output_tools.cache_output(
|
|
199
|
-
"get_structured_output_internal", cache_key, parsed_obj.json()
|
|
200
|
-
)
|
|
201
|
-
return parsed_obj, "SUCCESS"
|
|
202
|
-
|
|
203
|
-
except Exception:
|
|
204
|
-
logging.warning("ERROR: Could not parse JSON from model output.")
|
|
205
|
-
try:
|
|
206
|
-
fixed_json = repair_json(raw_text)
|
|
207
|
-
parsed_obj = response_format.parse_raw(fixed_json)
|
|
208
|
-
cache_output_tools.cache_output(
|
|
209
|
-
"get_structured_output_internal", cache_key, parsed_obj.json()
|
|
210
|
-
)
|
|
211
|
-
return parsed_obj, "SUCCESS"
|
|
212
|
-
except Exception as e2:
|
|
213
|
-
logging.warning("JSON repair failed: %s", str(e2))
|
|
214
|
-
return raw_text, "FAIL"
|
|
215
|
-
else:
|
|
216
|
-
return "No output returned", "FAIL"
|
|
295
|
+
# ─── handle model output ────────────────────────────────────────────
|
|
296
|
+
return _parse_completion_response(completion, response_format, cache_key)
|
|
217
297
|
|
|
218
298
|
# Safety fallback: catch any OpenAI errors not caught by inner retry loop
|
|
219
299
|
except OpenAIError as e:
|
|
@@ -226,6 +306,173 @@ async def get_structured_output_internal(
|
|
|
226
306
|
return f"Unexpected error: {str(e)}", "ERROR"
|
|
227
307
|
|
|
228
308
|
|
|
309
|
+
async def _get_structured_output_with_web_search(
|
|
310
|
+
client_async,
|
|
311
|
+
prompt: str,
|
|
312
|
+
response_format: BaseModel,
|
|
313
|
+
json_schema_format: Dict,
|
|
314
|
+
model: str,
|
|
315
|
+
effort: str,
|
|
316
|
+
tool_config: Optional[List[Dict]],
|
|
317
|
+
cache_key: str,
|
|
318
|
+
):
|
|
319
|
+
"""
|
|
320
|
+
Handles structured output with web search using Google search and URL scraping tools.
|
|
321
|
+
Works with both OpenAI and Azure OpenAI.
|
|
322
|
+
"""
|
|
323
|
+
tools = [SEARCH_GOOGLE_TOOL, FETCH_URL_CONTENT_TOOL]
|
|
324
|
+
|
|
325
|
+
system_content = (
|
|
326
|
+
"You are a helpful AI. Output JSON only.\n\n"
|
|
327
|
+
"Web Search Instructions:\n"
|
|
328
|
+
"- Use search_google to find relevant information on the web.\n"
|
|
329
|
+
"- Use fetch_url_content to read the full content of relevant URLs.\n"
|
|
330
|
+
"- After gathering information, provide your response in the required JSON format."
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
input_messages = [
|
|
334
|
+
{"role": "system", "content": system_content},
|
|
335
|
+
{"role": "user", "content": prompt},
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
base_request = {
|
|
339
|
+
"input": input_messages,
|
|
340
|
+
"model": model,
|
|
341
|
+
"text": {"format": json_schema_format},
|
|
342
|
+
"tools": tools,
|
|
343
|
+
"store": False,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if model.startswith("o"):
|
|
347
|
+
base_request["reasoning"] = {"effort": effort}
|
|
348
|
+
|
|
349
|
+
max_tool_iterations = 10
|
|
350
|
+
tool_iteration = 0
|
|
351
|
+
completion = None
|
|
352
|
+
|
|
353
|
+
while tool_iteration < max_tool_iterations:
|
|
354
|
+
tool_iteration += 1
|
|
355
|
+
|
|
356
|
+
# Retry logic for rate limits
|
|
357
|
+
for attempt in range(2):
|
|
358
|
+
try:
|
|
359
|
+
completion = await client_async.responses.create(**base_request)
|
|
360
|
+
break
|
|
361
|
+
except (RateLimitError, OpenAIError) as e:
|
|
362
|
+
is_rl = (
|
|
363
|
+
isinstance(e, RateLimitError)
|
|
364
|
+
or getattr(e, "status_code", None) == 429
|
|
365
|
+
or "rate_limit" in str(e).lower()
|
|
366
|
+
)
|
|
367
|
+
if attempt == 0 and is_rl:
|
|
368
|
+
wait_time = 20 + random.uniform(0, 2.0)
|
|
369
|
+
logging.warning(f"Rate-limit hit (429). Waiting {wait_time:.2f}s then retrying.")
|
|
370
|
+
await asyncio.sleep(wait_time)
|
|
371
|
+
continue
|
|
372
|
+
logging.error(f"OpenAI API error: {e}")
|
|
373
|
+
raise HTTPException(status_code=502, detail="Error communicating with the OpenAI API.")
|
|
374
|
+
|
|
375
|
+
if not completion:
|
|
376
|
+
raise HTTPException(status_code=502, detail="OpenAI request failed.")
|
|
377
|
+
|
|
378
|
+
# Check for function tool calls in the response
|
|
379
|
+
tool_calls = []
|
|
380
|
+
for item in (completion.output or []):
|
|
381
|
+
item_type = getattr(item, "type", None)
|
|
382
|
+
if item_type == "function_call":
|
|
383
|
+
tool_calls.append(item)
|
|
384
|
+
|
|
385
|
+
if not tool_calls:
|
|
386
|
+
# No tool calls, we have the final response
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
# Execute tool calls and prepare for next iteration
|
|
390
|
+
logging.info(f"Processing {len(tool_calls)} web search tool call(s) in iteration {tool_iteration}")
|
|
391
|
+
|
|
392
|
+
# Use previous_response_id for conversation threading
|
|
393
|
+
if hasattr(completion, "id") and completion.id:
|
|
394
|
+
base_request = {
|
|
395
|
+
"model": model,
|
|
396
|
+
"text": {"format": json_schema_format},
|
|
397
|
+
"tools": tools,
|
|
398
|
+
"store": False,
|
|
399
|
+
"previous_response_id": completion.id,
|
|
400
|
+
"input": [],
|
|
401
|
+
}
|
|
402
|
+
if model.startswith("o"):
|
|
403
|
+
base_request["reasoning"] = {"effort": effort}
|
|
404
|
+
|
|
405
|
+
for tc in tool_calls:
|
|
406
|
+
func_name = getattr(tc, "name", "")
|
|
407
|
+
call_id = getattr(tc, "call_id", "")
|
|
408
|
+
args_str = getattr(tc, "arguments", "{}")
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
args = json.loads(args_str) if args_str else {}
|
|
412
|
+
except json.JSONDecodeError:
|
|
413
|
+
args = {}
|
|
414
|
+
|
|
415
|
+
# Execute the tool
|
|
416
|
+
tool_result = await _execute_web_search_tool(func_name, args, tool_config)
|
|
417
|
+
|
|
418
|
+
# Add tool result to input
|
|
419
|
+
base_request["input"].append({
|
|
420
|
+
"type": "function_call_output",
|
|
421
|
+
"call_id": call_id,
|
|
422
|
+
"output": tool_result
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
logging.info(f"Executed web search tool {func_name}, result length: {len(tool_result)}")
|
|
426
|
+
else:
|
|
427
|
+
logging.warning("No response ID available, breaking tool call loop")
|
|
428
|
+
break
|
|
429
|
+
|
|
430
|
+
# Parse and return the final response
|
|
431
|
+
return _parse_completion_response(completion, response_format, cache_key)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parse_completion_response(completion, response_format: BaseModel, cache_key: str):
|
|
435
|
+
"""Parse completion response and return structured output."""
|
|
436
|
+
if completion and completion.output and len(completion.output) > 0:
|
|
437
|
+
raw_text = None
|
|
438
|
+
for out in completion.output:
|
|
439
|
+
if out.type == "message" and out.content:
|
|
440
|
+
for content_item in out.content:
|
|
441
|
+
if hasattr(content_item, "text"):
|
|
442
|
+
raw_text = content_item.text
|
|
443
|
+
break
|
|
444
|
+
else:
|
|
445
|
+
logging.warning("request refused: %s", str(content_item))
|
|
446
|
+
return "Request refused.", "FAIL"
|
|
447
|
+
if raw_text:
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
if not raw_text or not raw_text.strip():
|
|
451
|
+
return "No text returned (possibly refusal or empty response)", "FAIL"
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
parsed_obj = response_format.parse_raw(raw_text)
|
|
455
|
+
cache_output_tools.cache_output(
|
|
456
|
+
"get_structured_output_internal", cache_key, parsed_obj.json()
|
|
457
|
+
)
|
|
458
|
+
return parsed_obj, "SUCCESS"
|
|
459
|
+
|
|
460
|
+
except Exception:
|
|
461
|
+
logging.warning("ERROR: Could not parse JSON from model output.")
|
|
462
|
+
try:
|
|
463
|
+
fixed_json = repair_json(raw_text)
|
|
464
|
+
parsed_obj = response_format.parse_raw(fixed_json)
|
|
465
|
+
cache_output_tools.cache_output(
|
|
466
|
+
"get_structured_output_internal", cache_key, parsed_obj.json()
|
|
467
|
+
)
|
|
468
|
+
return parsed_obj, "SUCCESS"
|
|
469
|
+
except Exception as e2:
|
|
470
|
+
logging.warning("JSON repair failed: %s", str(e2))
|
|
471
|
+
return raw_text, "FAIL"
|
|
472
|
+
else:
|
|
473
|
+
return "No output returned", "FAIL"
|
|
474
|
+
|
|
475
|
+
|
|
229
476
|
|
|
230
477
|
async def get_structured_output_with_mcp(
|
|
231
478
|
prompt: str,
|
dhisana/utils/mailgun_tools.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import logging
|
|
3
2
|
import os
|
|
4
3
|
from typing import Optional, List, Dict
|
|
@@ -21,7 +20,7 @@ def get_mailgun_notify_key(tool_config: Optional[List[Dict]] = None) -> str:
|
|
|
21
20
|
if tool_config:
|
|
22
21
|
cfg = next((item for item in tool_config if item.get("name") == "mailgun"), None)
|
|
23
22
|
if cfg:
|
|
24
|
-
cfg_map = {i
|
|
23
|
+
cfg_map = {i.get("name"): i.get("value") for i in cfg.get("configuration", []) if i}
|
|
25
24
|
key = cfg_map.get("apiKey")
|
|
26
25
|
key = key or os.getenv("MAILGUN_NOTIFY_KEY")
|
|
27
26
|
if not key:
|
|
@@ -43,7 +42,7 @@ def get_mailgun_notify_domain(tool_config: Optional[List[Dict]] = None) -> str:
|
|
|
43
42
|
if tool_config:
|
|
44
43
|
cfg = next((item for item in tool_config if item.get("name") == "mailgun"), None)
|
|
45
44
|
if cfg:
|
|
46
|
-
cfg_map = {i
|
|
45
|
+
cfg_map = {i.get("name"): i.get("value") for i in cfg.get("configuration", []) if i}
|
|
47
46
|
domain = cfg_map.get("domain") or cfg_map.get("notifyDomain")
|
|
48
47
|
domain = domain or os.getenv("MAILGUN_DOMAIN") or os.getenv("MAILGUN_NOTIFY_DOMAIN")
|
|
49
48
|
if not domain:
|
|
@@ -96,11 +95,15 @@ async def send_email_with_mailgun(
|
|
|
96
95
|
# Try to return JSON payload if available
|
|
97
96
|
try:
|
|
98
97
|
return await response.json()
|
|
99
|
-
except Exception:
|
|
98
|
+
except Exception as parse_ex:
|
|
99
|
+
logging.debug(f"Could not parse Mailgun response as JSON: {parse_ex}")
|
|
100
100
|
return await response.text()
|
|
101
|
-
except
|
|
101
|
+
except (aiohttp.ClientError, ValueError) as ex:
|
|
102
102
|
logging.warning(f"Error sending email via Mailgun: {ex}")
|
|
103
103
|
return {"error": str(ex)}
|
|
104
|
+
except Exception as ex:
|
|
105
|
+
logging.exception(f"Unexpected error sending email via Mailgun: {ex}")
|
|
106
|
+
raise
|
|
104
107
|
|
|
105
108
|
|
|
106
109
|
async def send_email_using_mailgun_async(
|
|
@@ -147,10 +150,10 @@ async def send_email_using_mailgun_async(
|
|
|
147
150
|
raise RuntimeError(f"Mailgun send failed: {detail}")
|
|
148
151
|
try:
|
|
149
152
|
payload = await response.json()
|
|
150
|
-
except Exception:
|
|
153
|
+
except Exception as parse_ex:
|
|
154
|
+
logging.debug(f"Could not parse Mailgun response as JSON: {parse_ex}")
|
|
151
155
|
payload = {"message": await response.text()}
|
|
152
156
|
|
|
153
157
|
# Normalise return value akin to other providers
|
|
154
158
|
msg_id = payload.get("id") if isinstance(payload, dict) else None
|
|
155
|
-
await asyncio.sleep(20)
|
|
156
159
|
return msg_id or str(payload)
|
dhisana/utils/sendgrid_tools.py
CHANGED
|
@@ -119,13 +119,17 @@ async def send_email_with_sendgrid(
|
|
|
119
119
|
headers=headers,
|
|
120
120
|
json=payload,
|
|
121
121
|
) as response:
|
|
122
|
-
# SendGrid returns 202 Accepted on success with empty body
|
|
122
|
+
# SendGrid returns 202 Accepted on success with empty body but includes X-Message-Id header
|
|
123
123
|
if response.status == 202:
|
|
124
|
-
|
|
124
|
+
message_id = response.headers.get("X-Message-Id")
|
|
125
|
+
if not message_id:
|
|
126
|
+
logging.warning("SendGrid did not return X-Message-Id header")
|
|
127
|
+
return {"status": 202, "message": "accepted", "message_id": message_id}
|
|
125
128
|
# On error, try to parse JSON for helpful message
|
|
126
129
|
try:
|
|
127
130
|
err = await response.json()
|
|
128
|
-
except Exception:
|
|
131
|
+
except Exception as parse_ex:
|
|
132
|
+
logging.debug(f"Could not parse SendGrid error as JSON: {parse_ex}")
|
|
129
133
|
err = {"text": await response.text()}
|
|
130
134
|
return {"error": err, "status": response.status}
|
|
131
135
|
except Exception as ex:
|
|
@@ -139,12 +143,8 @@ async def send_email_using_sendgrid_async(
|
|
|
139
143
|
) -> str:
|
|
140
144
|
"""
|
|
141
145
|
Provider-style wrapper for SendGrid using SendEmailContext.
|
|
142
|
-
Returns
|
|
146
|
+
Returns the message ID from SendGrid's X-Message-Id response header.
|
|
143
147
|
"""
|
|
144
|
-
plain_body, html_body, _ = body_variants(
|
|
145
|
-
ctx.body,
|
|
146
|
-
getattr(ctx, "body_format", None),
|
|
147
|
-
)
|
|
148
148
|
result = await send_email_with_sendgrid(
|
|
149
149
|
sender=f"{ctx.sender_name} <{ctx.sender_email}>",
|
|
150
150
|
recipients=[ctx.recipient],
|
|
@@ -156,6 +156,10 @@ async def send_email_using_sendgrid_async(
|
|
|
156
156
|
)
|
|
157
157
|
# Normalise output to a string id-like value
|
|
158
158
|
if isinstance(result, dict) and result.get("status") == 202:
|
|
159
|
+
message_id = result.get("message_id")
|
|
160
|
+
if message_id:
|
|
161
|
+
return message_id
|
|
162
|
+
# Fallback if header wasn't present (shouldn't happen)
|
|
159
163
|
return f"sent:{ctx.sender_email}:{ctx.recipient}:{ctx.subject}"
|
|
160
164
|
if isinstance(result, dict) and "error" in result:
|
|
161
165
|
raise RuntimeError(f"SendGrid send failed: {result['error']}")
|
|
@@ -31,7 +31,7 @@ dhisana/utils/dataframe_tools.py,sha256=R6eUXjwR5SG6_K87rWjj4T5PT2w6xvVF2EKBajIv
|
|
|
31
31
|
dhisana/utils/domain_parser.py,sha256=Kw5MPP06wK2azWQzuSiOE-DffOezLqDyF-L9JEBsMSU,1206
|
|
32
32
|
dhisana/utils/email_body_utils.py,sha256=rlCVjdBlqNnEiUberJGXGcrYY1GQOkW0-aB6AEpS3L4,2302
|
|
33
33
|
dhisana/utils/email_parse_helpers.py,sha256=LIdm1B1IyGSW50y8EkxOk6YRjvxO2SJTgTKPLxYls_o,4613
|
|
34
|
-
dhisana/utils/email_provider.py,sha256=
|
|
34
|
+
dhisana/utils/email_provider.py,sha256=ukW_0nHcjTQmpnE9pdJci78LrZcsK1_0v6kcgc2ChPY,14573
|
|
35
35
|
dhisana/utils/enrich_lead_information.py,sha256=O0fV-8MlXFT_z5aXvmvXVT76AISN94GpvAOlq3q_Phw,39411
|
|
36
36
|
dhisana/utils/extract_email_content_for_llm.py,sha256=SQmMZ3YJtm3ZI44XiWEVAItcAwrsSSy1QzDne7LTu_Q,3713
|
|
37
37
|
dhisana/utils/fetch_openai_config.py,sha256=LjWdFuUeTNeAW106pb7DLXZNElos2PlmXRe6bHZJ2hw,5159
|
|
@@ -45,7 +45,7 @@ dhisana/utils/generate_flow.py,sha256=QMn6bWo0nH0fBvy2Ebub1XfH5udnVAqsPsbIqCtQPX
|
|
|
45
45
|
dhisana/utils/generate_leads_salesnav.py,sha256=FG7q6GSm9IywZ9TgQnn5_N3QNfiI-Qk2gaO_3GS99nY,12236
|
|
46
46
|
dhisana/utils/generate_linkedin_connect_message.py,sha256=QxsxDiT-3eQOqAAbW13d0HGJXV36WYPvC-7Zsw_2VTI,10208
|
|
47
47
|
dhisana/utils/generate_linkedin_response_message.py,sha256=mWoSs5p2JSTIoFZFGm86x1kgs67J7dHPvGKZPzcdGdU,14569
|
|
48
|
-
dhisana/utils/generate_structured_output_internal.py,sha256=
|
|
48
|
+
dhisana/utils/generate_structured_output_internal.py,sha256=N1C-LhczMiJhNaDECeFJIY1vc9ta6cwkLWKxvdK4v7M,31266
|
|
49
49
|
dhisana/utils/google_custom_search.py,sha256=5rQ4uAF-hjFpd9ooJkd6CjRvSmhZHhqM0jfHItsbpzk,10071
|
|
50
50
|
dhisana/utils/google_oauth_tools.py,sha256=ReG5lCpXL3_e_s0yn6ai4U7B4-feOWHJVtbv_c0g0rE,28525
|
|
51
51
|
dhisana/utils/google_workspace_tools.py,sha256=fuV0UcvAqF9drLzj7-p6D5zh7d5jMXl1jNJTICk4XOo,50224
|
|
@@ -54,7 +54,7 @@ dhisana/utils/hubspot_crm_tools.py,sha256=lbXFCeq690_TDLjDG8Gm5E-2f1P5EuDqNf5j8P
|
|
|
54
54
|
dhisana/utils/instantly_tools.py,sha256=hhqjDPyLE6o0dzzuvryszbK3ipnoGU2eBm6NlsUGJjY,4771
|
|
55
55
|
dhisana/utils/linkedin_crawler.py,sha256=6fMQTY5lTw2kc65SFHgOAM6YfezAS0Yhg-jkiX8LGHo,6533
|
|
56
56
|
dhisana/utils/lusha_tools.py,sha256=MdiWlxBBjSNpSKz8rhNOyLPtbeh-YWHgGiUq54vN_gM,12734
|
|
57
|
-
dhisana/utils/mailgun_tools.py,sha256=
|
|
57
|
+
dhisana/utils/mailgun_tools.py,sha256=brOgfEx-ciqEdDkXEfzMBfXkG0kRWVscg76tQDXb_lk,5826
|
|
58
58
|
dhisana/utils/mailreach_tools.py,sha256=uJ_gIcg8qrj5-k3jnYYhpwLVnQncoA1swzr5Jfkc1JU,3864
|
|
59
59
|
dhisana/utils/microsoft365_tools.py,sha256=ClqBzTrJ2SZM5K9nsOFyyHRfV-d-6jlxXNpNONtgLlY,18596
|
|
60
60
|
dhisana/utils/openai_assistant_and_file_utils.py,sha256=-eyPcxFvtS-DDtYQGle1SU6C6CuxjulVIojFy27HeWc,8957
|
|
@@ -70,7 +70,7 @@ dhisana/utils/sales_navigator_crawler.py,sha256=z8yurwUTLXdM71xWPDSAFNuDyA_SlanT
|
|
|
70
70
|
dhisana/utils/salesforce_crm_tools.py,sha256=r6tROej4PtfcRN2AViPD7tV24oxBNm6QCE7uwhDH5Hc,17169
|
|
71
71
|
dhisana/utils/search_router.py,sha256=p_1MPHbjalBM8gZuU4LADbmqSLNtZ4zll6CbPOc0POU,4610
|
|
72
72
|
dhisana/utils/search_router_jobs.py,sha256=LgCHNGLMSv-ovgzF32muprfaDTdTpIKgrP5F7swAqhk,1721
|
|
73
|
-
dhisana/utils/sendgrid_tools.py,sha256=
|
|
73
|
+
dhisana/utils/sendgrid_tools.py,sha256=0aafzCxcmCtKVt7kWYNTH_Np9KF0-RPqAZ9LsslMlqs,5931
|
|
74
74
|
dhisana/utils/serarch_router_local_business.py,sha256=n9yZjeXKOSgBnr0lCSQomP1nN3ucbC9ZTTSmSHQLeVo,2920
|
|
75
75
|
dhisana/utils/serpapi_additional_tools.py,sha256=Xb1tc_oK-IjI9ZrEruYhFg8UJMLHQDaO9B51YiNbeBs,10569
|
|
76
76
|
dhisana/utils/serpapi_google_jobs.py,sha256=HUJFZEW8UvYqsW0sWlEDXgI_IUomh5fTkzRJzEgsDGc,4509
|
|
@@ -95,8 +95,8 @@ dhisana/workflow/agent.py,sha256=esv7_i_XuMkV2j1nz_UlsHov_m6X5WZZiZm_tG4OBHU,565
|
|
|
95
95
|
dhisana/workflow/flow.py,sha256=xWE3qQbM7j2B3FH8XnY3zOL_QXX4LbTW4ArndnEYJE0,1638
|
|
96
96
|
dhisana/workflow/task.py,sha256=HlWz9mtrwLYByoSnePOemBUBrMEcj7KbgNjEE1oF5wo,1830
|
|
97
97
|
dhisana/workflow/test.py,sha256=E7lRnXK0PguTNzyasHytLzTJdkqIPxG5_4qk4hMEeKc,3399
|
|
98
|
-
dhisana-0.0.1.
|
|
99
|
-
dhisana-0.0.1.
|
|
100
|
-
dhisana-0.0.1.
|
|
101
|
-
dhisana-0.0.1.
|
|
102
|
-
dhisana-0.0.1.
|
|
98
|
+
dhisana-0.0.1.dev267.dist-info/METADATA,sha256=zAT6piCTBLFYagzP8-Vuogwo1s1meUG3ch_nk8yLiwg,1190
|
|
99
|
+
dhisana-0.0.1.dev267.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
100
|
+
dhisana-0.0.1.dev267.dist-info/entry_points.txt,sha256=jujxteZmNI9EkEaK-pOCoWuBujU8TCevdkfl9ZcKHek,49
|
|
101
|
+
dhisana-0.0.1.dev267.dist-info/top_level.txt,sha256=NETTHt6YifG_P7XtRHbQiXZlgSFk9Qh9aR-ng1XTf4s,8
|
|
102
|
+
dhisana-0.0.1.dev267.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|