Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,26 @@ 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 <sender@example.com>",
to: ["recipient@example.com"],
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")

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
Expand All @@ -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")
```

Expand Down
35 changes: 33 additions & 2 deletions lib/lettermint/email_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ defmodule Lettermint.EmailBuilder do
:html,
:text,
:route,
:tag,
:tags,
:headers,
:metadata,
:settings,
Expand All @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions lib/lettermint/message_tag.ex
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions test/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down