diff --git a/README.md b/README.md index a406995..b92d51a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Use a project token for email operations. Use a team token for management operat ```elixir client = Lettermint.email(System.fetch_env!("LETTERMINT_PROJECT_TOKEN")) +alias Lettermint.MessageTag {:ok, message} = Lettermint.Email.send(client, %{ from: "Example ", @@ -37,7 +38,8 @@ client = Lettermint.email(System.fetch_env!("LETTERMINT_PROJECT_TOKEN")) subject: "Hello", text: "Hello from Elixir", metadata: %{order_id: "123"}, - tags: [%{name: "type", value: "receipt"}], + tag: "legacy-tag", + tags: [MessageTag.new!("type", "receipt")], settings: %{tls: "enforced"} }, idempotency_key: "receipt-123") @@ -45,6 +47,9 @@ message.message_id message.status ``` +The legacy `tag` field remains available. `MessageTag.new!/2` validates typed +name/value tags before the request is sent. + All API calls return `{:ok, result}` or `{:error, %Lettermint.Error{}}`. Invalid local arguments raise `ArgumentError`. JSON responses use generated structs. Raw message source, HTML, and text remain unchanged strings. Ping returns a trimmed string. ### Using the Pipe Operator @@ -58,6 +63,7 @@ client |> Email.to(["recipient@example.com"]) |> Email.subject("Hello") |> Email.text("Hello from Elixir") +|> Email.tags([Lettermint.MessageTag.new!("campaign", "welcome")]) |> Email.send(idempotency_key: "hello-123") ``` diff --git a/lib/lettermint/email_builder.ex b/lib/lettermint/email_builder.ex index 5a466e7..df98822 100644 --- a/lib/lettermint/email_builder.ex +++ b/lib/lettermint/email_builder.ex @@ -19,8 +19,6 @@ defmodule Lettermint.EmailBuilder do :html, :text, :route, - :tag, - :tags, :headers, :metadata, :settings, @@ -34,6 +32,39 @@ defmodule Lettermint.EmailBuilder do end end + @doc "Set the legacy single tag." + @spec tag(t(), String.t() | nil) :: t() + def tag(%__MODULE__{} = builder, value) do + if not is_nil(value) and length(Map.get(builder.payload, :tags, [])) >= 20, + do: raise(ArgumentError, "A legacy tag and no more than 19 message tags are permitted") + + %{builder | payload: Map.put(builder.payload, :tag, value)} + end + + @doc "Set typed reusable name/value tags. Maps remain supported." + @spec tags(t(), [Lettermint.MessageTag.t() | map()]) :: t() + def tags(%__MODULE__{} = builder, tags) when is_list(tags) do + maximum = if is_nil(Map.get(builder.payload, :tag)), do: 20, else: 19 + + if length(tags) > maximum, + do: raise(ArgumentError, "No more than #{maximum} message tags are permitted") + + normalized = + Enum.map(tags, fn + %Lettermint.MessageTag{} = tag -> Lettermint.MessageTag.new!(tag.name, tag.value) + %{name: name, value: value} -> Lettermint.MessageTag.new!(name, value) + %{"name" => name, "value" => value} -> Lettermint.MessageTag.new!(name, value) + _ -> raise ArgumentError, "Message tags must contain a name and value" + end) + + names = Enum.map(normalized, & &1.name) + + if length(names) != length(Enum.uniq(names)), + do: raise(ArgumentError, "Message tag names must be unique and case-sensitive") + + %{builder | payload: Map.put(builder.payload, :tags, normalized)} + end + @doc "Send the email. Request options include an idempotency key." def send(%__MODULE__{client: client, payload: payload}, opts \\ []), do: Lettermint.Email.send(client, payload, opts) diff --git a/lib/lettermint/message_tag.ex b/lib/lettermint/message_tag.ex new file mode 100644 index 0000000..4c6673a --- /dev/null +++ b/lib/lettermint/message_tag.ex @@ -0,0 +1,26 @@ +defmodule Lettermint.MessageTag do + @moduledoc "A reusable exact-match message tag." + @enforce_keys [:name, :value] + defstruct [:name, :value, extra: %{}] + + @type t :: %__MODULE__{name: String.t(), value: String.t(), extra: map()} + + @spec new!(String.t(), String.t()) :: t() + def new!(name, value) when is_binary(name) and is_binary(value) do + unless Regex.match?(~r/\A[A-Za-z0-9_-]{1,32}\z/, name), + do: raise(ArgumentError, "Message tag names must match ^[A-Za-z0-9_-]{1,32}$") + + if String.starts_with?(String.downcase(name), "__lettermint"), + do: raise(ArgumentError, "Message tag names must not start with __lettermint") + + unless Regex.match?(~r/\A[A-Za-z0-9_-]{1,64}\z/, value), + do: raise(ArgumentError, "Message tag values must match ^[A-Za-z0-9_-]{1,64}$") + + %__MODULE__{name: name, value: value} + end + + def new!(_, _), do: raise(ArgumentError, "Message tag names and values must be strings") + + @doc false + def __schema__, do: [{"name", :name, :string}, {"value", :value, :string}] +end diff --git a/test/client_test.exs b/test/client_test.exs index 522e435..e59f590 100644 --- a/test/client_test.exs +++ b/test/client_test.exs @@ -49,6 +49,29 @@ defmodule Lettermint.ClientTest do ] end + test "typed message tags are validated and serialized" do + alias Lettermint.{EmailBuilder, MessageTag} + + builder = + client() + |> EmailBuilder.new() + |> EmailBuilder.tags([ + MessageTag.new!("campaign", "welcome"), + %{name: "customer", value: "new"} + ]) + + assert EmailBuilder.to_map(builder)["tags"] == [ + %{"name" => "campaign", "value" => "welcome"}, + %{"name" => "customer", "value" => "new"} + ] + + assert_raise ArgumentError, fn -> MessageTag.new!("__LETTERMINT_internal", "value") end + + assert_raise ArgumentError, fn -> + EmailBuilder.tags(builder, [MessageTag.new!("same", "one"), MessageTag.new!("same", "two")]) + end + end + test "optional unset and explicit null are distinct" do assert Model.to_map(%Models.SendMailRequest{subject: "test", text: nil}) == %{ "subject" => "test",