Skip to content

Commit 1b443fc

Browse files
authored
Merge pull request #219 from pentacent/feature/double-opt-in
Implement Double-Opt-In
2 parents 01c0c96 + 07b4c76 commit 1b443fc

File tree

69 files changed

+2140
-741
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+2140
-741
lines changed

Diff for: .devcontainer/Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Update the VARIANT arg in docker-compose.yml to pick an Elixir version: 1.9, 1.10, 1.10.4
2-
ARG VARIANT="1.14.4"
2+
ARG VARIANT="1.15.1"
33
FROM elixir:${VARIANT}
44

55
# This Dockerfile adds a non-root user with sudo access. Update the “remoteUser” property in

Diff for: .github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
strategy:
1515
matrix:
1616
include:
17-
- version: "1.14"
17+
- version: "1.15"
1818
services:
1919
postgres:
2020
image: postgres:13-alpine

Diff for: .github/workflows/release.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
matrix:
1515
include:
16-
- version: "1.14"
16+
- version: "1.15"
1717
services:
1818
postgres:
1919
image: postgres:13-alpine

Diff for: .tool-versions

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
elixir 1.15.6
1+
elixir 1.15.7-otp-26
22
erlang 26.0
33
dprint 0.36.0
44
nodejs lts

Diff for: config/test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ config :keila, KeilaWeb.Endpoint,
2525
server: false
2626

2727
# Print only warnings and errors during test
28-
config :logger, level: :warn
28+
config :logger, level: :warning
2929

3030
# Configure Swoosh
3131
config :keila, Keila.Mailer, adapter: Swoosh.Adapters.Test

Diff for: lib/keila/billing/billing.ex

+2
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,6 @@ defmodule Keila.Billing do
128128
def get_plan(paddle_plan_id) do
129129
Plans.all() |> Enum.find(&(&1.paddle_id == paddle_plan_id))
130130
end
131+
132+
defdelegate feature_available?(project_id, feature), to: __MODULE__.Features
131133
end

Diff for: lib/keila/billing/features.ex

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
defmodule Keila.Billing.Features do
2+
@moduledoc """
3+
This module provides the `feature_available?/2` function to determine whether
4+
a certain feature is covered by the current plan of a project’s account on
5+
managed Keila.
6+
"""
7+
8+
alias Keila.Accounts
9+
alias Keila.Billing
10+
alias Keila.Projects.Project
11+
12+
@features ~w[double_opt_in]a
13+
@type feature :: :double_opt_in
14+
15+
@doc """
16+
Returns `true` if the given feature is available for the specified project.
17+
18+
Always returns `true` if Billing is not enabled.
19+
"""
20+
@spec feature_available?(Project.id(), feature) :: boolean()
21+
def feature_available?(project_id, feature) when feature in @features do
22+
if Billing.billing_enabled?() do
23+
account = Accounts.get_project_account(project_id)
24+
do_feature_available?(account, feature)
25+
else
26+
true
27+
end
28+
end
29+
30+
defp do_feature_available?(account, :double_opt_in) do
31+
case Accounts.get_credits(account.id) do
32+
{n, _} when n > 0 -> true
33+
_other -> false
34+
end
35+
end
36+
end

Diff for: lib/keila/contacts/contacts.ex

+74-12
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule Keila.Contacts do
66
"""
77
use Keila.Repo
88
alias Keila.Projects.Project
9-
alias __MODULE__.{Contact, Import, Form, Segment}
9+
alias __MODULE__.{Contact, Import, Form, FormParams, Segment}
1010
import KeilaWeb.Gettext
1111

1212
@doc """
@@ -20,17 +20,8 @@ defmodule Keila.Contacts do
2020
|> Repo.insert()
2121
end
2222

23-
@doc """
24-
Creates a new Contact within the given Project with dynamic casts and
25-
validations based on the given form.
26-
"""
27-
@spec create_contact_from_form(Form.t(), map()) ::
28-
{:ok, Contact.t()} | {:error, Changeset.t(Contact.t())}
29-
def create_contact_from_form(form, params) do
30-
params
31-
|> Contact.changeset_from_form(form)
32-
|> Repo.insert()
33-
end
23+
defdelegate perform_form_action(form, params, opts), to: __MODULE__.FormActionHandler
24+
defdelegate perform_form_action(form, params), to: __MODULE__.FormActionHandler
3425

