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
+ + + + + + + {items_html} + + + + +
ItemQtyPrice
Total{currency_symbol}{total:.2f}
+
+ + +
+ + View Order Status + +
+ + +
+
What Happens Next
+
    +
  1. Your design is sent to the nearest print facility
  2. +
  3. Each item is printed on demand — just for you
  4. +
  5. You'll get a shipping email with tracking info
  6. +
  7. Revenue from your purchase supports the community
  8. +
+
+ + +
+

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""" +
+
Tracking Number
+ {tracking_number} +
""" + + 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} + +
+ + View Order + +
+ +
+

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