Harden authentication email delivery

This commit is contained in:
SimpleTest 2026-08-02 16:39:18 +03:00
parent 41b37ccf37
commit f5c0d1d9b2
13 changed files with 693 additions and 66 deletions

View File

@ -211,6 +211,15 @@ one `INSERT ... ON CONFLICT` statement. Failed transactional-email delivery
removes only the token created for that failed attempt. Expired buckets and removes only the token created for that failed attempt. Expired buckets and
tokens are pruned by the maintenance worker. tokens are pruned by the maintenance worker.
Authentication delivery also reuses the validity window of an existing
one-time link as an idempotency window: a repeated request for the same account
and flow does not create or send another link while the previous one remains
valid. The account row is locked only while reserving the token, so concurrent
requests cannot produce duplicate messages and SMTP work does not run inside a
database transaction. Google verification flows include a non-reversible hash
of the pending flow in the token context, so a new OAuth flow is not suppressed
by an older one.
The Compose and kind scripts finish by subscribing on one live BEAM node, The Compose and kind scripts finish by subscribing on one live BEAM node,
broadcasting through a different connected node, and failing if the PubSub broadcasting through a different connected node, and failing if the PubSub
probe is not received. probe is not received.

View File

@ -1067,9 +1067,10 @@ defmodule WhoNeedHelp.Accounts do
end end
@doc "Delivers a one-time local-account verification link before connecting Google." @doc "Delivers a one-time local-account verification link before connecting Google."
def deliver_google_link_instructions(%User{} = user, magic_link_url_fun) def deliver_google_link_instructions(%User{} = user, delivery_key, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do when is_binary(delivery_key) and is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login") context = "login:google:#{short_hash(delivery_key)}"
{encoded_token, user_token} = UserToken.build_email_token(user, context)
persist_email_token_and_deliver(user_token, fn -> persist_email_token_and_deliver(user_token, fn ->
UserNotifier.deliver_google_link_instructions(user, magic_link_url_fun.(encoded_token)) UserNotifier.deliver_google_link_instructions(user, magic_link_url_fun.(encoded_token))
@ -1093,8 +1094,54 @@ defmodule WhoNeedHelp.Accounts do
## Token helper ## Token helper
defp persist_email_token_and_deliver(user_token, deliver_fun) do defp persist_email_token_and_deliver(user_token, deliver_fun) do
persisted_token = Repo.insert!(user_token) case reserve_email_token(user_token) do
{:ok, :already_sent} ->
{:ok, :already_sent}
{:ok, persisted_token} ->
deliver_reserved_email_token(persisted_token, deliver_fun)
{:error, reason} ->
{:error, reason}
end
end
defp reserve_email_token(%UserToken{context: "login"} = user_token),
do: reserve_auth_email_token(user_token)
defp reserve_email_token(%UserToken{context: "login:google:" <> _} = user_token),
do: reserve_auth_email_token(user_token)
defp reserve_email_token(user_token), do: {:ok, Repo.insert!(user_token)}
defp reserve_auth_email_token(user_token) do
cutoff =
DateTime.utc_now(:second)
|> DateTime.add(-UserToken.magic_link_validity_in_minutes(), :minute)
Repo.transact(fn ->
# Serialize reservations per account so concurrent requests cannot both send.
Repo.one!(
from user in User,
where: user.id == ^user_token.user_id,
select: user.id,
lock: "FOR UPDATE"
)
recent_token? =
Repo.exists?(
from token in UserToken,
where: token.user_id == ^user_token.user_id,
where: token.context == ^user_token.context,
where: token.sent_to == ^user_token.sent_to,
where: token.inserted_at > ^cutoff
)
if recent_token?, do: {:ok, :already_sent}, else: Repo.insert(user_token)
end)
end
defp deliver_reserved_email_token(persisted_token, deliver_fun) do
case deliver_fun.() do case deliver_fun.() do
{:ok, _email} = delivered -> {:ok, _email} = delivered ->
delivered delivered
@ -1105,6 +1152,12 @@ defmodule WhoNeedHelp.Accounts do
end end
end end
defp short_hash(value) do
:crypto.hash(:sha256, value)
|> Base.url_encode64(padding: false)
|> binary_part(0, 22)
end
defp before_moderation_user(query, nil), do: query defp before_moderation_user(query, nil), do: query
defp before_moderation_user(query, {inserted_at, id}) do defp before_moderation_user(query, {inserted_at, id}) do

View File

@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
alias WhoNeedHelp.Accounts.User alias WhoNeedHelp.Accounts.User
# Delivers the email using the application mailer. # Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do defp deliver(recipient, subject, text_body, html_body) do
from = Application.fetch_env!(:who_need_help, :mailer_from) from = Application.fetch_env!(:who_need_help, :mailer_from)
email = email =
@ -15,7 +15,8 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
|> to(recipient) |> to(recipient)
|> from({from[:name], from[:address]}) |> from({from[:name], from[:address]})
|> subject(subject) |> subject(subject)
|> text_body(body) |> text_body(text_body)
|> html_body(html_body)
with {:ok, _metadata} <- Mailer.deliver(email) do with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email} {:ok, email}
@ -27,14 +28,18 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
""" """
def deliver_update_email_instructions(user, url) do def deliver_update_email_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver( deliver_action_email(user,
user.email, subject: gettext("Confirm your Who Need Help email change"),
gettext("Update email instructions"), heading: gettext("Confirm your new email address"),
gettext( introduction:
"Hi %{email},\n\nYou can change your email by visiting the URL below:\n\n%{url}\n\nIf you didn't request this change, please ignore this.", gettext("Use the secure link below to confirm this email address for your account."),
email: user.email, action_label: gettext("Confirm email address"),
url: url url: url,
) expiry_note: gettext("This confirmation link expires in 7 days."),
security_note:
gettext(
"If you did not request this change, ignore this email. Your address will not change."
)
) )
end) end)
end end
@ -52,46 +57,143 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
@doc "Delivers instructions for verifying a local account before connecting Google sign-in." @doc "Delivers instructions for verifying a local account before connecting Google sign-in."
def deliver_google_link_instructions(user, url) do def deliver_google_link_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver( deliver_action_email(user,
user.email, subject: gettext("Confirm Google sign-in for Who Need Help"),
gettext("Confirm Google sign-in"), heading: gettext("Confirm Google sign-in"),
gettext( introduction:
"Hi %{email},\n\nUse the secure link below to sign in and connect Google to your Who Need Help account:\n\n%{url}\n\nIf you did not request this, ignore this email. Google will not be connected.", gettext("Use the secure link below to sign in and connect Google to your account."),
email: user.email, action_label: gettext("Confirm Google sign-in"),
url: url url: url,
) expiry_note: gettext("This one-time link expires in 15 minutes."),
security_note:
gettext("If you did not request this, ignore this email. Google will not be connected.")
) )
end) end)
end end
defp deliver_magic_link_instructions(user, url) do defp deliver_magic_link_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver( deliver_action_email(user,
user.email, subject: gettext("Your Who Need Help sign-in link"),
gettext("Log in instructions"), heading: gettext("Sign in to Who Need Help"),
gettext( introduction: gettext("Use the secure link below to sign in to your account."),
"Hi %{email},\n\nYou can log into your account by visiting the URL below:\n\n%{url}\n\nIf you didn't request this email, please ignore this.", action_label: gettext("Sign in to Who Need Help"),
email: user.email, url: url,
url: url expiry_note: gettext("This one-time link expires in 15 minutes."),
) security_note:
gettext("If you did not request this sign-in, you can safely ignore this email.")
) )
end) end)
end end
defp deliver_confirmation_instructions(user, url) do defp deliver_confirmation_instructions(user, url) do
with_user_locale(user, fn -> with_user_locale(user, fn ->
deliver( deliver_action_email(user,
user.email, subject: gettext("Confirm your Who Need Help account"),
gettext("Confirmation instructions"), heading: gettext("Confirm your account"),
gettext( introduction: gettext("Use the secure link below to confirm your Who Need Help account."),
"Hi %{email},\n\nYou can confirm your account by visiting the URL below:\n\n%{url}\n\nIf you didn't create an account with us, please ignore this.", action_label: gettext("Confirm account"),
email: user.email, url: url,
url: url expiry_note: gettext("This one-time link expires in 15 minutes."),
) security_note:
gettext("If you did not create this account, you can safely ignore this email.")
) )
end) end)
end end
defp deliver_action_email(user, content) do
subject = Keyword.fetch!(content, :subject)
heading = Keyword.fetch!(content, :heading)
introduction = Keyword.fetch!(content, :introduction)
action_label = Keyword.fetch!(content, :action_label)
url = Keyword.fetch!(content, :url)
expiry_note = Keyword.fetch!(content, :expiry_note)
security_note = Keyword.fetch!(content, :security_note)
text = """
#{heading}
#{introduction}
#{action_label}: #{url}
#{expiry_note}
#{security_note}
Who Need Help
"""
html = action_email_html(heading, introduction, action_label, url, expiry_note, security_note)
deliver(user.email, subject, text, html)
end
defp action_email_html(heading, introduction, action_label, url, expiry_note, security_note) do
escaped_heading = escape_html(heading)
escaped_introduction = escape_html(introduction)
escaped_action_label = escape_html(action_label)
escaped_url = escape_html(url)
escaped_expiry_note = escape_html(expiry_note)
escaped_security_note = escape_html(security_note)
"""
<!doctype html>
<html lang="#{escape_html(Gettext.get_locale(WhoNeedHelpWeb.Gettext))}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>#{escaped_heading}</title>
</head>
<body style="margin:0;padding:0;background:#f5f7f6;color:#1d1d20;font-family:Arial,Helvetica,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f5f7f6;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:560px;background:#ffffff;border:1px solid #dfe5e2;border-radius:16px;">
<tr>
<td style="padding:28px 32px 12px;font-size:18px;font-weight:700;color:#007d6b;">Who Need Help</td>
</tr>
<tr>
<td style="padding:8px 32px 0;">
<h1 style="margin:0;font-size:26px;line-height:1.25;color:#1d1d20;">#{escaped_heading}</h1>
</td>
</tr>
<tr>
<td style="padding:16px 32px 0;font-size:16px;line-height:1.6;color:#555b58;">#{escaped_introduction}</td>
</tr>
<tr>
<td style="padding:24px 32px;">
<a href="#{escaped_url}" style="display:inline-block;padding:13px 20px;border-radius:8px;background:#a93612;color:#ffffff;text-decoration:none;font-size:16px;font-weight:700;">#{escaped_action_label}</a>
</td>
</tr>
<tr>
<td style="padding:0 32px 8px;font-size:14px;line-height:1.55;color:#555b58;">#{escaped_expiry_note}</td>
</tr>
<tr>
<td style="padding:8px 32px 0;font-size:14px;line-height:1.55;color:#555b58;">#{escaped_security_note}</td>
</tr>
<tr>
<td style="padding:20px 32px 8px;font-size:12px;line-height:1.5;color:#747a77;">#{escape_html(gettext("If the button does not work, copy and paste this address into your browser:"))}</td>
</tr>
<tr>
<td style="padding:0 32px 28px;font-size:12px;line-height:1.5;word-break:break-all;"><a href="#{escaped_url}" style="color:#007d6b;">#{escaped_url}</a></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
end
defp escape_html(value) do
value
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
defp with_user_locale(%User{locale: locale}, fun) do defp with_user_locale(%User{locale: locale}, fun) do
Gettext.with_locale(WhoNeedHelpWeb.Gettext, WhoNeedHelp.Locales.normalize(locale), fun) Gettext.with_locale(WhoNeedHelpWeb.Gettext, WhoNeedHelp.Locales.normalize(locale), fun)
end end

View File

@ -12,6 +12,8 @@ defmodule WhoNeedHelp.Accounts.UserToken do
@change_email_validity_in_days 7 @change_email_validity_in_days 7
@session_validity_in_days 14 @session_validity_in_days 14
def magic_link_validity_in_minutes, do: @magic_link_validity_in_minutes
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id @foreign_key_type :binary_id
schema "users_tokens" do schema "users_tokens" do
@ -113,7 +115,9 @@ defmodule WhoNeedHelp.Accounts.UserToken do
hashed_token = :crypto.hash(@hash_algorithm, decoded_token) hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query = query =
from token in by_token_and_context_query(hashed_token, "login"), from token in UserToken,
where: token.token == ^hashed_token,
where: token.context == "login" or like(token.context, "login:google:%"),
join: user in assoc(token, :user), join: user in assoc(token, :user),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email, where: token.sent_to == user.email,
@ -160,7 +164,7 @@ defmodule WhoNeedHelp.Accounts.UserToken do
from token in UserToken, from token in UserToken,
where: where:
(token.context == "login" and token.inserted_at <= ^magic_link_cutoff) or (like(token.context, "login%") and token.inserted_at <= ^magic_link_cutoff) or
(like(token.context, "change:%") and token.inserted_at <= ^change_email_cutoff) or (like(token.context, "change:%") and token.inserted_at <= ^change_email_cutoff) or
(token.context == "session" and token.inserted_at <= ^session_cutoff) (token.context == "session" and token.inserted_at <= ^session_cutoff)
end end

View File

@ -525,6 +525,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
Accounts.deliver_google_link_instructions( Accounts.deliver_google_link_instructions(
user, user,
pending_token,
&"#{login_url}?google_link=#{URI.encode_www_form(pending_token)}#token=#{URI.encode_www_form(&1)}" &"#{login_url}?google_link=#{URI.encode_www_form(pending_token)}#token=#{URI.encode_www_form(&1)}"
) )
end end

View File

@ -12,7 +12,7 @@
else: gettext("Sign-in request processed")} else: gettext("Sign-in request processed")}
<:subtitle> <:subtitle>
<%= if @email_flow == "register" do %> <%= if @email_flow == "register" do %>
{gettext("Use the newest confirmation link if it arrives.")} {gettext("Use the most recent confirmation link that arrived.")}
<% else %> <% else %>
{gettext("Signing in does not create a new account.")} {gettext("Signing in does not create a new account.")}
<% end %> <% end %>
@ -48,7 +48,7 @@
<div> <div>
<h2 class="text-lg font-bold"> <h2 class="text-lg font-bold">
<%= if @email_flow == "register" do %> <%= if @email_flow == "register" do %>
{gettext("Open the newest Who Need Help email")} {gettext("Open the most recent Who Need Help email")}
<% else %> <% else %>
{gettext("Already registered? Check your email")} {gettext("Already registered? Check your email")}
<% end %> <% end %>
@ -69,7 +69,7 @@
<div class="mt-6 rounded-2xl bg-base-200 p-4 text-sm leading-6 text-base-content/70"> <div class="mt-6 rounded-2xl bg-base-200 p-4 text-sm leading-6 text-base-content/70">
{gettext( {gettext(
"If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid."
)} )}
</div> </div>

View File

@ -4087,7 +4087,7 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid."
msgstr "" msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36
@ -4166,7 +4166,7 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open the newest Who Need Help email" msgid "Open the most recent Who Need Help email"
msgstr "" msgstr ""
#: lib/who_need_help_web/live/public_profile_live.ex:105 #: lib/who_need_help_web/live/public_profile_live.ex:105
@ -4380,7 +4380,7 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Use the newest confirmation link if it arrives." msgid "Use the most recent confirmation link that arrived."
msgstr "" msgstr ""
#: lib/who_need_help_web/live/request_live/show.ex:1658 #: lib/who_need_help_web/live/request_live/show.ex:1658
@ -6828,3 +6828,83 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr "" msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help email change"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your new email address"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm this email address for your account."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm email address"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This confirmation link expires in 7 days."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this change, ignore this email. Your address will not change."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm Google sign-in for Who Need Help"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in and connect Google to your account."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This one-time link expires in 15 minutes."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this, ignore this email. Google will not be connected."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Your Who Need Help sign-in link"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Sign in to Who Need Help"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in to your account."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this sign-in, you can safely ignore this email."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help account"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your account"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm your Who Need Help account."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm account"
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not create this account, you can safely ignore this email."
msgstr ""
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If the button does not work, copy and paste this address into your browser:"
msgstr ""

View File

@ -4087,8 +4087,8 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid."
msgstr "" msgstr "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid."
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -4166,8 +4166,8 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open the newest Who Need Help email" msgid "Open the most recent Who Need Help email"
msgstr "" msgstr "Open the most recent Who Need Help email"
#: lib/who_need_help_web/live/public_profile_live.ex:105 #: lib/who_need_help_web/live/public_profile_live.ex:105
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -4380,8 +4380,8 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Use the newest confirmation link if it arrives." msgid "Use the most recent confirmation link that arrived."
msgstr "" msgstr "Use the most recent confirmation link that arrived."
#: lib/who_need_help_web/live/request_live/show.ex:1658 #: lib/who_need_help_web/live/request_live/show.ex:1658
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -6828,3 +6828,83 @@ msgstr "Pending email verification"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgstr "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help email change"
msgstr "Confirm your Who Need Help email change"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your new email address"
msgstr "Confirm your new email address"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm this email address for your account."
msgstr "Use the secure link below to confirm this email address for your account."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm email address"
msgstr "Confirm email address"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This confirmation link expires in 7 days."
msgstr "This confirmation link expires in 7 days."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this change, ignore this email. Your address will not change."
msgstr "If you did not request this change, ignore this email. Your address will not change."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm Google sign-in for Who Need Help"
msgstr "Confirm Google sign-in for Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in and connect Google to your account."
msgstr "Use the secure link below to sign in and connect Google to your account."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This one-time link expires in 15 minutes."
msgstr "This one-time link expires in 15 minutes."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this, ignore this email. Google will not be connected."
msgstr "If you did not request this, ignore this email. Google will not be connected."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Your Who Need Help sign-in link"
msgstr "Your Who Need Help sign-in link"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Sign in to Who Need Help"
msgstr "Sign in to Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in to your account."
msgstr "Use the secure link below to sign in to your account."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this sign-in, you can safely ignore this email."
msgstr "If you did not request this sign-in, you can safely ignore this email."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help account"
msgstr "Confirm your Who Need Help account"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your account"
msgstr "Confirm your account"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm your Who Need Help account."
msgstr "Use the secure link below to confirm your Who Need Help account."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm account"
msgstr "Confirm account"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not create this account, you can safely ignore this email."
msgstr "If you did not create this account, you can safely ignore this email."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If the button does not work, copy and paste this address into your browser:"
msgstr "If the button does not work, copy and paste this address into your browser:"

View File

@ -4227,8 +4227,8 @@ msgstr "Помощники увидят только указанный выше
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid."
msgstr "Если письмо не появилось через минуту, проверьте папку «Спам». Новая защищённая ссылка могла заменить предыдущие." msgstr "Если письмо не появилось через минуту, проверьте папку «Спам». Повторный запрос не отправляет ещё одно письмо, пока недавняя защищённая ссылка остаётся действительной."
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -4306,8 +4306,8 @@ msgstr "Открыть ссылку для благодарности"
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open the newest Who Need Help email" msgid "Open the most recent Who Need Help email"
msgstr "Откройте последнее письмо Who Need Help" msgstr "Откройте последнее полученное письмо Who Need Help"
#: lib/who_need_help_web/live/public_profile_live.ex:105 #: lib/who_need_help_web/live/public_profile_live.ex:105
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -4520,8 +4520,8 @@ msgstr "Перейдите по ссылке подтверждения в пи
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Use the newest confirmation link if it arrives." msgid "Use the most recent confirmation link that arrived."
msgstr "Если пришла новая ссылка подтверждения, используйте её." msgstr "Используйте последнюю полученную ссылку подтверждения."
#: lib/who_need_help_web/live/request_live/show.ex:1658 #: lib/who_need_help_web/live/request_live/show.ex:1658
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -6971,3 +6971,83 @@ msgstr "Ожидает подтверждения email"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr "Мы сохранили ожидающую заявку %{reference}. Она ещё не попала в очередь поддержки. Откройте приватную ссылку из письма, чтобы подтвердить адрес и отправить заявку на рассмотрение." msgstr "Мы сохранили ожидающую заявку %{reference}. Она ещё не попала в очередь поддержки. Откройте приватную ссылку из письма, чтобы подтвердить адрес и отправить заявку на рассмотрение."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help email change"
msgstr "Подтвердите изменение email в Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your new email address"
msgstr "Подтвердите новый адрес электронной почты"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm this email address for your account."
msgstr "Перейдите по защищённой ссылке ниже, чтобы подтвердить этот адрес электронной почты для своей учётной записи."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm email address"
msgstr "Подтвердить email"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This confirmation link expires in 7 days."
msgstr "Ссылка для подтверждения действует 7 дней."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this change, ignore this email. Your address will not change."
msgstr "Если вы не запрашивали это изменение, проигнорируйте письмо. Ваш адрес не изменится."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm Google sign-in for Who Need Help"
msgstr "Подтвердите вход через Google в Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in and connect Google to your account."
msgstr "Перейдите по защищённой ссылке ниже, чтобы войти и подключить Google к своей учётной записи."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This one-time link expires in 15 minutes."
msgstr "Эта одноразовая ссылка действует 15 минут."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this, ignore this email. Google will not be connected."
msgstr "Если вы этого не запрашивали, проигнорируйте письмо. Google не будет подключён."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Your Who Need Help sign-in link"
msgstr "Ваша ссылка для входа в Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Sign in to Who Need Help"
msgstr "Войти в Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in to your account."
msgstr "Перейдите по защищённой ссылке ниже, чтобы войти в свою учётную запись."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this sign-in, you can safely ignore this email."
msgstr "Если вы не запрашивали вход, просто проигнорируйте это письмо."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help account"
msgstr "Подтвердите учётную запись Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your account"
msgstr "Подтвердите учётную запись"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm your Who Need Help account."
msgstr "Перейдите по защищённой ссылке ниже, чтобы подтвердить учётную запись Who Need Help."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm account"
msgstr "Подтвердить учётную запись"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not create this account, you can safely ignore this email."
msgstr "Если вы не создавали эту учётную запись, просто проигнорируйте письмо."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If the button does not work, copy and paste this address into your browser:"
msgstr "Если кнопка не работает, скопируйте этот адрес и вставьте его в браузер:"

View File

@ -4221,8 +4221,8 @@ msgstr "Помічники бачитимуть лише вказаний вищ
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If the message is not in your inbox after a minute, check Spam. Older secure links may have been replaced by the newest one." msgid "If the message is not in your inbox after a minute, check Spam. Repeated requests do not send another email while a recent secure link is still valid."
msgstr "Якщо лист не з’явився за хвилину, перевірте папку «Спам». Нове захищене посилання могло замінити попередні." msgstr "Якщо лист не з’явився за хвилину, перевірте папку «Спам». Повторний запит не надсилає ще одного листа, доки нещодавнє захищене посилання залишається чинним."
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -4300,8 +4300,8 @@ msgstr "Відкрити посилання для подяки"
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open the newest Who Need Help email" msgid "Open the most recent Who Need Help email"
msgstr "Відкрийте останній лист Who Need Help" msgstr "Відкрийте останній отриманий лист Who Need Help"
#: lib/who_need_help_web/live/public_profile_live.ex:105 #: lib/who_need_help_web/live/public_profile_live.ex:105
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -4514,8 +4514,8 @@ msgstr "Перейдіть за посиланням підтвердження
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15 #: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Use the newest confirmation link if it arrives." msgid "Use the most recent confirmation link that arrived."
msgstr "Якщо надійшло нове посилання підтвердження, використайте його." msgstr "Скористайтеся останнім отриманим посиланням підтвердження."
#: lib/who_need_help_web/live/request_live/show.ex:1658 #: lib/who_need_help_web/live/request_live/show.ex:1658
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -6965,3 +6965,83 @@ msgstr "Очікує підтвердження email"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review." msgid "We saved pending request %{reference}. It is not in the support queue yet. Open the private link sent to the contact email to verify the address and submit the request for review."
msgstr "Ми зберегли запит %{reference}, що очікує підтвердження. Він ще не потрапив до черги підтримки. Відкрийте приватне посилання з листа, щоб підтвердити адресу та надіслати запит на розгляд." msgstr "Ми зберегли запит %{reference}, що очікує підтвердження. Він ще не потрапив до черги підтримки. Відкрийте приватне посилання з листа, щоб підтвердити адресу та надіслати запит на розгляд."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help email change"
msgstr "Підтвердьте зміну email у Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your new email address"
msgstr "Підтвердьте нову адресу електронної пошти"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm this email address for your account."
msgstr "Перейдіть за захищеним посиланням нижче, щоб підтвердити цю адресу електронної пошти для свого облікового запису."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm email address"
msgstr "Підтвердити email"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This confirmation link expires in 7 days."
msgstr "Посилання для підтвердження діє 7 днів."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this change, ignore this email. Your address will not change."
msgstr "Якщо ви не запитували цю зміну, проігноруйте лист. Ваша адреса не зміниться."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm Google sign-in for Who Need Help"
msgstr "Підтвердьте вхід через Google у Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in and connect Google to your account."
msgstr "Перейдіть за захищеним посиланням нижче, щоб увійти й підключити Google до свого облікового запису."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "This one-time link expires in 15 minutes."
msgstr "Це одноразове посилання діє 15 хвилин."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this, ignore this email. Google will not be connected."
msgstr "Якщо ви цього не запитували, проігноруйте лист. Google не буде підключено."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Your Who Need Help sign-in link"
msgstr "Ваше посилання для входу в Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Sign in to Who Need Help"
msgstr "Увійти в Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to sign in to your account."
msgstr "Перейдіть за захищеним посиланням нижче, щоб увійти до свого облікового запису."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not request this sign-in, you can safely ignore this email."
msgstr "Якщо ви не запитували вхід, просто проігноруйте цей лист."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your Who Need Help account"
msgstr "Підтвердьте обліковий запис Who Need Help"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm your account"
msgstr "Підтвердьте обліковий запис"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Use the secure link below to confirm your Who Need Help account."
msgstr "Перейдіть за захищеним посиланням нижче, щоб підтвердити обліковий запис Who Need Help."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "Confirm account"
msgstr "Підтвердити обліковий запис"
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If you did not create this account, you can safely ignore this email."
msgstr "Якщо ви не створювали цей обліковий запис, просто проігноруйте лист."
#: lib/who_need_help/accounts/user_notifier.ex
msgid "If the button does not work, copy and paste this address into your browser:"
msgstr "Якщо кнопка не працює, скопіюйте цю адресу та вставте її у браузер:"

View File

@ -0,0 +1,72 @@
defmodule WhoNeedHelp.Accounts.UserNotifierTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.Accounts.{User, UserNotifier}
test "magic-link email is recognizable, multipart, and explicit about expiry" do
user = %User{
email: "person@example.com",
display_name: "Helpful neighbor",
locale: "en",
confirmed_at: DateTime.utc_now(:second)
}
url = "https://whoneedhelp.com/users/log-in#token=one-time-token"
assert {:ok, email} = UserNotifier.deliver_login_instructions(user, url)
assert email.subject == "Your Who Need Help sign-in link"
assert email.text_body =~ "This one-time link expires in 15 minutes."
assert email.text_body =~ url
refute email.text_body =~ user.email
assert email.html_body =~ "<title>Sign in to Who Need Help</title>"
assert email.html_body =~ ~s(href="#{url}")
assert email.html_body =~ "copy and paste this address into your browser"
refute email.html_body =~ "<img"
refute email.html_body =~ user.email
end
test "account email HTML escapes translated content and action URLs" do
user = %User{email: "person@example.com", locale: "en"}
url = ~s(https://whoneedhelp.com/users/log-in#token=a&next="unsafe")
assert {:ok, email} = UserNotifier.deliver_login_instructions(user, url)
assert email.text_body =~ url
assert email.html_body =~ "a&amp;next=&quot;unsafe&quot;"
refute email.html_body =~ ~s(href="#{url}")
end
test "new-account confirmation uses an account-specific subject" do
user = %User{email: "person@example.com", locale: "en", confirmed_at: nil}
assert {:ok, email} =
UserNotifier.deliver_login_instructions(
user,
"https://whoneedhelp.com/users/log-in#token=confirmation-token"
)
assert email.subject == "Confirm your Who Need Help account"
assert email.text_body =~ "Confirm your account"
assert email.html_body =~ "Confirm account"
end
test "Google-link verification has a distinct recognizable subject" do
user = %User{
email: "person@example.com",
locale: "en",
confirmed_at: DateTime.utc_now(:second)
}
assert {:ok, email} =
UserNotifier.deliver_google_link_instructions(
user,
"https://whoneedhelp.com/users/log-in?google_link=pending#token=one-time-token"
)
assert email.subject == "Confirm Google sign-in for Who Need Help"
assert email.text_body =~ "Google will not be connected"
assert email.html_body =~ "Confirm Google sign-in"
end
end

View File

@ -703,6 +703,72 @@ defmodule WhoNeedHelp.AccountsTest do
where: token.user_id == ^user.id and token.context == "login" where: token.user_id == ^user.id and token.context == "login"
) )
end end
test "does not send another login email while the previous link is valid", %{user: user} do
assert {:ok, first_email} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
assert {:ok, :already_sent} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
assert first_email.subject == "Confirm your Who Need Help account"
assert Repo.aggregate(
from(token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
),
:count
) == 1
end
test "allows a new login email after the existing link expires", %{user: user} do
assert {:ok, _email} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
Repo.update_all(
from(token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -16, :minute)]
)
assert {:ok, _email} =
Accounts.deliver_login_instructions(user, &"https://example.test/#{&1}")
assert Repo.aggregate(
from(token in UserToken,
where: token.user_id == ^user.id and token.context == "login"
),
:count
) == 2
end
test "deduplicates only the same pending Google verification flow", %{user: user} do
url = &"https://example.test/google/[TOKEN]#{&1}[TOKEN]"
assert {:ok, first_email} =
Accounts.deliver_google_link_instructions(user, "pending-flow-one", url)
[_, token | _] = String.split(first_email.text_body, "[TOKEN]")
assert %User{id: user_id} = Accounts.get_user_by_magic_link_token(token)
assert user_id == user.id
assert {:ok, :already_sent} =
Accounts.deliver_google_link_instructions(user, "pending-flow-one", url)
assert {:ok, _email} =
Accounts.deliver_google_link_instructions(user, "pending-flow-two", url)
contexts =
UserToken
|> where([token], token.user_id == ^user.id)
|> select([token], token.context)
|> Repo.all()
assert length(contexts) == 2
assert Enum.all?(contexts, &String.starts_with?(&1, "login:google:"))
assert contexts |> MapSet.new() |> MapSet.size() == 2
end
end end
describe "inspect/2 for the User module" do describe "inspect/2 for the User module" do

View File

@ -506,8 +506,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "finish connecting Google" assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "finish connecting Google"
assert_email_sent(fn email -> assert_email_sent(fn email ->
email.subject == "Confirm Google sign-in" and email.subject == "Confirm Google sign-in for Who Need Help" and
email.text_body =~ "connect Google to your Who Need Help account" and email.text_body =~ "connect Google to your account" and
email.text_body =~ "google_link=" and email.text_body =~ "#token=" email.text_body =~ "google_link=" and email.text_body =~ "#token="
end) end)