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:
parent
f4f1e140a9
commit
c4857a0222
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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>"""
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue