Bounce management

This commit is contained in:
James Pattinson
2025-11-10 16:57:29 +00:00
parent 7fd237c28b
commit 051bd05149
15 changed files with 1198 additions and 9 deletions

View File

@@ -1,8 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from ...services.email_service import email_service
from ...services.bounce_service import bounce_service
from ...api.dependencies import get_admin_user
from ...models.models import User
from typing import Dict, Any, List
from ...core.database import get_db
from sqlalchemy.orm import Session
router = APIRouter()
router = APIRouter()
@@ -21,7 +27,8 @@ class WelcomeEmailRequest(BaseModel):
@router.post("/test-email")
async def send_test_email(
request: TestEmailRequest,
current_user: User = Depends(get_admin_user)
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Send a test email (admin only)"""
html_body = f"<html><body><p>{request.message}</p></body></html>"
@@ -29,7 +36,8 @@ async def send_test_email(
to_email=request.to_email,
subject=request.subject,
html_body=html_body,
text_body=request.message
text_body=request.message,
db=db
)
return {"success": True, "result": result}
@@ -45,3 +53,118 @@ async def send_test_welcome_email(
first_name=request.first_name
)
return {"success": True, "result": result}
@router.post("/webhooks/smtp2go/bounce")
async def smtp2go_bounce_webhook(
webhook_data: Dict[str, Any],
db: Session = Depends(get_db)
):
"""
Webhook endpoint for SMTP2GO bounce notifications
This endpoint should be configured in SMTP2GO webhook settings
"""
try:
bounces_created = bounce_service.process_smtp2go_webhook(webhook_data, db)
return {
"success": True,
"bounces_processed": len(bounces_created),
"message": f"Successfully processed {len(bounces_created)} bounce events"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to process bounce webhook: {str(e)}")
@router.get("/bounces")
async def get_bounce_list(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Get list of email bounces (admin only)"""
from ...models.models import EmailBounce
bounces = db.query(EmailBounce).offset(skip).limit(limit).all()
total = db.query(EmailBounce).count()
return {
"bounces": [
{
"id": bounce.id,
"email": bounce.email,
"bounce_type": bounce.bounce_type.value,
"bounce_reason": bounce.bounce_reason,
"bounce_date": bounce.bounce_date.isoformat(),
"is_active": bounce.is_active,
"smtp2go_message_id": bounce.smtp2go_message_id
}
for bounce in bounces
],
"total": total,
"skip": skip,
"limit": limit
}
@router.get("/bounces/stats")
async def get_bounce_statistics(
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Get bounce statistics (admin only)"""
stats = bounce_service.get_bounce_statistics(db)
return stats
@router.get("/bounces/{email}")
async def get_bounce_history(
email: str,
current_user: User = Depends(get_admin_user)
):
"""Get bounce history for a specific email (admin only)"""
bounces = bounce_service.get_bounce_history(email)
return {
"email": email,
"bounces": [
{
"id": bounce.id,
"bounce_type": bounce.bounce_type.value,
"bounce_reason": bounce.bounce_reason,
"bounce_date": bounce.bounce_date.isoformat(),
"is_active": bounce.is_active,
"smtp2go_message_id": bounce.smtp2go_message_id
}
for bounce in bounces
]
}
@router.post("/bounces/cleanup")
async def cleanup_old_bounces(
days_old: int = 365,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Clean up old soft bounces (admin only)"""
cleaned_count = bounce_service.cleanup_old_bounces(days_old, db)
return {
"success": True,
"message": f"Cleaned up {cleaned_count} old soft bounce records"
}
@router.delete("/bounces/deactivate/{bounce_id}")
async def deactivate_bounce(
bounce_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Deactivate a bounce record (mark as resolved) (admin only)"""
success = bounce_service.deactivate_bounce(bounce_id, db)
if not success:
raise HTTPException(status_code=404, detail="Bounce record not found")
return {"success": True, "message": "Bounce record deactivated"}

View File

@@ -288,3 +288,24 @@ class EmailTemplate(Base):
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class BounceType(str, enum.Enum):
HARD = "hard"
SOFT = "soft"
COMPLAINT = "complaint"
UNSUBSCRIBE = "unsubscribe"
class EmailBounce(Base):
__tablename__ = "email_bounces"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), nullable=False, index=True)
bounce_type = Column(SQLEnum(BounceType), nullable=False)
bounce_reason = Column(String(500), nullable=True)
smtp2go_message_id = Column(String(255), nullable=True, index=True)
bounce_date = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -0,0 +1,269 @@
from typing import List, Optional, Dict, Any
from datetime import datetime
from sqlalchemy.orm import Session
from ..models.models import EmailBounce, BounceType
from ..core.database import get_db
class BounceService:
"""Service for managing email bounce tracking and prevention"""
def __init__(self):
pass
def record_bounce(
self,
email: str,
bounce_type: BounceType,
bounce_reason: Optional[str] = None,
smtp2go_message_id: Optional[str] = None,
bounce_date: Optional[datetime] = None,
db: Session = None
) -> EmailBounce:
"""
Record an email bounce in the database
Args:
email: The email address that bounced
bounce_type: Type of bounce (hard, soft, complaint, unsubscribe)
bounce_reason: Reason for the bounce (optional)
smtp2go_message_id: SMTP2GO message ID (optional)
bounce_date: When the bounce occurred (defaults to now)
db: Database session
Returns:
EmailBounce: The created bounce record
"""
if db is None:
db = next(get_db())
if bounce_date is None:
bounce_date = datetime.utcnow()
# Check if bounce already exists for this email and type
existing_bounce = db.query(EmailBounce).filter(
EmailBounce.email == email,
EmailBounce.bounce_type == bounce_type,
EmailBounce.is_active == True
).first()
if existing_bounce:
# Update existing bounce with new information
if bounce_reason:
existing_bounce.bounce_reason = bounce_reason
if smtp2go_message_id:
existing_bounce.smtp2go_message_id = smtp2go_message_id
existing_bounce.bounce_date = bounce_date
existing_bounce.updated_at = datetime.utcnow()
db.commit()
db.refresh(existing_bounce)
return existing_bounce
# Create new bounce record
bounce = EmailBounce(
email=email,
bounce_type=bounce_type,
bounce_reason=bounce_reason,
smtp2go_message_id=smtp2go_message_id,
bounce_date=bounce_date,
is_active=True
)
db.add(bounce)
db.commit()
db.refresh(bounce)
return bounce
def is_email_bounced(self, email: str, db: Session = None) -> bool:
"""
Check if an email address has any active bounces
Args:
email: Email address to check
db: Database session
Returns:
bool: True if email has active bounces
"""
if db is None:
db = next(get_db())
bounce = db.query(EmailBounce).filter(
EmailBounce.email == email,
EmailBounce.is_active == True
).first()
return bounce is not None
def get_bounce_history(self, email: str, db: Session = None) -> List[EmailBounce]:
"""
Get bounce history for an email address
Args:
email: Email address to check
db: Database session
Returns:
List[EmailBounce]: List of bounce records
"""
if db is None:
db = next(get_db())
return db.query(EmailBounce).filter(
EmailBounce.email == email
).order_by(EmailBounce.bounce_date.desc()).all()
def deactivate_bounce(self, bounce_id: int, db: Session = None) -> bool:
"""
Deactivate a bounce record (mark as resolved)
Args:
bounce_id: ID of the bounce record
db: Database session
Returns:
bool: True if successfully deactivated
"""
if db is None:
db = next(get_db())
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
if bounce:
bounce.is_active = False
bounce.updated_at = datetime.utcnow()
db.commit()
return True
return False
def process_smtp2go_webhook(self, webhook_data: Dict[str, Any], db: Session = None) -> List[EmailBounce]:
"""
Process webhook data from SMTP2GO bounce notifications
Args:
webhook_data: Webhook payload from SMTP2GO
db: Database session
Returns:
List[EmailBounce]: List of created/updated bounce records
"""
if db is None:
db = next(get_db())
bounces_created = []
# SMTP2GO webhook structure may vary, but typically includes:
# - email: recipient email
# - event: 'bounced', 'complained', etc.
# - reason: bounce reason
# - message_id: SMTP2GO message ID
# - timestamp: when it occurred
events = webhook_data.get('events', [])
if not isinstance(events, list):
events = [webhook_data] # Single event format
for event in events:
email = event.get('email') or event.get('recipient')
event_type = event.get('event', '').lower()
reason = event.get('reason') or event.get('bounce_reason')
message_id = event.get('message_id') or event.get('smtp2go_message_id')
timestamp = event.get('timestamp')
if not email:
continue
# Map SMTP2GO event types to our bounce types
bounce_type = None
if event_type in ['bounced', 'hard_bounce', 'permanent_bounce']:
bounce_type = BounceType.HARD
elif event_type in ['soft_bounce', 'transient_bounce']:
bounce_type = BounceType.SOFT
elif event_type in ['complained', 'spam_complaint']:
bounce_type = BounceType.COMPLAINT
elif event_type in ['unsubscribed', 'unsubscribe']:
bounce_type = BounceType.UNSUBSCRIBE
if bounce_type:
bounce_date = None
if timestamp:
try:
# SMTP2GO timestamps are typically Unix timestamps
if isinstance(timestamp, (int, float)):
bounce_date = datetime.fromtimestamp(timestamp)
elif isinstance(timestamp, str):
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
except (ValueError, TypeError):
pass
bounce = self.record_bounce(
email=email,
bounce_type=bounce_type,
bounce_reason=reason,
smtp2go_message_id=message_id,
bounce_date=bounce_date,
db=db
)
bounces_created.append(bounce)
return bounces_created
def get_bounce_statistics(self, db: Session = None) -> Dict[str, Any]:
"""
Get bounce statistics for reporting
Args:
db: Database session
Returns:
Dict[str, Any]: Bounce statistics
"""
if db is None:
db = next(get_db())
total_bounces = db.query(EmailBounce).count()
active_bounces = db.query(EmailBounce).filter(EmailBounce.is_active == True).count()
bounce_type_counts = {}
for bounce_type in BounceType:
count = db.query(EmailBounce).filter(
EmailBounce.bounce_type == bounce_type,
EmailBounce.is_active == True
).count()
bounce_type_counts[bounce_type.value] = count
return {
'total_bounces': total_bounces,
'active_bounces': active_bounces,
'bounce_types': bounce_type_counts
}
def cleanup_old_bounces(self, days_old: int = 365, db: Session = None) -> int:
"""
Deactivate bounce records older than specified days (for soft bounces that may be resolved)
Args:
days_old: Number of days after which to deactivate bounces
db: Database session
Returns:
int: Number of bounces deactivated
"""
if db is None:
db = next(get_db())
from datetime import timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
# Only deactivate soft bounces, keep hard bounces and complaints active
result = db.query(EmailBounce).filter(
EmailBounce.bounce_type == BounceType.SOFT,
EmailBounce.is_active == True,
EmailBounce.bounce_date < cutoff_date
).update({'is_active': False, 'updated_at': datetime.utcnow()})
db.commit()
return result
# Create a singleton instance
bounce_service = BounceService()

View File

@@ -5,6 +5,7 @@ from ..core.database import get_db
from ..models.models import EmailTemplate
from sqlalchemy.orm import Session
from ..core.config import settings
from .bounce_service import bounce_service
class EmailService:
@@ -21,7 +22,8 @@ class EmailService:
to_email: str,
subject: str,
html_body: str,
text_body: Optional[str] = None
text_body: Optional[str] = None,
db: Optional[Session] = None
) -> dict:
"""
Send an email using SMTP2GO API
@@ -31,10 +33,18 @@ class EmailService:
subject: Email subject
html_body: HTML content of the email
text_body: Plain text content (optional)
db: Database session for bounce checking
Returns:
dict: API response
Raises:
ValueError: If email is bounced or invalid
"""
# Check if email is bounced before sending
if db and bounce_service.is_email_bounced(to_email, db):
raise ValueError(f"Email {to_email} is in bounce list and cannot receive emails")
payload = {
"to": [to_email],
"sender": f"{self.from_name} <{self.from_email}>",
@@ -89,7 +99,7 @@ class EmailService:
if template.text_body:
text_body = self.render_template(template.text_body, variables)
return await self.send_email(to_email, subject, html_body, text_body)
return await self.send_email(to_email, subject, html_body, text_body, db)
async def send_welcome_email(self, to_email: str, first_name: str, db: Session) -> dict:
"""Send welcome email to new user"""