Added LiteLLM to the stack
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Base class for sending emails to user after creating keys or invite links
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
|
||||
EmailEvent,
|
||||
EmailParams,
|
||||
SendKeyCreatedEmailEvent,
|
||||
)
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
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.user_invitation_email import (
|
||||
USER_INVITATION_EMAIL_TEMPLATE,
|
||||
)
|
||||
from litellm.proxy._types import InvitationNew, UserAPIKeyAuth, WebhookEvent
|
||||
from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL
|
||||
|
||||
|
||||
class BaseEmailLogger(CustomLogger):
|
||||
DEFAULT_LITELLM_EMAIL = "notifications@alerts.litellm.ai"
|
||||
DEFAULT_SUPPORT_EMAIL = "support@berri.ai"
|
||||
DEFAULT_SUBJECT_TEMPLATES = {
|
||||
EmailEvent.new_user_invitation: "LiteLLM: {event_message}",
|
||||
EmailEvent.virtual_key_created: "LiteLLM: {event_message}",
|
||||
}
|
||||
|
||||
async def send_user_invitation_email(self, event: WebhookEvent):
|
||||
"""
|
||||
Send email to user after inviting them to the team
|
||||
"""
|
||||
email_params = await self._get_email_params(
|
||||
email_event=EmailEvent.new_user_invitation,
|
||||
user_id=event.user_id,
|
||||
user_email=getattr(event, "user_email", None),
|
||||
event_message=event.event_message,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"send_user_invitation_email_event: {json.dumps(event, indent=4, default=str)}"
|
||||
)
|
||||
|
||||
email_html_content = USER_INVITATION_EMAIL_TEMPLATE.format(
|
||||
email_logo_url=email_params.logo_url,
|
||||
recipient_email=email_params.recipient_email,
|
||||
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_created_email(
|
||||
self, send_key_created_email_event: SendKeyCreatedEmailEvent
|
||||
):
|
||||
"""
|
||||
Send email to user after creating key for the user
|
||||
"""
|
||||
email_params = await self._get_email_params(
|
||||
user_id=send_key_created_email_event.user_id,
|
||||
user_email=send_key_created_email_event.user_email,
|
||||
email_event=EmailEvent.virtual_key_created,
|
||||
event_message=send_key_created_email_event.event_message,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"send_key_created_email_event: {json.dumps(send_key_created_email_event, indent=4, default=str)}"
|
||||
)
|
||||
|
||||
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,
|
||||
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 _get_email_params(
|
||||
self,
|
||||
email_event: EmailEvent,
|
||||
user_id: Optional[str] = None,
|
||||
user_email: Optional[str] = None,
|
||||
event_message: Optional[str] = None,
|
||||
) -> EmailParams:
|
||||
"""
|
||||
Get common email parameters used across different email sending methods
|
||||
|
||||
Args:
|
||||
email_event: Type of email event
|
||||
user_id: Optional user ID to look up email
|
||||
user_email: Optional direct email address
|
||||
event_message: Optional message to include in email subject
|
||||
|
||||
Returns:
|
||||
EmailParams object containing logo_url, support_contact, base_url, recipient_email, subject, and signature
|
||||
"""
|
||||
# Get email parameters with premium check for custom values
|
||||
custom_logo = os.getenv("EMAIL_LOGO_URL", None)
|
||||
custom_support = os.getenv("EMAIL_SUPPORT_CONTACT", None)
|
||||
custom_signature = os.getenv("EMAIL_SIGNATURE", None)
|
||||
custom_subject_invitation = os.getenv("EMAIL_SUBJECT_INVITATION", None)
|
||||
custom_subject_key_created = os.getenv("EMAIL_SUBJECT_KEY_CREATED", None)
|
||||
|
||||
# Track which custom values were not applied
|
||||
unused_custom_fields = []
|
||||
|
||||
# Function to safely get custom value or default
|
||||
def get_custom_or_default(custom_value: Optional[str], default_value: str, field_name: str) -> str:
|
||||
if custom_value is not None: # Only check premium if trying to use custom value
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
if premium_user is not True:
|
||||
unused_custom_fields.append(field_name)
|
||||
return default_value
|
||||
return custom_value
|
||||
return default_value
|
||||
|
||||
# Get parameters, falling back to defaults if custom values aren't allowed
|
||||
logo_url = get_custom_or_default(custom_logo, LITELLM_LOGO_URL, "logo URL")
|
||||
support_contact = get_custom_or_default(custom_support, self.DEFAULT_SUPPORT_EMAIL, "support contact")
|
||||
base_url = os.getenv("PROXY_BASE_URL", "http://0.0.0.0:4000") # Not a premium feature
|
||||
signature = get_custom_or_default(custom_signature, EMAIL_FOOTER, "email signature")
|
||||
|
||||
# Get custom subject template based on email event type
|
||||
if email_event == EmailEvent.new_user_invitation:
|
||||
subject_template = get_custom_or_default(
|
||||
custom_subject_invitation,
|
||||
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.new_user_invitation],
|
||||
"invitation subject template"
|
||||
)
|
||||
elif email_event == EmailEvent.virtual_key_created:
|
||||
subject_template = get_custom_or_default(
|
||||
custom_subject_key_created,
|
||||
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_created],
|
||||
"key created subject template"
|
||||
)
|
||||
else:
|
||||
subject_template = "LiteLLM: {event_message}"
|
||||
|
||||
subject = subject_template.format(event_message=event_message) if event_message else "LiteLLM Notification"
|
||||
|
||||
recipient_email: Optional[
|
||||
str
|
||||
] = user_email or await self._lookup_user_email_from_db(user_id=user_id)
|
||||
if recipient_email is None:
|
||||
raise ValueError(
|
||||
f"User email not found for user_id: {user_id}. User email is required to send email."
|
||||
)
|
||||
|
||||
# if user invited event then send invitation link
|
||||
if email_event == EmailEvent.new_user_invitation:
|
||||
base_url = await self._get_invitation_link(
|
||||
user_id=user_id, base_url=base_url
|
||||
)
|
||||
|
||||
# If any custom fields were not applied, log a warning
|
||||
if unused_custom_fields:
|
||||
fields_str = ", ".join(unused_custom_fields)
|
||||
warning_msg = (
|
||||
f"Email sent with default values instead of custom values for: {fields_str}. "
|
||||
"This is an Enterprise feature. To use custom email fields, please upgrade to LiteLLM Enterprise. "
|
||||
"Schedule a meeting here: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat"
|
||||
)
|
||||
verbose_proxy_logger.warning(
|
||||
f"{warning_msg}"
|
||||
)
|
||||
|
||||
return EmailParams(
|
||||
logo_url=logo_url,
|
||||
support_contact=support_contact,
|
||||
base_url=base_url,
|
||||
recipient_email=recipient_email,
|
||||
subject=subject,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
def _format_key_budget(self, max_budget: Optional[float]) -> str:
|
||||
"""
|
||||
Format the key budget to be displayed in the email
|
||||
"""
|
||||
if max_budget is None:
|
||||
return "No budget"
|
||||
return f"${max_budget}"
|
||||
|
||||
async def _lookup_user_email_from_db(self, user_id: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Lookup user email from user_id
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
verbose_proxy_logger.debug(
|
||||
f"Prisma client not found. Unable to lookup user email for user_id: {user_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
user_row = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
)
|
||||
|
||||
if user_row is not None:
|
||||
return user_row.user_email
|
||||
return None
|
||||
|
||||
async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> str:
|
||||
"""
|
||||
Get invitation link for the user
|
||||
"""
|
||||
# Early validation
|
||||
if not user_id:
|
||||
verbose_proxy_logger.debug("No user_id provided for invitation link")
|
||||
return base_url
|
||||
|
||||
if not await self._is_prisma_client_available():
|
||||
return base_url
|
||||
|
||||
# Wait for any concurrent invitation creation to complete
|
||||
await self._wait_for_invitation_creation()
|
||||
|
||||
# Get or create invitation
|
||||
invitation = await self._get_or_create_invitation(user_id)
|
||||
if not invitation:
|
||||
verbose_proxy_logger.warning(f"Failed to get/create invitation for user_id: {user_id}")
|
||||
return base_url
|
||||
|
||||
return self._construct_invitation_link(invitation.id, base_url)
|
||||
|
||||
async def _is_prisma_client_available(self) -> bool:
|
||||
"""Check if Prisma client is available"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
verbose_proxy_logger.debug("Prisma client not found. Unable to lookup invitation")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _wait_for_invitation_creation(self) -> None:
|
||||
"""
|
||||
Wait for any concurrent invitation creation to complete.
|
||||
|
||||
The UI calls /invitation/new to generate the invitation link.
|
||||
We wait to ensure any pending invitation creation is completed.
|
||||
"""
|
||||
import asyncio
|
||||
await asyncio.sleep(10)
|
||||
|
||||
async def _get_or_create_invitation(self, user_id: str):
|
||||
"""
|
||||
Get existing invitation or create a new one for the user
|
||||
|
||||
Returns:
|
||||
Invitation object with id attribute, or None if failed
|
||||
"""
|
||||
from litellm.proxy.management_helpers.user_invitation import (
|
||||
create_invitation_for_user,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
verbose_proxy_logger.error("Prisma client is None in _get_or_create_invitation")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try to get existing invitation
|
||||
existing_invitations = await prisma_client.db.litellm_invitationlink.find_many(
|
||||
where={"user_id": user_id},
|
||||
order={"created_at": "desc"},
|
||||
)
|
||||
|
||||
if existing_invitations and len(existing_invitations) > 0:
|
||||
verbose_proxy_logger.debug(f"Found existing invitation for user_id: {user_id}")
|
||||
return existing_invitations[0]
|
||||
|
||||
# Create new invitation if none exists
|
||||
verbose_proxy_logger.debug(f"Creating new invitation for user_id: {user_id}")
|
||||
return await create_invitation_for_user(
|
||||
data=InvitationNew(user_id=user_id),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_id=user_id),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Error getting/creating invitation for user_id {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def _construct_invitation_link(self, invitation_id: str, base_url: str) -> str:
|
||||
"""
|
||||
Construct invitation link for the user
|
||||
|
||||
# http://localhost:4000/ui?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b
|
||||
"""
|
||||
return f"{base_url}/ui?invitation_id={invitation_id}"
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
from_email: str,
|
||||
to_email: List[str],
|
||||
subject: str,
|
||||
html_body: str,
|
||||
):
|
||||
pass
|
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Endpoints for managing email alerts on litellm
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from litellm_enterprise.types.enterprise_callbacks.send_emails import (
|
||||
DefaultEmailSettings,
|
||||
EmailEvent,
|
||||
EmailEventSettings,
|
||||
EmailEventSettingsResponse,
|
||||
EmailEventSettingsUpdateRequest,
|
||||
)
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _get_email_settings(prisma_client) -> Dict[str, bool]:
|
||||
"""Helper function to get email settings from general_settings in db"""
|
||||
try:
|
||||
# Get general settings from db
|
||||
general_settings_entry = await prisma_client.db.litellm_config.find_unique(
|
||||
where={"param_name": "general_settings"}
|
||||
)
|
||||
|
||||
# Initialize with default email settings
|
||||
settings_dict = DefaultEmailSettings.get_defaults()
|
||||
|
||||
if (
|
||||
general_settings_entry is not None
|
||||
and general_settings_entry.param_value is not None
|
||||
):
|
||||
# Get general settings value
|
||||
if isinstance(general_settings_entry.param_value, str):
|
||||
general_settings = json.loads(general_settings_entry.param_value)
|
||||
else:
|
||||
general_settings = general_settings_entry.param_value
|
||||
|
||||
# Extract email_settings from general settings if it exists
|
||||
if general_settings and "email_settings" in general_settings:
|
||||
email_settings = general_settings["email_settings"]
|
||||
# Update settings_dict with values from general_settings
|
||||
for event_name, enabled in email_settings.items():
|
||||
settings_dict[event_name] = enabled
|
||||
|
||||
return settings_dict
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"Error getting email settings from general_settings: {str(e)}"
|
||||
)
|
||||
# Return default settings in case of error
|
||||
return DefaultEmailSettings.get_defaults()
|
||||
|
||||
|
||||
async def _save_email_settings(prisma_client, settings: Dict[str, bool]):
|
||||
"""Helper function to save email settings to general_settings in db"""
|
||||
try:
|
||||
verbose_proxy_logger.debug(
|
||||
f"Saving email settings to general_settings: {settings}"
|
||||
)
|
||||
|
||||
# Get current general settings
|
||||
general_settings_entry = await prisma_client.db.litellm_config.find_unique(
|
||||
where={"param_name": "general_settings"}
|
||||
)
|
||||
|
||||
# Initialize general settings dict
|
||||
if (
|
||||
general_settings_entry is not None
|
||||
and general_settings_entry.param_value is not None
|
||||
):
|
||||
if isinstance(general_settings_entry.param_value, str):
|
||||
general_settings = json.loads(general_settings_entry.param_value)
|
||||
else:
|
||||
general_settings = dict(general_settings_entry.param_value)
|
||||
else:
|
||||
general_settings = {}
|
||||
|
||||
# Update email_settings in general_settings
|
||||
general_settings["email_settings"] = settings
|
||||
|
||||
# Convert to JSON for storage
|
||||
json_settings = json.dumps(general_settings, default=str)
|
||||
|
||||
# Save updated general settings
|
||||
await prisma_client.db.litellm_config.upsert(
|
||||
where={"param_name": "general_settings"},
|
||||
data={
|
||||
"create": {
|
||||
"param_name": "general_settings",
|
||||
"param_value": json_settings,
|
||||
},
|
||||
"update": {"param_value": json_settings},
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error saving email settings to general_settings: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/email/event_settings",
|
||||
response_model=EmailEventSettingsResponse,
|
||||
tags=["email management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def get_email_event_settings(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Get all email event settings
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
# Get existing settings
|
||||
settings_dict = await _get_email_settings(prisma_client)
|
||||
|
||||
# Create a response with all events (enabled or disabled)
|
||||
response_settings = []
|
||||
for event in EmailEvent:
|
||||
enabled = settings_dict.get(event.value, False)
|
||||
response_settings.append(EmailEventSettings(event=event, enabled=enabled))
|
||||
|
||||
return EmailEventSettingsResponse(settings=response_settings)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error getting email settings: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/email/event_settings",
|
||||
tags=["email management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def update_event_settings(
|
||||
request: EmailEventSettingsUpdateRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Update the settings for email events
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
# Get existing settings
|
||||
settings_dict = await _get_email_settings(prisma_client)
|
||||
|
||||
# Update with new settings
|
||||
for setting in request.settings:
|
||||
settings_dict[setting.event.value] = setting.enabled
|
||||
|
||||
# Save updated settings
|
||||
await _save_email_settings(prisma_client, settings_dict)
|
||||
|
||||
return {"message": "Email event settings updated successfully"}
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error updating email settings: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/email/event_settings/reset",
|
||||
tags=["email management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def reset_event_settings(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Reset all email event settings to default (new user invitations on, virtual key creation off)
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
try:
|
||||
# Reset to default settings using the Pydantic model
|
||||
default_settings = DefaultEmailSettings.get_defaults()
|
||||
|
||||
# Save default settings
|
||||
await _save_email_settings(prisma_client, default_settings)
|
||||
|
||||
return {"message": "Email event settings reset to defaults"}
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(f"Error resetting email settings: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
This is the litellm x resend email integration
|
||||
|
||||
https://resend.com/docs/api-reference/emails/send-email
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
|
||||
from .base_email import BaseEmailLogger
|
||||
|
||||
RESEND_API_ENDPOINT = "https://api.resend.com/emails"
|
||||
|
||||
|
||||
class ResendEmailLogger(BaseEmailLogger):
|
||||
def __init__(self):
|
||||
self.async_httpx_client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.LoggingCallback
|
||||
)
|
||||
self.resend_api_key = os.getenv("RESEND_API_KEY")
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
from_email: str,
|
||||
to_email: List[str],
|
||||
subject: str,
|
||||
html_body: str,
|
||||
):
|
||||
verbose_logger.debug(
|
||||
f"Sending email from {from_email} to {to_email} with subject {subject}"
|
||||
)
|
||||
response = await self.async_httpx_client.post(
|
||||
url=RESEND_API_ENDPOINT,
|
||||
json={
|
||||
"from": from_email,
|
||||
"to": to_email,
|
||||
"subject": subject,
|
||||
"html": html_body,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {self.resend_api_key}"},
|
||||
)
|
||||
verbose_logger.debug(
|
||||
f"Email sent with status code {response.status_code}. Got response: {response.json()}"
|
||||
)
|
||||
return
|
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
This is the litellm SMTP email integration
|
||||
"""
|
||||
import asyncio
|
||||
from typing import List
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
from .base_email import BaseEmailLogger
|
||||
|
||||
|
||||
class SMTPEmailLogger(BaseEmailLogger):
|
||||
"""
|
||||
This is the litellm SMTP email integration
|
||||
|
||||
Required SMTP environment variables:
|
||||
- SMTP_HOST
|
||||
- SMTP_PORT
|
||||
- SMTP_USERNAME
|
||||
- SMTP_PASSWORD
|
||||
- SMTP_SENDER_EMAIL
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
verbose_logger.debug("SMTP Email Logger initialized....")
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
from_email: str,
|
||||
to_email: List[str],
|
||||
subject: str,
|
||||
html_body: str,
|
||||
):
|
||||
from litellm.proxy.utils import send_email as send_smtp_email
|
||||
|
||||
verbose_logger.debug(
|
||||
f"Sending email from {from_email} to {to_email} with subject {subject}"
|
||||
)
|
||||
for receiver_email in to_email:
|
||||
asyncio.create_task(
|
||||
send_smtp_email(
|
||||
receiver_email=receiver_email,
|
||||
subject=subject,
|
||||
html=html_body,
|
||||
)
|
||||
)
|
||||
return
|
Reference in New Issue
Block a user