codetether 1.2.2__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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email Notifications for Hosted Workers
|
|
3
|
+
|
|
4
|
+
Sends task completion/failure emails via SendGrid.
|
|
5
|
+
Used by hosted_worker.py when tasks complete.
|
|
6
|
+
|
|
7
|
+
Configuration (environment variables):
|
|
8
|
+
- SENDGRID_API_KEY: SendGrid API key (required)
|
|
9
|
+
- SENDGRID_FROM_EMAIL: Sender email address (required)
|
|
10
|
+
- EMAIL_INBOUND_DOMAIN: Domain for reply-to addresses (optional)
|
|
11
|
+
- EMAIL_REPLY_PREFIX: Prefix for reply addresses, default "task" (optional)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import html as html_module
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# SendGrid API endpoint
|
|
25
|
+
SENDGRID_API_URL = 'https://api.sendgrid.com/v3/mail/send'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_config() -> Dict[str, Any]:
|
|
29
|
+
"""Get email configuration from environment."""
|
|
30
|
+
return {
|
|
31
|
+
'api_key': os.environ.get('SENDGRID_API_KEY', ''),
|
|
32
|
+
'from_email': os.environ.get('SENDGRID_FROM_EMAIL', ''),
|
|
33
|
+
'inbound_domain': os.environ.get('EMAIL_INBOUND_DOMAIN', ''),
|
|
34
|
+
'reply_prefix': os.environ.get('EMAIL_REPLY_PREFIX', 'task'),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_configured() -> bool:
|
|
39
|
+
"""Check if email notifications are properly configured."""
|
|
40
|
+
config = _get_config()
|
|
41
|
+
return bool(config['api_key'] and config['from_email'])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_reply_to_address(
|
|
45
|
+
session_id: str,
|
|
46
|
+
codebase_id: Optional[str] = None,
|
|
47
|
+
) -> Optional[str]:
|
|
48
|
+
"""
|
|
49
|
+
Build reply-to address for email continuation.
|
|
50
|
+
|
|
51
|
+
Format: {prefix}+{session_id}@{domain}
|
|
52
|
+
Or: {prefix}+{session_id}+{codebase_id}@{domain}
|
|
53
|
+
"""
|
|
54
|
+
config = _get_config()
|
|
55
|
+
if not config['inbound_domain']:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
prefix = config['reply_prefix']
|
|
59
|
+
domain = config['inbound_domain']
|
|
60
|
+
|
|
61
|
+
if codebase_id:
|
|
62
|
+
return f'{prefix}+{session_id}+{codebase_id}@{domain}'
|
|
63
|
+
return f'{prefix}+{session_id}@{domain}'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_runtime(seconds: Optional[int]) -> str:
|
|
67
|
+
"""Format runtime seconds as human-readable string."""
|
|
68
|
+
if not seconds:
|
|
69
|
+
return 'N/A'
|
|
70
|
+
|
|
71
|
+
minutes = seconds // 60
|
|
72
|
+
remaining_seconds = seconds % 60
|
|
73
|
+
|
|
74
|
+
if minutes > 0:
|
|
75
|
+
return f'{minutes}m {remaining_seconds}s'
|
|
76
|
+
return f'{seconds}s'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sanitize_result(result: Optional[str], max_length: int = 3000) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Sanitize and format result for HTML email.
|
|
82
|
+
|
|
83
|
+
Handles NDJSON streaming output, extracts text content,
|
|
84
|
+
escapes HTML, and truncates to max length.
|
|
85
|
+
"""
|
|
86
|
+
if not result:
|
|
87
|
+
return ''
|
|
88
|
+
|
|
89
|
+
text_parts = []
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
lines = result.strip().split('\n')
|
|
93
|
+
for line in lines:
|
|
94
|
+
line = line.strip()
|
|
95
|
+
if not line:
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
parsed = json.loads(line)
|
|
99
|
+
if isinstance(parsed, dict):
|
|
100
|
+
# OpenCode streaming format
|
|
101
|
+
event_type = parsed.get('type', '')
|
|
102
|
+
part = parsed.get('part', {})
|
|
103
|
+
|
|
104
|
+
if event_type == 'text' and isinstance(part, dict):
|
|
105
|
+
text = part.get('text', '')
|
|
106
|
+
if text:
|
|
107
|
+
text_parts.append(text)
|
|
108
|
+
elif 'text' in parsed and isinstance(parsed['text'], str):
|
|
109
|
+
text_parts.append(parsed['text'])
|
|
110
|
+
elif 'result' in parsed:
|
|
111
|
+
text_parts.append(str(parsed['result']))
|
|
112
|
+
elif 'output' in parsed:
|
|
113
|
+
text_parts.append(str(parsed['output']))
|
|
114
|
+
elif 'message' in parsed and isinstance(
|
|
115
|
+
parsed['message'], str
|
|
116
|
+
):
|
|
117
|
+
text_parts.append(parsed['message'])
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
if line and not line.startswith('{'):
|
|
120
|
+
text_parts.append(line)
|
|
121
|
+
|
|
122
|
+
if text_parts:
|
|
123
|
+
display_result = ' '.join(text_parts)
|
|
124
|
+
else:
|
|
125
|
+
# Try parsing as single JSON
|
|
126
|
+
try:
|
|
127
|
+
parsed = json.loads(result)
|
|
128
|
+
if isinstance(parsed, dict):
|
|
129
|
+
for key in [
|
|
130
|
+
'result',
|
|
131
|
+
'output',
|
|
132
|
+
'message',
|
|
133
|
+
'content',
|
|
134
|
+
'response',
|
|
135
|
+
'text',
|
|
136
|
+
]:
|
|
137
|
+
if key in parsed:
|
|
138
|
+
display_result = str(parsed[key])
|
|
139
|
+
break
|
|
140
|
+
else:
|
|
141
|
+
display_result = result
|
|
142
|
+
else:
|
|
143
|
+
display_result = result
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
display_result = result
|
|
146
|
+
except Exception:
|
|
147
|
+
display_result = result
|
|
148
|
+
|
|
149
|
+
# Escape HTML and truncate
|
|
150
|
+
display_result = html_module.escape(display_result)
|
|
151
|
+
display_result = display_result.replace('\n', '<br>')
|
|
152
|
+
|
|
153
|
+
if len(display_result) > max_length:
|
|
154
|
+
display_result = display_result[:max_length] + '...'
|
|
155
|
+
|
|
156
|
+
return display_result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _build_email_html(
|
|
160
|
+
task_id: str,
|
|
161
|
+
title: str,
|
|
162
|
+
status: str,
|
|
163
|
+
result: Optional[str] = None,
|
|
164
|
+
error: Optional[str] = None,
|
|
165
|
+
runtime_seconds: Optional[int] = None,
|
|
166
|
+
session_id: Optional[str] = None,
|
|
167
|
+
reply_enabled: bool = False,
|
|
168
|
+
worker_name: str = 'Hosted Worker',
|
|
169
|
+
) -> str:
|
|
170
|
+
"""Build HTML email body for task completion."""
|
|
171
|
+
status_color = '#22c55e' if status == 'completed' else '#ef4444'
|
|
172
|
+
status_icon = '✓' if status == 'completed' else '✗'
|
|
173
|
+
duration_str = _format_runtime(runtime_seconds)
|
|
174
|
+
|
|
175
|
+
# Result section
|
|
176
|
+
result_section = ''
|
|
177
|
+
if result and status == 'completed':
|
|
178
|
+
sanitized = _sanitize_result(result)
|
|
179
|
+
if sanitized:
|
|
180
|
+
result_section = f"""
|
|
181
|
+
<tr>
|
|
182
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px; vertical-align: top;">Output</td>
|
|
183
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">
|
|
184
|
+
<div style="font-size: 14px; line-height: 1.6; color: #1f2937;">{sanitized}</div>
|
|
185
|
+
</td>
|
|
186
|
+
</tr>"""
|
|
187
|
+
|
|
188
|
+
# Error section
|
|
189
|
+
error_section = ''
|
|
190
|
+
if error:
|
|
191
|
+
truncated = html_module.escape(
|
|
192
|
+
error[:1000] + '...' if len(error) > 1000 else error
|
|
193
|
+
)
|
|
194
|
+
error_section = f"""
|
|
195
|
+
<tr>
|
|
196
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px;">Error</td>
|
|
197
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">
|
|
198
|
+
<pre style="margin: 0; white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: 13px; background: #fef2f2; padding: 12px; border-radius: 6px; color: #dc2626;">{truncated}</pre>
|
|
199
|
+
</td>
|
|
200
|
+
</tr>"""
|
|
201
|
+
|
|
202
|
+
# Footer
|
|
203
|
+
if reply_enabled:
|
|
204
|
+
footer_html = f"""
|
|
205
|
+
<div style="background: #f9fafb; padding: 16px; text-align: center;">
|
|
206
|
+
<p style="margin: 0 0 8px 0; font-size: 13px; color: #374151; font-weight: 500;">
|
|
207
|
+
Reply to this email to continue the conversation
|
|
208
|
+
</p>
|
|
209
|
+
<p style="margin: 0; font-size: 12px; color: #6b7280;">
|
|
210
|
+
Your reply will be sent to the agent to continue working on this task.
|
|
211
|
+
</p>
|
|
212
|
+
<p style="margin: 8px 0 0 0; font-size: 11px; color: #9ca3af;">
|
|
213
|
+
Sent by CodeTether - {worker_name}
|
|
214
|
+
</p>
|
|
215
|
+
</div>"""
|
|
216
|
+
else:
|
|
217
|
+
footer_html = f"""
|
|
218
|
+
<div style="background: #f9fafb; padding: 16px; text-align: center; font-size: 12px; color: #6b7280;">
|
|
219
|
+
Sent by CodeTether - {worker_name}
|
|
220
|
+
</div>"""
|
|
221
|
+
|
|
222
|
+
return f"""
|
|
223
|
+
<!DOCTYPE html>
|
|
224
|
+
<html>
|
|
225
|
+
<head>
|
|
226
|
+
<meta charset="utf-8">
|
|
227
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
228
|
+
</head>
|
|
229
|
+
<body style="margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
|
|
230
|
+
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
231
|
+
<div style="background: linear-gradient(135deg, #1e293b 0%, #334155 100%); padding: 24px; text-align: center;">
|
|
232
|
+
<h1 style="margin: 0; color: white; font-size: 20px; font-weight: 600;">Task Report</h1>
|
|
233
|
+
</div>
|
|
234
|
+
<div style="padding: 24px;">
|
|
235
|
+
<div style="display: inline-block; padding: 6px 12px; border-radius: 20px; background: {status_color}20; color: {status_color}; font-weight: 600; font-size: 14px; margin-bottom: 16px;">
|
|
236
|
+
{status_icon} {status.upper()}
|
|
237
|
+
</div>
|
|
238
|
+
<table style="width: 100%; border-collapse: collapse; margin-top: 16px;">
|
|
239
|
+
<tr>
|
|
240
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px;">Task</td>
|
|
241
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">{html_module.escape(title)}</td>
|
|
242
|
+
</tr>
|
|
243
|
+
<tr>
|
|
244
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151;">Task ID</td>
|
|
245
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-family: monospace; font-size: 13px;">{task_id}</td>
|
|
246
|
+
</tr>
|
|
247
|
+
<tr>
|
|
248
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151;">Duration</td>
|
|
249
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">{duration_str}</td>
|
|
250
|
+
</tr>
|
|
251
|
+
{result_section}
|
|
252
|
+
{error_section}
|
|
253
|
+
</table>
|
|
254
|
+
</div>
|
|
255
|
+
{footer_html}
|
|
256
|
+
</div>
|
|
257
|
+
</body>
|
|
258
|
+
</html>"""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def send_task_completion_email(
|
|
262
|
+
to_email: str,
|
|
263
|
+
task_id: str,
|
|
264
|
+
title: str,
|
|
265
|
+
status: str,
|
|
266
|
+
result: Optional[str] = None,
|
|
267
|
+
error: Optional[str] = None,
|
|
268
|
+
runtime_seconds: Optional[int] = None,
|
|
269
|
+
session_id: Optional[str] = None,
|
|
270
|
+
codebase_id: Optional[str] = None,
|
|
271
|
+
worker_name: str = 'Hosted Worker',
|
|
272
|
+
) -> bool:
|
|
273
|
+
"""
|
|
274
|
+
Send a task completion email via SendGrid.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
to_email: Recipient email address
|
|
278
|
+
task_id: ID of the completed task
|
|
279
|
+
title: Task title
|
|
280
|
+
status: Task status ('completed' or 'failed')
|
|
281
|
+
result: Task result/output (optional)
|
|
282
|
+
error: Error message if failed (optional)
|
|
283
|
+
runtime_seconds: Task runtime in seconds (optional)
|
|
284
|
+
session_id: Session ID for reply continuation (optional)
|
|
285
|
+
codebase_id: Codebase ID for reply continuation (optional)
|
|
286
|
+
worker_name: Worker name for email footer
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
True if email sent successfully, False otherwise
|
|
290
|
+
"""
|
|
291
|
+
config = _get_config()
|
|
292
|
+
|
|
293
|
+
if not config['api_key'] or not config['from_email']:
|
|
294
|
+
logger.warning(
|
|
295
|
+
'Email not configured (missing SENDGRID_API_KEY or SENDGRID_FROM_EMAIL)'
|
|
296
|
+
)
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
# Build reply-to address if session_id is provided
|
|
300
|
+
reply_to = None
|
|
301
|
+
reply_enabled = False
|
|
302
|
+
if session_id and config['inbound_domain']:
|
|
303
|
+
reply_to = build_reply_to_address(session_id, codebase_id)
|
|
304
|
+
reply_enabled = True
|
|
305
|
+
|
|
306
|
+
# Build email content
|
|
307
|
+
html_body = _build_email_html(
|
|
308
|
+
task_id=task_id,
|
|
309
|
+
title=title,
|
|
310
|
+
status=status,
|
|
311
|
+
result=result,
|
|
312
|
+
error=error,
|
|
313
|
+
runtime_seconds=runtime_seconds,
|
|
314
|
+
session_id=session_id,
|
|
315
|
+
reply_enabled=reply_enabled,
|
|
316
|
+
worker_name=worker_name,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Conversion-optimized subject lines
|
|
320
|
+
if status == 'completed':
|
|
321
|
+
subject = f'✅ Done: {title}'
|
|
322
|
+
elif status == 'failed':
|
|
323
|
+
subject = f'❌ Failed: {title} (reply for a fix)'
|
|
324
|
+
else:
|
|
325
|
+
subject = f'[CodeTether] Task {status}: {title}'
|
|
326
|
+
|
|
327
|
+
# Build SendGrid payload
|
|
328
|
+
payload = {
|
|
329
|
+
'personalizations': [{'to': [{'email': to_email}]}],
|
|
330
|
+
'from': {'email': config['from_email']},
|
|
331
|
+
'subject': subject,
|
|
332
|
+
'content': [{'type': 'text/html', 'value': html_body}],
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if reply_to:
|
|
336
|
+
payload['reply_to'] = {'email': reply_to}
|
|
337
|
+
|
|
338
|
+
# Send via SendGrid
|
|
339
|
+
try:
|
|
340
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
341
|
+
response = await client.post(
|
|
342
|
+
SENDGRID_API_URL,
|
|
343
|
+
json=payload,
|
|
344
|
+
headers={
|
|
345
|
+
'Authorization': f'Bearer {config["api_key"]}',
|
|
346
|
+
'Content-Type': 'application/json',
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if response.status_code in (200, 202):
|
|
351
|
+
logger.info(f'Email sent to {to_email} for task {task_id}')
|
|
352
|
+
return True
|
|
353
|
+
else:
|
|
354
|
+
logger.error(
|
|
355
|
+
f'SendGrid error {response.status_code}: {response.text}'
|
|
356
|
+
)
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(f'Failed to send email: {e}')
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def send_task_question_email(
|
|
365
|
+
to_email: str,
|
|
366
|
+
task_id: str,
|
|
367
|
+
title: str,
|
|
368
|
+
question: str,
|
|
369
|
+
session_id: str,
|
|
370
|
+
codebase_id: Optional[str] = None,
|
|
371
|
+
worker_name: str = 'Hosted Worker',
|
|
372
|
+
) -> bool:
|
|
373
|
+
"""
|
|
374
|
+
Send a "needs input" email when task requires user response.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
to_email: Recipient email address
|
|
378
|
+
task_id: ID of the task needing input
|
|
379
|
+
title: Task title
|
|
380
|
+
question: The question/input needed from user
|
|
381
|
+
session_id: Session ID for reply (required for question emails)
|
|
382
|
+
codebase_id: Codebase ID for reply continuation
|
|
383
|
+
worker_name: Worker name for email footer
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
True if email sent successfully, False otherwise
|
|
387
|
+
"""
|
|
388
|
+
config = _get_config()
|
|
389
|
+
|
|
390
|
+
if not config['api_key'] or not config['from_email']:
|
|
391
|
+
logger.warning('Email not configured')
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
if not config['inbound_domain']:
|
|
395
|
+
logger.warning(
|
|
396
|
+
'Cannot send question email without EMAIL_INBOUND_DOMAIN configured'
|
|
397
|
+
)
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
reply_to = build_reply_to_address(session_id, codebase_id)
|
|
401
|
+
|
|
402
|
+
# Build question email HTML
|
|
403
|
+
question_escaped = html_module.escape(question).replace('\n', '<br>')
|
|
404
|
+
|
|
405
|
+
html_body = f"""
|
|
406
|
+
<!DOCTYPE html>
|
|
407
|
+
<html>
|
|
408
|
+
<head>
|
|
409
|
+
<meta charset="utf-8">
|
|
410
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
411
|
+
</head>
|
|
412
|
+
<body style="margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
|
|
413
|
+
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
414
|
+
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 24px; text-align: center;">
|
|
415
|
+
<h1 style="margin: 0; color: white; font-size: 20px; font-weight: 600;">Input Needed</h1>
|
|
416
|
+
</div>
|
|
417
|
+
<div style="padding: 24px;">
|
|
418
|
+
<div style="display: inline-block; padding: 6px 12px; border-radius: 20px; background: #fef3c720; color: #d97706; font-weight: 600; font-size: 14px; margin-bottom: 16px;">
|
|
419
|
+
⏳ WAITING FOR INPUT
|
|
420
|
+
</div>
|
|
421
|
+
<table style="width: 100%; border-collapse: collapse; margin-top: 16px;">
|
|
422
|
+
<tr>
|
|
423
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px;">Task</td>
|
|
424
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">{html_module.escape(title)}</td>
|
|
425
|
+
</tr>
|
|
426
|
+
<tr>
|
|
427
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; vertical-align: top;">Question</td>
|
|
428
|
+
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">
|
|
429
|
+
<div style="background: #fffbeb; padding: 16px; border-radius: 8px; border-left: 4px solid #f59e0b;">
|
|
430
|
+
<p style="margin: 0; font-size: 15px; line-height: 1.6; color: #1f2937;">{question_escaped}</p>
|
|
431
|
+
</div>
|
|
432
|
+
</td>
|
|
433
|
+
</tr>
|
|
434
|
+
</table>
|
|
435
|
+
</div>
|
|
436
|
+
<div style="background: #f9fafb; padding: 16px; text-align: center;">
|
|
437
|
+
<p style="margin: 0 0 8px 0; font-size: 14px; color: #374151; font-weight: 600;">
|
|
438
|
+
Reply to this email with your answer
|
|
439
|
+
</p>
|
|
440
|
+
<p style="margin: 0; font-size: 12px; color: #6b7280;">
|
|
441
|
+
Your response will be sent to the agent to continue the task.
|
|
442
|
+
</p>
|
|
443
|
+
<p style="margin: 8px 0 0 0; font-size: 11px; color: #9ca3af;">
|
|
444
|
+
Sent by CodeTether - {worker_name}
|
|
445
|
+
</p>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</body>
|
|
449
|
+
</html>"""
|
|
450
|
+
|
|
451
|
+
# Conversion-optimized subject line for questions
|
|
452
|
+
subject = f'❓ Question: {title} (reply to continue)'
|
|
453
|
+
|
|
454
|
+
payload = {
|
|
455
|
+
'personalizations': [{'to': [{'email': to_email}]}],
|
|
456
|
+
'from': {'email': config['from_email']},
|
|
457
|
+
'reply_to': {'email': reply_to},
|
|
458
|
+
'subject': subject,
|
|
459
|
+
'content': [{'type': 'text/html', 'value': html_body}],
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
464
|
+
response = await client.post(
|
|
465
|
+
SENDGRID_API_URL,
|
|
466
|
+
json=payload,
|
|
467
|
+
headers={
|
|
468
|
+
'Authorization': f'Bearer {config["api_key"]}',
|
|
469
|
+
'Content-Type': 'application/json',
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if response.status_code in (200, 202):
|
|
474
|
+
logger.info(
|
|
475
|
+
f'Question email sent to {to_email} for task {task_id}'
|
|
476
|
+
)
|
|
477
|
+
return True
|
|
478
|
+
else:
|
|
479
|
+
logger.error(
|
|
480
|
+
f'SendGrid error {response.status_code}: {response.text}'
|
|
481
|
+
)
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.error(f'Failed to send question email: {e}')
|
|
486
|
+
return False
|