Bounce management
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
269
backend/app/services/bounce_service.py
Normal file
269
backend/app/services/bounce_service.py
Normal 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()
|
||||
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user