[Feat] Email Notifications - Ensure Users get Key Rotated Email (#16292)

* add virtual_key_rotated

* add KEY_ROTATED_EMAIL_TEMPLATE

* fix base email

* add _send_key_rotated_email

* fix _send_key_rotated_email

* key rotated email fix

* fix: async_key_generated_hook ensure behavior matches regenerate

* test_send_key_rotated_email

* expose EMAIL_INCLUDE_API_KEY

* docs mask api keys

* add email regen

* docs key regen

* docs email

* docs email

* docs fix

* fix code qa check
This commit is contained in:
Ishaan Jaff 2025-11-05 15:56:49 -08:00 committed by GitHub
parent 3fc262e92a
commit 2541a54023
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 551 additions and 23 deletions

View File

@ -18,7 +18,7 @@ Send LiteLLM Proxy users emails for specific events.
| Category | Details |
|----------|---------|
| Supported Events | • User added as a user on LiteLLM Proxy<br/>• Proxy API Key created for user |
| Supported Events | • User added as a user on LiteLLM Proxy<br/>• Proxy API Key created for user<br/>• Proxy API Key rotated for user |
| Supported Email Integrations | • Resend API<br/>• SMTP |
## Usage
@ -123,6 +123,35 @@ On the Create Key Modal, Select Advanced Settings > Set Send Email to True.
style={{width: '70%', display: 'block', margin: '0 0 2rem 0'}}
/>
### 3. Proxy API Key Rotated for User
This email is sent when you rotate an API key for a user on LiteLLM Proxy.
<Image
img={require('../../img/email_regen2.png')}
style={{maxHeight: '600px', width: 'auto', display: 'block', margin: '0 0 2rem 0'}}
/>
**How to trigger this event**
On the LiteLLM Proxy UI, go to Virtual Keys > Click on a key > Click "Regenerate Key"
:::info
Ensure there is a `user_id` attached to the key. This would have been set when creating the key.
:::
<Image
img={require('../../img/email_regen.png')}
style={{width: '70%', display: 'block', margin: '0 0 2rem 0'}}
/>
After regenerating the key, the user will receive an email notification with:
- Security-focused messaging about the rotation
- The new API key (or a placeholder if `EMAIL_INCLUDE_API_KEY=false`)
- Instructions to update their applications
- Security best practices
## Email Customization
@ -141,6 +170,8 @@ LiteLLM allows you to customize various aspects of your email notifications. Bel
| Email Signature | `EMAIL_SIGNATURE` | string (HTML) | Standard LiteLLM footer | `"<p>Best regards,<br/>Your Team</p><p><a href='https://your-company.com'>Visit us</a></p>"` | HTML-formatted footer for all emails |
| Invitation Subject | `EMAIL_SUBJECT_INVITATION` | string | "LiteLLM: New User Invitation" | `"Welcome to Your Company!"` | Subject line for invitation emails |
| Key Creation Subject | `EMAIL_SUBJECT_KEY_CREATED` | string | "LiteLLM: API Key Created" | `"Your New API Key is Ready"` | Subject line for key creation emails |
| Key Rotation Subject | `EMAIL_SUBJECT_KEY_ROTATED` | string | "LiteLLM: API Key Rotated" | `"Your API Key Has Been Rotated"` | Subject line for key rotation emails |
| Include API Key | `EMAIL_INCLUDE_API_KEY` | boolean | true | `"false"` | Whether to include the actual API key in emails (set to false for enhanced security) |
| Proxy Base URL | `PROXY_BASE_URL` | string | http://0.0.0.0:4000 | `"https://proxy.your-company.com"` | Base URL for the LiteLLM Proxy (used in email links) |
@ -181,11 +212,44 @@ EMAIL_SIGNATURE="<p>Best regards,<br/>Your Company Team</p><p><a href='https://y
# Email Subject Lines
EMAIL_SUBJECT_INVITATION="Welcome to Your Company!" # Subject for invitation emails
EMAIL_SUBJECT_KEY_CREATED="Your API Key is Ready" # Subject for key creation emails
EMAIL_SUBJECT_KEY_ROTATED="Your API Key Has Been Rotated" # Subject for key rotation emails
# Security Settings
EMAIL_INCLUDE_API_KEY="false" # Set to false to hide API keys in emails (default: true)
# Proxy Configuration
PROXY_BASE_URL="https://proxy.your-company.com" # Base URL for the LiteLLM Proxy (used in email links)
```
## Security: Hiding API Keys in Emails
For enhanced security, you can configure LiteLLM to **not** include actual API keys in email notifications. This is useful when:
- You want to reduce the risk of key exposure via email interception
- Your security policy requires keys to only be retrieved from the secure dashboard
- You're concerned about email forwarding or storage security
When disabled, emails will show: `[Key hidden for security - retrieve from dashboard]` instead of the actual API key.
**Configuration:**
```bash
# Hide API keys in emails (enhanced security)
EMAIL_INCLUDE_API_KEY="false"
# Include API keys in emails (default behavior)
EMAIL_INCLUDE_API_KEY="true" # or omit this variable
```
**Behavior:**
| Setting | Key Created Email | Key Rotated Email |
|---------|------------------|-------------------|
| `true` (default) | Shows actual `sk-xxxxx` key | Shows actual `sk-xxxxx` key |
| `false` | Shows placeholder message | Shows placeholder message |
Users can always retrieve their keys from the LiteLLM Proxy dashboard.
## HTML Support in Email Signature
The `EMAIL_SIGNATURE` environment variable supports HTML formatting, allowing you to create rich, branded email footers. You can include:

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

View File

@ -173,7 +173,6 @@ const sidebars = {
href: "https://litellm-api.up.railway.app/",
},
"proxy/enterprise",
"proxy/management_cli",
{
type: "category",
label: "Authentication",
@ -185,19 +184,9 @@ const sidebars = {
"proxy/cli_sso",
"proxy/custom_auth",
"proxy/ip_address",
"proxy/email",
"proxy/multiple_admins",
],
},
{
type: "category",
label: "Spend Tracking",
items: [
"proxy/cost_tracking",
"proxy/custom_pricing",
"proxy/billing",
],
},
{
type: "category",
label: "Budgets + Rate Limits",
@ -221,6 +210,7 @@ const sidebars = {
"proxy/rules",
]
},
"proxy/management_cli",
{
type: "link",
label: "Load Balancing, Routing, Fallbacks",
@ -233,7 +223,8 @@ const sidebars = {
"proxy/dynamic_logging",
"proxy/logging",
"proxy/logging_spec",
"proxy/team_logging"
"proxy/team_logging",
"proxy/email",
],
},
{
@ -271,6 +262,15 @@ const sidebars = {
"oidc"
]
},
{
type: "category",
label: "Spend Tracking",
items: [
"proxy/cost_tracking",
"proxy/custom_pricing",
"proxy/billing",
],
},
]
},
{

View File

@ -11,6 +11,7 @@ from litellm_enterprise.types.enterprise_callbacks.send_emails import (
EmailEvent,
EmailParams,
SendKeyCreatedEmailEvent,
SendKeyRotatedEmailEvent,
)
from litellm._logging import verbose_proxy_logger
@ -19,10 +20,14 @@ from litellm.integrations.email_templates.email_footer import EMAIL_FOOTER
from litellm.integrations.email_templates.key_created_email import (
KEY_CREATED_EMAIL_TEMPLATE,
)
from litellm.integrations.email_templates.key_rotated_email import (
KEY_ROTATED_EMAIL_TEMPLATE,
)
from litellm.integrations.email_templates.user_invitation_email import (
USER_INVITATION_EMAIL_TEMPLATE,
)
from litellm.proxy._types import InvitationNew, UserAPIKeyAuth, WebhookEvent
from litellm.secret_managers.main import get_secret_bool
from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL
@ -32,6 +37,7 @@ class BaseEmailLogger(CustomLogger):
DEFAULT_SUBJECT_TEMPLATES = {
EmailEvent.new_user_invitation: "LiteLLM: {event_message}",
EmailEvent.virtual_key_created: "LiteLLM: {event_message}",
EmailEvent.virtual_key_rotated: "LiteLLM: {event_message}",
}
async def send_user_invitation_email(self, event: WebhookEvent):
@ -83,11 +89,58 @@ class BaseEmailLogger(CustomLogger):
f"send_key_created_email_event: {json.dumps(send_key_created_email_event, indent=4, default=str)}"
)
# Check if API key should be included in email
include_api_key = get_secret_bool(secret_name="EMAIL_INCLUDE_API_KEY", default_value=True)
if include_api_key is None:
include_api_key = True # Default to True if not set
key_token_display = send_key_created_email_event.virtual_key if include_api_key else "[Key hidden for security - retrieve from dashboard]"
email_html_content = KEY_CREATED_EMAIL_TEMPLATE.format(
email_logo_url=email_params.logo_url,
recipient_email=email_params.recipient_email,
key_budget=self._format_key_budget(send_key_created_email_event.max_budget),
key_token=send_key_created_email_event.virtual_key,
key_token=key_token_display,
base_url=email_params.base_url,
email_support_contact=email_params.support_contact,
email_footer=email_params.signature,
)
await self.send_email(
from_email=self.DEFAULT_LITELLM_EMAIL,
to_email=[email_params.recipient_email],
subject=email_params.subject,
html_body=email_html_content,
)
pass
async def send_key_rotated_email(
self, send_key_rotated_email_event: SendKeyRotatedEmailEvent
):
"""
Send email to user after rotating key for the user
"""
email_params = await self._get_email_params(
user_id=send_key_rotated_email_event.user_id,
user_email=send_key_rotated_email_event.user_email,
email_event=EmailEvent.virtual_key_rotated,
event_message=send_key_rotated_email_event.event_message,
)
verbose_proxy_logger.debug(
f"send_key_rotated_email_event: {json.dumps(send_key_rotated_email_event, indent=4, default=str)}"
)
# Check if API key should be included in email
include_api_key = get_secret_bool(secret_name="EMAIL_INCLUDE_API_KEY", default_value=True)
if include_api_key is None:
include_api_key = True # Default to True if not set
key_token_display = send_key_rotated_email_event.virtual_key if include_api_key else "[Key hidden for security - retrieve from dashboard]"
email_html_content = KEY_ROTATED_EMAIL_TEMPLATE.format(
email_logo_url=email_params.logo_url,
recipient_email=email_params.recipient_email,
key_budget=self._format_key_budget(send_key_rotated_email_event.max_budget),
key_token=key_token_display,
base_url=email_params.base_url,
email_support_contact=email_params.support_contact,
email_footer=email_params.signature,
@ -159,6 +212,13 @@ class BaseEmailLogger(CustomLogger):
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_created],
"key created subject template"
)
elif email_event == EmailEvent.virtual_key_rotated:
custom_subject_key_rotated = os.getenv("EMAIL_SUBJECT_KEY_ROTATED", None)
subject_template = get_custom_or_default(
custom_subject_key_rotated,
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_rotated],
"key rotated subject template"
)
else:
subject_template = "LiteLLM: {event_message}"

View File

@ -1,10 +1,11 @@
import enum
from typing import Dict, List
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from litellm.proxy._types import WebhookEvent
class EmailParams(BaseModel):
logo_url: str
support_contact: str
@ -22,9 +23,19 @@ class SendKeyCreatedEmailEvent(WebhookEvent):
"""
class SendKeyRotatedEmailEvent(WebhookEvent):
virtual_key: str
key_alias: Optional[str] = None
"""
The virtual key that was rotated
this will be sk-123xxx, since we will be emailing this to the user to start using the new key
"""
class EmailEvent(str, enum.Enum):
virtual_key_created = "Virtual Key Created"
new_user_invitation = "New User Invitation"
virtual_key_rotated = "Virtual Key Rotated"
class EmailEventSettings(BaseModel):
event: EmailEvent
@ -37,8 +48,9 @@ class DefaultEmailSettings(BaseModel):
"""Default settings for email events"""
settings: Dict[EmailEvent, bool] = Field(
default_factory=lambda: {
EmailEvent.virtual_key_created: False, # Off by default
EmailEvent.virtual_key_created: True, # On by default
EmailEvent.new_user_invitation: True, # On by default
EmailEvent.virtual_key_rotated: True, # On by default
}
)
def to_dict(self) -> Dict[str, bool]:

View File

@ -0,0 +1,225 @@
"""
Modern Email Templates for LiteLLM Email Service with professional styling
"""
KEY_ROTATED_EMAIL_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your API Key Has Been Rotated</title>
<style>
body, html {{
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #333333;
background-color: #f8fafc;
line-height: 1.5;
}}
.container {{
max-width: 560px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}}
.header {{
padding: 24px 0;
text-align: center;
border-bottom: 1px solid #f1f5f9;
}}
.content {{
padding: 32px 40px;
}}
.greeting {{
font-size: 16px;
margin-bottom: 20px;
color: #333333;
}}
.message {{
font-size: 16px;
color: #333333;
margin-bottom: 20px;
}}
.key-container {{
margin: 28px 0;
}}
.key-label {{
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #4b5563;
}}
.key {{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
background-color: #f9fafb;
border-radius: 6px;
padding: 16px;
font-size: 14px;
border: 1px solid #e5e7eb;
color: #4338ca;
}}
h2 {{
font-size: 18px;
font-weight: 600;
margin-top: 36px;
margin-bottom: 16px;
color: #333333;
}}
.budget-info {{
background-color: #f0fdf4;
border-radius: 6px;
padding: 14px 16px;
margin: 24px 0;
font-size: 14px;
border: 1px solid #dcfce7;
}}
.code-block {{
background-color: #f8fafc;
color: #334155;
border-radius: 8px;
padding: 20px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
overflow-x: auto;
margin: 20px 0;
line-height: 1.6;
border: 1px solid #e2e8f0;
}}
.code-comment {{
color: #64748b;
}}
.code-string {{
color: #0369a1;
}}
.code-keyword {{
color: #7e22ce;
}}
.btn {{
display: inline-block;
padding: 8px 20px;
background-color: #6366f1;
color: #ffffff !important;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin-top: 24px;
text-align: center;
font-size: 14px;
transition: background-color 0.2s;
}}
.btn:hover {{
background-color: #4f46e5;
color: #ffffff !important;
}}
.separator {{
height: 1px;
background-color: #f1f5f9;
margin: 40px 0 30px;
}}
.footer {{
padding: 24px 40px 32px;
text-align: center;
color: #64748b;
font-size: 13px;
background-color: #f8fafc;
border-top: 1px solid #f1f5f9;
}}
.social-links {{
margin-top: 12px;
}}
.social-links a {{
display: inline-block;
margin: 0 8px;
color: #64748b;
text-decoration: none;
}}
@media only screen and (max-width: 620px) {{
.container {{
width: 100%;
margin: 0;
border-radius: 0;
}}
.content {{
padding: 24px 20px;
}}
.footer {{
padding: 20px;
}}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{email_logo_url}" alt="LiteLLM Logo" style="height: 32px; width: auto;">
</div>
<div class="content">
<div class="greeting">
<p>Hi {recipient_email},</p>
</div>
<div class="message">
<p><strong>Your LiteLLM API key has been rotated</strong> as part of our ongoing commitment to security best practices.</p>
<p style="margin-top: 16px;">Your previous API key has been deactivated and will no longer work. Please update your applications with the new key below.</p>
</div>
<div class="key-container">
<div class="key-label">Your New API Key</div>
<div class="key">{key_token}</div>
</div>
<div class="budget-info">
<p style="margin: 0;"><strong>Monthly Budget:</strong> {key_budget}</p>
</div>
<h2>Action Required</h2>
<p>Update your applications and systems with the new API key. Here's an example:</p>
<div class="code-block">
<span class="code-keyword">import</span> openai<br>
<br>
client = openai.OpenAI(<br>
&nbsp;&nbsp;api_key=<span class="code-string">"{key_token}"</span>,<br>
&nbsp;&nbsp;base_url=<span class="code-string">"{base_url}"</span><br>
)<br>
<br>
response = client.chat.completions.create(<br>
&nbsp;&nbsp;model=<span class="code-string">"gpt-3.5-turbo"</span>, <span class="code-comment"># model to send to the proxy</span><br>
&nbsp;&nbsp;messages = [<br>
&nbsp;&nbsp;&nbsp;&nbsp;{{<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="code-string">"role"</span>: <span class="code-string">"user"</span>,<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="code-string">"content"</span>: <span class="code-string">"this is a test request, write a short poem"</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;}}<br>
&nbsp;&nbsp;]<br>
)
</div>
<div class="separator"></div>
<h2>Security Best Practices</h2>
<p style="margin-bottom: 12px;">To keep your API key secure:</p>
<ul style="margin: 0; padding-left: 20px; color: #333333;">
<li style="margin-bottom: 8px;">Never share your API key publicly or commit it to version control</li>
<li style="margin-bottom: 8px;">Store it securely using environment variables or secret management systems</li>
<li style="margin-bottom: 8px;">Monitor your API usage regularly for any unusual activity</li>
<li style="margin-bottom: 8px;">Rotate your keys periodically as a security best practice</li>
</ul>
<a href="https://docs.litellm.ai/docs/proxy/user_keys" class="btn" style="color: #ffffff;">View Documentation</a>
<div class="separator"></div>
<h2>Need Help?</h2>
<p>If you have any questions or need assistance updating your systems, please contact us at {email_support_contact}.</p>
</div>
{email_footer}
</div>
</body>
</html>
"""

View File

@ -2364,6 +2364,7 @@ class WebhookEvent(CallInfo):
"threshold_crossed",
"projected_limit_exceeded",
"key_created",
"key_rotated",
"internal_user_created",
"spend_tracked",
]

View File

@ -1,11 +1,11 @@
import asyncio
import json
from litellm._uuid import uuid
from datetime import datetime, timezone
from typing import Any, List, Optional
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.proxy._types import (
CommonProxyErrors,
GenerateKeyRequest,
@ -45,10 +45,9 @@ class KeyManagementEventHooks:
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
if data.send_invite_email is True:
await KeyManagementEventHooks._send_key_created_email(
response.model_dump(exclude_none=True)
)
await KeyManagementEventHooks._send_key_created_email(
response.model_dump(exclude_none=True)
)
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
if litellm.store_audit_logs is True:
@ -144,6 +143,12 @@ class KeyManagementEventHooks:
new_secret_value=response.key,
)
# send key rotated email if configured
await KeyManagementEventHooks._send_key_rotated_email(
response=response.model_dump(exclude_none=True),
existing_key_alias=existing_key_row.key_alias,
)
# store the audit log
if litellm.store_audit_logs is True and existing_key_row.token is not None:
asyncio.create_task(
@ -385,3 +390,53 @@ class KeyManagementEventHooks:
webhook_event=event,
)
)
@staticmethod
async def _send_key_rotated_email(response: dict, existing_key_alias: Optional[str]):
try:
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)
except ImportError:
raise Exception(
"Trying to use Email Hooks"
+ CommonProxyErrors.missing_enterprise_package.value
)
try:
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
SendKeyRotatedEmailEvent,
)
except ImportError:
raise Exception(
"Trying to use Email Hooks"
+ CommonProxyErrors.missing_enterprise_package.value
)
event = SendKeyRotatedEmailEvent(
virtual_key=response.get("key", ""),
event="key_rotated",
event_group=Litellm_EntityType.KEY,
event_message="API Key Rotated",
token=response.get("token", ""),
spend=response.get("spend", 0.0),
max_budget=response.get("max_budget", 0.0),
user_id=response.get("user_id", None),
team_id=response.get("team_id", "Default Team"),
key_alias=response.get("key_alias", existing_key_alias),
)
##########################
# v2 integration for emails
##########################
initialized_email_loggers = (
litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=BaseEmailLogger
)
)
if len(initialized_email_loggers) > 0:
for email_logger in initialized_email_loggers:
if isinstance(email_logger, BaseEmailLogger):
await email_logger.send_key_rotated_email(
send_key_rotated_email_event=event,
)

View File

@ -40,7 +40,7 @@ litellm_settings:
store_audit_logs: true
verbose: true
log_level: "DEBUG" # Options: DEBUG, INFO, WARNING, ERROR
success_callback: ["s3_v2"]
callbacks: ["s3_v2", "smtp_email"]
s3_callback_params:
s3_endpoint_url: "https://localhost:443" # Replace with your Minio server URL and port
s3_aws_access_key_id: "minioadmin"

View File

@ -4,14 +4,18 @@ import sys
import unittest.mock as mock
from unittest.mock import patch
from enterprise.litellm_enterprise.enterprise_callbacks.send_emails.base_email import BaseEmailLogger
import pytest
from fastapi.testclient import TestClient
from enterprise.litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)
sys.path.insert(0, os.path.abspath("../../.."))
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
EmailEvent,
SendKeyCreatedEmailEvent,
SendKeyRotatedEmailEvent,
)
from litellm.integrations.email_templates.email_footer import EMAIL_FOOTER
@ -208,6 +212,113 @@ async def test_send_key_created_email_no_email(
await base_email_logger.send_key_created_email(event)
@pytest.mark.asyncio
async def test_send_key_rotated_email(
base_email_logger, mock_send_email, mock_lookup_user_email
):
"""
Test that send_key_rotated_email sends an email with the correct parameters and content
"""
event = SendKeyRotatedEmailEvent(
user_id="test_user",
user_email="test@example.com",
virtual_key="sk-rotated-key-123",
key_alias="test-key-alias",
max_budget=200.0,
spend=50.0,
event_group=Litellm_EntityType.KEY,
event="key_rotated",
event_message="API Key Rotated",
)
with mock.patch.dict(
os.environ,
{
"EMAIL_LOGO_URL": "https://litellm-listing.s3.amazonaws.com/litellm_logo.png",
"EMAIL_SUPPORT_CONTACT": "support@berri.ai",
"PROXY_BASE_URL": "http://test.com",
},
):
await base_email_logger.send_key_rotated_email(event)
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args[1]
assert call_args["from_email"] == BaseEmailLogger.DEFAULT_LITELLM_EMAIL
assert call_args["to_email"] == ["test@example.com"]
assert call_args["subject"] == "LiteLLM: API Key Rotated"
assert "sk-rotated-key-123" in call_args["html_body"]
assert "$200.0" in call_args["html_body"]
assert "rotated" in call_args["html_body"].lower()
assert "Security Best Practices" in call_args["html_body"]
@pytest.mark.asyncio
async def test_send_key_created_email_without_key(
base_email_logger, mock_send_email, mock_lookup_user_email
):
"""
Test that send_key_created_email hides the API key when EMAIL_INCLUDE_API_KEY is false
"""
event = SendKeyCreatedEmailEvent(
user_id="test_user",
user_email="test@example.com",
virtual_key="sk-secret-key-456",
max_budget=100.0,
spend=0.0,
event_group=Litellm_EntityType.USER,
event="key_created",
event_message="Test Key Created",
)
with mock.patch.dict(
os.environ,
{
"EMAIL_INCLUDE_API_KEY": "false",
"PROXY_BASE_URL": "http://test.com",
},
):
await base_email_logger.send_key_created_email(event)
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args[1]
assert "sk-secret-key-456" not in call_args["html_body"]
assert "[Key hidden for security - retrieve from dashboard]" in call_args["html_body"]
@pytest.mark.asyncio
async def test_send_key_rotated_email_without_key(
base_email_logger, mock_send_email, mock_lookup_user_email
):
"""
Test that send_key_rotated_email hides the API key when EMAIL_INCLUDE_API_KEY is false
"""
event = SendKeyRotatedEmailEvent(
user_id="test_user",
user_email="test@example.com",
virtual_key="sk-secret-rotated-789",
key_alias="test-key-alias",
max_budget=200.0,
spend=50.0,
event_group=Litellm_EntityType.KEY,
event="key_rotated",
event_message="API Key Rotated",
)
with mock.patch.dict(
os.environ,
{
"EMAIL_INCLUDE_API_KEY": "false",
"PROXY_BASE_URL": "http://test.com",
},
):
await base_email_logger.send_key_rotated_email(event)
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args[1]
assert "sk-secret-rotated-789" not in call_args["html_body"]
assert "[Key hidden for security - retrieve from dashboard]" in call_args["html_body"]
@pytest.mark.asyncio
async def test_get_invitation_link(base_email_logger):
# Mock prisma client and its response