kairo-code 0.1.0__py3-none-any.whl → 0.2.0__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.
- kairo/backend/api/agents.py +337 -16
- kairo/backend/app.py +84 -4
- kairo/backend/config.py +4 -2
- kairo/backend/models/agent.py +216 -2
- kairo/backend/models/api_key.py +4 -1
- kairo/backend/models/task.py +31 -0
- kairo/backend/models/user_provider_key.py +26 -0
- kairo/backend/schemas/agent.py +249 -2
- kairo/backend/schemas/api_key.py +3 -0
- kairo/backend/services/agent/__init__.py +52 -0
- kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
- kairo/backend/services/agent/agent_alerts_service.py +201 -0
- kairo/backend/services/agent/agent_commands_service.py +142 -0
- kairo/backend/services/agent/agent_crud_service.py +150 -0
- kairo/backend/services/agent/agent_events_service.py +103 -0
- kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
- kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
- kairo/backend/services/agent/agent_metrics_service.py +259 -0
- kairo/backend/services/agent/agent_service.py +315 -0
- kairo/backend/services/agent/agent_setup_service.py +180 -0
- kairo/backend/services/agent/constants.py +28 -0
- kairo/backend/services/agent_service.py +18 -102
- kairo/backend/services/api_key_service.py +23 -3
- kairo/backend/services/byok_service.py +204 -0
- kairo/backend/services/chat_service.py +398 -63
- kairo/backend/services/deep_search_service.py +159 -0
- kairo/backend/services/email_service.py +418 -19
- kairo/backend/services/few_shot_service.py +223 -0
- kairo/backend/services/post_processor.py +261 -0
- kairo/backend/services/rag_service.py +150 -0
- kairo/backend/services/task_service.py +119 -0
- kairo/backend/tests/__init__.py +1 -0
- kairo/backend/tests/e2e/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/conftest.py +389 -0
- kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
- kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
- kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
- kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
- kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
- kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
- kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
- kairo/migrations/versions/010_agent_dashboard.py +246 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
- kairo_migrations/env.py +92 -0
- kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Deep search service.
|
|
2
|
+
|
|
3
|
+
Unlike web_search which only returns DuckDuckGo snippets, deep_search:
|
|
4
|
+
1. Searches the web for the query
|
|
5
|
+
2. Fetches the top 2 result pages
|
|
6
|
+
3. Extracts meaningful text content
|
|
7
|
+
4. Returns the actual page content to the model
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from html import unescape
|
|
13
|
+
from urllib.parse import unquote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_STRIP_TAGS = re.compile(
|
|
20
|
+
r"<(script|style|nav|footer|header|noscript|iframe|svg|aside)[^>]*>.*?</\1>",
|
|
21
|
+
re.DOTALL | re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
_HTML_TAG = re.compile(r"<[^>]+>")
|
|
24
|
+
_MULTI_NEWLINE = re.compile(r"\n{3,}")
|
|
25
|
+
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _html_to_text(html: str) -> str:
|
|
29
|
+
"""Extract readable text from HTML, stripping scripts/styles/nav."""
|
|
30
|
+
text = _STRIP_TAGS.sub("", html)
|
|
31
|
+
text = re.sub(r"<(br|hr|/p|/div|/li|/tr|/h[1-6])[^>]*>", "\n", text, flags=re.IGNORECASE)
|
|
32
|
+
text = _HTML_TAG.sub("", text)
|
|
33
|
+
text = unescape(text)
|
|
34
|
+
text = _MULTI_SPACE.sub(" ", text)
|
|
35
|
+
text = _MULTI_NEWLINE.sub("\n\n", text)
|
|
36
|
+
return text.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_main_content(text: str, max_chars: int = 3000) -> str:
|
|
40
|
+
"""Extract the most informative portion of page text."""
|
|
41
|
+
lines = text.split("\n")
|
|
42
|
+
content_lines = []
|
|
43
|
+
char_count = 0
|
|
44
|
+
for line in lines:
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if len(line) < 20:
|
|
47
|
+
if not line and content_lines and content_lines[-1]:
|
|
48
|
+
content_lines.append("")
|
|
49
|
+
continue
|
|
50
|
+
content_lines.append(line)
|
|
51
|
+
char_count += len(line)
|
|
52
|
+
if char_count >= max_chars:
|
|
53
|
+
break
|
|
54
|
+
return "\n".join(content_lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _fetch_page(url: str, client: httpx.AsyncClient) -> str | None:
|
|
58
|
+
"""Fetch a page and return extracted text content."""
|
|
59
|
+
try:
|
|
60
|
+
resp = await client.get(
|
|
61
|
+
url,
|
|
62
|
+
headers={
|
|
63
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
64
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
65
|
+
},
|
|
66
|
+
follow_redirects=True,
|
|
67
|
+
)
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
content_type = resp.headers.get("content-type", "")
|
|
70
|
+
if "text/html" not in content_type and "application/xhtml" not in content_type:
|
|
71
|
+
return None
|
|
72
|
+
html = resp.text
|
|
73
|
+
text = _html_to_text(html)
|
|
74
|
+
return _extract_main_content(text)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning("Failed to fetch %s: %s", url, e)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _search_ddg(query: str, client: httpx.AsyncClient) -> list[dict]:
|
|
81
|
+
"""Search DuckDuckGo and return results with URLs."""
|
|
82
|
+
results = []
|
|
83
|
+
try:
|
|
84
|
+
resp = await client.post(
|
|
85
|
+
"https://html.duckduckgo.com/html/",
|
|
86
|
+
data={"q": query, "b": ""},
|
|
87
|
+
headers={
|
|
88
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
resp.raise_for_status()
|
|
92
|
+
html = resp.text
|
|
93
|
+
|
|
94
|
+
link_pattern = re.compile(
|
|
95
|
+
r'<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
|
|
96
|
+
re.DOTALL,
|
|
97
|
+
)
|
|
98
|
+
snippet_pattern = re.compile(
|
|
99
|
+
r'<a[^>]+class="result__snippet"[^>]*>(.*?)</a>',
|
|
100
|
+
re.DOTALL,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
links = link_pattern.findall(html)
|
|
104
|
+
snippets = snippet_pattern.findall(html)
|
|
105
|
+
|
|
106
|
+
for i, (url, title) in enumerate(links[:5]):
|
|
107
|
+
clean_title = unescape(re.sub(r"<[^>]+>", "", title)).strip()
|
|
108
|
+
clean_snippet = ""
|
|
109
|
+
if i < len(snippets):
|
|
110
|
+
clean_snippet = unescape(re.sub(r"<[^>]+>", "", snippets[i])).strip()
|
|
111
|
+
actual_url = url
|
|
112
|
+
uddg_match = re.search(r"uddg=([^&]+)", url)
|
|
113
|
+
if uddg_match:
|
|
114
|
+
actual_url = unquote(uddg_match.group(1))
|
|
115
|
+
if clean_title and actual_url:
|
|
116
|
+
results.append({
|
|
117
|
+
"title": clean_title,
|
|
118
|
+
"url": actual_url,
|
|
119
|
+
"snippet": clean_snippet,
|
|
120
|
+
})
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.warning("Deep search DDG query failed: %s", e)
|
|
123
|
+
return results
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def deep_search(query: str, max_pages: int = 2) -> str:
|
|
127
|
+
"""Search the web and fetch actual page content from top results."""
|
|
128
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
129
|
+
results = await _search_ddg(query, client)
|
|
130
|
+
if not results:
|
|
131
|
+
return "Deep search found no results. Try rephrasing the query."
|
|
132
|
+
|
|
133
|
+
# Instructions go FIRST so the model sees them before the content
|
|
134
|
+
parts = [
|
|
135
|
+
"CRITICAL: The following contains LIVE page content fetched from real websites just now. "
|
|
136
|
+
"You MUST base your ENTIRE answer on this content. Do NOT use your training data. "
|
|
137
|
+
"Do NOT say 'sign up' or 'get an API key' unless the page content explicitly says so. "
|
|
138
|
+
"Extract exact endpoints, parameter names, and formats from the content below.",
|
|
139
|
+
"",
|
|
140
|
+
"=== DEEP SEARCH RESULTS ===",
|
|
141
|
+
f"Query: {query}",
|
|
142
|
+
"",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
pages_fetched = 0
|
|
146
|
+
for result in results:
|
|
147
|
+
parts.append(f"--- Result: {result['title']} ---")
|
|
148
|
+
parts.append(f"URL: {result['url']}")
|
|
149
|
+
parts.append(f"Snippet: {result['snippet']}")
|
|
150
|
+
|
|
151
|
+
if pages_fetched < max_pages:
|
|
152
|
+
content = await _fetch_page(result["url"], client)
|
|
153
|
+
if content and len(content) > 100:
|
|
154
|
+
parts.append(f"\nPage content:\n{content}")
|
|
155
|
+
pages_fetched += 1
|
|
156
|
+
parts.append("")
|
|
157
|
+
|
|
158
|
+
parts.append("=== END DEEP SEARCH RESULTS ===")
|
|
159
|
+
return "\n".join(parts)
|
|
@@ -1,55 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email service for Kairo transactional and notification emails.
|
|
3
|
+
|
|
4
|
+
Handles all outbound email including:
|
|
5
|
+
- Transactional (verification, password reset, welcome)
|
|
6
|
+
- Billing (subscription confirmations, payment failures)
|
|
7
|
+
- Alerts (agent offline, usage warnings)
|
|
8
|
+
- Sales (enterprise inquiries)
|
|
9
|
+
- Security (API key creation, new login alerts)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
1
13
|
import logging
|
|
2
14
|
import smtplib
|
|
3
15
|
from email.mime.multipart import MIMEMultipart
|
|
4
16
|
from email.mime.text import MIMEText
|
|
17
|
+
from html import escape
|
|
5
18
|
|
|
6
19
|
from backend.config import settings
|
|
7
20
|
|
|
8
21
|
logger = logging.getLogger(__name__)
|
|
9
22
|
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# From-address aliases (configured in Google Workspace)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
DOMAIN = "kaironlabs.io"
|
|
27
|
+
FROM_NOREPLY = f"Kairo <noreply@{DOMAIN}>"
|
|
28
|
+
FROM_BILLING = f"Kairo Billing <billing@{DOMAIN}>"
|
|
29
|
+
FROM_ALERTS = f"Kairo Alerts <alerts@{DOMAIN}>"
|
|
30
|
+
FROM_SALES = f"Kairon Labs <sales@{DOMAIN}>"
|
|
31
|
+
FROM_SECURITY = f"Kairo Security <security@{DOMAIN}>"
|
|
32
|
+
|
|
33
|
+
SITE_URL = "https://kaironlabs.io"
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Brand colors (matching kaironlabs.io)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
BRAND_ACCENT = "#f97066" # Coral/salmon - primary brand color
|
|
39
|
+
BRAND_ACCENT_HOVER = "#e85d4a" # Darker coral for dark mode
|
|
40
|
+
BRAND_BG = "#0a0a0a" # Dark background
|
|
41
|
+
BRAND_CARD_BG = "#141414" # Card background
|
|
42
|
+
BRAND_BORDER = "#262626" # Border color
|
|
43
|
+
BRAND_TEXT = "#fafafa" # Light text
|
|
44
|
+
BRAND_TEXT_MUTED = "#a3a3a3" # Muted text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Template helpers
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _base_html(body: str) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Branded email template matching kaironlabs.io dark theme.
|
|
54
|
+
- Dark background with light text
|
|
55
|
+
- Coral accent color (#f97066)
|
|
56
|
+
- Professional footer with compliance links
|
|
57
|
+
"""
|
|
58
|
+
return f'''<!DOCTYPE html>
|
|
59
|
+
<html lang="en">
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="utf-8">
|
|
62
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
63
|
+
<meta name="color-scheme" content="dark">
|
|
64
|
+
<title>Kairo</title>
|
|
65
|
+
</head>
|
|
66
|
+
<body style="margin:0;padding:0;background-color:{BRAND_BG};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
67
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:{BRAND_BG};">
|
|
68
|
+
<tr>
|
|
69
|
+
<td align="center" style="padding:40px 20px;">
|
|
70
|
+
<!-- Main card -->
|
|
71
|
+
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
|
|
72
|
+
style="max-width:560px;width:100%;background-color:{BRAND_CARD_BG};border-radius:12px;border:1px solid {BRAND_BORDER};">
|
|
73
|
+
|
|
74
|
+
<!-- Header with logo -->
|
|
75
|
+
<tr>
|
|
76
|
+
<td style="padding:32px 32px 24px;">
|
|
77
|
+
<span style="font-size:28px;font-weight:800;color:{BRAND_ACCENT};letter-spacing:-0.5px;">KAIRO</span>
|
|
78
|
+
</td>
|
|
79
|
+
</tr>
|
|
80
|
+
|
|
81
|
+
<!-- Body content -->
|
|
82
|
+
<tr>
|
|
83
|
+
<td style="padding:0 32px 32px;color:{BRAND_TEXT};font-size:15px;line-height:1.6;">
|
|
84
|
+
{body}
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
|
|
88
|
+
<!-- Footer -->
|
|
89
|
+
<tr>
|
|
90
|
+
<td style="padding:24px 32px;border-top:1px solid {BRAND_BORDER};">
|
|
91
|
+
<p style="margin:0 0 8px;font-size:12px;color:{BRAND_TEXT_MUTED};">
|
|
92
|
+
© Kairon Labs ·
|
|
93
|
+
<a href="{SITE_URL}" style="color:{BRAND_TEXT_MUTED};text-decoration:underline;">kaironlabs.io</a>
|
|
94
|
+
</p>
|
|
95
|
+
<p style="margin:0;font-size:12px;color:{BRAND_TEXT_MUTED};">
|
|
96
|
+
<a href="{SITE_URL}/settings/notifications" style="color:{BRAND_TEXT_MUTED};text-decoration:underline;">Email preferences</a>
|
|
97
|
+
·
|
|
98
|
+
<a href="{SITE_URL}/settings/notifications" style="color:{BRAND_TEXT_MUTED};text-decoration:underline;">Unsubscribe</a>
|
|
99
|
+
</p>
|
|
100
|
+
</td>
|
|
101
|
+
</tr>
|
|
102
|
+
|
|
103
|
+
</table>
|
|
104
|
+
</td>
|
|
105
|
+
</tr>
|
|
106
|
+
</table>
|
|
107
|
+
</body>
|
|
108
|
+
</html>'''
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _btn(url: str, label: str) -> str:
|
|
112
|
+
"""Branded button with coral accent color, Outlook-compatible."""
|
|
113
|
+
return f'''<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
|
|
114
|
+
<tr>
|
|
115
|
+
<td style="background:{BRAND_ACCENT};border-radius:8px;">
|
|
116
|
+
<a href="{url}" target="_blank"
|
|
117
|
+
style="display:inline-block;padding:14px 32px;background:{BRAND_ACCENT};color:#ffffff;
|
|
118
|
+
text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;text-align:center;">
|
|
119
|
+
{label}
|
|
120
|
+
</a>
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
</table>'''
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _secondary_btn(url: str, label: str) -> str:
|
|
127
|
+
"""Secondary outlined button."""
|
|
128
|
+
return f'''<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
|
129
|
+
<tr>
|
|
130
|
+
<td style="border:1px solid {BRAND_BORDER};border-radius:8px;">
|
|
131
|
+
<a href="{url}" target="_blank"
|
|
132
|
+
style="display:inline-block;padding:12px 24px;color:{BRAND_TEXT};
|
|
133
|
+
text-decoration:none;border-radius:8px;font-weight:500;font-size:14px;text-align:center;">
|
|
134
|
+
{label}
|
|
135
|
+
</a>
|
|
136
|
+
</td>
|
|
137
|
+
</tr>
|
|
138
|
+
</table>'''
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _link(url: str, text: str) -> str:
|
|
142
|
+
"""Inline link with brand color."""
|
|
143
|
+
return f'<a href="{url}" style="color:{BRAND_ACCENT};text-decoration:underline;">{text}</a>'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _muted(text: str) -> str:
|
|
147
|
+
"""Muted text styling."""
|
|
148
|
+
return f'<p style="font-size:13px;color:{BRAND_TEXT_MUTED};margin:16px 0;">{text}</p>'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _heading(text: str) -> str:
|
|
152
|
+
"""Heading with brand styling."""
|
|
153
|
+
return f'<h2 style="margin:0 0 16px;font-size:24px;font-weight:700;color:{BRAND_TEXT};">{text}</h2>'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _code(text: str) -> str:
|
|
157
|
+
"""Inline code styling."""
|
|
158
|
+
return f'<code style="background:{BRAND_BG};padding:3px 8px;border-radius:4px;font-family:monospace;font-size:13px;color:{BRAND_ACCENT};">{text}</code>'
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _product_item(name: str, desc: str) -> str:
|
|
162
|
+
"""Product list item for welcome email."""
|
|
163
|
+
return f'''<tr>
|
|
164
|
+
<td style="padding:12px 0;border-bottom:1px solid {BRAND_BORDER};">
|
|
165
|
+
<strong style="color:{BRAND_TEXT};font-size:15px;">{name}</strong>
|
|
166
|
+
<p style="margin:4px 0 0;color:{BRAND_TEXT_MUTED};font-size:14px;">{desc}</p>
|
|
167
|
+
</td>
|
|
168
|
+
</tr>'''
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Email Service
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
10
174
|
|
|
11
175
|
class EmailService:
|
|
176
|
+
"""Service for sending transactional and notification emails."""
|
|
177
|
+
|
|
178
|
+
# -----------------------------------------------------------------------
|
|
179
|
+
# Transactional (noreply@)
|
|
180
|
+
# -----------------------------------------------------------------------
|
|
181
|
+
|
|
12
182
|
def send_verification_email(self, to: str, token: str) -> None:
|
|
183
|
+
"""Send email verification link. Expires in 24 hours."""
|
|
13
184
|
link = f"{settings.APP_BASE_URL}/verify-email?token={token}"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
f"
|
|
17
|
-
f"
|
|
18
|
-
f'
|
|
19
|
-
f"
|
|
20
|
-
f"
|
|
185
|
+
html = _base_html(
|
|
186
|
+
f'{_heading("Verify your email")}'
|
|
187
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 8px;">Thanks for signing up for Kairo. Click below to verify your email address.</p>'
|
|
188
|
+
f'{_muted("This link expires in 24 hours.")}'
|
|
189
|
+
f'{_btn(link, "Verify Email")}'
|
|
190
|
+
f'{_muted(f"Or copy this link: {_link(link, link)}")}'
|
|
191
|
+
f'{_muted("If you didn\'t create a Kairo account, you can ignore this email.")}'
|
|
21
192
|
)
|
|
22
|
-
self._send(to,
|
|
193
|
+
self._send(to, "Verify your Kairo email", html, FROM_NOREPLY)
|
|
23
194
|
|
|
24
195
|
def send_password_reset_email(self, to: str, token: str) -> None:
|
|
196
|
+
"""Send password reset link. Expires in 1 hour."""
|
|
25
197
|
link = f"{settings.APP_BASE_URL}/reset-password?token={token}"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
f"
|
|
29
|
-
f"
|
|
30
|
-
f'
|
|
31
|
-
f"
|
|
32
|
-
f"
|
|
198
|
+
html = _base_html(
|
|
199
|
+
f'{_heading("Reset your password")}'
|
|
200
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 8px;">We received a request to reset your password.</p>'
|
|
201
|
+
f'{_muted("This link expires in 1 hour.")}'
|
|
202
|
+
f'{_btn(link, "Reset Password")}'
|
|
203
|
+
f'{_muted(f"Or copy this link: {_link(link, link)}")}'
|
|
204
|
+
f'{_muted("If you didn\'t request this, you can safely ignore this email.")}'
|
|
205
|
+
)
|
|
206
|
+
self._send(to, "Reset your Kairo password", html, FROM_NOREPLY)
|
|
207
|
+
|
|
208
|
+
def send_welcome_email(self, to: str) -> None:
|
|
209
|
+
"""Welcome email after verification with all products."""
|
|
210
|
+
html = _base_html(
|
|
211
|
+
f'{_heading("Welcome to Kairo")}'
|
|
212
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 24px;">Your email is verified and your account is ready. Here\'s what you can do with Kairo:</p>'
|
|
213
|
+
|
|
214
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px;">'
|
|
215
|
+
f'{_product_item("Kairo Desktop", "AI workspace with multi-provider support. Bring your own API keys.")}'
|
|
216
|
+
f'{_product_item("Kairo Web", "Chat with Nyx and other models instantly in your browser.")}'
|
|
217
|
+
f'{_product_item("Kairo Code", "AI coding assistant in your terminal. Context-aware generation.")}'
|
|
218
|
+
f'{_product_item("Kairo Agents", "Deploy autonomous agents on your own infrastructure.")}'
|
|
219
|
+
f'{_product_item("Kairo API", "OpenAI-compatible endpoint for programmatic access.")}'
|
|
220
|
+
f'<tr><td style="padding:12px 0;">'
|
|
221
|
+
f'<strong style="color:{BRAND_TEXT};font-size:15px;">Nyx</strong>'
|
|
222
|
+
f'<p style="margin:4px 0 0;color:{BRAND_TEXT_MUTED};font-size:14px;">Our flagship model, optimized for code and reasoning.</p>'
|
|
223
|
+
f'</td></tr>'
|
|
224
|
+
f'</table>'
|
|
225
|
+
|
|
226
|
+
f'{_btn(settings.APP_BASE_URL, "Get Started")}'
|
|
227
|
+
)
|
|
228
|
+
self._send(to, "Welcome to Kairo", html, FROM_NOREPLY)
|
|
229
|
+
|
|
230
|
+
def send_usage_limit_warning(self, to: str, percent: int, limit_type: str, current: int, limit: int) -> None:
|
|
231
|
+
"""Usage limit warning at 80% and 100% thresholds."""
|
|
232
|
+
if percent >= 100:
|
|
233
|
+
urgency = "at"
|
|
234
|
+
urgency_text = "You've reached your limit. Requests will be paused until it resets."
|
|
235
|
+
elif percent >= 90:
|
|
236
|
+
urgency = "nearly at"
|
|
237
|
+
urgency_text = "You're about to hit your limit."
|
|
238
|
+
else:
|
|
239
|
+
urgency = "approaching"
|
|
240
|
+
urgency_text = "Consider upgrading for higher limits."
|
|
241
|
+
|
|
242
|
+
html = _base_html(
|
|
243
|
+
f'{_heading(f"You\'re {urgency} your {limit_type} limit")}'
|
|
244
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">'
|
|
245
|
+
f'You\'ve used <strong style="color:{BRAND_ACCENT};">{percent}%</strong> of your {limit_type} token allowance.'
|
|
246
|
+
f'</p>'
|
|
247
|
+
f'<p style="color:{BRAND_TEXT_MUTED};font-size:14px;margin:0 0 16px;">'
|
|
248
|
+
f'{current:,} / {limit:,} tokens used'
|
|
249
|
+
f'</p>'
|
|
250
|
+
f'{_muted(urgency_text)}'
|
|
251
|
+
f'{_btn(SITE_URL + "/pricing", "View Plans")}'
|
|
252
|
+
)
|
|
253
|
+
self._send(to, f"Kairo — {percent}% of {limit_type} limit used", html, FROM_NOREPLY)
|
|
254
|
+
|
|
255
|
+
# -----------------------------------------------------------------------
|
|
256
|
+
# Billing (billing@)
|
|
257
|
+
# -----------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def send_subscription_confirmed(self, to: str, plan: str) -> None:
|
|
260
|
+
"""Subscription confirmation email."""
|
|
261
|
+
html = _base_html(
|
|
262
|
+
f'{_heading("Subscription confirmed")}'
|
|
263
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">'
|
|
264
|
+
f'You\'re now on the <strong style="color:{BRAND_ACCENT};">Kairo {plan}</strong> plan.'
|
|
265
|
+
f'</p>'
|
|
266
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 8px;">Your new limits are active immediately.</p>'
|
|
267
|
+
f'{_muted("A receipt has been sent to this email. Manage your subscription anytime from account settings.")}'
|
|
268
|
+
f'{_btn(settings.APP_BASE_URL + "/settings", "Account Settings")}'
|
|
269
|
+
)
|
|
270
|
+
self._send(to, f"Kairo {plan} — subscription confirmed", html, FROM_BILLING)
|
|
271
|
+
|
|
272
|
+
def send_subscription_cancelled(self, to: str) -> None:
|
|
273
|
+
"""Subscription cancellation confirmation."""
|
|
274
|
+
html = _base_html(
|
|
275
|
+
f'{_heading("Subscription cancelled")}'
|
|
276
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">'
|
|
277
|
+
f'Your Kairo subscription has been cancelled as requested.'
|
|
278
|
+
f'</p>'
|
|
279
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 8px;">'
|
|
280
|
+
f'You\'ll continue to have access to your current plan until the end of your billing period. '
|
|
281
|
+
f'After that, your account will revert to the Free plan.'
|
|
282
|
+
f'</p>'
|
|
283
|
+
f'{_muted("Your conversations and data will remain intact.")}'
|
|
284
|
+
f'{_muted("Changed your mind?")}'
|
|
285
|
+
f'{_secondary_btn(SITE_URL + "/pricing", "View Plans")}'
|
|
286
|
+
)
|
|
287
|
+
self._send(to, "Kairo — subscription cancelled", html, FROM_BILLING)
|
|
288
|
+
|
|
289
|
+
def send_payment_failed(self, to: str) -> None:
|
|
290
|
+
"""Payment failure notification with 7-day deadline."""
|
|
291
|
+
html = _base_html(
|
|
292
|
+
f'{_heading("Payment failed")}'
|
|
293
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">'
|
|
294
|
+
f'We couldn\'t process your latest payment for Kairo.'
|
|
295
|
+
f'</p>'
|
|
296
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 8px;">'
|
|
297
|
+
f'Please update your payment method within <strong style="color:{BRAND_ACCENT};">7 days</strong> to keep your subscription active.'
|
|
298
|
+
f'</p>'
|
|
299
|
+
f'{_btn(settings.APP_BASE_URL + "/settings/billing", "Update Payment Method")}'
|
|
300
|
+
f'{_muted("We\'ll retry automatically. If not updated, your account will be downgraded to Free.")}'
|
|
301
|
+
)
|
|
302
|
+
self._send(to, "Kairo — payment failed", html, FROM_BILLING)
|
|
303
|
+
|
|
304
|
+
# -----------------------------------------------------------------------
|
|
305
|
+
# Alerts (alerts@)
|
|
306
|
+
# -----------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def send_agent_offline_alert(self, to: str, agent_name: str, agent_id: str, offline_since: str = None) -> None:
|
|
309
|
+
"""Agent offline notification."""
|
|
310
|
+
time_info = f'<p style="color:{BRAND_TEXT_MUTED};font-size:13px;margin:8px 0;">Last seen: {offline_since}</p>' if offline_since else ''
|
|
311
|
+
|
|
312
|
+
html = _base_html(
|
|
313
|
+
f'{_heading("Agent offline")}'
|
|
314
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 8px;">'
|
|
315
|
+
f'Your agent <strong>{escape(agent_name)}</strong> ({_code(agent_id[:8] + "...")}) '
|
|
316
|
+
f'has stopped responding and was marked offline.'
|
|
317
|
+
f'</p>'
|
|
318
|
+
f'{time_info}'
|
|
319
|
+
f'<p style="color:{BRAND_TEXT};margin:16px 0 8px;">Please verify:</p>'
|
|
320
|
+
f'<ul style="color:{BRAND_TEXT_MUTED};margin:0;padding-left:20px;">'
|
|
321
|
+
f'<li>The agent process is running</li>'
|
|
322
|
+
f'<li>The host can reach the Kairo API</li>'
|
|
323
|
+
f'</ul>'
|
|
324
|
+
f'{_btn(settings.APP_BASE_URL + "/agents", "View Agents")}'
|
|
33
325
|
)
|
|
34
|
-
self._send(to,
|
|
326
|
+
self._send(to, f'Kairo — agent "{agent_name}" went offline', html, FROM_ALERTS)
|
|
35
327
|
|
|
36
|
-
def
|
|
328
|
+
async def send_agent_offline_alert_async(self, to: str, agent_name: str, agent_id: str, offline_since: str = None) -> None:
|
|
329
|
+
"""Async version that doesn't block the event loop."""
|
|
330
|
+
await asyncio.to_thread(self.send_agent_offline_alert, to, agent_name, agent_id, offline_since)
|
|
331
|
+
|
|
332
|
+
# -----------------------------------------------------------------------
|
|
333
|
+
# Sales (sales@)
|
|
334
|
+
# -----------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def send_enterprise_inquiry_confirmation(self, to: str, name: str) -> None:
|
|
337
|
+
"""Confirmation email to enterprise inquiry sender."""
|
|
338
|
+
html = _base_html(
|
|
339
|
+
f'{_heading("Thanks for reaching out")}'
|
|
340
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">Hi {escape(name)},</p>'
|
|
341
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">'
|
|
342
|
+
f'We received your inquiry about Kairo Enterprise. Our team will review your request '
|
|
343
|
+
f'and respond within 1 business day.'
|
|
344
|
+
f'</p>'
|
|
345
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 24px;">'
|
|
346
|
+
f'In the meantime, explore our products at {_link(SITE_URL, "kaironlabs.io")}.'
|
|
347
|
+
f'</p>'
|
|
348
|
+
f'<p style="color:{BRAND_TEXT_MUTED};margin:24px 0 0;">— The Kairon Labs Team</p>'
|
|
349
|
+
)
|
|
350
|
+
self._send(to, "Kairon Labs — we received your inquiry", html, FROM_SALES)
|
|
351
|
+
|
|
352
|
+
def send_enterprise_inquiry_internal(self, name: str, email: str, company: str, message: str) -> None:
|
|
353
|
+
"""Internal notification for enterprise inquiries (sent to sales@)."""
|
|
354
|
+
html = _base_html(
|
|
355
|
+
f'{_heading("New Enterprise Inquiry")}'
|
|
356
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">'
|
|
357
|
+
f'<tr><td style="padding:8px 0;color:{BRAND_TEXT_MUTED};width:80px;">Name</td>'
|
|
358
|
+
f'<td style="padding:8px 0;color:{BRAND_TEXT};">{escape(name)}</td></tr>'
|
|
359
|
+
f'<tr><td style="padding:8px 0;color:{BRAND_TEXT_MUTED};">Email</td>'
|
|
360
|
+
f'<td style="padding:8px 0;">{_link("mailto:" + escape(email), escape(email))}</td></tr>'
|
|
361
|
+
f'<tr><td style="padding:8px 0;color:{BRAND_TEXT_MUTED};">Company</td>'
|
|
362
|
+
f'<td style="padding:8px 0;color:{BRAND_TEXT};">{escape(company) or "—"}</td></tr>'
|
|
363
|
+
f'</table>'
|
|
364
|
+
f'<p style="color:{BRAND_TEXT_MUTED};margin:24px 0 8px;font-size:13px;">MESSAGE</p>'
|
|
365
|
+
f'<div style="background:{BRAND_BG};padding:16px;border-radius:8px;border:1px solid {BRAND_BORDER};">'
|
|
366
|
+
f'<p style="color:{BRAND_TEXT};margin:0;white-space:pre-wrap;">{escape(message)}</p>'
|
|
367
|
+
f'</div>'
|
|
368
|
+
)
|
|
369
|
+
self._send(f"sales@{DOMAIN}", f"Enterprise inquiry from {escape(name)}", html, FROM_SALES)
|
|
370
|
+
|
|
371
|
+
# -----------------------------------------------------------------------
|
|
372
|
+
# Security (security@)
|
|
373
|
+
# -----------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def send_api_key_created(self, to: str, key_name: str, key_prefix: str) -> None:
|
|
376
|
+
"""Security notification when an API key is created."""
|
|
377
|
+
html = _base_html(
|
|
378
|
+
f'{_heading("New API key created")}'
|
|
379
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">'
|
|
380
|
+
f'A new API key was created on your Kairo account.'
|
|
381
|
+
f'</p>'
|
|
382
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
|
383
|
+
f'style="background:{BRAND_BG};padding:16px;border-radius:8px;border:1px solid {BRAND_BORDER};margin:16px 0;">'
|
|
384
|
+
f'<tr><td style="padding:4px 0;color:{BRAND_TEXT_MUTED};width:60px;">Name</td>'
|
|
385
|
+
f'<td style="padding:4px 0;color:{BRAND_TEXT};">{escape(key_name)}</td></tr>'
|
|
386
|
+
f'<tr><td style="padding:4px 0;color:{BRAND_TEXT_MUTED};">Key</td>'
|
|
387
|
+
f'<td style="padding:4px 0;">{_code(key_prefix + "...")}</td></tr>'
|
|
388
|
+
f'</table>'
|
|
389
|
+
f'{_muted("If you didn\'t create this key, please revoke it immediately and change your password.")}'
|
|
390
|
+
f'{_btn(settings.APP_BASE_URL + "/settings", "Manage API Keys")}'
|
|
391
|
+
)
|
|
392
|
+
self._send(to, "Kairo — new API key created", html, FROM_SECURITY)
|
|
393
|
+
|
|
394
|
+
def send_new_login_alert(self, to: str, device: str, location: str, time: str) -> None:
|
|
395
|
+
"""Security alert for login from a new device."""
|
|
396
|
+
html = _base_html(
|
|
397
|
+
f'{_heading("New login detected")}'
|
|
398
|
+
f'<p style="color:{BRAND_TEXT};margin:0 0 16px;">'
|
|
399
|
+
f'Your Kairo account was accessed from a new device.'
|
|
400
|
+
f'</p>'
|
|
401
|
+
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
|
402
|
+
f'style="background:{BRAND_BG};padding:16px;border-radius:8px;border:1px solid {BRAND_BORDER};margin:16px 0;">'
|
|
403
|
+
f'<tr><td style="padding:4px 0;color:{BRAND_TEXT_MUTED};width:80px;">Device</td>'
|
|
404
|
+
f'<td style="padding:4px 0;color:{BRAND_TEXT};">{escape(device)}</td></tr>'
|
|
405
|
+
f'<tr><td style="padding:4px 0;color:{BRAND_TEXT_MUTED};">Location</td>'
|
|
406
|
+
f'<td style="padding:4px 0;color:{BRAND_TEXT};">{escape(location)}</td></tr>'
|
|
407
|
+
f'<tr><td style="padding:4px 0;color:{BRAND_TEXT_MUTED};">Time</td>'
|
|
408
|
+
f'<td style="padding:4px 0;color:{BRAND_TEXT};">{escape(time)}</td></tr>'
|
|
409
|
+
f'</table>'
|
|
410
|
+
f'{_muted("If this was you, no action is needed.")}'
|
|
411
|
+
f'{_muted("If you don\'t recognize this login, secure your account immediately.")}'
|
|
412
|
+
f'{_btn(settings.APP_BASE_URL + "/settings", "Review Account Security")}'
|
|
413
|
+
)
|
|
414
|
+
self._send(to, "Kairo — new login from new device", html, FROM_SECURITY)
|
|
415
|
+
|
|
416
|
+
# -----------------------------------------------------------------------
|
|
417
|
+
# Core send methods
|
|
418
|
+
# -----------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def _send(self, to: str, subject: str, html_body: str, from_header: str = None) -> None:
|
|
421
|
+
"""Synchronous email send."""
|
|
37
422
|
if not settings.SMTP_USERNAME:
|
|
38
423
|
logger.warning("SMTP not configured, skipping email to %s", to)
|
|
39
424
|
return
|
|
40
425
|
|
|
426
|
+
if from_header is None:
|
|
427
|
+
from_header = FROM_NOREPLY
|
|
428
|
+
|
|
429
|
+
# Extract bare email from "Name <email>" format
|
|
430
|
+
if "<" in from_header:
|
|
431
|
+
from_email = from_header.split("<")[1].rstrip(">")
|
|
432
|
+
else:
|
|
433
|
+
from_email = from_header
|
|
434
|
+
|
|
41
435
|
msg = MIMEMultipart("alternative")
|
|
42
436
|
msg["Subject"] = subject
|
|
43
|
-
msg["From"] =
|
|
437
|
+
msg["From"] = from_header
|
|
44
438
|
msg["To"] = to
|
|
439
|
+
msg["Reply-To"] = f"support@{DOMAIN}"
|
|
45
440
|
msg.attach(MIMEText(html_body, "html"))
|
|
46
441
|
|
|
47
442
|
try:
|
|
48
443
|
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
|
49
444
|
server.starttls()
|
|
50
445
|
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
|
|
51
|
-
server.sendmail(
|
|
446
|
+
server.sendmail(from_email, to, msg.as_string())
|
|
52
447
|
logger.info("Email sent to %s: %s", to, subject)
|
|
53
448
|
except Exception:
|
|
54
449
|
logger.exception("Failed to send email to %s", to)
|
|
55
450
|
raise
|
|
451
|
+
|
|
452
|
+
async def _send_async(self, to: str, subject: str, html_body: str, from_header: str = None) -> None:
|
|
453
|
+
"""Async email send using thread pool."""
|
|
454
|
+
await asyncio.to_thread(self._send, to, subject, html_body, from_header)
|