Files
Gondulf/src/gondulf/email.py
Phil Skentelbery d3c3e8dc6b feat(security): merge Phase 4b security hardening
Complete security hardening implementation including HTTPS enforcement,
security headers, rate limiting, and comprehensive security test suite.

Key features:
- HTTPS enforcement with HSTS support
- Security headers (CSP, X-Frame-Options, X-Content-Type-Options)
- Rate limiting for all critical endpoints
- Enhanced email template security
- 87% test coverage with security-specific tests

Architect approval: 9.5/10

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 18:28:50 -07:00

177 lines
5.6 KiB
Python

"""
Email service for sending verification codes via SMTP.
Supports both STARTTLS (port 587) and implicit TLS (port 465) based on
configuration. Handles authentication and error cases.
"""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
logger = logging.getLogger("gondulf.email")
class EmailError(Exception):
"""Raised when email sending fails."""
pass
class EmailService:
"""
SMTP email service for sending verification emails.
Supports STARTTLS and implicit TLS configurations based on port number.
"""
def __init__(
self,
smtp_host: str,
smtp_port: int,
smtp_from: str,
smtp_username: str | None = None,
smtp_password: str | None = None,
smtp_use_tls: bool = True,
):
"""
Initialize email service.
Args:
smtp_host: SMTP server hostname
smtp_port: SMTP server port (587 for STARTTLS, 465 for implicit TLS)
smtp_from: From address for sent emails
smtp_username: SMTP username for authentication (optional)
smtp_password: SMTP password for authentication (optional)
smtp_use_tls: Whether to use TLS (STARTTLS on port 587)
"""
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.smtp_from = smtp_from
self.smtp_username = smtp_username
self.smtp_password = smtp_password
self.smtp_use_tls = smtp_use_tls
logger.debug(
f"EmailService initialized: host={smtp_host} port={smtp_port} "
f"tls={smtp_use_tls}"
)
def send_verification_code(self, to_email: str, code: str, domain: str) -> None:
"""
Send domain verification code via email.
Args:
to_email: Recipient email address
code: Verification code to send
domain: Domain being verified
Raises:
EmailError: If sending fails
"""
subject = f"Domain Verification Code for {domain}"
body = f"""
Hello,
Your domain verification code for {domain} is:
{code}
This code will expire in 10 minutes.
If you did not request this verification, please ignore this email.
---
Gondulf IndieAuth Server
"""
try:
self._send_email(to_email, subject, body)
logger.info(f"Verification code sent for domain={domain}")
except Exception as e:
logger.error(f"Failed to send verification email for domain={domain}: {e}")
raise EmailError(f"Failed to send verification email: {e}") from e
def _send_email(self, to_email: str, subject: str, body: str) -> None:
"""
Send email via SMTP.
Handles STARTTLS vs implicit TLS based on port configuration.
Args:
to_email: Recipient email address
subject: Email subject
body: Email body (plain text)
Raises:
EmailError: If sending fails
"""
# Create message
msg = MIMEMultipart()
msg["From"] = self.smtp_from
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
try:
# Determine connection type based on port
if self.smtp_port == 465:
# Implicit TLS (SSL/TLS from start)
logger.debug("Using implicit TLS (SMTP_SSL)")
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=10)
elif self.smtp_port == 587 and self.smtp_use_tls:
# STARTTLS (upgrade plain connection to TLS)
logger.debug("Using STARTTLS")
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
server.starttls()
else:
# Unencrypted (for testing only)
logger.warning("Using unencrypted SMTP connection")
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
# Authenticate if credentials provided
if self.smtp_username and self.smtp_password:
logger.debug(f"Authenticating as {self.smtp_username}")
server.login(self.smtp_username, self.smtp_password)
# Send email
server.send_message(msg)
server.quit()
logger.debug("Email sent successfully")
except smtplib.SMTPAuthenticationError as e:
raise EmailError(f"SMTP authentication failed: {e}") from e
except smtplib.SMTPException as e:
raise EmailError(f"SMTP error: {e}") from e
except Exception as e:
raise EmailError(f"Failed to send email: {e}") from e
def test_connection(self) -> bool:
"""
Test SMTP connection and authentication.
Returns:
True if connection successful, False otherwise
"""
try:
if self.smtp_port == 465:
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=10)
elif self.smtp_port == 587 and self.smtp_use_tls:
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
server.starttls()
else:
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
if self.smtp_username and self.smtp_password:
server.login(self.smtp_username, self.smtp_password)
server.quit()
logger.info("SMTP connection test successful")
return True
except Exception as e:
logger.warning(f"SMTP connection test failed: {e}")
return False