This commit is contained in:
retoor 2025-11-11 12:47:26 +01:00
parent 1df5621c90
commit cf800df2a9
6 changed files with 177 additions and 2 deletions

View File

@ -133,6 +133,50 @@ class InvoiceGenerator:
except Exception as e: except Exception as e:
print(f"Failed to finalize Stripe invoice: {e}") print(f"Failed to finalize Stripe invoice: {e}")
# Send invoice email
from ..mail import queue_email
line_items = await invoice.line_items.all()
items_text = "\n".join([f"- {item.description}: ${item.amount}" for item in line_items])
body = f"""Dear {invoice.user.username},
Your invoice {invoice.invoice_number} for the period {invoice.period_start} to {invoice.period_end} is now available.
Invoice Details:
{items_text}
Subtotal: ${invoice.subtotal}
Tax: ${invoice.tax}
Total: ${invoice.total}
Due Date: {invoice.due_date}
You can view and pay your invoice at: {invoice.user.email} # Placeholder, should be a link to invoice page
Best regards,
The RBox Team
"""
html = f"""
<h2>Invoice {invoice.invoice_number}</h2>
<p>Dear {invoice.user.username},</p>
<p>Your invoice for the period {invoice.period_start} to {invoice.period_end} is now available.</p>
<table border="1">
<tr><th>Description</th><th>Amount</th></tr>
{"".join([f"<tr><td>{item.description}</td><td>${item.amount}</td></tr>" for item in line_items])}
<tr><td><strong>Subtotal</strong></td><td><strong>${invoice.subtotal}</strong></td></tr>
<tr><td><strong>Tax</strong></td><td><strong>${invoice.tax}</strong></td></tr>
<tr><td><strong>Total</strong></td><td><strong>${invoice.total}</strong></td></tr>
</table>
<p>Due Date: {invoice.due_date}</p>
<p>You can view and pay your invoice at: <a href="#">Invoice Link</a></p>
<p>Best regards,<br>The RBox Team</p>
"""
queue_email(
to_email=invoice.user.email,
subject=f"Your RBox Invoice {invoice.invoice_number}",
body=body,
html=html
)
return invoice return invoice
@staticmethod @staticmethod

105
rbox/mail.py Normal file
View File

@ -0,0 +1,105 @@
import asyncio
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional, Dict, Any
from .settings import settings
class EmailTask:
def __init__(self, to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
self.to_email = to_email
self.subject = subject
self.body = body
self.html = html
self.kwargs = kwargs
class EmailService:
def __init__(self):
self.queue = asyncio.Queue()
self.worker_task: Optional[asyncio.Task] = None
self.running = False
async def start(self):
"""Start the email worker"""
if self.running:
return
self.running = True
self.worker_task = asyncio.create_task(self._worker())
async def stop(self):
"""Stop the email worker"""
if not self.running:
return
self.running = False
if self.worker_task:
self.worker_task.cancel()
try:
await self.worker_task
except asyncio.CancelledError:
pass
async def send_email(self, to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
"""Queue an email for sending"""
task = EmailTask(to_email, subject, body, html, **kwargs)
await self.queue.put(task)
async def _worker(self):
"""Email worker coroutine"""
while self.running:
try:
# Wait for a task with timeout to allow checking running flag
task = await asyncio.wait_for(self.queue.get(), timeout=1.0)
await self._send_email_task(task)
self.queue.task_done()
except asyncio.TimeoutError:
continue
except Exception as e:
# Log error, but continue processing
print(f"Email worker error: {e}")
continue
async def _send_email_task(self, task: EmailTask):
"""Send a single email task"""
if not settings.SMTP_SERVER or not settings.SMTP_USERNAME or not settings.SMTP_PASSWORD:
print("SMTP not configured, skipping email send")
return
msg = MIMEMultipart('alternative')
msg['From'] = settings.SMTP_FROM_EMAIL
msg['To'] = task.to_email
msg['Subject'] = task.subject
# Add text part
text_part = MIMEText(task.body, 'plain')
msg.attach(text_part)
# Add HTML part if provided
if task.html:
html_part = MIMEText(task.html, 'html')
msg.attach(html_part)
try:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_SERVER,
port=settings.SMTP_PORT,
username=settings.SMTP_USERNAME,
password=settings.SMTP_PASSWORD,
use_tls=True
) as smtp:
await smtp.send_message(msg)
print(f"Email sent to {task.to_email}")
except Exception as e:
print(f"Failed to send email to {task.to_email}: {e}")
raise # Re-raise to let caller handle
# Global email service instance
email_service = EmailService()
# Convenience functions
async def send_email(to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
"""Send an email asynchronously"""
await email_service.send_email(to_email, subject, body, html, **kwargs)
def queue_email(to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
"""Queue an email for sending (fire and forget)"""
asyncio.create_task(email_service.send_email(to_email, subject, body, html, **kwargs))

View File

@ -20,8 +20,11 @@ async def lifespan(app: FastAPI):
logger.info("Database connected.") logger.info("Database connected.")
from .billing.scheduler import start_scheduler from .billing.scheduler import start_scheduler
from .billing.models import PricingConfig from .billing.models import PricingConfig
from .mail import email_service
start_scheduler() start_scheduler()
logger.info("Billing scheduler started") logger.info("Billing scheduler started")
await email_service.start()
logger.info("Email service started")
pricing_count = await PricingConfig.all().count() pricing_count = await PricingConfig.all().count()
if pricing_count == 0: if pricing_count == 0:
from decimal import Decimal from decimal import Decimal
@ -38,6 +41,8 @@ async def lifespan(app: FastAPI):
from .billing.scheduler import stop_scheduler from .billing.scheduler import stop_scheduler
stop_scheduler() stop_scheduler()
logger.info("Billing scheduler stopped") logger.info("Billing scheduler stopped")
await email_service.stop()
logger.info("Email service stopped")
print("Shutting down...") print("Shutting down...")
app = FastAPI( app = FastAPI(

View File

@ -95,4 +95,15 @@ async def delete_user_by_admin(user_id: int):
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await user.delete() await user.delete()
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}
@router.post("/test-email")
async def send_test_email(to_email: str, subject: str = "Test Email", body: str = "This is a test email"):
from ..mail import queue_email
queue_email(
to_email=to_email,
subject=subject,
body=body,
html=f"<h1>{subject}</h1><p>{body}</p>"
)
return {"message": "Test email queued"}

View File

@ -60,7 +60,16 @@ async def register_user(user_in: UserCreate):
email=user_in.email, email=user_in.email,
hashed_password=hashed_password, hashed_password=hashed_password,
) )
# Send welcome email
from ..mail import queue_email
queue_email(
to_email=user.email,
subject="Welcome to RBox!",
body=f"Hi {user.username},\n\nWelcome to RBox! Your account has been created successfully.\n\nBest regards,\nThe RBox Team",
html=f"<h1>Welcome to RBox!</h1><p>Hi {user.username},</p><p>Welcome to RBox! Your account has been created successfully.</p><p>Best regards,<br>The RBox Team</p>"
)
access_token_expires = timedelta(minutes=30) # Use settings access_token_expires = timedelta(minutes=30) # Use settings
access_token = create_access_token( access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires data={"sub": user.username}, expires_delta=access_token_expires

View File

@ -118,3 +118,4 @@ watchfiles==1.1.1
websockets==15.0.1 websockets==15.0.1
yarl==1.22.0 yarl==1.22.0
zstandard==0.25.0 zstandard==0.25.0
aiosmtplib==5.0.0