mail

37cd9a6 · gmorell · 2026-05-21 13:53

4 files +124 -0

Files changed

modified config/runtime.exs
+121 −0
@@ -97,6 +97,127 @@ if config_env() == :prod do
97 97 ]
98 98 ]
99 99
100 + # Mailer adapter selection:
101 + #
102 + # * SMTP_RELAY set → Swoosh.Adapters.SMTP (via gen_smtp)
103 + # * otherwise → Swoosh.Adapters.Logger
104 + #
105 + # The Logger adapter doesn't actually send mail — it logs the
106 + # rendered email at :info — but it gets us off Swoosh.Adapters.Local
107 + # (the dev mailbox), so the magic-link flow at least leaves a trail
108 + # in the pod log when SMTP isn't configured.
109 + smtp_tri = fn var, default ->
110 + case System.get_env(var, default) do
111 + v when v in ~w(always if_available never) -> String.to_existing_atom(v)
112 + other -> raise "#{var} must be one of always|if_available|never, got: #{inspect(other)}"
113 + end
114 + end
115 +
116 + # Eager-load the OS CA bundle into OTP's `public_key` cache. Without
117 + # this, `:public_key.cacerts_get/0` returns `:undefined`, and
118 + # `gen_smtp`'s internal default `tls_options` injects
119 + # `cacerts: :undefined` — which `:ssl` then rejects as incompatible
120 + # with `verify: :verify_peer`, clobbering our explicit `cacertfile`.
121 + case :public_key.cacerts_load() do
122 + :ok ->
123 + :ok
124 +
125 + {:error, reason} ->
126 + require Logger
127 + Logger.warning("public_key:cacerts_load failed: #{inspect(reason)} — TLS verify may fail")
128 + end
129 +
130 + # CA bundle for TLS verification. We pass both `cacertfile` (path)
131 + # and rely on the cacerts_load above for `cacerts_get/0` to work,
132 + # belt-and-braces. Override `SMTP_CACERTFILE` if your image puts the
133 + # bundle elsewhere; set `SMTP_TLS_VERIFY=verify_none` for self-signed
134 + # / private relays where you'd rather skip cert validation.
135 + smtp_cacertfile =
136 + System.get_env("SMTP_CACERTFILE", "/etc/ssl/certs/ca-certificates.crt")
137 +
138 + smtp_verify =
139 + case System.get_env("SMTP_TLS_VERIFY", "verify_peer") do
140 + v when v in ~w(verify_peer verify_none) -> String.to_existing_atom(v)
141 + other -> raise "SMTP_TLS_VERIFY must be verify_peer or verify_none, got: #{inspect(other)}"
142 + end
143 +
144 + # Resolve the CA store NOW (after `cacerts_load`) rather than letting
145 + # the downstream stack call `cacerts_get/0` lazily. gen_smtp / ssl
146 + # has a habit of evaluating it in a context where it still returns
147 + # `:undefined`, which then collides with our `cacertfile`. Passing
148 + # `cacerts: <list>` explicitly is unambiguous — `:ssl` honors it
149 + # and never re-derives.
150 + smtp_cacerts =
151 + case :public_key.cacerts_get() do
152 + certs when is_list(certs) and certs != [] -> certs
153 + _ -> nil
154 + end
155 +
156 + mailer_opts =
157 + if relay = System.get_env("SMTP_RELAY") do
158 + tls_options =
159 + [
160 + verify: smtp_verify,
161 + server_name_indication: String.to_charlist(relay),
162 + depth: 99
163 + ] ++
164 + cond do
165 + smtp_verify != :verify_peer ->
166 + []
167 +
168 + is_list(smtp_cacerts) ->
169 + [cacerts: smtp_cacerts]
170 +
171 + true ->
172 + [cacertfile: smtp_cacertfile]
173 + end
174 +
175 + # Pin modern TLS at both the top level (read by gen_smtp before
176 + # it calls :ssl.connect) and inside tls_options (read by :ssl
177 + # itself). Matches the working pattern from gitgud.
178 + tls_versions = [:"tlsv1.2", :"tlsv1.3"]
179 +
180 + # gen_smtp 1.2 has two distinct connection paths with different
181 + # option keys:
182 + #
183 + # * `ssl: true` (port 465 implicit TLS) reads its TLS opts
184 + # from `sockopts`. `tls_options` is ignored on this path.
185 + # * `tls: :always|:if_available` (STARTTLS upgrade) reads from
186 + # `tls_options` — only used during do_STARTTLS.
187 + #
188 + # Without sockopts on the implicit-TLS path, OTP 28's `:ssl`
189 + # falls through to its own defaults (verify_peer +
190 + # `public_key.cacerts_get()` which returns `:undefined`) and
191 + # bails with `{options, incompatible, [verify: verify_peer,
192 + # cacerts: undefined]}`. Putting the same opts on both keys
193 + # covers both relay setups.
194 + ssl_opts = [{:versions, tls_versions} | tls_options]
195 +
196 + [
197 + adapter: Swoosh.Adapters.SMTP,
198 + relay: relay,
199 + port: String.to_integer(System.get_env("SMTP_PORT", "587")),
200 + username: System.get_env("SMTP_USERNAME"),
201 + password: System.get_env("SMTP_PASSWORD"),
202 + tls: smtp_tri.("SMTP_TLS", "if_available"),
203 + ssl: System.get_env("SMTP_SSL", "false") == "true",
204 + auth: smtp_tri.("SMTP_AUTH", "if_available"),
205 + allowed_tls_versions: tls_versions,
206 + sockopts: ssl_opts,
207 + tls_options: ssl_opts,
208 + retries: 1,
209 + no_mx_lookups: false
210 + ]
211 + else
212 + [adapter: Swoosh.Adapters.Logger, level: :info]
213 + end
214 +
215 + config :waxx, Waxx.Mailer, mailer_opts
216 +
217 + config :waxx, Waxx.Mailer,
218 + from_name: System.get_env("MAIL_FROM_NAME", "Waxx"),
219 + from_address: System.get_env("MAIL_FROM_ADDRESS") || "noreply@#{host}"
220 +
100 221 config :waxx, WaxxWeb.Endpoint,
101 222 url: [host: host, port: 443, scheme: "https"],
102 223 # Accept both http and https origins for the configured host. The
modified mix.exs
+1 −0
@@ -60,6 +60,7 @@ defmodule Waxx.MixProject do
60 60 compile: false,
61 61 depth: 1},
62 62 {:swoosh, "~> 1.16"},
63 + {:gen_smtp, "~> 1.2"},
63 64 {:req, "~> 0.5"},
64 65 {:telemetry_metrics, "~> 1.0"},
65 66 {:telemetry_poller, "~> 1.0"},
modified mix.lock
+2 −0
@@ -15,6 +15,7 @@
15 15 "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
16 16 "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
17 17 "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
18 + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
18 19 "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
19 20 "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
20 21 "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
@@ -42,6 +43,7 @@
42 43 "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
43 44 "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
44 45 "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
46 + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
45 47 "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
46 48 "sentry": {:hex, :sentry, "10.10.0", "d058b635f3796947545c8057a42996f6dbefd12152da947209b56d16af41b161", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "7c7ddd3cfdd63fcee53b1e28f9a653037e6927b2b1dbd300b7aeee9687c7a8f6"},
47 49 "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},

Parents: 6670896