From cf800df2a97538fdd0ffd5e8dc0dd328d47138cd Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 11 Nov 2025 12:47:26 +0100 Subject: [PATCH] Update. --- rbox/billing/invoice_generator.py | 44 +++++++++++++ rbox/mail.py | 105 ++++++++++++++++++++++++++++++ rbox/main.py | 5 ++ rbox/routers/admin.py | 13 +++- rbox/routers/auth.py | 11 +++- requirements.txt | 1 + 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 rbox/mail.py diff --git a/rbox/billing/invoice_generator.py b/rbox/billing/invoice_generator.py index 1f208e7..319b1f4 100644 --- a/rbox/billing/invoice_generator.py +++ b/rbox/billing/invoice_generator.py @@ -133,6 +133,50 @@ class InvoiceGenerator: except Exception as 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""" +

Invoice {invoice.invoice_number}

+

Dear {invoice.user.username},

+

Your invoice for the period {invoice.period_start} to {invoice.period_end} is now available.

+ + +{"".join([f"" for item in line_items])} + + + +
DescriptionAmount
{item.description}${item.amount}
Subtotal${invoice.subtotal}
Tax${invoice.tax}
Total${invoice.total}
+

Due Date: {invoice.due_date}

+

You can view and pay your invoice at: Invoice Link

+

Best regards,
The RBox Team

+""" + queue_email( + to_email=invoice.user.email, + subject=f"Your RBox Invoice {invoice.invoice_number}", + body=body, + html=html + ) + return invoice @staticmethod diff --git a/rbox/mail.py b/rbox/mail.py new file mode 100644 index 0000000..429f0e6 --- /dev/null +++ b/rbox/mail.py @@ -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)) \ No newline at end of file diff --git a/rbox/main.py b/rbox/main.py index 915368d..ef80848 100644 --- a/rbox/main.py +++ b/rbox/main.py @@ -20,8 +20,11 @@ async def lifespan(app: FastAPI): logger.info("Database connected.") from .billing.scheduler import start_scheduler from .billing.models import PricingConfig + from .mail import email_service start_scheduler() logger.info("Billing scheduler started") + await email_service.start() + logger.info("Email service started") pricing_count = await PricingConfig.all().count() if pricing_count == 0: from decimal import Decimal @@ -38,6 +41,8 @@ async def lifespan(app: FastAPI): from .billing.scheduler import stop_scheduler stop_scheduler() logger.info("Billing scheduler stopped") + await email_service.stop() + logger.info("Email service stopped") print("Shutting down...") app = FastAPI( diff --git a/rbox/routers/admin.py b/rbox/routers/admin.py index 809043d..320861c 100644 --- a/rbox/routers/admin.py +++ b/rbox/routers/admin.py @@ -95,4 +95,15 @@ async def delete_user_by_admin(user_id: int): if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") await user.delete() - return {"message": "User deleted successfully"} \ No newline at end of file + 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"

{subject}

{body}

" + ) + return {"message": "Test email queued"} \ No newline at end of file diff --git a/rbox/routers/auth.py b/rbox/routers/auth.py index 90c0025..455f6fd 100644 --- a/rbox/routers/auth.py +++ b/rbox/routers/auth.py @@ -60,7 +60,16 @@ async def register_user(user_in: UserCreate): email=user_in.email, 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"

Welcome to RBox!

Hi {user.username},

Welcome to RBox! Your account has been created successfully.

Best regards,
The RBox Team

" + ) + access_token_expires = timedelta(minutes=30) # Use settings access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires diff --git a/requirements.txt b/requirements.txt index 367c290..73fc17a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -118,3 +118,4 @@ watchfiles==1.1.1 websockets==15.0.1 yarl==1.22.0 zstandard==0.25.0 +aiosmtplib==5.0.0