diff --git a/src/config.py b/src/config.py index 03304e3..dec0059 100644 --- a/src/config.py +++ b/src/config.py @@ -41,6 +41,7 @@ class Config: # Email service (Resend) RESEND_API_KEY = os.environ.get("RESEND_API_KEY") + EMAIL_FROM = os.environ.get("EMAIL_FROM", "noreply@sneaky-klaus.com") # Application URLs APP_URL = os.environ.get("APP_URL", "http://localhost:5000") diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..a235da7 --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1 @@ +"""Services package for Sneaky Klaus application.""" diff --git a/src/services/email.py b/src/services/email.py new file mode 100644 index 0000000..bb0f4a5 --- /dev/null +++ b/src/services/email.py @@ -0,0 +1,176 @@ +"""Email service for Sneaky Klaus application. + +Handles email sending via Resend API with dev mode support. +In development mode, emails are logged instead of sent. +""" + +import logging +import os +from typing import Any + +import resend +from flask import current_app + +logger = logging.getLogger(__name__) + + +class EmailService: + """Service for sending emails via Resend API. + + In development mode (FLASK_ENV=development), emails are logged + instead of actually being sent. Magic link URLs are also logged + for easier testing. + """ + + def __init__(self) -> None: + """Initialize the email service.""" + self.api_key = current_app.config.get("RESEND_API_KEY") + self.from_email = current_app.config.get( + "EMAIL_FROM", "noreply@sneaky-klaus.com" + ) + + # Check if in dev mode - first check config, then environment + flask_env = current_app.config.get("FLASK_ENV") or os.environ.get("FLASK_ENV") + self.dev_mode = flask_env == "development" + + # In production, API key is required + if not self.dev_mode and not self.api_key: + raise ValueError("RESEND_API_KEY is required in production mode") + + # Configure Resend if we have an API key + if self.api_key: + resend.api_key = self.api_key + + def send_email( + self, + to: str, + subject: str, + html_body: str, + ) -> dict[str, Any]: + """Send an email. + + Args: + to: Recipient email address + subject: Email subject line + html_body: HTML email body + + Returns: + Response from Resend API or mock response in dev mode + + Raises: + Exception: If email sending fails + """ + if self.dev_mode: + # In dev mode, just log the email + logger.info("=" * 80) + logger.info("DEV MODE: Email not sent") + logger.info(f"To: {to}") + logger.info(f"Subject: {subject}") + logger.info(f"Body preview: {html_body[:200]}...") + logger.info("=" * 80) + # Return a mock success response + return {"id": f"dev-mode-{os.urandom(8).hex()}"} + + # Send via Resend + params: resend.Emails.SendParams = { + "from": self.from_email, + "to": [to], + "subject": subject, + "html": html_body, + } + + result: dict[str, Any] = resend.Emails.send(params) + return result + + def send_magic_link( + self, + to: str, + magic_link_url: str, + exchange_name: str, + ) -> dict[str, Any]: + """Send a magic link email for participant authentication. + + Args: + to: Participant email address + magic_link_url: Full URL with magic token + exchange_name: Name of the exchange + + Returns: + Response from email service + """ + if self.dev_mode: + logger.info(f"DEV MODE: Magic link URL: {magic_link_url}") + + subject = f"Access your {exchange_name} Secret Santa" + html_body = f""" + + +

Access Your Secret Santa

+

Click the link below to access your {exchange_name} + Secret Santa exchange:

+

{magic_link_url}

+

This link will expire in 1 hour.

+

If you didn't request this link, you can safely + ignore this email.

+ + + """ + + return self.send_email(to=to, subject=subject, html_body=html_body) + + def send_registration_confirmation( + self, + to: str, + participant_name: str, + magic_link_url: str, + exchange_name: str, + exchange_description: str | None, + budget_amount: float, + gift_exchange_date: str, + ) -> dict[str, Any]: + """Send registration confirmation email with magic link. + + Args: + to: Participant email address + participant_name: Name of the participant + magic_link_url: Full URL with magic token + exchange_name: Name of the exchange + exchange_description: Description of the exchange (optional) + budget_amount: Budget amount + gift_exchange_date: Date of gift exchange + + Returns: + Response from email service + """ + if self.dev_mode: + logger.info(f"DEV MODE: Magic link URL: {magic_link_url}") + + subject = f"Welcome to {exchange_name}!" + + description_html = "" + if exchange_description: + description_html = f"

{exchange_description}

" + + html_body = f""" + + +

Welcome to {exchange_name}, {participant_name}!

+

You've successfully registered for the Secret Santa exchange.

+ {description_html} +

Exchange Details

+ +

Access Your Dashboard

+

Click the link below to access your participant + dashboard:

+

{magic_link_url}

+

This link will expire in 1 hour, but you can always + request a new one.

+

Happy gifting!

