Packages

phoenix_kit

1.7.203
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit email provider_options.ex
Raw

lib/phoenix_kit/email/provider_options.ex

defmodule PhoenixKit.Email.ProviderOptions do
@moduledoc """
Per-provider send settings — the "Advanced" half of a `SendProfile`.
One source of truth for what each email provider actually accepts,
shared by the Send Settings form (which renders `fields_for/1`) and by
the delivery worker (which calls `to_provider_options/2` to turn a
profile's `advanced` map into Swoosh provider options).
Only options the underlying Swoosh adapter really reads are declared
here. An option the adapter ignores is worse than a missing one: it
looks configured and then silently does nothing — which is exactly what
the old free-form "Advanced (JSON)" textarea did, since nothing ever
read `advanced` at send time.
`smtp` declares no options on purpose: `Swoosh.Adapters.SMTP` takes all
its knobs (relay, port, TLS) from the connection itself and reads no
per-email `provider_options` at all. Rendering fields for it would be
the same lie in a new shape.
## Storage vs. wire format
`advanced` is a JSONB column, so it round-trips with **string** keys.
Swoosh, meanwhile, matches provider options on **atom** keys — and for
SES tags it pattern-matches `%{name: name, value: value}` strictly, so
a string-keyed tag map would raise inside the adapter. `cast/2` handles
the storage side, `to_provider_options/2` the wire side. Atoms come
from the `:option` field of a literal spec below, never from user
input, so there is no dynamic atom creation.
"""
use Gettext, backend: PhoenixKitWeb.Gettext
@type field :: %{
key: String.t(),
option: atom(),
label: String.t(),
type: :text | :number,
cast: :string | :integer | :string_list | :name_value_list,
placeholder: String.t(),
help: String.t()
}
@doc """
Returns the send settings a given provider supports, in display order.
An unknown provider returns `[]` rather than raising: a profile whose
integration was deleted, or one pointing at a provider some other module
registered, must still render its common fields instead of 500ing.
"""
@spec fields_for(String.t() | nil) :: [field()]
def fields_for("aws_ses") do
[
%{
key: "configuration_set_name",
option: :configuration_set_name,
label: gettext("Configuration set"),
type: :text,
cast: :string,
placeholder: "my-configuration-set",
help:
gettext(
"SES configuration set applied to every send from this profile — this is what routes bounce/complaint/open events and selects a dedicated IP pool."
)
},
%{
key: "tags",
option: :tags,
label: gettext("Message tags"),
type: :text,
cast: :name_value_list,
placeholder: "campaign=newsletter, env=prod",
help:
gettext(
"Comma-separated name=value pairs, attached to each message as SES message tags (visible in CloudWatch metrics)."
)
}
]
end
def fields_for("brevo_api") do
[
%{
key: "sender_id",
option: :sender_id,
label: gettext("Sender ID"),
type: :number,
cast: :integer,
placeholder: "12",
help:
gettext(
"Numeric ID of a verified Brevo sender. Leave empty to send from the From address above."
)
},
%{
key: "tags",
option: :tags,
label: gettext("Tags"),
type: :text,
cast: :string_list,
placeholder: "newsletter, ops",
help:
gettext(
"Comma-separated tags attached to each message, for filtering in Brevo's statistics."
)
}
]
end
def fields_for(_provider_kind), do: []
@doc """
Normalizes raw form input into the map stored in `advanced`.
Keeps only keys the provider declares, so flipping a profile from SES to
Brevo can't leave an orphan `configuration_set_name` behind to be sent to
an adapter that has no idea what it is. Blank values are dropped rather
than stored as `""`, so an untouched field stores nothing at all.
"""
@spec cast(String.t() | nil, map() | nil) :: map()
def cast(provider_kind, raw) when is_map(raw) do
provider_kind
|> fields_for()
|> Enum.reduce(%{}, fn field, acc ->
case raw |> Map.get(field.key) |> cast_value(field.cast) do
nil -> acc
value -> Map.put(acc, field.key, value)
end
end)
end
def cast(_provider_kind, _raw), do: %{}
@doc """
Translates a profile's stored `advanced` map into Swoosh provider options.
Returns a map keyed by the atoms Swoosh expects. Anything not declared by
the provider is ignored — including leftovers written by the old raw-JSON
textarea, which must never reach an adapter unvetted.
"""
@spec to_provider_options(String.t() | nil, map() | nil) :: map()
def to_provider_options(provider_kind, advanced) when is_map(advanced) do
provider_kind
|> fields_for()
|> Enum.reduce(%{}, fn field, acc ->
case Map.get(advanced, field.key) do
nil -> acc
value -> put_option(acc, field, value)
end
end)
end
def to_provider_options(_provider_kind, _advanced), do: %{}
@doc """
Renders a stored value back into the text the form input shows.
The inverse of `cast_value/2` for the list types, so editing a profile
shows `campaign=newsletter, env=prod` rather than a raw JSON array.
"""
@spec to_input_value(field(), map() | nil) :: String.t()
def to_input_value(field, advanced) when is_map(advanced) do
advanced |> Map.get(field.key) |> format_value(field.cast)
end
def to_input_value(_field, _advanced), do: ""
# --- Private ---
defp put_option(acc, %{cast: :name_value_list, option: option}, value) do
# Swoosh's SES adapter matches %{name: _, value: _} strictly — string
# keys (which is how JSONB gives them back) would raise inside the
# adapter, so rebuild with atom keys. Skip the option entirely if the
# stored shape isn't what we wrote, rather than sending junk to AWS.
tags =
value
|> List.wrap()
|> Enum.flat_map(fn
%{"name" => name, "value" => v} -> [%{name: name, value: v}]
%{name: name, value: v} -> [%{name: name, value: v}]
_ -> []
end)
if tags == [], do: acc, else: Map.put(acc, option, tags)
end
defp put_option(acc, %{option: option}, value), do: Map.put(acc, option, value)
defp cast_value(nil, _cast), do: nil
defp cast_value(value, :string) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp cast_value(value, :integer) when is_binary(value) do
case value |> String.trim() |> Integer.parse() do
{int, ""} -> int
_ -> nil
end
end
defp cast_value(value, :integer) when is_integer(value), do: value
defp cast_value(value, :string_list) when is_binary(value) do
value
|> split_list()
|> case do
[] -> nil
tags -> tags
end
end
defp cast_value(value, :name_value_list) when is_binary(value) do
value
|> split_list()
|> Enum.flat_map(&parse_name_value/1)
|> case do
[] -> nil
tags -> tags
end
end
# Already-normalized values (e.g. re-casting a loaded profile) pass through.
defp cast_value(value, _cast) when is_list(value), do: if(value == [], do: nil, else: value)
defp cast_value(_value, _cast), do: nil
# A bare tag with no "=" is dropped rather than guessed at: SES requires both
# a name and a value, and inventing one would silently tag every message with
# something the operator never typed.
defp parse_name_value(pair) do
with [name, value] <- String.split(pair, "=", parts: 2),
name = String.trim(name),
value = String.trim(value),
true <- name != "" and value != "" do
[%{"name" => name, "value" => value}]
else
_ -> []
end
end
defp split_list(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp format_value(nil, _cast), do: ""
defp format_value(value, :name_value_list) when is_list(value) do
Enum.map_join(value, ", ", fn
%{"name" => name, "value" => v} -> "#{name}=#{v}"
%{name: name, value: v} -> "#{name}=#{v}"
other -> to_string(other)
end)
end
defp format_value(value, :string_list) when is_list(value), do: Enum.join(value, ", ")
defp format_value(value, _cast), do: to_string(value)
end