feat: add transactional emails for order confirmation and shipping

Plain text emails via Swoosh OrderNotifier module. Order confirmation
triggered from Stripe webhook after payment, shipping notification
from Printify shipment webhook with polling fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-08 10:17:19 +00:00
parent 3e19887499
commit 0af8997623
6 changed files with 328 additions and 15 deletions

View File

@ -17,9 +17,10 @@
- Admin credentials page with guided Stripe setup flow
- Encrypted settings for API keys and secrets
- Search modal with keyboard shortcut
- Transactional emails (order confirmation, shipping notification)
- Demo content polished and ready for production
**Next up:** Transactional emails — order confirmation and shipping notifications (Tier 1, Roadmap #3)
**Next up:** Default content pages — terms, privacy, delivery policy (Tier 1, Roadmap #4)
## Roadmap
@ -27,7 +28,7 @@
1. ~~**Order management admin**~~ — ✅ Complete (02cdc81). Admin UI at `/admin/orders` with status filter tabs, streamed order table, and detail view showing items, totals, and shipping address.
2. ~~**Orders & fulfilment**~~ — ✅ Complete. Submit paid orders to Printify, track fulfilment status (submitted → processing → shipped → delivered), webhook-driven status updates with polling fallback, admin UI with submit/refresh actions.
3. **Transactional emails** — Order confirmation email on payment. Shipping notification with tracking link. Use Swoosh (already configured) with a simple HTML template.
3. ~~**Transactional emails**~~ — ✅ Complete. Plain text order confirmation (on payment via Stripe webhook) and shipping notification (on dispatch via Printify webhook + polling fallback). OrderNotifier module, 10 tests.
4. **Default content pages** — Static pages for terms of service, delivery & refunds policy, and privacy policy. Needed for legal compliance before taking real orders. Can be simple markdown-rendered pages initially, upgraded to editable via page editor later.
### Tier 2 — Production readiness (can deploy and run reliably)
@ -172,7 +173,7 @@ See: [ROADMAP.md](ROADMAP.md) for design notes
- CSSCache test startup crash fixed (handle_continue pattern)
### Orders & Fulfilment
**Status:** Complete (checkout, admin, fulfilment). Transactional emails pending (Roadmap #3).
**Status:** Complete
- [x] Orders context with schemas (ff1bc48)
- [x] Stripe Checkout integration with webhook handling
@ -191,7 +192,12 @@ See: [ROADMAP.md](ROADMAP.md) for design notes
- Stripe webhook auto-enqueues submission after payment confirmed
- Admin UI: fulfilment badge column, fulfilment card with tracking, submit/refresh buttons
- Mox provider mocking for test isolation, 33 new tests (555 total)
- [ ] Transactional emails (Roadmap #3)
- [x] Transactional emails (Roadmap #3)
- OrderNotifier module with plain text emails via Swoosh
- Order confirmation sent from Stripe webhook after payment + address/email updates
- Shipping notification sent from Printify shipment webhook + polling fallback
- Guards for missing customer_email, graceful tracking info handling
- 10 tests (565 total)
See: [docs/plans/products-context.md](docs/plans/products-context.md) for schema design
@ -208,6 +214,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
| Feature | Commit | Notes |
|---------|--------|-------|
| Transactional emails | — | Plain text order confirmation + shipping notification, 10 tests |
| Printify order submission & fulfilment | — | Submit, track, webhooks, polling, admin UI, 33 tests |
| Order management admin | 02cdc81 | List/detail views, status filters, 15 tests |
| Encrypted settings & Stripe setup | eede9bb | Guided setup flow, encrypted secrets, admin credentials page |

View File

@ -9,7 +9,7 @@ defmodule SimpleshopTheme.Orders do
import Ecto.Query
alias SimpleshopTheme.Repo
alias SimpleshopTheme.Orders.{Order, OrderItem}
alias SimpleshopTheme.Orders.{Order, OrderItem, OrderNotifier}
alias SimpleshopTheme.Products
alias SimpleshopTheme.Providers.Provider
@ -233,7 +233,13 @@ defmodule SimpleshopTheme.Orders do
}
|> maybe_set_timestamp(order)
update_fulfilment(order, attrs)
with {:ok, updated_order} <- update_fulfilment(order, attrs) do
if attrs[:fulfilment_status] == "shipped" and order.fulfilment_status != "shipped" do
OrderNotifier.deliver_shipping_notification(updated_order)
end
{:ok, updated_order}
end
end
end

View File

@ -0,0 +1,130 @@
defmodule SimpleshopTheme.Orders.OrderNotifier do
@moduledoc """
Sends transactional emails for orders.
Order confirmation after payment, shipping notification when dispatched.
"""
import Swoosh.Email
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Mailer
require Logger
@doc """
Sends an order confirmation email after successful payment.
Skips silently if the order has no customer email.
"""
def deliver_order_confirmation(%{customer_email: nil}), do: {:ok, :no_email}
def deliver_order_confirmation(%{customer_email: ""}), do: {:ok, :no_email}
def deliver_order_confirmation(order) do
subject = "Order confirmed - #{order.order_number}"
body = """
==============================
Thanks for your order!
Order: #{order.order_number}
#{format_items(order.items)}
Total: #{Cart.format_price(order.total)}
#{format_shipping_address(order.shipping_address)}
We'll send you another email when your order ships.
==============================
"""
deliver(order.customer_email, subject, body)
end
@doc """
Sends a shipping notification with tracking info.
Skips silently if the order has no customer email.
"""
def deliver_shipping_notification(%{customer_email: nil}), do: {:ok, :no_email}
def deliver_shipping_notification(%{customer_email: ""}), do: {:ok, :no_email}
def deliver_shipping_notification(order) do
subject = "Your order has shipped - #{order.order_number}"
body = """
==============================
Good news! Your order #{order.order_number} is on its way.
#{format_tracking(order)}
Thanks for shopping with us.
==============================
"""
deliver(order.customer_email, subject, body)
end
# --- Private ---
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"SimpleshopTheme", "contact@example.com"})
|> subject(subject)
|> text_body(body)
case Mailer.deliver(email) do
{:ok, _metadata} = result ->
result
{:error, reason} = error ->
Logger.warning("Failed to send email to #{recipient}: #{inspect(reason)}")
error
end
end
defp format_items(items) when is_list(items) do
items
|> Enum.map_join("\n", fn item ->
price = Cart.format_price(item.unit_price * item.quantity)
" #{item.quantity}x #{item.product_name} (#{item.variant_title}) - #{price}"
end)
end
defp format_items(_), do: ""
defp format_shipping_address(address) when is_map(address) and map_size(address) > 0 do
lines =
[
address["name"],
address["line1"],
address["line2"],
[address["city"], address["postal_code"]] |> Enum.reject(&is_nil/1) |> Enum.join(" "),
address["state"],
address["country"]
]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.map_join("\n", &" #{&1}")
"Shipping to:\n#{lines}\n\n"
end
defp format_shipping_address(_), do: ""
defp format_tracking(order) do
cond do
order.tracking_url not in [nil, ""] and order.tracking_number not in [nil, ""] ->
"Tracking: #{order.tracking_number}\n#{order.tracking_url}\n\n"
order.tracking_number not in [nil, ""] ->
"Tracking: #{order.tracking_number}\n\n"
true ->
"Tracking details will follow once the carrier updates.\n\n"
end
end
end

View File

@ -4,6 +4,7 @@ defmodule SimpleshopTheme.Webhooks do
"""
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.OrderNotifier
alias SimpleshopTheme.Products
alias SimpleshopTheme.Sync.ProductSyncWorker
alias SimpleshopTheme.Webhooks.ProductDeleteWorker
@ -44,14 +45,17 @@ defmodule SimpleshopTheme.Webhooks do
def handle_printify_event("order:shipment:created", resource) do
shipment = extract_shipment(resource)
with {:ok, order} <- find_order_from_resource(resource) do
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
provider_status: "shipped",
tracking_number: shipment.tracking_number,
tracking_url: shipment.tracking_url,
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
with {:ok, order} <- find_order_from_resource(resource),
{:ok, updated_order} <-
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
provider_status: "shipped",
tracking_number: shipment.tracking_number,
tracking_url: shipment.tracking_url,
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
}) do
OrderNotifier.deliver_shipping_notification(updated_order)
{:ok, updated_order}
end
end

View File

@ -2,7 +2,7 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
use SimpleshopThemeWeb, :controller
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.OrderSubmissionWorker
alias SimpleshopTheme.Orders.{OrderNotifier, OrderSubmissionWorker}
require Logger
@ -56,6 +56,9 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
order
end
# Reload items for the email (update_order doesn't preload)
order = Orders.get_order(order.id)
# Broadcast to success page via PubSub
Phoenix.PubSub.broadcast(
SimpleshopTheme.PubSub,
@ -63,6 +66,8 @@ defmodule SimpleshopThemeWeb.StripeWebhookController do
{:order_paid, order}
)
OrderNotifier.deliver_order_confirmation(order)
# Submit to fulfilment provider
if order.shipping_address && order.shipping_address != %{} do
OrderSubmissionWorker.enqueue(order.id)

View File

@ -0,0 +1,161 @@
defmodule SimpleshopTheme.Orders.OrderNotifierTest do
use SimpleshopTheme.DataCase, async: true
import Swoosh.TestAssertions
import SimpleshopTheme.OrdersFixtures
alias SimpleshopTheme.Orders
alias SimpleshopTheme.Orders.OrderNotifier
describe "deliver_order_confirmation/1" do
test "sends confirmation with order details" do
order =
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
shipping_address: %{
"name" => "Jane Doe",
"line1" => "42 Test Street",
"city" => "London",
"postal_code" => "SW1A 1AA",
"country" => "GB"
}
})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(fn email ->
assert email.to == [{"", "buyer@example.com"}]
assert email.subject =~ "Order confirmed"
assert email.subject =~ order.order_number
assert email.text_body =~ order.order_number
assert email.text_body =~ "Test product"
assert email.text_body =~ "Jane Doe"
assert email.text_body =~ "42 Test Street"
assert email.text_body =~ "London"
end)
end
test "includes item quantities and prices" do
order =
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
quantity: 2,
unit_price: 1500
})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(fn email ->
assert email.text_body =~ "2x Test product"
end)
end
test "includes order total" do
order =
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
unit_price: 2500
})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(fn email ->
assert email.text_body =~ "Total:"
end)
end
test "skips when customer_email is nil" do
order = order_fixture(%{customer_email: nil})
# Override to nil since fixture sets a default
{:ok, order} = Orders.update_order(order, %{customer_email: nil})
assert {:ok, :no_email} = OrderNotifier.deliver_order_confirmation(order)
assert_no_email_sent()
end
test "skips when customer_email is empty string" do
order = order_fixture()
{:ok, order} = Orders.update_order(order, %{customer_email: ""})
assert {:ok, :no_email} = OrderNotifier.deliver_order_confirmation(order)
assert_no_email_sent()
end
test "handles missing shipping address" do
order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(subject: "Order confirmed - #{order.order_number}")
end
end
describe "deliver_shipping_notification/1" do
test "sends notification with tracking info" do
{order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
tracking_number: "1Z999AA1",
tracking_url: "https://ups.com/track/1Z999AA1",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order)
assert_email_sent(fn email ->
assert email.to == [{"", "buyer@example.com"}]
assert email.subject =~ "Your order has shipped"
assert email.subject =~ order.order_number
assert email.text_body =~ "1Z999AA1"
assert email.text_body =~ "https://ups.com/track/1Z999AA1"
end)
end
test "handles missing tracking info gracefully" do
{order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order)
assert_email_sent(fn email ->
assert email.text_body =~ "Tracking details will follow"
end)
end
test "includes tracking number without URL" do
{order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
tracking_number: "RM123456789GB",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order)
assert_email_sent(fn email ->
assert email.text_body =~ "RM123456789GB"
assert not (email.text_body =~ "Tracking details will follow")
end)
end
test "skips when customer_email is nil" do
{order, _v, _p, _c} = submitted_order_fixture()
{:ok, order} = Orders.update_order(order, %{customer_email: nil})
assert {:ok, :no_email} = OrderNotifier.deliver_shipping_notification(order)
assert_no_email_sent()
end
end
end