feat: add join confirmation and mail templating
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
3672ef0d03
commit
6385fbc831
24 changed files with 585 additions and 53 deletions
|
|
@ -30,6 +30,7 @@ defmodule Mv.Membership do
|
|||
import Ash.Expr
|
||||
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||
alias Mv.Membership.JoinRequest
|
||||
alias MvWeb.Emails.JoinConfirmationEmail
|
||||
|
||||
admin do
|
||||
show? true
|
||||
|
|
@ -85,7 +86,7 @@ defmodule Mv.Membership do
|
|||
end
|
||||
|
||||
resource Mv.Membership.JoinRequest do
|
||||
define :submit_join_request, action: :submit
|
||||
# submit_join_request/2 implemented as custom function below (create + send email)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -350,6 +351,47 @@ defmodule Mv.Membership do
|
|||
|> then(&Ash.read_one(query, &1))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a join request (submit flow) and sends the confirmation email.
|
||||
|
||||
Generates a confirmation token if not provided in attrs (e.g. for tests, pass
|
||||
`:confirmation_token` to get a known token). On success, sends one email with
|
||||
the confirm link to the request email.
|
||||
|
||||
## Options
|
||||
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
|
||||
|
||||
## Returns
|
||||
- `{:ok, request}` - Created JoinRequest in status pending_confirmation
|
||||
- `{:error, error}` - Validation or authorization error
|
||||
"""
|
||||
def submit_join_request(attrs, opts \\ []) do
|
||||
actor = Keyword.get(opts, :actor)
|
||||
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
||||
|
||||
attrs_with_token =
|
||||
attrs |> Map.drop([:confirmation_token]) |> Map.put(:confirmation_token, token)
|
||||
|
||||
case Ash.create(JoinRequest, attrs_with_token,
|
||||
action: :submit,
|
||||
actor: actor,
|
||||
domain: __MODULE__
|
||||
) do
|
||||
{:ok, request} ->
|
||||
JoinConfirmationEmail.send(request.email, token)
|
||||
{:ok, request}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_confirmation_token do
|
||||
32
|
||||
|> :crypto.strong_rand_bytes()
|
||||
|> Base.url_encode64(padding: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a join request by token (public confirmation link).
|
||||
|
||||
|
|
|
|||
56
lib/mix/tasks/join_requests.cleanup_expired.ex
Normal file
56
lib/mix/tasks/join_requests.cleanup_expired.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
||||
@moduledoc """
|
||||
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
|
||||
|
||||
Retention: records with `confirmation_token_expires_at` older than now are deleted.
|
||||
Intended for cron or Oban (e.g. every hour). See docs/onboarding-join-concept.md.
|
||||
|
||||
## Usage
|
||||
|
||||
mix join_requests.cleanup_expired
|
||||
|
||||
## Examples
|
||||
|
||||
$ mix join_requests.cleanup_expired
|
||||
Deleted 3 expired join request(s).
|
||||
"""
|
||||
use Mix.Task
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
||||
|
||||
@impl Mix.Task
|
||||
def run(_args) do
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
now = DateTime.utc_now()
|
||||
|
||||
query =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(status == :pending_confirmation)
|
||||
|> Ash.Query.filter(confirmation_token_expires_at < ^now)
|
||||
|
||||
# Bypass authorization: cleanup is a system maintenance task (cron/Oban).
|
||||
case Ash.read(query, domain: Mv.Membership, authorize?: false) do
|
||||
{:ok, requests} ->
|
||||
count = delete_expired_requests(requests)
|
||||
Mix.shell().info("Deleted #{count} expired join request(s).")
|
||||
count
|
||||
|
||||
{:error, error} ->
|
||||
Mix.raise("Failed to list expired join requests: #{inspect(error)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp delete_expired_requests(requests) do
|
||||
Enum.reduce_while(requests, 0, fn request, acc ->
|
||||
case Ash.destroy(request, domain: Mv.Membership, authorize?: false) do
|
||||
:ok -> {:cont, acc + 1}
|
||||
{:error, _} -> {:halt, acc}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||
@moduledoc """
|
||||
Sends an email for a new user to confirm their email address.
|
||||
|
||||
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
|
||||
central mail from config (Mv.Mailer.mail_from/0).
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
|
|
@ -26,21 +34,16 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
confirm_url = url(~p"/confirm_new_user/#{token}")
|
||||
subject = gettext("Confirm your email address")
|
||||
|
||||
new()
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Confirm your email address")
|
||||
|> html_body(body(token: token))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||
|> render_body("user_confirmation.html", %{confirm_url: confirm_url, subject: subject})
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
|
||||
"""
|
||||
<p>Click this link to confirm your email:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||
@moduledoc """
|
||||
Sends a password reset email
|
||||
Sends a password reset email.
|
||||
|
||||
Uses the unified email layout (MvWeb.EmailsView + EmailLayoutView) and
|
||||
central mail from config (Mv.Mailer.mail_from/0).
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
|
|
@ -26,21 +34,16 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
|||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
reset_url = url(~p"/password-reset/#{token}")
|
||||
subject = gettext("Reset your password")
|
||||
|
||||
new()
|
||||
# Replace with email from env
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Reset your password")
|
||||
|> html_body(body(token: token))
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||
|> render_body("password_reset.html", %{reset_url: reset_url, subject: subject})
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/password-reset/#{params[:token]}")
|
||||
|
||||
"""
|
||||
<p>Click this link to reset your password:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,19 @@
|
|||
defmodule Mv.Mailer do
|
||||
@moduledoc """
|
||||
Swoosh mailer for transactional emails.
|
||||
|
||||
Use `mail_from/0` for the configured sender address (join confirmation,
|
||||
user confirmation, password reset).
|
||||
"""
|
||||
use Swoosh.Mailer, otp_app: :mv
|
||||
|
||||
@doc """
|
||||
Returns the configured "from" address for transactional emails.
|
||||
|
||||
Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`.
|
||||
Default: `{"Mila", "noreply@example.com"}`.
|
||||
"""
|
||||
def mail_from do
|
||||
Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
12
lib/mv_web/emails/email_layout_view.ex
Normal file
12
lib/mv_web/emails/email_layout_view.ex
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
defmodule MvWeb.EmailLayoutView do
|
||||
@moduledoc """
|
||||
Layout view for transactional emails (join confirmation, user confirmation, password reset).
|
||||
|
||||
Renders a single layout template that wraps all email body content.
|
||||
See docs/email-layout-mockup.md for the layout structure.
|
||||
"""
|
||||
use Phoenix.View,
|
||||
root: "lib/mv_web",
|
||||
path: "templates/emails/layouts",
|
||||
namespace: MvWeb
|
||||
end
|
||||
13
lib/mv_web/emails/emails_view.ex
Normal file
13
lib/mv_web/emails/emails_view.ex
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
defmodule MvWeb.EmailsView do
|
||||
@moduledoc """
|
||||
View for transactional email body templates.
|
||||
|
||||
Templates are rendered inside EmailLayoutView layout when sent via Phoenix.Swoosh.
|
||||
"""
|
||||
use Phoenix.View,
|
||||
root: "lib/mv_web",
|
||||
path: "templates/emails",
|
||||
namespace: MvWeb
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
end
|
||||
33
lib/mv_web/emails/join_confirmation_email.ex
Normal file
33
lib/mv_web/emails/join_confirmation_email.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule MvWeb.Emails.JoinConfirmationEmail do
|
||||
@moduledoc """
|
||||
Sends the join request confirmation email (double opt-in) using the unified email layout.
|
||||
"""
|
||||
use Phoenix.Swoosh,
|
||||
view: MvWeb.EmailsView,
|
||||
layout: {MvWeb.EmailLayoutView, :layout}
|
||||
|
||||
use MvWeb, :verified_routes
|
||||
import Swoosh.Email
|
||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Sends the join confirmation email to the given address with the confirmation link.
|
||||
|
||||
Called from the domain after a JoinRequest is created (submit flow).
|
||||
"""
|
||||
def send(email_address, token) when is_binary(email_address) and is_binary(token) do
|
||||
confirm_url = url(~p"/confirm_join/#{token}")
|
||||
subject = gettext("Confirm your membership request")
|
||||
|
||||
new()
|
||||
|> from(Mailer.mail_from())
|
||||
|> to(email_address)
|
||||
|> subject(subject)
|
||||
|> put_view(MvWeb.EmailsView)
|
||||
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|
||||
|> render_body("join_confirmation.html", %{confirm_url: confirm_url, subject: subject})
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
end
|
||||
18
lib/mv_web/templates/emails/join_confirmation.html.heex
Normal file
18
lib/mv_web/templates/emails/join_confirmation.html.heex
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<div style="color: #111827;">
|
||||
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
|
||||
{gettext(
|
||||
"We have received your membership request. To complete it, please click the link below."
|
||||
)}
|
||||
</p>
|
||||
<p style="margin: 0 0 24px;">
|
||||
<a
|
||||
href={@confirm_url}
|
||||
style="display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;"
|
||||
>
|
||||
{gettext("Confirm my request")}
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
{gettext("If you did not submit this request, you can ignore this email.")}
|
||||
</p>
|
||||
</div>
|
||||
33
lib/mv_web/templates/emails/layouts/layout.html.heex
Normal file
33
lib/mv_web/templates/emails/layouts/layout.html.heex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{assigns[:subject] || "Mila"}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; background-color: #f3f4f6;">
|
||||
<table
|
||||
role="presentation"
|
||||
width="100%"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
style="max-width: 600px; margin: 0 auto;"
|
||||
>
|
||||
<tr>
|
||||
<td style="padding: 24px 16px 16px;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #111827;">Mila</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 16px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
||||
{@inner_content}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 16px 24px; font-size: 12px; color: #6b7280;">
|
||||
© {DateTime.utc_now().year} Mila · Mitgliederverwaltung
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
18
lib/mv_web/templates/emails/password_reset.html.heex
Normal file
18
lib/mv_web/templates/emails/password_reset.html.heex
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<div style="color: #111827;">
|
||||
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
|
||||
{gettext("You requested a password reset. Click the link below to set a new password.")}
|
||||
</p>
|
||||
<p style="margin: 0 0 24px;">
|
||||
<a
|
||||
href={@reset_url}
|
||||
style="display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;"
|
||||
>
|
||||
{gettext("Reset password")}
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
{gettext(
|
||||
"If you did not request this, you can ignore this email. Your password will remain unchanged."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
16
lib/mv_web/templates/emails/user_confirmation.html.heex
Normal file
16
lib/mv_web/templates/emails/user_confirmation.html.heex
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<div style="color: #111827;">
|
||||
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.5;">
|
||||
{gettext("Please confirm your email address by clicking the link below.")}
|
||||
</p>
|
||||
<p style="margin: 0 0 24px;">
|
||||
<a
|
||||
href={@confirm_url}
|
||||
style="display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500;"
|
||||
>
|
||||
{gettext("Confirm my email")}
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #6b7280;">
|
||||
{gettext("If you did not create an account, you can ignore this email.")}
|
||||
</p>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue