diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 2650d16..f955683 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -102,6 +102,13 @@ Each tier will have associated annual fees and benefits. - Advanced reporting and analytics - Integration with other alliance systems - Multi-language support +- **Asynchronous Batch Processing**: Implement automated renewal reminder system using scheduled batch jobs + - Create containerized renewal reminder script that runs daily + - Query memberships expiring within configurable timeframe (e.g., 30 days) + - Send personalized renewal reminder emails using existing email templates + - Add batch job scheduling with cron or similar scheduler + - Include logging and monitoring for batch job execution + - Extend pattern for other automated tasks (payment reminders, membership expiration processing) ## Project Requirements diff --git a/backend/app/api/v1/email.py b/backend/app/api/v1/email.py index ae1b679..84938a1 100644 --- a/backend/app/api/v1/email.py +++ b/backend/app/api/v1/email.py @@ -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"

{request.message}

" @@ -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"} diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 1d61920..b352c76 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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) diff --git a/backend/app/services/bounce_service.py b/backend/app/services/bounce_service.py new file mode 100644 index 0000000..f63dba5 --- /dev/null +++ b/backend/app/services/bounce_service.py @@ -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() \ No newline at end of file diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 056c071..1907492 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -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""" diff --git a/database/init.sql b/database/init.sql index e65c906..5cf8a5b 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1,5 +1,19 @@ -- Initialize database with default membership tiers +-- Create email bounces table +CREATE TABLE email_bounces ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + bounce_type ENUM('hard', 'soft') NOT NULL, + bounce_reason VARCHAR(500), + smtp2go_message_id VARCHAR(255), + bounced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP NULL, + is_active BOOLEAN DEFAULT TRUE, + INDEX idx_email (email), + INDEX idx_active (is_active) +); + -- Create default membership tiers INSERT INTO membership_tiers (name, description, annual_fee, benefits, is_active, created_at, updated_at) VALUES diff --git a/frontend/package.json b/frontend/package.json index bb37a76..4aa6cbe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,10 @@ "axios": "^1.6.2", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "tailwindcss": "^3.4.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32" }, "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index e99ebc2..96bb01e 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,4 +1,4 @@ -export default { +module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e78e829..9969b8c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import ResetPassword from './pages/ResetPassword'; import Dashboard from './pages/Dashboard'; import EmailTemplates from './pages/EmailTemplates'; import MembershipTiers from './pages/MembershipTiers'; +import BounceManagement from './pages/BounceManagement'; import './App.css'; const App: React.FC = () => { @@ -21,6 +22,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> ); diff --git a/frontend/src/components/BounceManagement.tsx b/frontend/src/components/BounceManagement.tsx new file mode 100644 index 0000000..9eba483 --- /dev/null +++ b/frontend/src/components/BounceManagement.tsx @@ -0,0 +1,349 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; + +interface BounceRecord { + id: number; + email: string; + bounce_type: 'hard' | 'soft' | 'complaint' | 'unsubscribe'; + bounce_reason: string | null; + bounce_date: string; + is_active: boolean; + smtp2go_message_id: string | null; +} + +interface BounceStats { + total_bounces: number; + active_bounces: number; + bounce_types: { + hard: number; + soft: number; + complaint: number; + unsubscribe: number; + }; +} + +const BounceManagement: React.FC = () => { + const [bounces, setBounces] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [searchEmail, setSearchEmail] = useState(''); + const [filteredBounces, setFilteredBounces] = useState([]); + + useEffect(() => { + fetchBounces(); + fetchStats(); + }, []); + + useEffect(() => { + if (searchEmail.trim() === '') { + setFilteredBounces(bounces); + } else { + setFilteredBounces( + bounces.filter(bounce => + bounce.email.toLowerCase().includes(searchEmail.toLowerCase()) + ) + ); + } + }, [bounces, searchEmail]); + + const fetchBounces = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get('/api/v1/email/bounces', { + headers: { Authorization: `Bearer ${token}` } + }); + setBounces(response.data.bounces); + } catch (error) { + console.error('Error fetching bounces:', error); + } + }; + + const fetchStats = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get('/api/v1/email/bounces/stats', { + headers: { Authorization: `Bearer ${token}` } + }); + setStats(response.data); + } catch (error) { + console.error('Error fetching bounce stats:', error); + } finally { + setLoading(false); + } + }; + + const handleDeactivateBounce = async (bounceId: number) => { + if (!window.confirm('Are you sure you want to deactivate this bounce record?')) { + return; + } + + try { + const token = localStorage.getItem('token'); + await axios.delete(`/api/v1/email/bounces/${bounceId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + fetchBounces(); // Refresh the list + fetchStats(); // Refresh stats + } catch (error) { + console.error('Error deactivating bounce:', error); + alert('Failed to deactivate bounce record'); + } + }; + + const handleCleanupOldBounces = async () => { + if (!window.confirm('Are you sure you want to cleanup old soft bounces? This will deactivate soft bounces older than 365 days.')) { + return; + } + + try { + const token = localStorage.getItem('token'); + const response = await axios.post('/api/v1/email/bounces/cleanup', {}, { + headers: { Authorization: `Bearer ${token}` } + }); + alert(response.data.message); + fetchBounces(); // Refresh the list + fetchStats(); // Refresh stats + } catch (error) { + console.error('Error cleaning up bounces:', error); + alert('Failed to cleanup old bounces'); + } + }; + + const getBounceTypeColor = (type: string) => { + switch (type) { + case 'hard': return '#dc3545'; + case 'soft': return '#ffc107'; + case 'complaint': return '#fd7e14'; + case 'unsubscribe': return '#6c757d'; + default: return '#6c757d'; + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + if (loading) { + return ( +
+
Loading bounce data...
+
+ ); + } + + return ( +
+ {/* Statistics Cards */} + {stats && ( +
+
+

Total Bounces

+
+ {stats.total_bounces} +
+
+ +
+

Active Bounces

+
+ {stats.active_bounces} +
+
+ +
+

Hard Bounces

+
+ {stats.bounce_types.hard} +
+
+ +
+

Soft Bounces

+
+ {stats.bounce_types.soft} +
+
+
+ )} + + {/* Controls */} +
+
+ + setSearchEmail(e.target.value)} + placeholder="Enter email address..." + style={{ + padding: '8px 12px', + border: '1px solid #ced4da', + borderRadius: '4px', + minWidth: '250px' + }} + /> +
+ + +
+ + {/* Bounce Records Table */} +
+
+

Bounce Records

+
+ +
+ + + + + + + + + + + + + {filteredBounces.length === 0 ? ( + + + + ) : ( + filteredBounces.map((bounce) => ( + + + + + + + + + )) + )} + +
EmailTypeReasonDateStatusActions
+ {searchEmail ? 'No bounces found matching your search.' : 'No bounce records found.'} +
+
{bounce.email}
+ {bounce.smtp2go_message_id && ( +
+ ID: {bounce.smtp2go_message_id} +
+ )} +
+ + {bounce.bounce_type} + + +
+ {bounce.bounce_reason || 'No reason provided'} +
+
+ {formatDate(bounce.bounce_date)} + + + {bounce.is_active ? 'Active' : 'Resolved'} + + + {bounce.is_active && ( + + )} +
+
+
+
+ ); +}; + +export default BounceManagement; \ No newline at end of file diff --git a/frontend/src/components/ProfileEdit.tsx b/frontend/src/components/ProfileEdit.tsx new file mode 100644 index 0000000..24be320 --- /dev/null +++ b/frontend/src/components/ProfileEdit.tsx @@ -0,0 +1,222 @@ +import React, { useState } from 'react'; +import { userService, User } from '../services/membershipService'; + +interface ProfileEditProps { + user: User; + onSave: (updatedUser: User) => void; + onCancel: () => void; +} + +const ProfileEdit: React.FC = ({ user, onSave, onCancel }) => { + const [formData, setFormData] = useState({ + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + phone: user.phone || '', + address: user.address || '' + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const updatedUser = await userService.updateProfile({ + email: formData.email, + first_name: formData.first_name, + last_name: formData.last_name, + phone: formData.phone || null, + address: formData.address || null + }); + onSave(updatedUser); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to update profile'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Edit Profile

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +