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:
@@ -41,6 +41,7 @@ class Config:
|
|||||||
|
|
||||||
# Email service (Resend)
|
# Email service (Resend)
|
||||||
RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
|
RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
|
||||||
|
EMAIL_FROM = os.environ.get("EMAIL_FROM", "noreply@sneaky-klaus.com")
|
||||||
|
|
||||||
# Application URLs
|
# Application URLs
|
||||||
APP_URL = os.environ.get("APP_URL", "http://localhost:5000")
|
APP_URL = os.environ.get("APP_URL", "http://localhost:5000")
|
||||||
|
|||||||
1
src/services/__init__.py
Normal file
1
src/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Services package for Sneaky Klaus application."""
|
||||||
176
src/services/email.py
Normal file
176
src/services/email.py
Normal 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)
|
||||||
200
tests/unit/test_email_service.py
Normal file
200
tests/unit/test_email_service.py
Normal 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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user