diff --git a/docs/architecture.md b/docs/architecture.md
index 14a916d..b899d4b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
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,
broadcasting through a different connected node, and failing if the PubSub
probe is not received.
diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex
index d9a71fb..4abd7fb 100644
--- a/lib/who_need_help/accounts.ex
+++ b/lib/who_need_help/accounts.ex
@@ -1067,9 +1067,10 @@ defmodule WhoNeedHelp.Accounts do
end
@doc "Delivers a one-time local-account verification link before connecting Google."
- def deliver_google_link_instructions(%User{} = user, magic_link_url_fun)
- when is_function(magic_link_url_fun, 1) do
- {encoded_token, user_token} = UserToken.build_email_token(user, "login")
+ def deliver_google_link_instructions(%User{} = user, delivery_key, magic_link_url_fun)
+ when is_binary(delivery_key) and is_function(magic_link_url_fun, 1) do
+ 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 ->
UserNotifier.deliver_google_link_instructions(user, magic_link_url_fun.(encoded_token))
@@ -1093,8 +1094,54 @@ defmodule WhoNeedHelp.Accounts do
## Token helper
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
{:ok, _email} = delivered ->
delivered
@@ -1105,6 +1152,12 @@ defmodule WhoNeedHelp.Accounts do
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, {inserted_at, id}) do
diff --git a/lib/who_need_help/accounts/user_notifier.ex b/lib/who_need_help/accounts/user_notifier.ex
index d8b6592..98f363f 100644
--- a/lib/who_need_help/accounts/user_notifier.ex
+++ b/lib/who_need_help/accounts/user_notifier.ex
@@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
alias WhoNeedHelp.Accounts.User
# 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)
email =
@@ -15,7 +15,8 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
|> to(recipient)
|> from({from[:name], from[:address]})
|> subject(subject)
- |> text_body(body)
+ |> text_body(text_body)
+ |> html_body(html_body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
@@ -27,14 +28,18 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
"""
def deliver_update_email_instructions(user, url) do
with_user_locale(user, fn ->
- deliver(
- user.email,
- gettext("Update email instructions"),
- gettext(
- "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.",
- email: user.email,
- url: url
- )
+ deliver_action_email(user,
+ subject: gettext("Confirm your Who Need Help email change"),
+ heading: gettext("Confirm your new email address"),
+ introduction:
+ gettext("Use the secure link below to confirm this email address for your account."),
+ action_label: gettext("Confirm email address"),
+ 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
@@ -52,46 +57,143 @@ defmodule WhoNeedHelp.Accounts.UserNotifier do
@doc "Delivers instructions for verifying a local account before connecting Google sign-in."
def deliver_google_link_instructions(user, url) do
with_user_locale(user, fn ->
- deliver(
- user.email,
- gettext("Confirm Google sign-in"),
- gettext(
- "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.",
- email: user.email,
- url: url
- )
+ deliver_action_email(user,
+ subject: gettext("Confirm Google sign-in for Who Need Help"),
+ heading: gettext("Confirm Google sign-in"),
+ introduction:
+ gettext("Use the secure link below to sign in and connect Google to your account."),
+ action_label: gettext("Confirm Google sign-in"),
+ 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
defp deliver_magic_link_instructions(user, url) do
with_user_locale(user, fn ->
- deliver(
- user.email,
- gettext("Log in instructions"),
- gettext(
- "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.",
- email: user.email,
- url: url
- )
+ deliver_action_email(user,
+ subject: gettext("Your Who Need Help sign-in link"),
+ heading: gettext("Sign in to Who Need Help"),
+ introduction: gettext("Use the secure link below to sign in to your account."),
+ action_label: gettext("Sign in to Who Need Help"),
+ 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
defp deliver_confirmation_instructions(user, url) do
with_user_locale(user, fn ->
- deliver(
- user.email,
- gettext("Confirmation instructions"),
- gettext(
- "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.",
- email: user.email,
- url: url
- )
+ deliver_action_email(user,
+ subject: gettext("Confirm your Who Need Help account"),
+ heading: gettext("Confirm your account"),
+ introduction: gettext("Use the secure link below to confirm your Who Need Help account."),
+ action_label: gettext("Confirm account"),
+ 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
+ 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)
+
+ """
+
+
+
+
+
+
+ #{escaped_heading}
+
+
+
+
+
+
+
+ | Who Need Help |
+
+
+
+ #{escaped_heading}
+ |
+
+
+ | #{escaped_introduction} |
+
+
+ |
+ #{escaped_action_label}
+ |
+
+
+ | #{escaped_expiry_note} |
+
+
+ | #{escaped_security_note} |
+
+
+ | #{escape_html(gettext("If the button does not work, copy and paste this address into your browser:"))} |
+
+
+ | #{escaped_url} |
+
+
+ |
+
+
+
+
+ """
+ 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
Gettext.with_locale(WhoNeedHelpWeb.Gettext, WhoNeedHelp.Locales.normalize(locale), fun)
end
diff --git a/lib/who_need_help/accounts/user_token.ex b/lib/who_need_help/accounts/user_token.ex
index 11845d2..a52a1cd 100644
--- a/lib/who_need_help/accounts/user_token.ex
+++ b/lib/who_need_help/accounts/user_token.ex
@@ -12,6 +12,8 @@ defmodule WhoNeedHelp.Accounts.UserToken do
@change_email_validity_in_days 7
@session_validity_in_days 14
+ def magic_link_validity_in_minutes, do: @magic_link_validity_in_minutes
+
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
@@ -113,7 +115,9 @@ defmodule WhoNeedHelp.Accounts.UserToken do
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
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),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email,
@@ -160,7 +164,7 @@ defmodule WhoNeedHelp.Accounts.UserToken do
from token in UserToken,
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
(token.context == "session" and token.inserted_at <= ^session_cutoff)
end
diff --git a/lib/who_need_help_web/controllers/google_auth_controller.ex b/lib/who_need_help_web/controllers/google_auth_controller.ex
index a37ccac..8cdf1ae 100644
--- a/lib/who_need_help_web/controllers/google_auth_controller.ex
+++ b/lib/who_need_help_web/controllers/google_auth_controller.ex
@@ -525,6 +525,7 @@ defmodule WhoNeedHelpWeb.GoogleAuthController do
Accounts.deliver_google_link_instructions(
user,
+ pending_token,
&"#{login_url}?google_link=#{URI.encode_www_form(pending_token)}#token=#{URI.encode_www_form(&1)}"
)
end
diff --git a/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex b/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex
index 396286b..f7f074a 100644
--- a/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex
+++ b/lib/who_need_help_web/controllers/user_registration_html/sent.html.heex
@@ -12,7 +12,7 @@
else: gettext("Sign-in request processed")}
<:subtitle>
<%= if @email_flow == "register" do %>
- {gettext("Use the newest confirmation link if it arrives.")}
+ {gettext("Use the most recent confirmation link that arrived.")}
<% else %>
{gettext("Signing in does not create a new account.")}
<% end %>
@@ -48,7 +48,7 @@
<%= if @email_flow == "register" do %>
- {gettext("Open the newest Who Need Help email")}
+ {gettext("Open the most recent Who Need Help email")}
<% else %>
{gettext("Already registered? Check your email")}
<% end %>
@@ -69,7 +69,7 @@
{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."
)}
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 50a89a8..96cc31c 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -4087,7 +4087,7 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, 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 ""
#: 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
#, elixir-autogen, elixir-format
-msgid "Open the newest Who Need Help email"
+msgid "Open the most recent Who Need Help email"
msgstr ""
#: 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
#, elixir-autogen, elixir-format
-msgid "Use the newest confirmation link if it arrives."
+msgid "Use the most recent confirmation link that arrived."
msgstr ""
#: lib/who_need_help_web/live/request_live/show.ex:1658
@@ -6828,3 +6828,83 @@ msgstr ""
#, 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."
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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 772d3cc..d6ac092 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -4087,8 +4087,8 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, 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."
-msgstr ""
+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 "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
#, elixir-autogen, elixir-format
@@ -4166,8 +4166,8 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51
#, elixir-autogen, elixir-format
-msgid "Open the newest Who Need Help email"
-msgstr ""
+msgid "Open the most recent Who Need Help email"
+msgstr "Open the most recent Who Need Help email"
#: lib/who_need_help_web/live/public_profile_live.ex:105
#, elixir-autogen, elixir-format, fuzzy
@@ -4380,8 +4380,8 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15
#, elixir-autogen, elixir-format
-msgid "Use the newest confirmation link if it arrives."
-msgstr ""
+msgid "Use the most recent confirmation link that arrived."
+msgstr "Use the most recent confirmation link that arrived."
#: lib/who_need_help_web/live/request_live/show.ex:1658
#, elixir-autogen, elixir-format
@@ -6828,3 +6828,83 @@ msgstr "Pending email verification"
#, 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."
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:"
diff --git a/priv/gettext/ru/LC_MESSAGES/default.po b/priv/gettext/ru/LC_MESSAGES/default.po
index 431aadd..2d6dd97 100644
--- a/priv/gettext/ru/LC_MESSAGES/default.po
+++ b/priv/gettext/ru/LC_MESSAGES/default.po
@@ -4227,8 +4227,8 @@ msgstr "Помощники увидят только указанный выше
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, 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."
-msgstr "Если письмо не появилось через минуту, проверьте папку «Спам». Новая защищённая ссылка могла заменить предыдущие."
+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 "Если письмо не появилось через минуту, проверьте папку «Спам». Повторный запрос не отправляет ещё одно письмо, пока недавняя защищённая ссылка остаётся действительной."
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36
#, elixir-autogen, elixir-format
@@ -4306,8 +4306,8 @@ msgstr "Открыть ссылку для благодарности"
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51
#, elixir-autogen, elixir-format
-msgid "Open the newest Who Need Help email"
-msgstr "Откройте последнее письмо Who Need Help"
+msgid "Open the most recent Who Need Help email"
+msgstr "Откройте последнее полученное письмо Who Need Help"
#: lib/who_need_help_web/live/public_profile_live.ex:105
#, elixir-autogen, elixir-format
@@ -4520,8 +4520,8 @@ msgstr "Перейдите по ссылке подтверждения в пи
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15
#, elixir-autogen, elixir-format
-msgid "Use the newest confirmation link if it arrives."
-msgstr "Если пришла новая ссылка подтверждения, используйте её."
+msgid "Use the most recent confirmation link that arrived."
+msgstr "Используйте последнюю полученную ссылку подтверждения."
#: lib/who_need_help_web/live/request_live/show.ex:1658
#, elixir-autogen, elixir-format
@@ -6971,3 +6971,83 @@ msgstr "Ожидает подтверждения email"
#, 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."
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 "Если кнопка не работает, скопируйте этот адрес и вставьте его в браузер:"
diff --git a/priv/gettext/uk/LC_MESSAGES/default.po b/priv/gettext/uk/LC_MESSAGES/default.po
index 2341a4c..0100343 100644
--- a/priv/gettext/uk/LC_MESSAGES/default.po
+++ b/priv/gettext/uk/LC_MESSAGES/default.po
@@ -4221,8 +4221,8 @@ msgstr "Помічники бачитимуть лише вказаний вищ
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:71
#, 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."
-msgstr "Якщо лист не з’явився за хвилину, перевірте папку «Спам». Нове захищене посилання могло замінити попередні."
+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 "Якщо лист не з’явився за хвилину, перевірте папку «Спам». Повторний запит не надсилає ще одного листа, доки нещодавнє захищене посилання залишається чинним."
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:36
#, elixir-autogen, elixir-format
@@ -4300,8 +4300,8 @@ msgstr "Відкрити посилання для подяки"
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:51
#, elixir-autogen, elixir-format
-msgid "Open the newest Who Need Help email"
-msgstr "Відкрийте останній лист Who Need Help"
+msgid "Open the most recent Who Need Help email"
+msgstr "Відкрийте останній отриманий лист Who Need Help"
#: lib/who_need_help_web/live/public_profile_live.ex:105
#, elixir-autogen, elixir-format
@@ -4514,8 +4514,8 @@ msgstr "Перейдіть за посиланням підтвердження
#: lib/who_need_help_web/controllers/user_registration_html/sent.html.heex:15
#, elixir-autogen, elixir-format
-msgid "Use the newest confirmation link if it arrives."
-msgstr "Якщо надійшло нове посилання підтвердження, використайте його."
+msgid "Use the most recent confirmation link that arrived."
+msgstr "Скористайтеся останнім отриманим посиланням підтвердження."
#: lib/who_need_help_web/live/request_live/show.ex:1658
#, elixir-autogen, elixir-format
@@ -6965,3 +6965,83 @@ msgstr "Очікує підтвердження email"
#, 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."
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 "Якщо кнопка не працює, скопіюйте цю адресу та вставте її у браузер:"
diff --git a/test/who_need_help/accounts/user_notifier_test.exs b/test/who_need_help/accounts/user_notifier_test.exs
new file mode 100644
index 0000000..84248fb
--- /dev/null
+++ b/test/who_need_help/accounts/user_notifier_test.exs
@@ -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 =~ "Sign in to Who Need Help"
+ assert email.html_body =~ ~s(href="#{url}")
+ assert email.html_body =~ "copy and paste this address into your browser"
+ refute email.html_body =~ "
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
describe "inspect/2 for the User module" do
diff --git a/test/who_need_help_web/controllers/google_auth_controller_test.exs b/test/who_need_help_web/controllers/google_auth_controller_test.exs
index 0175927..01e0e88 100644
--- a/test/who_need_help_web/controllers/google_auth_controller_test.exs
+++ b/test/who_need_help_web/controllers/google_auth_controller_test.exs
@@ -506,8 +506,8 @@ defmodule WhoNeedHelpWeb.GoogleAuthControllerTest do
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "finish connecting Google"
assert_email_sent(fn email ->
- email.subject == "Confirm Google sign-in" and
- email.text_body =~ "connect Google to your Who Need Help account" and
+ email.subject == "Confirm Google sign-in for Who Need Help" and
+ email.text_body =~ "connect Google to your account" and
email.text_body =~ "google_link=" and email.text_body =~ "#token="
end)