Fix bamboo smtp network error by forwarding tls options

This commit is contained in:
Simon Prévost 2024-01-24 15:19:08 -05:00
parent 33e77726db
commit 335e30c80a
5 changed files with 599 additions and 2 deletions

View File

@ -174,8 +174,7 @@ cond do
get_env("SMTP_ADDRESS") ->
config :accent, Accent.Mailer,
tls: :never,
adapter: Bamboo.SMTPAdapter,
adapter: BambooSMTPAdapterWithTlsOptions,
server: get_env("SMTP_ADDRESS"),
port: get_env("SMTP_PORT"),
username: get_env("SMTP_USERNAME"),

View File

@ -20,6 +20,10 @@ defmodule Accent do
{:ok, _} = Logger.add_backend(Sentry.LoggerBackend)
end
if Application.get_env(:accent, Accent.Mailer)[:adapter] === BambooSMTPAdapterWithTlsOptions do
add_tls_options_to_mailer_smtp_adapter()
end
Ecto.DevLogger.install(Accent.Repo,
ignore_event: fn metadata ->
not is_nil(metadata[:options][:telemetry_ui_conf])
@ -35,6 +39,12 @@ defmodule Accent do
:ok
end
defp add_tls_options_to_mailer_smtp_adapter do
config = Application.get_env(:accent, Accent.Mailer)
new_config = Keyword.put(config, :tls_options, :tls_certificate_check.options(config[:server]))
Application.put_env(:accent, Accent.Mailer, new_config)
end
defp language_tool_config do
[
languages: Application.get_env(:accent, LanguageTool)[:languages],

View File

@ -97,6 +97,7 @@ defmodule Accent.Mixfile do
# Mails
{:bamboo, "~> 2.3", override: true},
{:tls_certificate_check, "~> 1.21"},
{:bamboo_phoenix, "~> 1.0"},
{:bamboo_smtp, "~> 4.2"},

View File

@ -95,6 +95,7 @@
"tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"},
"thousand_island": {:hex, :thousand_island, "1.2.0", "4f548ae771ab5f96bc7e199f9824c0c2ce6d365f8c93f5f64dbbb33988e484bf", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "521671fea179672addb6af46455fc2a77be1edda4c0ed351633e0ef37a4b3584"},
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
"tls_certificate_check": {:hex, :tls_certificate_check, "1.21.0", "042ab2c0c860652bc5cf69c94e3a31f96676d14682e22ec7813bd173ceff1788", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6cee6cffc35a390840d48d463541d50746a7b0e421acaadb833cfc7961e490e7"},
"tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"},
"ueberauth": {:hex, :ueberauth, "0.10.5", "806adb703df87e55b5615cf365e809f84c20c68aa8c08ff8a416a5a6644c4b02", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3efd1f31d490a125c7ed453b926f7c31d78b97b8a854c755f5c40064bf3ac9e1"},
"ueberauth_auth0": {:hex, :ueberauth_auth0, "2.1.0", "0632d5844049fa2f26823f15e1120aa32f27df6f27ce515a4b04641736594bf4", [:mix], [{:oauth2, "~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "8d3b30fa27c95c9e82c30c4afb016251405706d2e9627e603c3c9787fd1314fc"},

View File

@ -0,0 +1,586 @@
defmodule BambooSMTPAdapterWithTlsOptions do
@moduledoc """
Sends email using SMTP.
Use this adapter to send emails through SMTP. This adapter requires
that some settings are set in the config. See the example section below.
*Sensitive credentials should not be committed to source control and are best kept in environment variables.
Using `{:system, "ENV_NAME"}` configuration is read from the named environment variable at runtime.*
## Example config
# In config/config.exs, or config.prod.exs, etc.
config :my_app, MyApp.Mailer,
adapter: Bamboo.SMTPAdapter,
server: "smtp.domain",
hostname: "www.mydomain.com",
port: 1025,
username: "your.name@your.domain", # or {:system, "SMTP_USERNAME"}
password: "pa55word", # or {:system, "SMTP_PASSWORD"}
tls: :if_available, # can be `:always` or `:never`
allowed_tls_versions: [:"tlsv1", :"tlsv1.1", :"tlsv1.2"],
# or {":system", ALLOWED_TLS_VERSIONS"} w/ comma separated values (e.g. "tlsv1.1,tlsv1.2")
tls_log_level: :error,
tls_verify: :verify_peer, # optional, can be `:verify_peer` or `:verify_none`
tls_cacertfile: "/somewhere/on/disk", # optional, path to the ca truststore
tls_cacerts: "", # optional, DER-encoded trusted certificates
tls_depth: 3, # optional, tls certificate chain depth
tls_verify_fun: {&:ssl_verify_hostname.verify_fun/3, check_hostname: "example.com"}, # optional, tls verification function
ssl: false, # can be `true`,
retries: 1,
no_mx_lookups: false, # can be `true`
auth: :if_available # can be `:always`. If your smtp relay requires authentication set it to `:always`.
# Define a Mailer. Maybe in lib/my_app/mailer.ex
defmodule MyApp.Mailer do
use Bamboo.Mailer, otp_app: :my_app
end
"""
@behaviour Bamboo.Adapter
require Logger
@required_configuration [:server, :port]
@default_configuration %{
tls: :if_available,
ssl: false,
retries: 1,
transport: :gen_smtp_client,
auth: :if_available
}
@tls_versions ~w(tlsv1 tlsv1.1 tlsv1.2)
@log_levels [:critical, :error, :warning, :notice]
@tls_verify [:verify_peer, :verify_none]
defmodule SMTPError do
@moduledoc false
defexception [:message, :raw]
def exception({reason, detail} = raw) do
message = """
There was a problem sending the email through SMTP.
The error is #{inspect(reason)}
More detail below:
#{inspect(detail)}
"""
%SMTPError{message: message, raw: raw}
end
end
def deliver(email, config) do
gen_smtp_config =
to_gen_smtp_server_config(config)
response =
try do
email
|> Bamboo.Mailer.normalize_addresses()
|> to_gen_smtp_message()
|> config[:transport].send_blocking(gen_smtp_config)
catch
e ->
raise SMTPError, {:not_specified, e}
end
handle_response(response)
end
@doc false
def handle_config(config) do
config
|> check_required_configuration()
|> put_default_configuration()
end
@doc false
def supports_attachments?, do: true
defp handle_response({:error, :no_credentials}) do
{:error, "Username and password were not provided for authentication."}
end
defp handle_response({:error, _reason, detail}) do
{:error, detail}
end
defp handle_response({:error, detail}) do
{:error, detail}
end
defp handle_response(response) do
{:ok, response}
end
defp add_bcc(body, %Bamboo.Email{bcc: []}) do
body
end
defp add_bcc(body, %Bamboo.Email{bcc: recipients}) do
add_smtp_header_line(body, :bcc, format_email_as_string(recipients, :bcc))
end
defp add_cc(body, %Bamboo.Email{cc: []}) do
body
end
defp add_cc(body, %Bamboo.Email{cc: recipients}) do
add_smtp_header_line(body, :cc, format_email_as_string(recipients, :cc))
end
defp add_custom_header(body, {key, value}) do
add_smtp_header_line(body, key, value)
end
defp add_custom_headers(body, %Bamboo.Email{headers: headers}) do
Enum.reduce(headers, body, &add_custom_header(&2, &1))
end
defp add_ending_header(body) do
add_smtp_line(body, "")
end
defp add_ending_multipart(body, delimiter) do
add_smtp_line(body, "--#{delimiter}--")
end
defp add_html_body(body, %Bamboo.Email{html_body: html_body}, _multi_part_delimiter) when html_body == nil do
body
end
defp add_html_body(body, %Bamboo.Email{html_body: html_body}, multi_part_delimiter) do
base64_html_body = base64_and_split(html_body)
body
|> add_multipart_delimiter(multi_part_delimiter)
|> add_smtp_header_line("Content-Type", "text/html;charset=UTF-8")
|> add_smtp_line("Content-Transfer-Encoding: base64")
|> add_smtp_line("")
|> add_smtp_line(base64_html_body)
end
defp add_from(body, %Bamboo.Email{from: from}) do
add_smtp_header_line(body, :from, format_email_as_string(from, :from))
end
defp add_mime_header(body) do
add_smtp_header_line(body, "MIME-Version", "1.0")
end
defp add_multipart_delimiter(body, delimiter) do
add_smtp_line(body, "--#{delimiter}")
end
defp add_multipart_header(body, delimiter) do
add_smtp_header_line(body, "Content-Type", ~s(multipart/alternative; boundary="#{delimiter}"))
end
defp add_multipart_mixed_header(body, delimiter) do
add_smtp_header_line(body, "Content-Type", ~s(multipart/mixed; boundary="#{delimiter}"))
end
defp add_smtp_header_line(body, type, content) when is_list(content) do
Enum.reduce(content, body, &add_smtp_header_line(&2, type, &1))
end
defp add_smtp_header_line(body, type, content) when is_atom(type) do
add_smtp_header_line(body, String.capitalize(to_string(type)), content)
end
defp add_smtp_header_line(body, type, content) when is_binary(type) do
add_smtp_line(body, "#{type}: #{content}")
end
defp add_smtp_line(body, content), do: body <> content <> "\r\n"
defp add_subject(body, %Bamboo.Email{subject: subject}) when is_nil(subject) do
add_smtp_header_line(body, :subject, "")
end
defp add_subject(body, %Bamboo.Email{subject: subject}) do
add_smtp_header_line(body, :subject, rfc822_encode(subject))
end
defp rfc822_encode(content) do
"=?UTF-8?B?#{Base.encode64(content)}?="
end
defp rfc2231_encode(content) do
"UTF-8''#{URI.encode(content)}"
end
def base64_and_split(data) do
data
|> Base.encode64()
|> Stream.unfold(&String.split_at(&1, 76))
|> Enum.take_while(&(&1 != ""))
|> Enum.join("\r\n")
end
defp add_text_body(body, %Bamboo.Email{text_body: text_body}, _multi_part_delimiter) when text_body == nil do
body
end
defp add_text_body(body, %Bamboo.Email{text_body: text_body}, multi_part_delimiter) do
body
|> add_multipart_delimiter(multi_part_delimiter)
|> add_smtp_header_line("Content-Type", "text/plain;charset=UTF-8")
|> add_smtp_line("")
|> add_smtp_line(text_body)
end
defp add_attachment_header(body, attachment) do
case attachment.content_id do
nil ->
add_common_attachment_header(body, attachment)
cid ->
body
|> add_common_attachment_header(attachment)
|> add_smtp_line("Content-ID: <#{cid}>")
end
end
defp add_common_attachment_header(body, %{content_type: content_type} = attachment)
when content_type == "message/rfc822" do
<<random::size(32)>> = :crypto.strong_rand_bytes(4)
rfc2231_encoded_filename = rfc2231_encode(attachment.filename)
body
|> add_smtp_line("Content-Type: #{attachment.content_type}; name*=#{rfc2231_encoded_filename}")
|> add_smtp_line("Content-Disposition: attachment; filename*=#{rfc2231_encoded_filename}")
|> add_smtp_line("X-Attachment-Id: #{random}")
end
defp add_common_attachment_header(body, attachment) do
<<random::size(32)>> = :crypto.strong_rand_bytes(4)
rfc2231_encoded_filename = rfc2231_encode(attachment.filename)
body
|> add_smtp_line("Content-Type: #{attachment.content_type}; name*=#{rfc2231_encoded_filename}")
|> add_smtp_line("Content-Disposition: attachment; filename*=#{rfc2231_encoded_filename}")
|> add_smtp_line("Content-Transfer-Encoding: base64")
|> add_smtp_line("X-Attachment-Id: #{random}")
end
defp add_attachment_body(body, data) do
data =
if String.contains?(body, "Content-Type: message/rfc822") do
data
else
base64_and_split(data)
end
add_smtp_line(body, data)
end
defp add_attachment(nil, _), do: ""
defp add_attachment(attachment, multi_part_mixed_delimiter) do
""
|> add_multipart_delimiter(multi_part_mixed_delimiter)
|> add_attachment_header(attachment)
|> add_smtp_line("")
|> add_attachment_body(attachment.data)
end
defp add_attachments(body, %Bamboo.Email{attachments: nil}, _), do: body
defp add_attachments(body, %Bamboo.Email{attachments: attachments}, multi_part_mixed_delimiter) do
attachment_part =
Enum.map(attachments, fn attachment -> add_attachment(attachment, multi_part_mixed_delimiter) end)
"#{body}#{attachment_part}"
end
defp add_to(body, %Bamboo.Email{to: recipients}) do
add_smtp_header_line(body, :to, format_email_as_string(recipients, :to))
end
defp aggregate_errors(config, key, errors) do
config
|> Map.fetch(key)
|> build_error(key, errors)
end
defp apply_default_configuration({:ok, value}, _default, config) when value != nil do
config
end
defp apply_default_configuration(_not_found_value, {key, default_value}, config) do
Map.put_new(config, key, default_value)
end
defp generate_multi_part_delimiter do
<<random1::size(32), random2::size(32), random3::size(32)>> = :crypto.strong_rand_bytes(12)
"----=_Part_#{random1}_#{random2}.#{random3}"
end
defp body(%Bamboo.Email{} = email) do
multi_part_delimiter = generate_multi_part_delimiter()
multi_part_mixed_delimiter = generate_multi_part_delimiter()
""
|> add_subject(email)
|> add_from(email)
|> add_bcc(email)
|> add_cc(email)
|> add_to(email)
|> add_custom_headers(email)
|> add_mime_header()
|> add_multipart_mixed_header(multi_part_mixed_delimiter)
|> add_ending_header()
|> add_multipart_delimiter(multi_part_mixed_delimiter)
|> add_multipart_header(multi_part_delimiter)
|> add_ending_header()
|> add_text_body(email, multi_part_delimiter)
|> add_html_body(email, multi_part_delimiter)
|> add_ending_multipart(multi_part_delimiter)
|> add_attachments(email, multi_part_mixed_delimiter)
|> add_ending_multipart(multi_part_mixed_delimiter)
end
defp build_error({:ok, value}, _key, errors) when value != nil, do: errors
defp build_error(_not_found_value, key, errors) do
["Key #{key} is required for SMTP Adapter" | errors]
end
defp check_required_configuration(config) do
@required_configuration
|> Enum.reduce([], &aggregate_errors(config, &1, &2))
|> raise_on_missing_configuration(config)
end
defp puny_encode(email) do
[local_part, domain_part] = String.split(email, "@")
Enum.join([local_part, :idna.utf8_to_ascii(domain_part)], "@")
end
defp format_email({nil, email}, _format), do: puny_encode(email)
defp format_email({name, email}, true), do: "#{rfc822_encode(name)} <#{puny_encode(email)}>"
defp format_email({_name, email}, false), do: puny_encode(email)
defp format_email(emails, format) when is_list(emails) do
Enum.map(emails, &format_email(&1, format))
end
defp format_email(email, type, format \\ true) do
email
|> Bamboo.Formatter.format_email_address(type)
|> format_email(format)
end
defp format_email_as_string(emails) when is_list(emails) do
Enum.join(emails, ", ")
end
defp format_email_as_string(email) do
email
end
defp format_email_as_string(email, type) do
email
|> format_email(type)
|> format_email_as_string()
end
defp from_without_format(%Bamboo.Email{from: from}) do
format_email(from, :from, false)
end
defp put_default_configuration(config) do
Enum.reduce(@default_configuration, config, &put_default_configuration(&2, &1))
end
defp put_default_configuration(config, {key, _default_value} = default) do
config
|> Map.fetch(key)
|> apply_default_configuration(default, config)
end
defp raise_on_missing_configuration([], config), do: config
defp raise_on_missing_configuration(errors, config) do
formatted_errors = Enum.map_join(errors, "\n", &"* #{&1}")
raise ArgumentError, """
The following settings have not been found in your settings:
#{formatted_errors}
They are required to make the SMTP adapter work. Here you configuration:
#{inspect(config)}
"""
end
defp to_without_format(%Bamboo.Email{} = email) do
email
|> Bamboo.Email.all_recipients()
|> format_email(:to, false)
end
defp to_gen_smtp_message(%Bamboo.Email{} = email) do
{from_without_format(email), to_without_format(email), body(email)}
end
defp to_gen_smtp_server_config(config) do
Enum.reduce(config, [], &to_gen_smtp_server_config/2)
end
defp to_gen_smtp_server_config({:server, value}, config) when is_binary(value) do
[{:relay, value} | config]
end
defp to_gen_smtp_server_config({:username, value}, config) when is_binary(value) do
[{:username, value} | config]
end
defp to_gen_smtp_server_config({:password, value}, config) when is_binary(value) do
[{:password, value} | config]
end
defp to_gen_smtp_server_config({:tls, "if_available"}, config) do
[{:tls, :if_available} | config]
end
defp to_gen_smtp_server_config({:tls, "always"}, config) do
[{:tls, :always} | config]
end
defp to_gen_smtp_server_config({:tls, "never"}, config) do
[{:tls, :never} | config]
end
defp to_gen_smtp_server_config({:tls, value}, config) when is_atom(value) do
[{:tls, value} | config]
end
defp to_gen_smtp_server_config({:tls_options, value}, config) do
Keyword.put(config, :tls_options, value)
end
defp to_gen_smtp_server_config({:allowed_tls_versions, value}, config) when is_binary(value) do
Keyword.update(config, :tls_options, [{:versions, string_to_tls_versions(value)}], fn c ->
[{:versions, string_to_tls_versions(value)} | c]
end)
end
defp to_gen_smtp_server_config({:allowed_tls_versions, value}, config) when is_list(value) do
Keyword.update(config, :tls_options, [{:versions, value}], fn c ->
[{:versions, value} | c]
end)
end
defp to_gen_smtp_server_config({:tls_log_level, value}, config) when value in @log_levels do
Keyword.update(config, :tls_options, [{:log_level, value}], fn c ->
[{:log_level, value} | c]
end)
end
defp to_gen_smtp_server_config({:tls_verify, value}, config) when value in @tls_verify do
Keyword.update(config, :tls_options, [{:verify, value}], fn c -> [{:verify, value} | c] end)
end
defp to_gen_smtp_server_config({:tls_cacertfile, value}, config) when is_binary(value) do
Keyword.update(config, :tls_options, [{:cacertfile, value}], fn c ->
[{:cacertfile, value} | c]
end)
end
defp to_gen_smtp_server_config({:tls_cacerts, value}, config) when is_binary(value) do
Keyword.update(config, :tls_options, [{:cacerts, value}], fn c -> [{:cacerts, value} | c] end)
end
defp to_gen_smtp_server_config({:tls_depth, value}, config) when is_integer(value) and value >= 0 do
Keyword.update(config, :tls_options, [{:depth, value}], fn c -> [{:depth, value} | c] end)
end
defp to_gen_smtp_server_config({:tls_verify_fun, value}, config) when is_tuple(value) do
Keyword.update(config, :tls_options, [{:verify_fun, value}], fn c ->
[{:verify_fun, value} | c]
end)
end
defp to_gen_smtp_server_config({:port, value}, config) when is_binary(value) do
[{:port, String.to_integer(value)} | config]
end
defp to_gen_smtp_server_config({:port, value}, config) when is_integer(value) do
[{:port, value} | config]
end
defp to_gen_smtp_server_config({:ssl, "true"}, config) do
[{:ssl, true} | config]
end
defp to_gen_smtp_server_config({:ssl, "false"}, config) do
[{:ssl, false} | config]
end
defp to_gen_smtp_server_config({:ssl, value}, config) when is_boolean(value) do
[{:ssl, value} | config]
end
defp to_gen_smtp_server_config({:retries, value}, config) when is_binary(value) do
[{:retries, String.to_integer(value)} | config]
end
defp to_gen_smtp_server_config({:retries, value}, config) when is_integer(value) do
[{:retries, value} | config]
end
defp to_gen_smtp_server_config({:hostname, value}, config) when is_binary(value) do
[{:hostname, value} | config]
end
defp to_gen_smtp_server_config({:no_mx_lookups, "true"}, config) do
[{:no_mx_lookups, true} | config]
end
defp to_gen_smtp_server_config({:no_mx_lookups, "false"}, config) do
[{:no_mx_lookups, false} | config]
end
defp to_gen_smtp_server_config({:no_mx_lookups, value}, config) when is_boolean(value) do
[{:no_mx_lookups, value} | config]
end
defp to_gen_smtp_server_config({:auth, "if_available"}, config) do
[{:auth, :if_available} | config]
end
defp to_gen_smtp_server_config({:auth, "always"}, config) do
[{:auth, :always} | config]
end
defp to_gen_smtp_server_config({:auth, value}, config) when is_atom(value) do
[{:auth, value} | config]
end
defp to_gen_smtp_server_config({:sockopts, value}, config) do
[{:sockopts, value} | config]
end
defp to_gen_smtp_server_config({conf, {:system, var}}, config) do
to_gen_smtp_server_config({conf, System.get_env(var)}, config)
end
defp to_gen_smtp_server_config({_key, _value}, config) do
config
end
defp string_to_tls_versions(version_string) do
version_string
|> String.split(",")
|> Enum.filter(&(&1 in @tls_versions))
|> Enum.map(&String.to_atom/1)
end
end