defmodule OpenAgents.Notifications.EmailChannel do
@moduledoc """
The address an account is willing to receive mail at, and the proof of it.
## Why the address is typed rather than taken
GitHub OAuth can be asked for `user:email` and hand over whatever address the
provider holds. This deployment does not ask. An address taken from a
provider is an address nobody chose to give this application, and the first
thing it would be used for is unsolicited mail to a mailbox its owner never
named here. So the address is typed into the notification settings, by the
person who wants mail, on purpose.
## The gate
`verified_address/1` is the only function anything sends to, and it returns
`nil` unless the account both names an address and confirmed it. Confirmation
is a code mailed to the address and typed back, which is the only evidence
this application can have that the person asking controls the mailbox.
Three things make the gate hold rather than merely exist:
* The code is held as a SHA-256 digest. A database read is not enough to
claim somebody else's mailbox.
* Guesses are counted and bounded at five. A short code with free guesses
is not a secret.
* The check constraint `users_notification_email_state_check` refuses a
verified timestamp on a row with no address, so the gate survives a bug
in this module.
Changing the address clears the verification, because the evidence was about
the old mailbox. Removing it clears everything, including the outstanding
code.
## What a deployment without a mail provider does
`deliverable?/0` reads one configuration key rather than inferring from the
Swoosh adapter, because the inference is wrong in both directions: the local
adapter is real delivery in development — the mailbox preview at
`/dev/mailbox` — and it is a black hole in production. A deployment that
configures no provider says so, and the settings surface offers no address
field rather than accepting one it cannot mail to.
"""
alias OpenAgents.Accounts.User
alias OpenAgents.Notifications.Email
alias OpenAgents.Repo
# Long enough that five guesses are hopeless, short enough to retype from a
# phone. Crockford's alphabet without I, L, O and U: no character in it can
# be confused with another in a proportional font, and none of them spell
# anything.
@code_alphabet ~c"0123456789ABCDEFGHJKMNPQRSTVWXYZ"
@code_length 8
@code_lifetime_seconds 1_800
@resend_after_seconds 60
@maximum_attempts 5
@typedoc "What the settings surface needs to render the address, and nothing more."
@type state :: %{address: String.t() | nil, verified?: boolean(), pending?: boolean()}
@typedoc "Why an address or a code was refused."
@type refusal ::
:not_deliverable
| :invalid_address
| :too_soon
| :nothing_pending
| :expired
| :incorrect_code
| :too_many_attempts
@doc """
The address this account may be mailed at, or `nil`.
Every outbound notification resolves its recipient here. An address that was
typed but never confirmed returns `nil`, which is what makes an unverified
address unreachable rather than merely discouraged.
"""
@spec verified_address(User.t() | nil) :: String.t() | nil
def verified_address(%User{notification_email: address, notification_email_verified_at: at})
when is_binary(address) and not is_nil(at),
do: address
def verified_address(_user), do: nil
@doc "Whether this deployment can send at all. See the module note on `deliverable?/0`."
@spec deliverable?() :: boolean()
def deliverable?, do: Keyword.get(configuration(), :deliverable, false)
@doc "The `{name, address}` every message this channel sends is from."
@spec from() :: {String.t(), String.t()}
def from, do: Keyword.fetch!(configuration(), :from)
@doc """
What the settings surface renders: the address, whether it is confirmed, and
whether a code is outstanding.
Never the code, and never its digest.
"""
@spec state(User.t()) :: state()
def state(%User{} = user) do
%{
address: user.notification_email,
verified?: not is_nil(user.notification_email_verified_at),
pending?: pending?(user)
}
end
@doc """
Records an address and mails a code to it.
The address is inert until the code comes back. Re-recording the address an
account already confirmed changes nothing rather than quietly unverifying it,
because retyping what you already proved is not a withdrawal of the proof.
"""
@spec set_address(User.t(), String.t()) :: {:ok, User.t()} | {:error, refusal()}
def set_address(%User{} = user, address) when is_binary(address) do
with :ok <- require_deliverable(),
{:ok, normalized} <- normalize(address) do
if normalized == verified_address(user) do
{:ok, user}
else
issue_code(user, normalized)
end
end
end
@doc """
Mails another code to the address already on the account.
Bounded by `#{@resend_after_seconds}` seconds since the last one, so the send
button cannot be turned into a way to mail somebody repeatedly. Issuing a new
code retires the old one and resets the attempt count: the person is asking
again, not guessing again.
"""
@spec resend_code(User.t()) :: {:ok, User.t()} | {:error, refusal()}
def resend_code(%User{notification_email: address} = user) when is_binary(address) do
with :ok <- require_deliverable(),
:ok <- require_resend_window(user),
do: issue_code(user, address)
end
def resend_code(%User{}), do: {:error, :nothing_pending}
@doc """
Confirms the address with the code that was mailed to it.
A correct code marks the address verified and clears the outstanding one, so
the same code cannot be replayed. A wrong one counts, and at the fifth the
code is retired entirely: the next step is a fresh send, not another guess.
The comparison is constant-time over digests, so a caller cannot learn the
code one character at a time.
"""
@spec verify(User.t(), String.t()) :: {:ok, User.t()} | {:error, refusal()}
def verify(%User{} = user, code) when is_binary(code) do
with :ok <- require_pending(user),
:ok <- require_unexpired(user),
:ok <- require_attempts_left(user) do
if Plug.Crypto.secure_compare(
user.notification_email_code_digest,
digest(normalize_code(code))
) do
confirm(user)
else
count_failure(user)
end
end
end
@doc """
Forgets the address, the verification, and any outstanding code.
One update rather than a soft delete: there is nothing here worth keeping
once the account has said to stop mailing it.
"""
@spec remove_address(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def remove_address(%User{} = user) do
update(user, %{
notification_email: nil,
notification_email_verified_at: nil,
notification_email_code_digest: nil,
notification_email_code_sent_at: nil,
notification_email_code_attempts: 0
})
end
## Internals
# The row and the message have to agree, and only one of them is
# transactional. Writing first is the order that cannot mail a code the
# database does not hold; rolling back on a refused send is what keeps the
# other direction from mattering, so a provider hiccup while changing an
# address does not leave the account with its previous verification quietly
# withdrawn.
defp issue_code(user, address) do
code = generate_code()
Repo.transaction(fn ->
case update(user, %{
notification_email: address,
notification_email_verified_at: nil,
notification_email_code_digest: digest(code),
notification_email_code_sent_at: DateTime.utc_now(),
notification_email_code_attempts: 0
}) do
{:ok, updated} ->
case Email.deliver_verification(address, code) do
{:ok, _delivery} -> updated
{:error, _reason} -> Repo.rollback(:not_deliverable)
end
{:error, %Ecto.Changeset{}} ->
Repo.rollback(:invalid_address)
end
end)
end
defp confirm(user) do
update(user, %{
notification_email_verified_at: DateTime.utc_now(),
notification_email_code_digest: nil,
notification_email_code_attempts: 0
})
end
defp count_failure(user) do
attempts = user.notification_email_code_attempts + 1
attributes =
if attempts >= @maximum_attempts do
%{notification_email_code_digest: nil, notification_email_code_attempts: attempts}
else
%{notification_email_code_attempts: attempts}
end
case update(user, attributes) do
{:ok, _updated} when attempts >= @maximum_attempts -> {:error, :too_many_attempts}
{:ok, _updated} -> {:error, :incorrect_code}
{:error, _changeset} -> {:error, :incorrect_code}
end
end
defp update(user, attributes) do
user
|> Ecto.Changeset.change(attributes)
|> Ecto.Changeset.check_constraint(:notification_email,
name: :users_notification_email_state_check
)
|> Repo.update()
end
defp require_deliverable do
if deliverable?(), do: :ok, else: {:error, :not_deliverable}
end
defp require_pending(user) do
if pending?(user), do: :ok, else: {:error, :nothing_pending}
end
defp require_unexpired(%User{notification_email_code_sent_at: sent_at}) do
if DateTime.diff(DateTime.utc_now(), sent_at) <= @code_lifetime_seconds do
:ok
else
{:error, :expired}
end
end
defp require_attempts_left(%User{notification_email_code_attempts: attempts}) do
if attempts < @maximum_attempts, do: :ok, else: {:error, :too_many_attempts}
end
defp require_resend_window(user) do
case resend_available_at(user) do
nil ->
:ok
available_at ->
if DateTime.compare(DateTime.utc_now(), available_at) == :lt do
{:error, :too_soon}
else
:ok
end
end
end
defp pending?(%User{notification_email_code_digest: digest}), do: is_binary(digest)
defp resend_available_at(%User{notification_email_code_sent_at: nil}), do: nil
defp resend_available_at(%User{notification_email_code_sent_at: sent_at}),
do: DateTime.add(sent_at, @resend_after_seconds, :second)
# Deliberately conservative, and deliberately not a full RFC 5322 grammar. The
# address is not being parsed, it is being refused early: one at-sign, no
# whitespace, a dot in the domain, and a length a column and a provider both
# accept. Anything this admits that the provider rejects fails at the send,
# which the outbox already handles.
defp normalize(address) do
normalized = address |> String.trim() |> String.downcase()
if String.match?(normalized, ~r/\A[^\s@]+@[^\s@.]+(\.[^\s@.]+)+\z/) and
String.length(normalized) <= 254 do
{:ok, normalized}
else
{:error, :invalid_address}
end
end
defp normalize_code(code), do: code |> String.trim() |> String.upcase()
defp generate_code do
size = length(@code_alphabet)
@code_length
|> :crypto.strong_rand_bytes()
|> :binary.bin_to_list()
|> Enum.map(&Enum.at(@code_alphabet, rem(&1, size)))
|> List.to_string()
end
defp digest(value), do: :crypto.hash(:sha256, value)
defp configuration, do: Application.get_env(:openagents, __MODULE__, [])
end