|
1
|
+ |
defmodule OpenAgents.Notifications.EmailChannel do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
The address an account is willing to receive mail at, and the proof of it.
|
|
4
|
+ |
|
|
5
|
+ |
## Why the address is typed rather than taken
|
|
6
|
+ |
|
|
7
|
+ |
GitHub OAuth can be asked for `user:email` and hand over whatever address the
|
|
8
|
+ |
provider holds. This deployment does not ask. An address taken from a
|
|
9
|
+ |
provider is an address nobody chose to give this application, and the first
|
|
10
|
+ |
thing it would be used for is unsolicited mail to a mailbox its owner never
|
|
11
|
+ |
named here. So the address is typed into the notification settings, by the
|
|
12
|
+ |
person who wants mail, on purpose.
|
|
13
|
+ |
|
|
14
|
+ |
## The gate
|
|
15
|
+ |
|
|
16
|
+ |
`verified_address/1` is the only function anything sends to, and it returns
|
|
17
|
+ |
`nil` unless the account both names an address and confirmed it. Confirmation
|
|
18
|
+ |
is a code mailed to the address and typed back, which is the only evidence
|
|
19
|
+ |
this application can have that the person asking controls the mailbox.
|
|
20
|
+ |
|
|
21
|
+ |
Three things make the gate hold rather than merely exist:
|
|
22
|
+ |
|
|
23
|
+ |
* The code is held as a SHA-256 digest. A database read is not enough to
|
|
24
|
+ |
claim somebody else's mailbox.
|
|
25
|
+ |
* Guesses are counted and bounded at five. A short code with free guesses
|
|
26
|
+ |
is not a secret.
|
|
27
|
+ |
* The check constraint `users_notification_email_state_check` refuses a
|
|
28
|
+ |
verified timestamp on a row with no address, so the gate survives a bug
|
|
29
|
+ |
in this module.
|
|
30
|
+ |
|
|
31
|
+ |
Changing the address clears the verification, because the evidence was about
|
|
32
|
+ |
the old mailbox. Removing it clears everything, including the outstanding
|
|
33
|
+ |
code.
|
|
34
|
+ |
|
|
35
|
+ |
## What a deployment without a mail provider does
|
|
36
|
+ |
|
|
37
|
+ |
`deliverable?/0` reads one configuration key rather than inferring from the
|
|
38
|
+ |
Swoosh adapter, because the inference is wrong in both directions: the local
|
|
39
|
+ |
adapter is real delivery in development — the mailbox preview at
|
|
40
|
+ |
`/dev/mailbox` — and it is a black hole in production. A deployment that
|
|
41
|
+ |
configures no provider says so, and the settings surface offers no address
|
|
42
|
+ |
field rather than accepting one it cannot mail to.
|
|
43
|
+ |
"""
|
|
44
|
+ |
|
|
45
|
+ |
alias OpenAgents.Accounts.User
|
|
46
|
+ |
alias OpenAgents.Notifications.Email
|
|
47
|
+ |
alias OpenAgents.Repo
|
|
48
|
+ |
|
|
49
|
+ |
# Long enough that five guesses are hopeless, short enough to retype from a
|
|
50
|
+ |
# phone. Crockford's alphabet without I, L, O and U: no character in it can
|
|
51
|
+ |
# be confused with another in a proportional font, and none of them spell
|
|
52
|
+ |
# anything.
|
|
53
|
+ |
@code_alphabet ~c"0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
54
|
+ |
@code_length 8
|
|
55
|
+ |
|
|
56
|
+ |
@code_lifetime_seconds 1_800
|
|
57
|
+ |
@resend_after_seconds 60
|
|
58
|
+ |
@maximum_attempts 5
|
|
59
|
+ |
|
|
60
|
+ |
@typedoc "What the settings surface needs to render the address, and nothing more."
|
|
61
|
+ |
@type state :: %{address: String.t() | nil, verified?: boolean(), pending?: boolean()}
|
|
62
|
+ |
|
|
63
|
+ |
@typedoc "Why an address or a code was refused."
|
|
64
|
+ |
@type refusal ::
|
|
65
|
+ |
:not_deliverable
|
|
66
|
+ |
| :invalid_address
|
|
67
|
+ |
| :too_soon
|
|
68
|
+ |
| :nothing_pending
|
|
69
|
+ |
| :expired
|
|
70
|
+ |
| :incorrect_code
|
|
71
|
+ |
| :too_many_attempts
|
|
72
|
+ |
|
|
73
|
+ |
@doc """
|
|
74
|
+ |
The address this account may be mailed at, or `nil`.
|
|
75
|
+ |
|
|
76
|
+ |
Every outbound notification resolves its recipient here. An address that was
|
|
77
|
+ |
typed but never confirmed returns `nil`, which is what makes an unverified
|
|
78
|
+ |
address unreachable rather than merely discouraged.
|
|
79
|
+ |
"""
|
|
80
|
+ |
@spec verified_address(User.t() | nil) :: String.t() | nil
|
|
81
|
+ |
def verified_address(%User{notification_email: address, notification_email_verified_at: at})
|
|
82
|
+ |
when is_binary(address) and not is_nil(at),
|
|
83
|
+ |
do: address
|
|
84
|
+ |
|
|
85
|
+ |
def verified_address(_user), do: nil
|
|
86
|
+ |
|
|
87
|
+ |
@doc "Whether this deployment can send at all. See the module note on `deliverable?/0`."
|
|
88
|
+ |
@spec deliverable?() :: boolean()
|
|
89
|
+ |
def deliverable?, do: Keyword.get(configuration(), :deliverable, false)
|
|
90
|
+ |
|
|
91
|
+ |
@doc "The `{name, address}` every message this channel sends is from."
|
|
92
|
+ |
@spec from() :: {String.t(), String.t()}
|
|
93
|
+ |
def from, do: Keyword.fetch!(configuration(), :from)
|
|
94
|
+ |
|
|
95
|
+ |
@doc """
|
|
96
|
+ |
What the settings surface renders: the address, whether it is confirmed, and
|
|
97
|
+ |
whether a code is outstanding.
|
|
98
|
+ |
|
|
99
|
+ |
Never the code, and never its digest.
|
|
100
|
+ |
"""
|
|
101
|
+ |
@spec state(User.t()) :: state()
|
|
102
|
+ |
def state(%User{} = user) do
|
|
103
|
+ |
%{
|
|
104
|
+ |
address: user.notification_email,
|
|
105
|
+ |
verified?: not is_nil(user.notification_email_verified_at),
|
|
106
|
+ |
pending?: pending?(user)
|
|
107
|
+ |
}
|
|
108
|
+ |
end
|
|
109
|
+ |
|
|
110
|
+ |
@doc """
|
|
111
|
+ |
Records an address and mails a code to it.
|
|
112
|
+ |
|
|
113
|
+ |
The address is inert until the code comes back. Re-recording the address an
|
|
114
|
+ |
account already confirmed changes nothing rather than quietly unverifying it,
|
|
115
|
+ |
because retyping what you already proved is not a withdrawal of the proof.
|
|
116
|
+ |
"""
|
|
117
|
+ |
@spec set_address(User.t(), String.t()) :: {:ok, User.t()} | {:error, refusal()}
|
|
118
|
+ |
def set_address(%User{} = user, address) when is_binary(address) do
|
|
119
|
+ |
with :ok <- require_deliverable(),
|
|
120
|
+ |
{:ok, normalized} <- normalize(address) do
|
|
121
|
+ |
if normalized == verified_address(user) do
|
|
122
|
+ |
{:ok, user}
|
|
123
|
+ |
else
|
|
124
|
+ |
issue_code(user, normalized)
|
|
125
|
+ |
end
|
|
126
|
+ |
end
|
|
127
|
+ |
end
|
|
128
|
+ |
|
|
129
|
+ |
@doc """
|
|
130
|
+ |
Mails another code to the address already on the account.
|
|
131
|
+ |
|
|
132
|
+ |
Bounded by `#{@resend_after_seconds}` seconds since the last one, so the send
|
|
133
|
+ |
button cannot be turned into a way to mail somebody repeatedly. Issuing a new
|
|
134
|
+ |
code retires the old one and resets the attempt count: the person is asking
|
|
135
|
+ |
again, not guessing again.
|
|
136
|
+ |
"""
|
|
137
|
+ |
@spec resend_code(User.t()) :: {:ok, User.t()} | {:error, refusal()}
|
|
138
|
+ |
def resend_code(%User{notification_email: address} = user) when is_binary(address) do
|
|
139
|
+ |
with :ok <- require_deliverable(),
|
|
140
|
+ |
:ok <- require_resend_window(user),
|
|
141
|
+ |
do: issue_code(user, address)
|
|
142
|
+ |
end
|
|
143
|
+ |
|
|
144
|
+ |
def resend_code(%User{}), do: {:error, :nothing_pending}
|
|
145
|
+ |
|
|
146
|
+ |
@doc """
|
|
147
|
+ |
Confirms the address with the code that was mailed to it.
|
|
148
|
+ |
|
|
149
|
+ |
A correct code marks the address verified and clears the outstanding one, so
|
|
150
|
+ |
the same code cannot be replayed. A wrong one counts, and at the fifth the
|
|
151
|
+ |
code is retired entirely: the next step is a fresh send, not another guess.
|
|
152
|
+ |
|
|
153
|
+ |
The comparison is constant-time over digests, so a caller cannot learn the
|
|
154
|
+ |
code one character at a time.
|
|
155
|
+ |
"""
|
|
156
|
+ |
@spec verify(User.t(), String.t()) :: {:ok, User.t()} | {:error, refusal()}
|
|
157
|
+ |
def verify(%User{} = user, code) when is_binary(code) do
|
|
158
|
+ |
with :ok <- require_pending(user),
|
|
159
|
+ |
:ok <- require_unexpired(user),
|
|
160
|
+ |
:ok <- require_attempts_left(user) do
|
|
161
|
+ |
if Plug.Crypto.secure_compare(
|
|
162
|
+ |
user.notification_email_code_digest,
|
|
163
|
+ |
digest(normalize_code(code))
|
|
164
|
+ |
) do
|
|
165
|
+ |
confirm(user)
|
|
166
|
+ |
else
|
|
167
|
+ |
count_failure(user)
|
|
168
|
+ |
end
|
|
169
|
+ |
end
|
|
170
|
+ |
end
|
|
171
|
+ |
|
|
172
|
+ |
@doc """
|
|
173
|
+ |
Forgets the address, the verification, and any outstanding code.
|
|
174
|
+ |
|
|
175
|
+ |
One update rather than a soft delete: there is nothing here worth keeping
|
|
176
|
+ |
once the account has said to stop mailing it.
|
|
177
|
+ |
"""
|
|
178
|
+ |
@spec remove_address(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
179
|
+ |
def remove_address(%User{} = user) do
|
|
180
|
+ |
update(user, %{
|
|
181
|
+ |
notification_email: nil,
|
|
182
|
+ |
notification_email_verified_at: nil,
|
|
183
|
+ |
notification_email_code_digest: nil,
|
|
184
|
+ |
notification_email_code_sent_at: nil,
|
|
185
|
+ |
notification_email_code_attempts: 0
|
|
186
|
+ |
})
|
|
187
|
+ |
end
|
|
188
|
+ |
|
|
189
|
+ |
## Internals
|
|
190
|
+ |
|
|
191
|
+ |
# The row and the message have to agree, and only one of them is
|
|
192
|
+ |
# transactional. Writing first is the order that cannot mail a code the
|
|
193
|
+ |
# database does not hold; rolling back on a refused send is what keeps the
|
|
194
|
+ |
# other direction from mattering, so a provider hiccup while changing an
|
|
195
|
+ |
# address does not leave the account with its previous verification quietly
|
|
196
|
+ |
# withdrawn.
|
|
197
|
+ |
defp issue_code(user, address) do
|
|
198
|
+ |
code = generate_code()
|
|
199
|
+ |
|
|
200
|
+ |
Repo.transaction(fn ->
|
|
201
|
+ |
case update(user, %{
|
|
202
|
+ |
notification_email: address,
|
|
203
|
+ |
notification_email_verified_at: nil,
|
|
204
|
+ |
notification_email_code_digest: digest(code),
|
|
205
|
+ |
notification_email_code_sent_at: DateTime.utc_now(),
|
|
206
|
+ |
notification_email_code_attempts: 0
|
|
207
|
+ |
}) do
|
|
208
|
+ |
{:ok, updated} ->
|
|
209
|
+ |
case Email.deliver_verification(address, code) do
|
|
210
|
+ |
{:ok, _delivery} -> updated
|
|
211
|
+ |
{:error, _reason} -> Repo.rollback(:not_deliverable)
|
|
212
|
+ |
end
|
|
213
|
+ |
|
|
214
|
+ |
{:error, %Ecto.Changeset{}} ->
|
|
215
|
+ |
Repo.rollback(:invalid_address)
|
|
216
|
+ |
end
|
|
217
|
+ |
end)
|
|
218
|
+ |
end
|
|
219
|
+ |
|
|
220
|
+ |
defp confirm(user) do
|
|
221
|
+ |
update(user, %{
|
|
222
|
+ |
notification_email_verified_at: DateTime.utc_now(),
|
|
223
|
+ |
notification_email_code_digest: nil,
|
|
224
|
+ |
notification_email_code_attempts: 0
|
|
225
|
+ |
})
|
|
226
|
+ |
end
|
|
227
|
+ |
|
|
228
|
+ |
defp count_failure(user) do
|
|
229
|
+ |
attempts = user.notification_email_code_attempts + 1
|
|
230
|
+ |
|
|
231
|
+ |
attributes =
|
|
232
|
+ |
if attempts >= @maximum_attempts do
|
|
233
|
+ |
%{notification_email_code_digest: nil, notification_email_code_attempts: attempts}
|
|
234
|
+ |
else
|
|
235
|
+ |
%{notification_email_code_attempts: attempts}
|
|
236
|
+ |
end
|
|
237
|
+ |
|
|
238
|
+ |
case update(user, attributes) do
|
|
239
|
+ |
{:ok, _updated} when attempts >= @maximum_attempts -> {:error, :too_many_attempts}
|
|
240
|
+ |
{:ok, _updated} -> {:error, :incorrect_code}
|
|
241
|
+ |
{:error, _changeset} -> {:error, :incorrect_code}
|
|
242
|
+ |
end
|
|
243
|
+ |
end
|
|
244
|
+ |
|
|
245
|
+ |
defp update(user, attributes) do
|
|
246
|
+ |
user
|
|
247
|
+ |
|> Ecto.Changeset.change(attributes)
|
|
248
|
+ |
|> Ecto.Changeset.check_constraint(:notification_email,
|
|
249
|
+ |
name: :users_notification_email_state_check
|
|
250
|
+ |
)
|
|
251
|
+ |
|> Repo.update()
|
|
252
|
+ |
end
|
|
253
|
+ |
|
|
254
|
+ |
defp require_deliverable do
|
|
255
|
+ |
if deliverable?(), do: :ok, else: {:error, :not_deliverable}
|
|
256
|
+ |
end
|
|
257
|
+ |
|
|
258
|
+ |
defp require_pending(user) do
|
|
259
|
+ |
if pending?(user), do: :ok, else: {:error, :nothing_pending}
|
|
260
|
+ |
end
|
|
261
|
+ |
|
|
262
|
+ |
defp require_unexpired(%User{notification_email_code_sent_at: sent_at}) do
|
|
263
|
+ |
if DateTime.diff(DateTime.utc_now(), sent_at) <= @code_lifetime_seconds do
|
|
264
|
+ |
:ok
|
|
265
|
+ |
else
|
|
266
|
+ |
{:error, :expired}
|
|
267
|
+ |
end
|
|
268
|
+ |
end
|
|
269
|
+ |
|
|
270
|
+ |
defp require_attempts_left(%User{notification_email_code_attempts: attempts}) do
|
|
271
|
+ |
if attempts < @maximum_attempts, do: :ok, else: {:error, :too_many_attempts}
|
|
272
|
+ |
end
|
|
273
|
+ |
|
|
274
|
+ |
defp require_resend_window(user) do
|
|
275
|
+ |
case resend_available_at(user) do
|
|
276
|
+ |
nil ->
|
|
277
|
+ |
:ok
|
|
278
|
+ |
|
|
279
|
+ |
available_at ->
|
|
280
|
+ |
if DateTime.compare(DateTime.utc_now(), available_at) == :lt do
|
|
281
|
+ |
{:error, :too_soon}
|
|
282
|
+ |
else
|
|
283
|
+ |
:ok
|
|
284
|
+ |
end
|
|
285
|
+ |
end
|
|
286
|
+ |
end
|
|
287
|
+ |
|
|
288
|
+ |
defp pending?(%User{notification_email_code_digest: digest}), do: is_binary(digest)
|
|
289
|
+ |
|
|
290
|
+ |
defp resend_available_at(%User{notification_email_code_sent_at: nil}), do: nil
|
|
291
|
+ |
|
|
292
|
+ |
defp resend_available_at(%User{notification_email_code_sent_at: sent_at}),
|
|
293
|
+ |
do: DateTime.add(sent_at, @resend_after_seconds, :second)
|
|
294
|
+ |
|
|
295
|
+ |
# Deliberately conservative, and deliberately not a full RFC 5322 grammar. The
|
|
296
|
+ |
# address is not being parsed, it is being refused early: one at-sign, no
|
|
297
|
+ |
# whitespace, a dot in the domain, and a length a column and a provider both
|
|
298
|
+ |
# accept. Anything this admits that the provider rejects fails at the send,
|
|
299
|
+ |
# which the outbox already handles.
|
|
300
|
+ |
defp normalize(address) do
|
|
301
|
+ |
normalized = address |> String.trim() |> String.downcase()
|
|
302
|
+ |
|
|
303
|
+ |
if String.match?(normalized, ~r/\A[^\s@]+@[^\s@.]+(\.[^\s@.]+)+\z/) and
|
|
304
|
+ |
String.length(normalized) <= 254 do
|
|
305
|
+ |
{:ok, normalized}
|
|
306
|
+ |
else
|
|
307
|
+ |
{:error, :invalid_address}
|
|
308
|
+ |
end
|
|
309
|
+ |
end
|
|
310
|
+ |
|
|
311
|
+ |
defp normalize_code(code), do: code |> String.trim() |> String.upcase()
|
|
312
|
+ |
|
|
313
|
+ |
defp generate_code do
|
|
314
|
+ |
size = length(@code_alphabet)
|
|
315
|
+ |
|
|
316
|
+ |
@code_length
|
|
317
|
+ |
|> :crypto.strong_rand_bytes()
|
|
318
|
+ |
|> :binary.bin_to_list()
|
|
319
|
+ |
|> Enum.map(&Enum.at(@code_alphabet, rem(&1, size)))
|
|
320
|
+ |
|> List.to_string()
|
|
321
|
+ |
end
|
|
322
|
+ |
|
|
323
|
+ |
defp digest(value), do: :crypto.hash(:sha256, value)
|
|
324
|
+ |
|
|
325
|
+ |
defp configuration, do: Application.get_env(:openagents, __MODULE__, [])
|
|
326
|
+ |
end
|