fix(proxy): auto-send user invitation emails when email is configured

When send_invite_email is not explicitly set (None/omitted), auto-detect
whether to send by checking if email infrastructure is configured and
the user has an email address. Previously, emails were silently skipped
because the field defaulted to None and both V1/V2 paths required
strict `is True`.

Fixes #20499

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
HammerZhaoTuro 2026-04-22 00:24:21 +00:00
parent b8f7d61400
commit 4a3c69c43d
3 changed files with 194 additions and 4 deletions

View file

@ -90,9 +90,28 @@ litellm_settings:
### 2. Create a new user
On the LiteLLM Proxy UI, go to users > create a new user.
On the LiteLLM Proxy UI, go to users > create a new user with an email address.
After creating a new user, they will automatically receive an invitation email if you have configured an email integration (step 1 above). No additional flag or toggle is needed — the proxy detects that email is configured and sends the invite automatically.
When using the API directly, you can also explicitly control this behavior with the `send_invite_email` parameter:
```shell
curl -X POST '<your_proxy_base_url>/user/new' \
-H 'Authorization: Bearer <your_api_key>' \
-H 'Content-Type: application/json' \
-d '{
"user_email": "user@example.com",
"send_invite_email": true
}'
```
| `send_invite_email` value | Behavior |
|--------------------------|----------|
| `true` | Always send invitation email |
| `false` | Never send invitation email |
| omitted / `null` | Auto-detect: send if email integration is configured and user has an email |
After creating a new user, they will receive an email invite a the email you specified when creating the user.
### 3. Configure Budget Alerts (Optional)

View file

@ -27,6 +27,59 @@ from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_upd
class UserManagementEventHooks:
@staticmethod
def _is_email_sending_enabled() -> bool:
"""
Check if email sending is enabled via v2 enterprise loggers or v0 alerting config.
Returns True only if email is actually configured, preventing any email
processing when the user has not opted in.
"""
try:
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)
initialized_email_loggers = (
litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=BaseEmailLogger
)
)
if len(initialized_email_loggers) > 0:
return True
except ImportError:
pass
from litellm.proxy.proxy_server import general_settings
if "email" in general_settings.get("alerting", []):
return True
return False
@staticmethod
def _should_send_user_invitation_email(
data: NewUserRequest,
response: NewUserResponse,
) -> bool:
"""
Determine whether a user invitation email should be sent.
- send_invite_email=True -> always send
- send_invite_email=False -> never send
- send_invite_email=None -> auto-detect: send if email infra is configured
AND the user has an email address
"""
if data.send_invite_email is True:
return True
if data.send_invite_email is False:
return False
if not UserManagementEventHooks._is_email_sending_enabled():
return False
if not response.user_email:
return False
return True
@staticmethod
async def async_user_created_hook(
data: NewUserRequest,
@ -105,6 +158,10 @@ class UserManagementEventHooks:
key_alias=response.key_alias,
)
should_send_email = UserManagementEventHooks._should_send_user_invitation_email(
data=data, response=response
)
#########################################################
########## V2 USER INVITATION EMAIL ################
#########################################################
@ -121,7 +178,7 @@ class UserManagementEventHooks:
)
use_enterprise_email_hooks = False
if use_enterprise_email_hooks and (data.send_invite_email is True):
if use_enterprise_email_hooks and should_send_email:
initialized_email_loggers = litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=BaseEmailLogger # type: ignore
)
@ -135,7 +192,7 @@ class UserManagementEventHooks:
#########################################################
########## LEGACY V1 USER INVITATION EMAIL ################
#########################################################
if data.send_invite_email is True:
if should_send_email:
await UserManagementEventHooks.send_legacy_v1_user_invitation_email(
data=data,
response=response,

View file

@ -152,3 +152,117 @@ async def test_v1_key_generation_no_email_when_send_invite_email_false():
user_api_key_dict=user_api_key_dict,
)
mock_send_key_created_email.assert_not_called()
@pytest.mark.asyncio
async def test_v1_user_creation_sends_email_when_send_invite_email_none_and_email_configured():
"""
When send_invite_email is None (default) and email alerting is configured
and the user has an email address, an invitation email should be sent.
"""
mock_slack_alerting = MagicMock()
mock_slack_alerting.send_key_created_or_user_invited_email = AsyncMock()
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting
with patch(
"litellm.logging_callback_manager.get_custom_loggers_for_type", return_value=[]
):
mock_proxy_server = SimpleNamespace(
general_settings={"alerting": ["email"]},
proxy_logging_obj=mock_proxy_logging_obj,
litellm_proxy_admin_name="admin-user",
)
with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}):
data = NewUserRequest(
user_email="test@example.com",
)
response = NewUserResponse(
user_id="test-user",
user_email="test@example.com",
key="sk-test-key",
)
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user", api_key="admin-key"
)
await UserManagementEventHooks.async_send_user_invitation_email(
data=data,
response=response,
user_api_key_dict=user_api_key_dict,
)
mock_slack_alerting.send_key_created_or_user_invited_email.assert_called_once()
@pytest.mark.asyncio
async def test_v1_user_creation_no_email_when_send_invite_email_none_and_email_not_configured():
"""
When send_invite_email is None (default) and email alerting is NOT configured,
no invitation email should be sent.
"""
mock_slack_alerting = MagicMock()
mock_slack_alerting.send_key_created_or_user_invited_email = AsyncMock()
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting
with patch(
"litellm.logging_callback_manager.get_custom_loggers_for_type", return_value=[]
):
mock_proxy_server = SimpleNamespace(
general_settings={},
proxy_logging_obj=mock_proxy_logging_obj,
litellm_proxy_admin_name="admin-user",
)
with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}):
data = NewUserRequest(
user_email="test@example.com",
)
response = NewUserResponse(
user_id="test-user",
user_email="test@example.com",
key="sk-test-key",
)
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user", api_key="admin-key"
)
await UserManagementEventHooks.async_send_user_invitation_email(
data=data,
response=response,
user_api_key_dict=user_api_key_dict,
)
mock_slack_alerting.send_key_created_or_user_invited_email.assert_not_called()
@pytest.mark.asyncio
async def test_v1_user_creation_no_email_when_send_invite_email_none_and_no_user_email():
"""
When send_invite_email is None (default) and email alerting is configured
but the user has no email address, no invitation email should be sent.
"""
mock_slack_alerting = MagicMock()
mock_slack_alerting.send_key_created_or_user_invited_email = AsyncMock()
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting
with patch(
"litellm.logging_callback_manager.get_custom_loggers_for_type", return_value=[]
):
mock_proxy_server = SimpleNamespace(
general_settings={"alerting": ["email"]},
proxy_logging_obj=mock_proxy_logging_obj,
litellm_proxy_admin_name="admin-user",
)
with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}):
data = NewUserRequest()
response = NewUserResponse(
user_id="test-user",
key="sk-test-key",
)
user_api_key_dict = UserAPIKeyAuth(
user_id="admin-user", api_key="admin-key"
)
await UserManagementEventHooks.async_send_user_invitation_email(
data=data,
response=response,
user_api_key_dict=user_api_key_dict,
)
mock_slack_alerting.send_key_created_or_user_invited_email.assert_not_called()