add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
defmodule BerrypodWeb.UnsubscribeController do
|
|
|
|
|
use BerrypodWeb, :controller
|
|
|
|
|
|
2026-02-28 23:25:28 +00:00
|
|
|
alias Berrypod.{Newsletter, Orders}
|
add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
|
|
|
|
|
# Unsubscribe links should be long-lived — use 2 years
|
|
|
|
|
@max_age 2 * 365 * 24 * 3600
|
|
|
|
|
|
|
|
|
|
def unsubscribe(conn, %{"token" => token}) do
|
|
|
|
|
case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do
|
|
|
|
|
{:ok, email} ->
|
|
|
|
|
Orders.add_suppression(email, "unsubscribed")
|
2026-02-28 23:25:28 +00:00
|
|
|
Newsletter.unsubscribe(email)
|
add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
|
|
|
|
|
conn
|
|
|
|
|
|> put_status(200)
|
|
|
|
|
|> html("""
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>Unsubscribed</title>
|
|
|
|
|
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1>
|
2026-02-28 23:25:28 +00:00
|
|
|
<p style="color:#555">We've removed #{email} from our marketing emails. You won't hear from us again.</p>
|
add abandoned cart recovery
When a Stripe checkout session expires without payment, if the customer
entered their email, we record an AbandonedCart and schedule a single
plain-text recovery email (1h delay via Oban).
Privacy design:
- feature is off by default; shop owner opts in via admin settings
- only contacts customers who entered their email at Stripe checkout
- single email, never more (emailed_at timestamp gate)
- suppression list blocks repeat contact; one-click unsubscribe via
signed token (/unsubscribe/:token)
- records pruned after 30 days (nightly Oban cron)
- no tracking pixels, no redirected links, no HTML
Legal notes:
- custom_text added to Stripe session footer when recovery is on
- UK PECR soft opt-in; EU legitimate interests both satisfied by this design
Files:
- migration: abandoned_carts + email_suppressions tables
- schemas: AbandonedCart, EmailSuppression
- context: Orders.create_abandoned_cart, check_suppression, add_suppression,
has_recent_paid_order?, get_abandoned_cart_by_session, mark_abandoned_cart_emailed
- workers: AbandonedCartEmailWorker (checkout queue), AbandonedCartPruneWorker (cron)
- notifier: OrderNotifier.deliver_cart_recovery/3
- webhook: extended checkout.session.expired handler
- controller: UnsubscribeController, admin settings toggle
- tests: 28 new tests across context, workers, and controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 10:02:37 +00:00
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
{:error, _reason} ->
|
|
|
|
|
conn
|
|
|
|
|
|> put_status(400)
|
|
|
|
|
|> html("""
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>Invalid link</title>
|
|
|
|
|
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">Link invalid or expired</h1>
|
|
|
|
|
<p style="color:#555">This unsubscribe link has expired or is invalid. If you'd like to unsubscribe, reply to any email we've sent you.</p>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
""")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|