3526
@doc """
3627
Updates the specified Contact.
@@ -303,6 +294,77 @@ defmodule Keila.Contacts do
303294
:ok
304295
end
305296

297+
@doc """
298+
Creates a new `FormParams` entity for the given `Form` ID and `attrs` map.
299+
`FormParams` are used to implement the double opt-in mechanism; they are an
300+
intermediate storage for the attributes submitted by a contact who has
301+
submitted a signup form.
302+
"""
303+
@spec create_form_params(Form.id(), map()) ::
304+
{:ok, FormParams.t()} | {:error, Changeset.t(FormParams.t())}
305+
def create_form_params(form_id, attrs) do
306+
FormParams.changeset(form_id, attrs)
307+
|> Repo.insert()
308+
end
309+
310+
@doc """
311+
Returns the `FormParams` entity for the given `id`. Returns `nil` if no such
312+
entity exists.
313+
"""
314+
@spec get_form_params(FormParams.id()) :: FormParams.t() | nil
315+
def get_form_params(id) do
316+
Repo.get(FormParams, id)
317+
end
318+
319+
@doc """
320+
Retrieves, deletes, and returns the `FormParams` entity with the given `id`.
321+
Returns `nil` if no such entity exists.
322+
"""
323+
@spec get_and_delete_form_params(FormParams.id()) :: FormParams.t() | nil
324+
def get_and_delete_form_params(id) do
325+
from(fa in FormParams, where: fa.id == ^id, select: fa)
326+
|> Repo.delete_all()
327+
|> case do
328+
{1, [form_params]} -> form_params
329+
_ -> nil
330+
end
331+
end
332+
333+
@doc """
334+
Deletes the `FormParams` entity with the given `id`. Always returns `:ok`.
335+
"""
336+
@spec delete_form_params(FormParams.id()) :: :ok
337+
def delete_form_params(id) do
338+
from(fa in FormParams, where: fa.id == ^id)
339+
|> Repo.delete_all()
340+
341+
:ok
342+
end
343+
344+
@doc """
345+
Returns an HMAC string for the given `FormParams` ID that can be
346+
used when verifying a contact in the double opt-in process.
347+
"""
348+
@spec double_opt_in_hmac(Form.id(), FormParams.id()) :: String.t()
349+
def double_opt_in_hmac(form_id, form_params_id) do
350+
key = Application.get_env(:keila, KeilaWeb.Endpoint) |> Keyword.fetch!(:secret_key_base)
351+
message = "double-opt-in:" <> form_id <> ":" <> form_params_id
352+
353+
:crypto.mac(:hmac, :sha256, key, message)
354+
|> Base.url_encode64(padding: false)
355+
end
356+
357+
@doc """
358+
Verifies a HMAC string for the given `FormParams` ID.
359+
"""
360+
@spec valid_double_opt_in_hmac?(String.t(), Form.id(), FormParams.id()) :: boolean()
361+
def valid_double_opt_in_hmac?(hmac, form_id, form_params_id) do
362+
case double_opt_in_hmac(form_id, form_params_id) do
363+
^hmac -> true
364+
_other -> false
365+
end
366+
end
367+
306368
@doc """
307369
Updates the status of a Contact.
308370

