"""Tests for email utility."""
import pytest
from unittest.mock import MagicMock, patch
from preloop.utils.email import (
EmailError,
send_email,
send_verification_email,
send_password_reset_email,
send_invitation_email,
send_tracker_registered_email,
send_product_notification_email,
)
class TestSendEmail:
"""Test send_email function."""
@patch("preloop.utils.email.smtplib.SMTP")
@patch("preloop.utils.email.SMTP_USERNAME", "test@example.com")
@patch("preloop.utils.email.SMTP_PASSWORD", "password")
@patch("preloop.utils.email.SMTP_HOST", "smtp.example.com")
@patch("preloop.utils.email.SMTP_PORT", 587)
def test_send_email_text_only(self, mock_smtp):
"""Test sending email with text body only."""
mock_server = MagicMock()
mock_smtp.return_value.__enter__.return_value = mock_server
send_email(
to_email="recipient@example.com",
subject="Test Subject",
body_text="Test body",
)
mock_smtp.assert_called_once_with("smtp.example.com", 587)
mock_server.starttls.assert_called_once()
mock_server.login.assert_called_once_with("test@example.com", "password")
mock_server.sendmail.assert_called_once()
@patch("preloop.utils.email.smtplib.SMTP")
@patch("preloop.utils.email.SMTP_USERNAME", "test@example.com")
@patch("preloop.utils.email.SMTP_PASSWORD", "password")
def test_send_email_with_html(self, mock_smtp):
"""Test sending email with both text and HTML bodies."""
mock_server = MagicMock()
mock_smtp.return_value.__enter__.return_value = mock_server
send_email(
to_email="recipient@example.com",
subject="Test Subject",
body_text="Test body",
body_html="<html><body>Test body</body></html>",
)
mock_server.sendmail.assert_called_once()
# Verify HTML was included
call_args = mock_server.sendmail.call_args
message_str = call_args[0][2]
assert "text/html" in message_str
assert "<html>" in message_str
@patch("preloop.utils.email.smtplib.SMTP")
def test_send_email_no_credentials_does_not_send(self, mock_smtp, monkeypatch):
"""Test that email logs warning and returns gracefully when credentials are missing."""
# Use monkeypatch to modify module-level variables at runtime
import preloop.utils.email as email_module
monkeypatch.setattr(email_module, "SMTP_USERNAME", "")
monkeypatch.setattr(email_module, "SMTP_PASSWORD", "")
# Call should succeed without raising
send_email(
to_email="recipient@example.com",
subject="Test Subject",
body_text="Test body",
)
# Verify SMTP connection was never attempted - this is the key behavior
mock_smtp.assert_not_called()
@patch("preloop.utils.email.smtplib.SMTP")
@patch("preloop.utils.email.SMTP_USERNAME", "test@example.com")
@patch("preloop.utils.email.SMTP_PASSWORD", "password")
def test_send_email_smtp_error_raises_email_error(self, mock_smtp):
"""Test that SMTP errors raise EmailError."""
mock_server = MagicMock()
mock_server.sendmail.side_effect = Exception("SMTP connection failed")
mock_smtp.return_value.__enter__.return_value = mock_server
with pytest.raises(EmailError) as exc_info:
send_email(
to_email="recipient@example.com",
subject="Test Subject",
body_text="Test body",
)
assert "Failed to send email" in str(exc_info.value)
assert "SMTP connection failed" in str(exc_info.value)
@patch("preloop.utils.email.smtplib.SMTP")
@patch("preloop.utils.email.SMTP_USERNAME", "test@example.com")
@patch("preloop.utils.email.SMTP_PASSWORD", "password")
def test_send_email_custom_from_address(self, mock_smtp):
"""Test sending email with custom from address."""
mock_server = MagicMock()
mock_smtp.return_value.__enter__.return_value = mock_server
send_email(
to_email="recipient@example.com",
subject="Test Subject",
body_text="Test body",
from_email="custom@example.com",
)
call_args = mock_server.sendmail.call_args
from_addr = call_args[0][0]
assert from_addr == "custom@example.com"
@patch("preloop.utils.email.smtplib.SMTP")
@patch("preloop.utils.email.SMTP_USERNAME", "test@example.com")
@patch("preloop.utils.email.SMTP_PASSWORD", "password")
@patch("preloop.utils.email.settings")
def test_send_email_includes_portal_url_in_subject(self, mock_settings, mock_smtp):
"""Test that portal URL is included in subject."""
mock_settings.preloop_url = "https://preloop.ai"
mock_server = MagicMock()
mock_smtp.return_value.__enter__.return_value = mock_server
send_email(
to_email="recipient@example.com",
subject="Test Subject",
body_text="Test body",
)
call_args = mock_server.sendmail.call_args
message_str = call_args[0][2]
assert "preloop.ai" in message_str
assert "Test Subject" in message_str
class TestSendVerificationEmail:
"""Test send_verification_email function."""
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.PRELOOP_URL", "https://app.test.com")
def test_send_verification_email_calls_send_email(self, mock_send_email):
"""Test that verification email calls send_email with correct params."""
send_verification_email("user@example.com", "test_token_123")
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
# Check recipient
assert call_args[0][0] == "user@example.com"
# Check subject
subject = call_args[0][1]
assert "Verify" in subject
# Check body contains verification link
text_body = call_args[0][2]
assert "https://app.test.com/verify-email?token=test_token_123" in text_body
# Check HTML body
html_body = call_args[0][3]
assert "test_token_123" in html_body
assert "verify-email" in html_body
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.APP_NAME", "Preloop")
def test_send_verification_email_includes_welcome_message(self, mock_send_email):
"""Test that verification email includes welcome message."""
send_verification_email("user@example.com", "token")
call_args = mock_send_email.call_args
text_body = call_args[0][2]
assert "Welcome to Preloop" in text_body
class TestSendPasswordResetEmail:
"""Test send_password_reset_email function."""
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.PRELOOP_URL", "https://app.test.com")
def test_send_password_reset_email_calls_send_email(self, mock_send_email):
"""Test that password reset email calls send_email with correct params."""
send_password_reset_email("user@example.com", "reset_token_456")
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
# Check recipient
assert call_args[0][0] == "user@example.com"
# Check subject
subject = call_args[0][1]
assert "Reset" in subject
assert "password" in subject.lower()
# Check body contains reset link
text_body = call_args[0][2]
assert "https://app.test.com/reset-password?token=reset_token_456" in text_body
# Check HTML body
html_body = call_args[0][3]
assert "reset_token_456" in html_body
assert "reset-password" in html_body
@patch("preloop.utils.email.send_email")
def test_send_password_reset_email_includes_instructions(self, mock_send_email):
"""Test that password reset email includes instructions."""
send_password_reset_email("user@example.com", "token")
call_args = mock_send_email.call_args
text_body = call_args[0][2]
assert "set a new password" in text_body.lower()
class TestSendInvitationEmail:
"""Test send_invitation_email function."""
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.PRELOOP_URL", "https://app.test.com")
def test_send_invitation_email_calls_send_email(self, mock_send_email):
"""Test that invitation email calls send_email with correct params."""
send_invitation_email(
user_email="invitee@example.com",
token="invite_token_123",
organization_name="Test Org",
invited_by="admin@example.com",
)
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
# Check recipient
assert call_args[0][0] == "invitee@example.com"
# Check subject includes organization name
subject = call_args[0][1]
assert "invited" in subject.lower()
assert "Test Org" in subject
# Check body contains invitation link
text_body = call_args[0][2]
assert (
"https://app.test.com/invitations/accept?token=invite_token_123"
in text_body
)
assert "Test Org" in text_body
assert "admin@example.com" in text_body
# Check HTML body
html_body = call_args[0][3]
assert "invite_token_123" in html_body
assert "invitations/accept" in html_body
assert "Test Org" in html_body
@patch("preloop.utils.email.send_email")
def test_send_invitation_email_includes_welcome_message(self, mock_send_email):
"""Test that invitation email includes welcome/greeting."""
send_invitation_email(
user_email="invitee@example.com",
token="token",
organization_name="Acme Corp",
invited_by="boss@example.com",
)
call_args = mock_send_email.call_args
text_body = call_args[0][2]
assert "Hello" in text_body
assert "invited" in text_body.lower()
@patch("preloop.utils.email.send_email")
def test_send_invitation_email_includes_expiration_notice(self, mock_send_email):
"""Test that invitation email includes expiration information."""
send_invitation_email(
user_email="invitee@example.com",
token="token",
organization_name="Test Org",
invited_by="admin@example.com",
)
call_args = mock_send_email.call_args
text_body = call_args[0][2]
assert "7 days" in text_body or "expire" in text_body.lower()
@patch("preloop.utils.email.send_email")
def test_send_invitation_email_html_has_button(self, mock_send_email):
"""Test that HTML invitation email includes a styled button."""
send_invitation_email(
user_email="invitee@example.com",
token="token",
organization_name="Test Org",
invited_by="admin@example.com",
)
call_args = mock_send_email.call_args
html_body = call_args[0][3]
# Check for button-like styling
assert "button" in html_body.lower() or "class=" in html_body
@patch("preloop.utils.email.send_email")
def test_send_invitation_email_includes_inviter_name(self, mock_send_email):
"""Test that invitation email mentions who invited them."""
send_invitation_email(
user_email="invitee@example.com",
token="token",
organization_name="Test Org",
invited_by="John Doe",
)
call_args = mock_send_email.call_args
text_body = call_args[0][2]
assert "John Doe" in text_body
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.PRELOOP_URL", "https://custom.domain.com")
def test_send_invitation_email_uses_custom_url(self, mock_send_email):
"""Test that invitation email uses configured PRELOOP_URL."""
send_invitation_email(
user_email="invitee@example.com",
token="token",
organization_name="Test Org",
invited_by="admin@example.com",
)
call_args = mock_send_email.call_args
text_body = call_args[0][2]
assert "https://custom.domain.com/invitations/accept" in text_body
class TestSendTrackerRegisteredEmail:
"""Test send_tracker_registered_email function."""
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.PRELOOP_URL", "https://app.test.com")
def test_send_tracker_registered_email_github(self, mock_send_email):
"""Test tracker registered email for GitHub tracker."""
send_tracker_registered_email(
user_email="user@example.com",
tracker_name="My GitHub Repo",
tracker_type="github",
)
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
# Check recipient
assert call_args[0][0] == "user@example.com"
# Check subject includes tracker name and type
subject = call_args[0][1]
assert "My GitHub Repo" in subject
assert "Github" in subject
# Check body includes tracker details
text_body = call_args[0][2]
assert "My GitHub Repo" in text_body
assert "Github" in text_body
assert "https://app.test.com/console/trackers" in text_body
@patch("preloop.utils.email.send_email")
def test_send_tracker_registered_email_gitlab(self, mock_send_email):
"""Test tracker registered email for GitLab tracker."""
send_tracker_registered_email(
user_email="user@example.com",
tracker_name="My GitLab Project",
tracker_type="gitlab",
)
call_args = mock_send_email.call_args
subject = call_args[0][1]
assert "Gitlab" in subject
@patch("preloop.utils.email.send_email")
def test_send_tracker_registered_email_jira(self, mock_send_email):
"""Test tracker registered email for Jira tracker."""
send_tracker_registered_email(
user_email="user@example.com",
tracker_name="My Jira Board",
tracker_type="jira",
)
call_args = mock_send_email.call_args
subject = call_args[0][1]
assert "Jira" in subject
class TestSendProductNotificationEmail:
"""Test send_product_notification_email function."""
@pytest.mark.asyncio
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.settings")
async def test_send_product_notification_email_with_user_data(
self, mock_settings, mock_send_email
):
"""Test sending product notification with user data."""
mock_settings.product_team_email = "team@example.com"
user_data = {"username": "testuser", "email": "test@example.com"}
await send_product_notification_email(
user_data=user_data, tracker_data=None, source_ip="192.168.1.1"
)
mock_send_email.assert_called_once()
call_args = mock_send_email.call_args
# Check recipient
assert call_args[0][0] == "team@example.com"
# Check body includes user data
text_body = call_args[0][2]
assert "testuser" in text_body
assert "test@example.com" in text_body
assert "192.168.1.1" in text_body
@pytest.mark.asyncio
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.settings")
async def test_send_product_notification_email_with_tracker_data(
self, mock_settings, mock_send_email
):
"""Test sending product notification with tracker data."""
mock_settings.product_team_email = "team@example.com"
user_data = {"username": "testuser"}
tracker_data = {"name": "My Tracker", "type": "github"}
await send_product_notification_email(
user_data=user_data, tracker_data=tracker_data, source_ip="192.168.1.1"
)
call_args = mock_send_email.call_args
text_body = call_args[0][2]
assert "My Tracker" in text_body
assert "github" in text_body
@pytest.mark.asyncio
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.settings")
async def test_send_product_notification_email_filters_sensitive_data(
self, mock_settings, mock_send_email
):
"""Test that sensitive fields are filtered out."""
mock_settings.product_team_email = "team@example.com"
user_data = {
"username": "testuser",
"password": "secret123",
"token": "abc123",
"api_key": "key123",
}
await send_product_notification_email(
user_data=user_data, tracker_data=None, source_ip="192.168.1.1"
)
call_args = mock_send_email.call_args
text_body = call_args[0][2]
# Should include username
assert "testuser" in text_body
# Should NOT include sensitive fields
assert "secret123" not in text_body
assert "abc123" not in text_body
assert "key123" not in text_body
@pytest.mark.asyncio
@patch("preloop.utils.email.send_email")
@patch("preloop.utils.email.settings")
async def test_send_product_notification_email_html_body(
self, mock_settings, mock_send_email
):
"""Test that HTML body is generated correctly."""
mock_settings.product_team_email = "team@example.com"
user_data = {"username": "testuser"}
await send_product_notification_email(
user_data=user_data, tracker_data=None, source_ip="192.168.1.1"
)
call_args = mock_send_email.call_args
html_body = call_args[0][3]
# Check HTML structure
assert "<html>" in html_body
assert "<body>" in html_body
assert "<h2>Product Notification" in html_body
assert "testuser" in html_body
@pytest.mark.asyncio
@patch("preloop.utils.email.settings")
async def test_send_product_notification_email_raises_on_error(
self, mock_settings, monkeypatch
):
"""Test that EmailError is logged but not raised when send_email fails."""
mock_settings.product_team_email = "team@example.com"
# Patch send_email to raise EmailError
def mock_send_email(*args, **kwargs):
raise EmailError("SMTP failed")
import preloop.utils.email as email_module
monkeypatch.setattr(email_module, "send_email", mock_send_email)
user_data = {"username": "testuser"}
# Should not raise - errors are logged but not raised to avoid breaking flows
# The key behavior is that no exception propagates
await send_product_notification_email(
user_data=user_data, tracker_data=None, source_ip="192.168.1.1"
)
# If we get here without exception, the test passes
@pytest.mark.asyncio
@patch("preloop.utils.email.settings")
async def test_send_product_notification_email_wraps_unexpected_errors(
self, mock_settings, monkeypatch
):
"""Test that unexpected errors are logged but not raised."""
mock_settings.product_team_email = "team@example.com"
# Patch send_email to raise RuntimeError
def mock_send_email(*args, **kwargs):
raise RuntimeError("Unexpected error")
import preloop.utils.email as email_module
monkeypatch.setattr(email_module, "send_email", mock_send_email)
user_data = {"username": "testuser"}
# Should not raise - errors are logged but not raised to avoid breaking flows
# The key behavior is that no exception propagates
await send_product_notification_email(
user_data=user_data, tracker_data=None, source_ip="192.168.1.1"
)
# If we get here without exception, the test passes
class TestEmailError:
"""Test EmailError exception."""
def test_email_error_can_be_raised(self):
"""Test that EmailError can be raised with a message."""
with pytest.raises(EmailError) as exc_info:
raise EmailError("Test error message")
assert "Test error message" in str(exc_info.value)
def test_email_error_inherits_from_exception(self):
"""Test that EmailError inherits from Exception."""
assert issubclass(EmailError, Exception)