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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 23:33:22 -08:00
parent f4f1e140a9
commit c4857a0222
4 changed files with 331 additions and 1 deletions

View File

@ -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"

View File

@ -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("<br>", "\n").replace("</p>", "\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"""
<tr>
<td style="padding:8px 0;border-bottom:1px solid #222;">{name}{variant_str}</td>
<td style="padding:8px 0;border-bottom:1px solid #222;text-align:center;">{qty}</td>
<td style="padding:8px 0;border-bottom:1px solid #222;text-align:right;">{currency_symbol}{price:.2f}</td>
</tr>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="max-width:560px;margin:0 auto;padding:32px 24px;">
<!-- Header -->
<div style="text-align:center;padding-bottom:24px;border-bottom:1px solid #222;">
<div style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#f59e0b);border-radius:10px;width:40px;height:40px;line-height:40px;font-size:12px;font-weight:900;color:#0a0a0a;text-align:center;">rSw</div>
<h1 style="margin:12px 0 0;font-size:22px;color:#fff;">Order Confirmed</h1>
</div>
<!-- Greeting -->
<p style="margin:24px 0 8px;font-size:15px;">{greeting}</p>
<p style="margin:0 0 24px;font-size:15px;">
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.
</p>
<!-- Order Summary -->
<div style="background:#111;border:1px solid #222;border-radius:12px;padding:20px;margin-bottom:24px;">
<div style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:12px;">Order Summary</div>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<tr style="color:#888;font-size:12px;">
<td style="padding-bottom:8px;">Item</td>
<td style="padding-bottom:8px;text-align:center;">Qty</td>
<td style="padding-bottom:8px;text-align:right;">Price</td>
</tr>
{items_html}
<tr>
<td style="padding:12px 0 0;font-weight:700;color:#22d3ee;" colspan="2">Total</td>
<td style="padding:12px 0 0;font-weight:700;color:#22d3ee;text-align:right;">{currency_symbol}{total:.2f}</td>
</tr>
</table>
</div>
<!-- Status Link -->
<div style="text-align:center;margin-bottom:24px;">
<a href="{order_url}" style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#0891b2);color:#fff;text-decoration:none;padding:12px 32px;border-radius:8px;font-weight:600;font-size:14px;">
View Order Status
</a>
</div>
<!-- What happens next -->
<div style="background:#111;border:1px solid #222;border-radius:12px;padding:20px;margin-bottom:24px;">
<div style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:12px;">What Happens Next</div>
<ol style="margin:0;padding-left:20px;font-size:14px;line-height:1.8;">
<li>Your design is sent to the nearest print facility</li>
<li>Each item is printed on demand just for you</li>
<li>You'll get a shipping email with tracking info</li>
<li>Revenue from your purchase supports the community</li>
</ol>
</div>
<!-- Footer -->
<div style="text-align:center;padding-top:24px;border-top:1px solid #222;font-size:12px;color:#555;">
<p style="margin:0;">Order #{order_id[:8]}</p>
<p style="margin:8px 0 0;">{settings.app_name} Community merch, on demand.</p>
<p style="margin:4px 0 0;">Part of the <a href="https://rstack.online" style="color:#22d3ee;text-decoration:none;">rStack</a> ecosystem.</p>
</div>
</div>
</body>
</html>"""
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"""
<div style="background:#111;border:1px solid #222;border-radius:12px;padding:20px;margin-bottom:24px;text-align:center;">
<div style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#888;margin-bottom:8px;">Tracking Number</div>
<a href="{track_link}" style="font-size:18px;font-weight:700;color:#22d3ee;text-decoration:none;letter-spacing:1px;">{tracking_number}</a>
</div>"""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="max-width:560px;margin:0 auto;padding:32px 24px;">
<!-- Header -->
<div style="text-align:center;padding-bottom:24px;border-bottom:1px solid #222;">
<div style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#f59e0b);border-radius:10px;width:40px;height:40px;line-height:40px;font-size:12px;font-weight:900;color:#0a0a0a;text-align:center;">rSw</div>
<h1 style="margin:12px 0 0;font-size:22px;color:#fff;">Your Order Has Shipped!</h1>
</div>
<p style="margin:24px 0 8px;font-size:15px;">{greeting}</p>
<p style="margin:0 0 24px;font-size:15px;">
Great news your order is on its way! It was printed at the nearest fulfillment center and is now heading to you.
</p>
{tracking_html}
<div style="text-align:center;margin-bottom:24px;">
<a href="{order_url}" style="display:inline-block;background:linear-gradient(135deg,#22d3ee,#0891b2);color:#fff;text-decoration:none;padding:12px 32px;border-radius:8px;font-weight:600;font-size:14px;">
View Order
</a>
</div>
<div style="text-align:center;padding-top:24px;border-top:1px solid #222;font-size:12px;color:#555;">
<p style="margin:0;">Order #{order_id[:8]}</p>
<p style="margin:8px 0 0;">{settings.app_name} Community merch, on demand.</p>
</div>
</div>
</body>
</html>"""

View File

@ -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.

View File

@ -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