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

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