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.
+
+| Description | Amount |
+{"".join([f"| {item.description} | ${item.amount} |
" for item in line_items])}
+| 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