From c4857a0222b41e99a2f9a40c5401352b974af158 Mon Sep 17 00:00:00 2001
From: Jeff Emmett
Date: Tue, 24 Feb 2026 23:33:22 -0800
Subject: [PATCH] feat: add order confirmation and shipping notification emails
- EmailService with async SMTP via aiosmtplib (Mailcow)
- HTML email templates matching rSwag dark theme branding
- Order confirmation sent after successful Mollie payment
- Shipping notification with tracking info sent when POD ships
- Non-blocking: email failures logged but don't break order flow
- SMTP config: smtp_host, smtp_port, smtp_user, smtp_password in .env
Co-Authored-By: Claude Opus 4.6
---
backend/app/config.py | 8 +
backend/app/services/email_service.py | 249 ++++++++++++++++++++++++++
backend/app/services/order_service.py | 72 +++++++-
backend/requirements.txt | 3 +
4 files changed, 331 insertions(+), 1 deletion(-)
create mode 100644 backend/app/services/email_service.py
diff --git a/backend/app/config.py b/backend/app/config.py
index cb508f6..bbbe328 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -41,6 +41,14 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24
+ # Email (SMTP via Mailcow)
+ smtp_host: str = "mx.jeffemmett.com"
+ smtp_port: int = 587
+ smtp_user: str = ""
+ smtp_password: str = ""
+ smtp_from_email: str = "noreply@rswag.online"
+ smtp_from_name: str = "rSwag"
+
# CORS
cors_origins: str = "http://localhost:3000"
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
new file mode 100644
index 0000000..609bc2b
--- /dev/null
+++ b/backend/app/services/email_service.py
@@ -0,0 +1,249 @@
+"""Email service for order confirmations and shipping notifications."""
+
+import logging
+import ssl
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import aiosmtplib
+
+from app.config import get_settings
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+
+class EmailService:
+ """Async email sender via SMTP (Mailcow)."""
+
+ @property
+ def enabled(self) -> bool:
+ return bool(settings.smtp_user and settings.smtp_password)
+
+ async def send_order_confirmation(
+ self,
+ *,
+ to_email: str,
+ to_name: str | None,
+ order_id: str,
+ items: list[dict],
+ total: float,
+ currency: str = "USD",
+ ):
+ """Send order confirmation email after successful payment."""
+ if not self.enabled:
+ logger.info("SMTP not configured, skipping order confirmation email")
+ return
+
+ subject = f"Order Confirmed — {settings.app_name} #{order_id[:8]}"
+ html = self._render_confirmation_html(
+ to_name=to_name,
+ order_id=order_id,
+ items=items,
+ total=total,
+ currency=currency,
+ )
+
+ await self._send(to_email=to_email, subject=subject, html=html)
+
+ async def send_shipping_notification(
+ self,
+ *,
+ to_email: str,
+ to_name: str | None,
+ order_id: str,
+ tracking_number: str | None = None,
+ tracking_url: str | None = None,
+ ):
+ """Send shipping notification when POD provider ships the order."""
+ if not self.enabled:
+ return
+
+ subject = f"Your Order Has Shipped — {settings.app_name}"
+ html = self._render_shipping_html(
+ to_name=to_name,
+ order_id=order_id,
+ tracking_number=tracking_number,
+ tracking_url=tracking_url,
+ )
+
+ await self._send(to_email=to_email, subject=subject, html=html)
+
+ async def _send(self, *, to_email: str, subject: str, html: str):
+ """Send an HTML email via SMTP."""
+ msg = MIMEMultipart("alternative")
+ msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
+ msg["To"] = to_email
+ msg["Subject"] = subject
+
+ # Plain-text fallback
+ plain = html.replace("
", "\n").replace("
", "\n")
+ # Strip remaining tags
+ import re
+ plain = re.sub(r"<[^>]+>", "", plain)
+ msg.attach(MIMEText(plain, "plain"))
+ msg.attach(MIMEText(html, "html"))
+
+ tls_context = ssl.create_default_context()
+ tls_context.check_hostname = False
+ tls_context.verify_mode = ssl.CERT_NONE # self-signed cert on Mailcow
+
+ try:
+ await aiosmtplib.send(
+ msg,
+ hostname=settings.smtp_host,
+ port=settings.smtp_port,
+ username=settings.smtp_user,
+ password=settings.smtp_password,
+ start_tls=True,
+ tls_context=tls_context,
+ )
+ logger.info(f"Sent email to {to_email}: {subject}")
+ except Exception as e:
+ logger.error(f"Failed to send email to {to_email}: {e}")
+
+ def _render_confirmation_html(
+ self,
+ *,
+ to_name: str | None,
+ order_id: str,
+ items: list[dict],
+ total: float,
+ currency: str,
+ ) -> str:
+ greeting = f"Hi {to_name}," if to_name else "Hi there,"
+ order_url = f"{settings.public_url}/checkout/success?order_id={order_id}"
+ currency_symbol = "$" if currency == "USD" else currency + " "
+
+ items_html = ""
+ for item in items:
+ qty = item.get("quantity", 1)
+ name = item.get("product_name", "Item")
+ variant = item.get("variant", "")
+ price = item.get("unit_price", 0)
+ variant_str = f" ({variant})" if variant else ""
+ items_html += f"""
+
+ | {name}{variant_str} |
+ {qty} |
+ {currency_symbol}{price:.2f} |
+
"""
+
+ return f"""
+
+
+
+
+
+
+
+
rSw
+
Order Confirmed
+
+
+
+
{greeting}
+
+ Thank you for your order! Your items are being prepared for production.
+ Print-on-demand means each piece is made just for you at the nearest fulfillment center.
+
+
+
+
+
Order Summary
+
+
+ | Item |
+ Qty |
+ Price |
+
+ {items_html}
+
+ | Total |
+ {currency_symbol}{total:.2f} |
+
+
+
+
+
+
+
+
+
+
What Happens Next
+
+ - Your design is sent to the nearest print facility
+ - Each item is printed on demand — just for you
+ - You'll get a shipping email with tracking info
+ - Revenue from your purchase supports the community
+
+
+
+
+
+
Order #{order_id[:8]}
+
{settings.app_name} — Community merch, on demand.
+
Part of the rStack ecosystem.
+
+
+
+
+"""
+
+ def _render_shipping_html(
+ self,
+ *,
+ to_name: str | None,
+ order_id: str,
+ tracking_number: str | None,
+ tracking_url: str | None,
+ ) -> str:
+ greeting = f"Hi {to_name}," if to_name else "Hi there,"
+ order_url = f"{settings.public_url}/checkout/success?order_id={order_id}"
+
+ tracking_html = ""
+ if tracking_number:
+ track_link = tracking_url or "#"
+ tracking_html = f"""
+ """
+
+ return f"""
+
+
+
+
+
+
+
+
rSw
+
Your Order Has Shipped!
+
+
+
{greeting}
+
+ Great news — your order is on its way! It was printed at the nearest fulfillment center and is now heading to you.
+
+
+ {tracking_html}
+
+
+
+
+
Order #{order_id[:8]}
+
{settings.app_name} — Community merch, on demand.
+
+
+
+
+"""
diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py
index fd91d96..3cf3d21 100644
--- a/backend/app/services/order_service.py
+++ b/backend/app/services/order_service.py
@@ -17,6 +17,7 @@ from app.services.flow_service import FlowService
from app.pod.printful_client import PrintfulClient
from app.pod.prodigi_client import ProdigiClient
from app.services.design_service import DesignService
+from app.services.email_service import EmailService
logger = logging.getLogger(__name__)
settings = get_settings()
@@ -157,7 +158,27 @@ class OrderService:
# Submit to POD providers
await self._submit_to_pod(order)
- # TODO: Send confirmation email
+ # Send confirmation email (non-blocking — don't fail the order if email fails)
+ try:
+ email_service = EmailService()
+ await email_service.send_order_confirmation(
+ to_email=order.shipping_email or "",
+ to_name=order.shipping_name,
+ order_id=str(order.id),
+ items=[
+ {
+ "product_name": item.product_name,
+ "variant": item.variant,
+ "quantity": item.quantity,
+ "unit_price": float(item.unit_price),
+ }
+ for item in order.items
+ ],
+ total=float(order.total),
+ currency=order.currency or "USD",
+ )
+ except Exception as e:
+ logger.error(f"Failed to send confirmation email for order {order.id}: {e}")
async def update_pod_status(
self,
@@ -182,6 +203,55 @@ class OrderService:
)
await self.db.commit()
+ # Send shipping notification when items ship
+ if status in ("shipped", "in_transit") and tracking_number:
+ await self._send_shipping_email(
+ pod_provider=pod_provider,
+ pod_order_id=pod_order_id,
+ tracking_number=tracking_number,
+ tracking_url=tracking_url,
+ )
+
+ async def _send_shipping_email(
+ self,
+ pod_provider: str,
+ pod_order_id: str,
+ tracking_number: str | None,
+ tracking_url: str | None,
+ ):
+ """Send shipping notification for an order."""
+ try:
+ # Find the order via its items
+ result = await self.db.execute(
+ select(OrderItem)
+ .where(
+ OrderItem.pod_provider == pod_provider,
+ OrderItem.pod_order_id == pod_order_id,
+ )
+ .limit(1)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ return
+
+ result = await self.db.execute(
+ select(Order).where(Order.id == item.order_id)
+ )
+ order = result.scalar_one_or_none()
+ if not order or not order.shipping_email:
+ return
+
+ email_service = EmailService()
+ await email_service.send_shipping_notification(
+ to_email=order.shipping_email,
+ to_name=order.shipping_name,
+ order_id=str(order.id),
+ tracking_number=tracking_number,
+ tracking_url=tracking_url,
+ )
+ except Exception as e:
+ logger.error(f"Failed to send shipping email: {e}")
+
async def _submit_to_pod(self, order: Order):
"""Route order items to the correct POD provider for fulfillment.
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 39d6549..2dd93db 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -28,5 +28,8 @@ pillow>=10.0.0
python-multipart>=0.0.6
aiofiles>=23.0.0
+# Email
+aiosmtplib>=3.0.0
+
# Cache
redis>=5.0.0