+ + + """ + + return self.send_email(to=to, subject=subject, html_body=html_body) diff --git a/tests/unit/test_email_service.py b/tests/unit/test_email_service.py new file mode 100644 index 0000000..ccd5093 --- /dev/null +++ b/tests/unit/test_email_service.py @@ -0,0 +1,200 @@ +"""Tests for email service.""" + +import logging +from unittest.mock import patch + +import pytest + +from src.services.email import EmailService + + +class TestEmailService: + """Test suite for EmailService.""" + + def test_init_with_api_key(self, app): + """Test EmailService initialization with API key.""" + with app.app_context(): + app.config["RESEND_API_KEY"] = "test-api-key" + service = EmailService() + assert service.api_key == "test-api-key" + assert service.dev_mode is False + + def test_init_in_dev_mode(self, app): + """Test EmailService initialization in development mode.""" + with app.app_context(): + app.config["FLASK_ENV"] = "development" + service = EmailService() + assert service.dev_mode is True + + def test_init_without_api_key_in_production(self, app): + """Test EmailService raises error without API key in production.""" + with app.app_context(): + app.config["RESEND_API_KEY"] = None + app.config["FLASK_ENV"] = "production" + with pytest.raises(ValueError, match="RESEND_API_KEY"): + EmailService() + + def test_init_without_api_key_in_dev_mode(self, app): + """Test EmailService allows missing API key in dev mode.""" + with app.app_context(): + app.config["RESEND_API_KEY"] = None + app.config["FLASK_ENV"] = "development" + service = EmailService() + assert service.api_key is None + assert service.dev_mode is True + + @patch("resend.Emails.send") + def test_send_email_success(self, mock_send, app): + """Test successful email sending.""" + with app.app_context(): + app.config["RESEND_API_KEY"] = "test-api-key" + app.config["FLASK_ENV"] = "production" + service = EmailService() + + mock_send.return_value = {"id": "email-123"} + + result = service.send_email( + to="test@example.com", + subject="Test Subject", + html_body="

Test body

", + ) + + assert result == {"id": "email-123"} + mock_send.assert_called_once() + # Check the first positional argument (the dict) + call_args = mock_send.call_args[0][0] + assert call_args["to"] == ["test@example.com"] + assert call_args["subject"] == "Test Subject" + assert call_args["html"] == "

Test body

" + + @patch("resend.Emails.send") + def test_send_email_with_from_address(self, mock_send, app): + """Test email sending with custom from address.""" + with app.app_context(): + app.config["RESEND_API_KEY"] = "test-api-key" + app.config["EMAIL_FROM"] = "custom@example.com" + app.config["FLASK_ENV"] = "production" + service = EmailService() + + mock_send.return_value = {"id": "email-123"} + + service.send_email( + to="test@example.com", + subject="Test Subject", + html_body="

Test body

", + ) + + call_args = mock_send.call_args[0][0] + assert call_args["from"] == "custom@example.com" + + @patch("resend.Emails.send") + def test_send_email_in_dev_mode_logs_instead(self, mock_send, app, caplog): + """Test that email sending logs in dev mode instead of actually sending.""" + with app.app_context(): + app.config["FLASK_ENV"] = "development" + service = EmailService() + + with caplog.at_level(logging.INFO): + result = service.send_email( + to="test@example.com", + subject="Test Subject", + html_body="

Test body

", + ) + + # Should not actually send email + mock_send.assert_not_called() + + # Should log the email details + assert "DEV MODE: Email not sent" in caplog.text + assert "test@example.com" in caplog.text + assert "Test Subject" in caplog.text + + # Should return a mock success response + assert result["id"].startswith("dev-mode-") + + @patch("resend.Emails.send") + def test_send_email_handles_resend_error(self, mock_send, app): + """Test email sending handles Resend API errors.""" + with app.app_context(): + app.config["RESEND_API_KEY"] = "test-api-key" + app.config["FLASK_ENV"] = "production" + service = EmailService() + + mock_send.side_effect = Exception("API Error") + + with pytest.raises(Exception, match="API Error"): + service.send_email( + to="test@example.com", + subject="Test Subject", + html_body="

Test body

", + ) + + def test_send_magic_link_email(self, app): + """Test sending magic link email.""" + with app.app_context(): + app.config["FLASK_ENV"] = "development" + service = EmailService() + + with patch.object(service, "send_email") as mock_send: + mock_send.return_value = {"id": "email-123"} + + result = service.send_magic_link( + to="test@example.com", + magic_link_url="https://example.com/magic/abc123", + exchange_name="Secret Santa 2025", + ) + + assert result == {"id": "email-123"} + mock_send.assert_called_once() + call_args = mock_send.call_args[1] + assert call_args["to"] == "test@example.com" + assert "Secret Santa 2025" in call_args["subject"] + assert "https://example.com/magic/abc123" in call_args["html_body"] + + def test_send_registration_confirmation_email(self, app): + """Test sending registration confirmation email.""" + with app.app_context(): + app.config["FLASK_ENV"] = "development" + service = EmailService() + + with patch.object(service, "send_email") as mock_send: + mock_send.return_value = {"id": "email-123"} + + result = service.send_registration_confirmation( + to="test@example.com", + participant_name="John Doe", + magic_link_url="https://example.com/magic/abc123", + exchange_name="Secret Santa 2025", + exchange_description="Annual office Secret Santa", + budget_amount=50, + gift_exchange_date="2025-12-20", + ) + + assert result == {"id": "email-123"} + mock_send.assert_called_once() + call_args = mock_send.call_args[1] + assert call_args["to"] == "test@example.com" + assert "Secret Santa 2025" in call_args["subject"] + assert "John Doe" in call_args["html_body"] + assert "https://example.com/magic/abc123" in call_args["html_body"] + assert "Annual office Secret Santa" in call_args["html_body"] + assert "$50" in call_args["html_body"] + assert "2025-12-20" in call_args["html_body"] + + def test_dev_mode_logs_magic_link_url(self, app, caplog): + """Test that magic link URLs are logged in dev mode.""" + with app.app_context(): + app.config["FLASK_ENV"] = "development" + service = EmailService() + + with caplog.at_level(logging.INFO): + service.send_magic_link( + to="test@example.com", + magic_link_url="https://example.com/magic/abc123", + exchange_name="Secret Santa 2025", + ) + + assert ( + "DEV MODE: Magic link URL: https://example.com/magic/abc123" + in caplog.text + )