Diff for: lib/keila/contacts/form_action_handler.ex

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
defmodule Keila.Contacts.FormActionHandler do
2+
@moduledoc """
3+
Module to handle the submission for a Form.
4+
"""
5+
6+
use Keila.Repo
7+
alias Keila.Contacts
8+
alias Keila.Contacts.Contact
9+
alias Keila.Contacts.FormParams
10+
alias Keila.Mailings.SendDoubleOptInMailWorker
11+
12+
@doc """
13+
Creates a new Contact within the given Project with dynamic casts and
14+
validations based on the given form.
15+
16+
If the Form settings specify that Double Opt-in is required for form contacts,
17+
creates a `FormParams` entity instead and sends the opt-in email.
18+
19+
## Options:
20+
- `:changeset_transform` - function to transform the changeset before
21+
`Repo.insert/1` is called. This is primarily used to add CAPTCHA checking
22+
from the controller layer
23+
"""
24+
@spec perform_form_action(Form.t(), map()) ::
25+
{:ok, Contact.t() | FormParams.t()} | {:error, Changeset.t(Contact.t())}
26+
def perform_form_action(form, params, opts \\ []) do
27+
changeset_transform = Keyword.get(opts, :changeset_transform, & &1)
28+
29+
params
30+
|> Contact.changeset_from_form(form)
31+
|> changeset_transform.()
32+
|> Repo.insert()
33+
|> case do
34+
{:ok, contact} ->
35+
{:ok, contact}
36+
37+
{:error, changeset = %{errors: [double_opt_in: {"HMAC missing", _}]}} ->
38+
create_form_params_from_changeset(form, changeset)
39+
40+
{:error, changeset} ->
41+
{:error, changeset}
42+
end
43+
end
44+
45+
defp create_form_params_from_changeset(form, changeset) do
46+
{:ok, form_params} = Contacts.create_form_params(form.id, changeset.changes)
47+
48+
SendDoubleOptInMailWorker.new(%{"form_params_id" => form_params.id})
49+
|> Oban.insert()
50+
51+
{:ok, form_params}
52+
end
53+
end

Diff for: lib/keila/contacts/query.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ defmodule Keila.Contacts.Query do
3838

3939
@type opts :: {:filter, map()} | {:sort, map()}
4040

41-
@fields ["id", "email", "inserted_at", "first_name", "last_name", "status"]
41+
@fields ["id", "email", "inserted_at", "first_name", "last_name", "status", "double_opt_in_at"]
4242

4343
@spec apply(Ecto.Query.t(), [opts]) :: Ecto.Query.t()
4444
def apply(query, opts) do

Diff for: lib/keila/contacts/schemas/contact.ex

+37
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ defmodule Keila.Contacts.Contact do
1414
field(:last_name, :string)
1515
field(:status, Ecto.Enum, values: @statuses, default: :active)
1616
field(:data, Keila.Repo.JsonField)
17+
field(:double_opt_in_at, :utc_datetime)
1718
belongs_to(:project, Keila.Projects.Project, type: Keila.Projects.Project.Id)
1819
timestamps()
1920
end
@@ -34,10 +35,14 @@ defmodule Keila.Contacts.Contact do
3435
|> cast(params, [:email, :first_name, :last_name, :data])
3536
|> validate_email()
3637
|> check_data_size_constraint()
38+
|> maybe_remove_double_opt_in_at()
3739
end
3840

3941
@spec changeset_from_form(t(), Ecto.Changeset.data(), Form.t()) :: Ecto.Changeset.t(t())
4042
def changeset_from_form(struct \\ %__MODULE__{}, params, form) do
43+
form_params_id = get_param(params, :form_params_id)
44+
double_opt_in_hmac = get_param(params, :double_opt_in_hmac)
45+
4146
cast_fields =
4247
form.field_settings
4348
|> Enum.filter(& &1.cast)
@@ -52,9 +57,14 @@ defmodule Keila.Contacts.Contact do
5257
|> cast(params, cast_fields)
5358
|> validate_dynamic_required(required_fields)
5459
|> validate_email()
60+
|> validate_double_opt_in(form, form_params_id, double_opt_in_hmac)
5561
|> put_change(:project_id, form.project_id)
5662
end
5763

