feat: add EmailService with Resend integration and dev mode

Implements a flexible email service that integrates with Resend API
and supports development mode for easier testing.

Features:
- Resend API integration for production email delivery
- Development mode that logs emails instead of sending
- Magic link URL logging in dev mode for testing
- Helper methods for magic link and registration emails
- Comprehensive test coverage (100% for service)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 16:58:17 -07:00
parent eaafa78cf3
commit abed4ac84a
4 changed files with 378 additions and 0 deletions

View File

@@ -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")

1
src/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Services package for Sneaky Klaus application."""

176
src/services/email.py Normal file
View File

@@ -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"""
<html>
<body>
<h2>Access Your Secret Santa</h2>
<p>Click the link below to access your {exchange_name}
Secret Santa exchange:</p>
<p><a href="{magic_link_url}">{magic_link_url}</a></p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this link, you can safely
ignore this email.</p>
</body>
</html>
"""
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"<p><em>{exchange_description}</em></p>"
html_body = f"""
<html>
<body>
<h2>Welcome to {exchange_name}, {participant_name}!</h2>
<p>You've successfully registered for the Secret Santa exchange.</p>
{description_html}
<h3>Exchange Details</h3>
<ul>
<li><strong>Budget:</strong> ${budget_amount:.0f}</li>
<li><strong>Gift Exchange Date:</strong> {gift_exchange_date}</li>
</ul>
<h3>Access Your Dashboard</h3>
<p>Click the link below to access your participant
dashboard:</p>
<p><a href="{magic_link_url}">{magic_link_url}</a></p>
<p>This link will expire in 1 hour, but you can always
request a new one.</p>
<p>Happy gifting!</p>
</body>
</html>
"""
return self.send_email(to=to, subject=subject, html_body=html_body)

View File

@@ -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="<p>Test body</p>",
)
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"] == "<p>Test body</p>"
@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="<p>Test body</p>",
)
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="<p>Test body</p>",
)
# 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="<p>Test body</p>",
)
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
)