diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d067b783..9635718f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,3 +107,11 @@ In `prod` mode (or in the Docker container), Cannery will listen for the same en - `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated with `docker run -it shibaobun/cannery mix phx.gen.secret` and set for server to start. +- `SMTP_HOST`: The url for your SMTP email provider. Must be set +- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. +- `SMTP_USERNAME`: The username for your SMTP relay. Must be set! +- `SMTP_PASSWORD`: The password for your SMTP relay. Must be set! +- `SMTP_SSL`: Set to `true` to enable SSL for emails. Defaults to `false`. +- `EMAIL_FROM`: Sets the sender email in sent emails. Defaults to + `no-reply@HOST` where `HOST` was previously defined. +- `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "Cannery". diff --git a/README.md b/README.md index 8ebd1ae2..b574f3ff 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ You can use the following environment variables to configure Cannery in - `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`. - `LOCALE`: Sets a custom locale. Defaults to `en_US`. +- `SMTP_HOST`: The url for your SMTP email provider. Must be set +- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. +- `SMTP_USERNAME`: The username for your SMTP relay. Must be set! +- `SMTP_PASSWORD`: The password for your SMTP relay. Must be set! +- `SMTP_SSL`: Set to `true` to enable SSL for emails. Defaults to `false`. +- `EMAIL_FROM`: Sets the sender email in sent emails. Defaults to + `no-reply@HOST` where `HOST` was previously defined. +- `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "Cannery". # Contribution diff --git a/config/config.exs b/config/config.exs index 3083f460..bada0d66 100644 --- a/config/config.exs +++ b/config/config.exs @@ -43,6 +43,12 @@ config :swoosh, :api_client, false # Gettext config :gettext, :default_locale, "en_US" +# Configure Oban +config :cannery, Oban, + repo: Cannery.Repo, + plugins: [Oban.Plugins.Pruner], + queues: [default: 10, mailers: 20] + # Configure esbuild (the version is required) # config :esbuild, # version: "0.14.0", diff --git a/config/runtime.exs b/config/runtime.exs index 03e51c87..5de081a4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -26,7 +26,9 @@ database_url = "ecto://postgres:postgres@cannery-db/cannery" end -host = System.get_env("HOST") || "localhost" +host = + System.get_env("HOST") || + raise "No hostname set! Must be the domain and tld like `cannery.bubbletea.dev`." interface = if config_env() in [:dev, :test], @@ -65,6 +67,17 @@ if config_env() == :prod do config :cannery, CanneryWeb.Endpoint, secret_key_base: secret_key_base + # Set up SMTP settings + config :cannery, Cannery.Mailer, + adapter: Swoosh.Adapters.SMTP, + relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"), + port: System.get_env("SMTP_PORT") || 587, + username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"), + password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"), + ssl: System.get_env("SMTP_SSL") == "true", + email_from: System.get_env("EMAIL_FROM") || "no-reply@#{System.get_env("HOST")}", + email_name: System.get_env("EMAIL_NAME") || "Cannery" + # ## Using releases # # If you are doing OTP releases, you need to instruct Phoenix diff --git a/config/test.exs b/config/test.exs index 24fae83e..718ffc75 100644 --- a/config/test.exs +++ b/config/test.exs @@ -27,3 +27,6 @@ config :logger, level: :warn # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime + +# Disable Oban +config :cannery, Oban, queues: false, plugins: false diff --git a/docker-compose.yml b/docker-compose.yml index f97dace4..dffd4b91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,15 @@ services: - DATABASE_URL="ecto://postgres:postgres@cannery-db/cannery" # Use `docker run -it shibaobun/cannery mix phx.gen.secret` to generate a secret key base - SECRET_KEY_BASE="change-me-this-is-really-important-seriously-change-it" - # uncomment to enable sign ups, watch for spam! + # uncomment to enable public sign ups, not recommended # - REGISTRATION="public" + # - SMTP_HOST="cannery.example.tld" # must be set! + # - SMTP_PORT="587" # optional + # - SMTP_USERNAME="username" + # - SMTP_PASSWORD="password" + # - SMTP_SSL="false" # optional + # - EMAIL_FROM="no-reply@cannery.example.tld" # optional + # - EMAIL_NAME="Cannery" # optional expose: - "4000" depends_on: diff --git a/lib/cannery/accounts.ex b/lib/cannery/accounts.ex index a1b1f064..ba31af49 100644 --- a/lib/cannery/accounts.ex +++ b/lib/cannery/accounts.ex @@ -5,7 +5,8 @@ defmodule Cannery.Accounts do import Ecto.Query, warn: false alias Cannery.Repo - alias Cannery.Accounts.{User, UserNotifier, UserToken} + alias Cannery.Accounts.{User, UserToken} + alias Cannery.Mailer alias Ecto.{Changeset, Multi} ## Database getters @@ -208,7 +209,7 @@ defmodule Cannery.Accounts do {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") Repo.insert!(user_token) - UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) + Mailer.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) end @doc """ @@ -319,7 +320,7 @@ defmodule Cannery.Accounts do else {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") Repo.insert!(user_token) - UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) + Mailer.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) end end @@ -364,7 +365,7 @@ defmodule Cannery.Accounts do when is_function(reset_password_url_fun, 1) do {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") Repo.insert!(user_token) - UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) + Mailer.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) end @doc """ diff --git a/lib/cannery/accounts/email.ex b/lib/cannery/accounts/email.ex new file mode 100644 index 00000000..06136bc9 --- /dev/null +++ b/lib/cannery/accounts/email.ex @@ -0,0 +1,54 @@ +defmodule Cannery.Email do + @moduledoc """ + Emails that can be sent using Swoosh. + + You can find the base email templates at + `lib/cannery_web/templates/layout/email.html.heex` for html emails and + `lib/cannery_web/templates/layout/email.txt.heex` for text emails. + """ + + use Phoenix.Swoosh, view: Cannery.EmailView, layout: {Cannery.LayoutView, :email} + import CanneryWeb.Gettext + alias Cannery.Accounts.User + alias CanneryWeb.EmailView + + @typedoc """ + Represents an HTML and text body email that can be sent + """ + @type t() :: Swoosh.Email.t() + + @spec base_email(User.t(), String.t()) :: t() + defp base_email(%User{email: email}, subject) do + new() + |> to(email) + |> from({ + Application.get_env(:cannery, Cannery.Mailer)[:email_name], + Application.get_env(:cannery, Cannery.Mailer)[:email_from] + }) + |> subject(subject) + end + + @spec welcome_email(User.t(), String.t()) :: t() + def welcome_email(user, url) do + user + |> base_email(dgettext("emails", "Confirm your %{name} account", name: "Cannery")) + |> render_body("confirm_email.html", %{user: user, url: url}) + |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) + end + + @spec reset_password_email(User.t(), String.t()) :: t() + def reset_password_email(user, url) do + user + |> base_email(dgettext("emails", "Reset your %{name} password", name: "Cannery")) + |> render_body("reset_password.html", %{user: user, url: url}) + |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) + end + + @spec update_email(User.t(), String.t()) :: t() + def update_email(user, url) do + user + |> base_email(dgettext("emails", "Update your %{name} email", name: "Cannery")) + |> render_body("update_email.html", %{user: user, url: url}) + |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) + end +end diff --git a/lib/cannery/accounts/email_worker.ex b/lib/cannery/accounts/email_worker.ex new file mode 100644 index 00000000..5198c71a --- /dev/null +++ b/lib/cannery/accounts/email_worker.ex @@ -0,0 +1,9 @@ +defmodule Cannery.EmailWorker do + use Oban.Worker, queue: :mailers + alias Cannery.Mailer + + @impl Oban.Worker + def perform(%Oban.Job{args: email}) do + email |> Mailer.deliver() + end +end diff --git a/lib/cannery/accounts/user_notifier.ex b/lib/cannery/accounts/user_notifier.ex deleted file mode 100644 index f4c5360a..00000000 --- a/lib/cannery/accounts/user_notifier.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule Cannery.Accounts.UserNotifier do - @moduledoc """ - Contains all user emails and notifications - """ - - # For simplicity, this module simply logs messages to the terminal. - # You should replace it by a proper email or notification tool, such as: - # - # * Swoosh - https://hexdocs.pm/swoosh - # * Bamboo - https://hexdocs.pm/bamboo - # - defp deliver(to, body) do - require Logger - Logger.debug(body) - {:ok, %{to: to, body: body}} - end - - @doc """ - Deliver instructions to confirm account. - """ - def deliver_confirmation_instructions(user, url) do - deliver(user.email, """ - - ============================== - - Hi #{user.email}, - - You can confirm your account by visiting the URL below: - - #{url} - - If you didn't create an account with us, please ignore this. - - ============================== - """) - end - - @doc """ - Deliver instructions to reset a user password. - """ - def deliver_reset_password_instructions(user, url) do - deliver(user.email, """ - - ============================== - - Hi #{user.email}, - - You can reset your password by visiting the URL below: - - #{url} - - If you didn't request this change, please ignore this. - - ============================== - """) - end - - @doc """ - Deliver instructions to update a user email. - """ - def deliver_update_email_instructions(user, url) do - deliver(user.email, """ - - ============================== - - Hi #{user.email}, - - You can change your email by visiting the URL below: - - #{url} - - If you didn't request this change, please ignore this. - - ============================== - """) - end -end diff --git a/lib/cannery/application.ex b/lib/cannery/application.ex index 9ad1c7db..82c2d114 100644 --- a/lib/cannery/application.ex +++ b/lib/cannery/application.ex @@ -15,7 +15,9 @@ defmodule Cannery.Application do # Start the PubSub system {Phoenix.PubSub, name: Cannery.PubSub}, # Start the Endpoint (http/https) - CanneryWeb.Endpoint + CanneryWeb.Endpoint, + # Add Oban + {Oban, oban_config()} # Start a worker by calling: Cannery.Worker.start_link(arg) # {Cannery.Worker, arg} ] @@ -39,4 +41,8 @@ defmodule Cannery.Application do CanneryWeb.Endpoint.config_change(changed, removed) :ok end + + defp oban_config() do + Application.fetch_env!(:cannery, Oban) + end end diff --git a/lib/cannery/mailer.ex b/lib/cannery/mailer.ex index ee9a7845..cb72e97d 100644 --- a/lib/cannery/mailer.ex +++ b/lib/cannery/mailer.ex @@ -4,4 +4,30 @@ defmodule Cannery.Mailer do """ use Swoosh.Mailer, otp_app: :cannery + alias Cannery.{Accounts.User, Email, EmailWorker} + alias Oban.Job + + @doc """ + Deliver instructions to confirm account. + """ + @spec deliver_confirmation_instructions(User.t(), String.t()) :: {:ok, Job.t()} + def deliver_confirmation_instructions(user, url) do + {:ok, Email.welcome_email(user, url) |> EmailWorker.new() |> Oban.insert!()} + end + + @doc """ + Deliver instructions to reset a user password. + """ + @spec deliver_reset_password_instructions(User.t(), String.t()) :: {:ok, Job.t()} + def deliver_reset_password_instructions(user, url) do + {:ok, Email.reset_password_email(user, url) |> EmailWorker.new() |> Oban.insert!()} + end + + @doc """ + Deliver instructions to update a user email. + """ + @spec deliver_update_email_instructions(User.t(), String.t()) :: {:ok, Job.t()} + def deliver_update_email_instructions(user, url) do + {:ok, Email.update_email(user, url) |> EmailWorker.new() |> Oban.insert!()} + end end diff --git a/lib/cannery_web/templates/email/confirm_email.html.eex b/lib/cannery_web/templates/email/confirm_email.html.eex new file mode 100644 index 00000000..18adab58 --- /dev/null +++ b/lib/cannery_web/templates/email/confirm_email.html.eex @@ -0,0 +1,12 @@ + +<%= dgettext("emails", "Hi %{email},", email: @user.email) %> + +<%= dgettext("emails", "Welcome to %{name}!", name: "Cannery") %> + +<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %> + +<%= @url %> + +<%= dgettext("emails", + "If you didn't create an account at %{url}, please ignore this.", + url: Routes.live_url(Endpoint, HomeLive)) %> diff --git a/lib/cannery_web/templates/email/confirm_email.txt.eex b/lib/cannery_web/templates/email/confirm_email.txt.eex new file mode 100644 index 00000000..9f8ded8c --- /dev/null +++ b/lib/cannery_web/templates/email/confirm_email.txt.eex @@ -0,0 +1,12 @@ + +<%= dgettext("emails", "Hi %{email},", email: @user.email) %> + +<%= dgettext("emails", "Welcome to %{name}%!", name: "Cannery") %> + +<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %> + +<%= @url %> + +<%= dgettext("emails", + "If you didn't create an account at %{url}, please ignore this.", + url: Routes.live_url(Endpoint, HomeLive)) %> diff --git a/lib/cannery_web/templates/email/reset_password.html.eex b/lib/cannery_web/templates/email/reset_password.html.eex new file mode 100644 index 00000000..cf9efb39 --- /dev/null +++ b/lib/cannery_web/templates/email/reset_password.html.eex @@ -0,0 +1,10 @@ + +<%= dgettext("emails", "Hi %{email},", email: @user.email) %> + +<%= dgettext("emails", "You can reset your password by visiting the URL below:") %> + +<%= @url %> + +<%= dgettext("emails", + "If you didn't request this change from %{url}, please ignore this.", + url: Routes.live_url(Endpoint, HomeLive)) %> diff --git a/lib/cannery_web/templates/email/reset_password.txt.eex b/lib/cannery_web/templates/email/reset_password.txt.eex new file mode 100644 index 00000000..cf9efb39 --- /dev/null +++ b/lib/cannery_web/templates/email/reset_password.txt.eex @@ -0,0 +1,10 @@ + +<%= dgettext("emails", "Hi %{email},", email: @user.email) %> + +<%= dgettext("emails", "You can reset your password by visiting the URL below:") %> + +<%= @url %> + +<%= dgettext("emails", + "If you didn't request this change from %{url}, please ignore this.", + url: Routes.live_url(Endpoint, HomeLive)) %> diff --git a/lib/cannery_web/templates/email/update_email.html.eex b/lib/cannery_web/templates/email/update_email.html.eex new file mode 100644 index 00000000..c23da11c --- /dev/null +++ b/lib/cannery_web/templates/email/update_email.html.eex @@ -0,0 +1,10 @@ + +<%= dgettext("emails", "Hi %{email},", email: @user.email) %> + +<%= dgettext("emails", "You can change your email by visiting the URL below:") %> + +<%= @url %> + +<%= dgettext("emails", + "If you didn't request this change from %{url}, please ignore this.", + url: Routes.live_url(Endpoint, HomeLive)) %> diff --git a/lib/cannery_web/templates/email/update_email.txt.eex b/lib/cannery_web/templates/email/update_email.txt.eex new file mode 100644 index 00000000..c23da11c --- /dev/null +++ b/lib/cannery_web/templates/email/update_email.txt.eex @@ -0,0 +1,10 @@ + +<%= dgettext("emails", "Hi %{email},", email: @user.email) %> + +<%= dgettext("emails", "You can change your email by visiting the URL below:") %> + +<%= @url %> + +<%= dgettext("emails", + "If you didn't request this change from %{url}, please ignore this.", + url: Routes.live_url(Endpoint, HomeLive)) %> diff --git a/lib/cannery_web/templates/layout/email.html.heex b/lib/cannery_web/templates/layout/email.html.heex new file mode 100644 index 00000000..bc324238 --- /dev/null +++ b/lib/cannery_web/templates/layout/email.html.heex @@ -0,0 +1,16 @@ + +
+