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

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