64+
defp get_param(params, param) do
65+
params[param] || params[to_string(param)]
66+
end
67+
5868
defp validate_dynamic_required(changeset, required_fields)
5969
defp validate_dynamic_required(changeset, []), do: changeset
6070
defp validate_dynamic_required(changeset, fields), do: validate_required(changeset, fields)
@@ -75,4 +85,31 @@ defmodule Keila.Contacts.Contact do
7585
|> validate_length(:email, max: 255)
7686
|> unique_constraint([:email, :project_id])
7787
end
88+
89+
defp maybe_remove_double_opt_in_at(changeset) do
90+
changed_email = get_change(changeset, :email)
91+
current_email = changeset.data.email
92+
93+
if not is_nil(changed_email) and not is_nil(current_email) and changed_email != current_email do
94+
put_change(changeset, :double_opt_in_at, nil)
95+
else
96+
changeset
97+
end
98+
end
99+
100+
defp validate_double_opt_in(changeset, %{settings: %{double_opt_in_required: false}}, _, _),
101+
do: changeset
102+
103+
defp validate_double_opt_in(changeset, form, form_params_id, hmac)
104+
when is_binary(form_params_id) and is_binary(hmac) do
105+
if Keila.Contacts.valid_double_opt_in_hmac?(hmac, form.id, form_params_id) do
106+
put_change(changeset, :double_opt_in_at, DateTime.utc_now(:second))
107+
else
108+
add_error(changeset, :double_opt_in, "Invalid HMAC")
109+
end
110+
end
111+
112+
defp validate_double_opt_in(changeset, _, _, _) do
113+
add_error(changeset, :double_opt_in, "HMAC missing")
114+
end
78115
end

Diff for: lib/keila/contacts/schemas/form.ex

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
defmodule Keila.Contacts.Form do
22
use Keila.Schema, prefix: "frm"
3+
alias Keila.Templates.Template
4+
alias Keila.Mailings.Sender
35

46
schema "contacts_forms" do
57
belongs_to(:project, Keila.Projects.Project, type: Keila.Projects.Project.Id)
@@ -8,20 +10,28 @@ defmodule Keila.Contacts.Form do
810
embeds_one(:settings, Keila.Contacts.Form.Settings)
911
embeds_many(:field_settings, Keila.Contacts.Form.FieldSettings)
1012

13+
# Double opt-in properties
14+
belongs_to(:sender, Sender, type: Sender.Id)
15+
belongs_to(:template, Template, type: Template.Id)
16+
1117
timestamps()
1218
end
1319

1420
def creation_changeset(struct \\ %__MODULE__{}, params) do
1521
struct
16-
|> cast(params, [:name, :project_id])
22+
|> cast(params, [:name, :project_id, :sender_id, :template_id])
1723
|> cast_embed(:settings)
1824
|> cast_embed(:field_settings)
25+
|> validate_assoc_project(:sender, Sender)
26+
|> validate_assoc_project(:template, Template)
1927
end
2028

2129
def update_changeset(struct \\ %__MODULE__{}, params) do
2230
struct
23-
|> cast(params, [:name])
31+
|> cast(params, [:name, :sender_id, :template_id])
2432
|> cast_embed(:settings)
2533
|> cast_embed(:field_settings)
34+
|> validate_assoc_project(:sender, Sender)
35+
|> validate_assoc_project(:template, Template)
2636
end
2737
end

Diff for: lib/keila/contacts/schemas/form_params.ex

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
defmodule Keila.Contacts.FormParams do
2+
@moduledoc """
3+
FormParams hold the parameters for a `Keila.Contacts.Form` to be submitted
4+
again after a double opt-in process.
5+
"""
6+
use Keila.Schema, prefix: "f_attr"
7+
alias Keila.Contacts.Form
8+
9+
@expiry_in_days 60
10+
11+
schema "contacts_form_params" do
12+
field :params, :map
13+
field :expires_at, :utc_datetime
14+
belongs_to :form, Form, type: Form.Id
15+
timestamps()
16+
end
17+
18+
def changeset(struct \\ %__MODULE__{}, form_id, params) do
19+
struct
20+
|> change()
21+
|> put_change(:form_id, form_id)
22+
|> put_change(:params, params)
23+
|> maybe_put_expires_at()
24+
end
25+
26+
defp maybe_put_expires_at(changeset) do
27+
case get_field(changeset, :expires_at) do
28+
nil -> put_change(changeset, :expires_at, expires_at())
29+
_ -> changeset
30+
end
31+
end
32+
33+
defp expires_at() do
34+
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.add(@expiry_in_days, :day)
35+
end
36+
end

0 commit comments

Comments
 (0)