From b27893016f219d19d682ed6b783f6c1b05289783 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Wed, 16 Jul 2025 06:17:31 -0400 Subject: [PATCH 1/4] feat(audiences): add Audiences module for managing audience groups - Introduced `Resend.Audiences` module with functions to create, list, retrieve, and delete audiences. - Added `Resend.Audiences.Audience` struct to represent audience data. - Updated `mix.exs` to include new modules in the project. - Implemented comprehensive tests for audience management functionalities. --- lib/resend/audiences.ex | 117 ++++++++++++++ lib/resend/audiences/audience.ex | 44 +++++ mix.exs | 2 + test/resend/audiences/audience_test.exs | 74 +++++++++ test/resend/audiences_test.exs | 203 ++++++++++++++++++++++++ 5 files changed, 440 insertions(+) create mode 100644 lib/resend/audiences.ex create mode 100644 lib/resend/audiences/audience.ex create mode 100644 test/resend/audiences/audience_test.exs create mode 100644 test/resend/audiences_test.exs diff --git a/lib/resend/audiences.ex b/lib/resend/audiences.ex new file mode 100644 index 0000000..4603105 --- /dev/null +++ b/lib/resend/audiences.ex @@ -0,0 +1,117 @@ +defmodule Resend.Audiences do + @moduledoc """ + Manage audiences in Resend. + + Audiences are groups of contacts that you can send broadcasts to. This module + provides functions to create, list, retrieve, and delete audiences. + + ## Examples + + # Create a new audience + {:ok, audience} = Resend.Audiences.create(name: "Newsletter Subscribers") + + # List all audiences + {:ok, audiences} = Resend.Audiences.list() + + # Get a specific audience + {:ok, audience} = Resend.Audiences.get("78261eea-8f8b-4381-83c6-79fa7120f1cf") + + # Remove an audience + {:ok, removed_audience} = Resend.Audiences.remove("78261eea-8f8b-4381-83c6-79fa7120f1cf") + """ + + alias Resend.Audiences.Audience + + @doc """ + Creates a new audience. + + ## Parameters + + * `:name` - The name of the audience you want to create (required) + + ## Examples + + iex> Resend.Audiences.create(name: "Newsletter Subscribers") + {:ok, %Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", name: "Newsletter Subscribers"}} + + iex> Resend.Audiences.create(name: "") + {:error, %Resend.Error{message: "Audience name is required"}} + + """ + @spec create(Keyword.t()) :: Resend.Client.response(Audience.t()) + @spec create(Resend.Client.t(), Keyword.t()) :: Resend.Client.response(Audience.t()) + def create(client \\ Resend.client(), opts) do + Resend.Client.post(client, Audience, "/audiences", %{ + name: opts[:name] + }) + end + + @doc """ + Lists all audiences in your account. + + ## Examples + + iex> Resend.Audiences.list() + {:ok, %Resend.List{data: [%Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", name: "Newsletter Subscribers"}]}} + + """ + @spec list() :: Resend.Client.response(Resend.List.t(Audience.t())) + @spec list(Resend.Client.t()) :: Resend.Client.response(Resend.List.t(Audience.t())) + def list(client \\ Resend.client()) do + Resend.Client.get(client, Resend.List.of(Audience), "/audiences") + end + + @doc """ + Gets details of a specific audience by its ID. + + ## Parameters + + * `audience_id` - The Audience ID (required) + + ## Examples + + iex> Resend.Audiences.get("78261eea-8f8b-4381-83c6-79fa7120f1cf") + {:ok, %Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", name: "Newsletter Subscribers"}} + + iex> Resend.Audiences.get("non-existent-id") + {:error, %Resend.Error{message: "Audience not found"}} + + """ + @spec get(String.t()) :: Resend.Client.response(Audience.t()) + @spec get(Resend.Client.t(), String.t()) :: Resend.Client.response(Audience.t()) + def get(client \\ Resend.client(), audience_id) do + Resend.Client.get(client, Audience, "/audiences/:id", + opts: [ + path_params: [id: audience_id] + ] + ) + end + + @doc """ + Removes an audience permanently. This action cannot be undone. + + Deleting an audience will also delete all associated contacts. + + ## Parameters + + * `audience_id` - The Audience ID (required) + + ## Examples + + iex> Resend.Audiences.remove("78261eea-8f8b-4381-83c6-79fa7120f1cf") + {:ok, %Resend.Audiences.Audience{id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", deleted: true}} + + iex> Resend.Audiences.remove("non-existent-id") + {:error, %Resend.Error{message: "Audience not found"}} + + """ + @spec remove(String.t()) :: Resend.Client.response(Audience.t()) + @spec remove(Resend.Client.t(), String.t()) :: Resend.Client.response(Audience.t()) + def remove(client \\ Resend.client(), audience_id) do + Resend.Client.delete(client, Audience, "/audiences/:id", %{}, + opts: [ + path_params: [id: audience_id] + ] + ) + end +end diff --git a/lib/resend/audiences/audience.ex b/lib/resend/audiences/audience.ex new file mode 100644 index 0000000..09f80cf --- /dev/null +++ b/lib/resend/audiences/audience.ex @@ -0,0 +1,44 @@ +defmodule Resend.Audiences.Audience do + @moduledoc """ + Resend Audience struct. + + Represents an audience, which is a group of contacts that you can send broadcasts to. + Each audience has a unique ID that is used to reference it in other API calls. + """ + + alias Resend.Util + + @behaviour Resend.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + name: String.t() | nil, + created_at: DateTime.t() | nil, + deleted: boolean() | nil + } + + @enforce_keys [:id] + defstruct [ + :id, + :name, + :created_at, + :deleted + ] + + @doc """ + Casts a raw map from the API response into an Audience struct. + + This function is used internally by the Castable protocol to convert + JSON responses from the Resend API into properly typed Elixir structs. + """ + @impl true + @spec cast(map()) :: t() + def cast(map) do + %__MODULE__{ + id: map["id"], + name: map["name"], + created_at: Util.parse_iso8601(map["created_at"]), + deleted: map["deleted"] + } + end +end diff --git a/mix.exs b/mix.exs index 4b308a8..84d3943 100644 --- a/mix.exs +++ b/mix.exs @@ -76,11 +76,13 @@ defmodule Resend.MixProject do [ "Core API": [ Resend.ApiKeys, + Resend.Audiences, Resend.Domains, Resend.Emails ], "Response Structs": [ Resend.ApiKeys.ApiKey, + Resend.Audiences.Audience, Resend.Domains.Domain, Resend.Domains.Domain.Record, Resend.Emails.Email, diff --git a/test/resend/audiences/audience_test.exs b/test/resend/audiences/audience_test.exs new file mode 100644 index 0000000..8a4ce44 --- /dev/null +++ b/test/resend/audiences/audience_test.exs @@ -0,0 +1,74 @@ +defmodule Resend.Audiences.AudienceTest do + use ExUnit.Case, async: true + + alias Resend.Audiences.Audience + + describe "cast/1" do + test "casts a valid audience map to struct" do + audience_map = %{ + "id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Registered Users", + "created_at" => "2023-10-06T22:59:55.977Z" + } + + result = Audience.cast(audience_map) + + assert %Audience{ + id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + name: "Registered Users", + created_at: ~U[2023-10-06 22:59:55.977Z], + deleted: nil + } = result + end + + test "casts an audience map with deleted flag" do + audience_map = %{ + "id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Registered Users", + "created_at" => "2023-10-06T22:59:55.977Z", + "deleted" => true + } + + result = Audience.cast(audience_map) + + assert %Audience{ + id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + name: "Registered Users", + created_at: ~U[2023-10-06 22:59:55.977Z], + deleted: true + } = result + end + + test "casts a minimal audience map" do + audience_map = %{ + "id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf" + } + + result = Audience.cast(audience_map) + + assert %Audience{ + id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + name: nil, + created_at: nil, + deleted: nil + } = result + end + + test "handles nil datetime" do + audience_map = %{ + "id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Test Audience", + "created_at" => nil + } + + result = Audience.cast(audience_map) + + assert %Audience{ + id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + name: "Test Audience", + created_at: nil, + deleted: nil + } = result + end + end +end diff --git a/test/resend/audiences_test.exs b/test/resend/audiences_test.exs new file mode 100644 index 0000000..3e42ac4 --- /dev/null +++ b/test/resend/audiences_test.exs @@ -0,0 +1,203 @@ +defmodule Resend.AudiencesTest do + use Resend.TestCase, async: true + + alias Resend.Audiences.Audience + + setup :setup_env + + describe "/audiences" do + test "creates an audience", context do + name = "Test Audience" + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/audiences" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["name"] == name + + success_body = %{ + "object" => "audience", + "id" => audience_id, + "name" => name + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Audience{id: ^audience_id, name: ^name}} = + Resend.Audiences.create(name: name) + end + + test "lists all audiences", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + audience_name = "Registered Users" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/audiences" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "list", + "data" => [ + %{ + "id" => audience_id, + "name" => audience_name, + "created_at" => "2023-10-06T22:59:55.977Z" + } + ] + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Resend.List{data: [%Audience{id: ^audience_id, name: ^audience_name}]}} = + Resend.Audiences.list() + end + + test "gets an audience by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + audience_name = "Registered Users" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/audiences/#{audience_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "audience", + "id" => audience_id, + "name" => audience_name, + "created_at" => "2023-10-06T22:59:55.977Z" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Audience{id: ^audience_id, name: ^audience_name}} = + Resend.Audiences.get(audience_id) + end + + test "removes an audience by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + assert request.url == "https://api.resend.com/audiences/#{audience_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "audience", + "id" => audience_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Audience{id: ^audience_id, deleted: true}} = + Resend.Audiences.remove(audience_id) + end + + if not live_mode?() do + test "returns an error when creating audience fails", context do + name = "Test Audience" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/audiences" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Audience name is required", + "name" => "validation_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "validation_error", status_code: 400}} = + Resend.Audiences.create(name: name) + end + + test "returns an error when audience not found", context do + audience_id = "non-existent-id" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/audiences/#{audience_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Audience not found", + "name" => "not_found", + "statusCode" => 404 + } + + %Tesla.Env{status: 404, body: error_body} + end) + + assert {:error, %Resend.Error{name: "not_found", status_code: 404}} = + Resend.Audiences.get(audience_id) + end + end + end + + describe "with custom client" do + test "creates an audience with custom client", _context do + name = "Test Audience" + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :post + assert request.url == "https://api.resend.com/audiences" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + body = Jason.decode!(request.body) + assert body["name"] == name + + success_body = %{ + "object" => "audience", + "id" => audience_id, + "name" => name + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Audience{id: ^audience_id, name: ^name}} = + Resend.Audiences.create(custom_client, name: name) + end + end +end From 5b1b44ef677835054a8cee99d7aac7ee7ad46205 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Wed, 16 Jul 2025 06:29:19 -0400 Subject: [PATCH 2/4] feat(contacts): introduce Contacts module for managing audience contacts - Added `Resend.Contacts` module with functions to create, list, retrieve, update, and delete contacts within audiences. - Introduced `Resend.Contacts.Contact` struct to represent individual contact data. - Updated `mix.exs` to include the new Contacts module. - Implemented comprehensive tests for contact management functionalities. --- lib/resend/client.ex | 16 ++ lib/resend/contacts.ex | 231 ++++++++++++++++ lib/resend/contacts/contact.ex | 53 ++++ mix.exs | 2 + test/resend/contacts/contact_test.exs | 119 +++++++++ test/resend/contacts_test.exs | 362 ++++++++++++++++++++++++++ 6 files changed, 783 insertions(+) create mode 100644 lib/resend/contacts.ex create mode 100644 lib/resend/contacts/contact.ex create mode 100644 test/resend/contacts/contact_test.exs create mode 100644 test/resend/contacts_test.exs diff --git a/lib/resend/client.ex b/lib/resend/client.ex index 0a42e60..f2c59c0 100644 --- a/lib/resend/client.ex +++ b/lib/resend/client.ex @@ -81,6 +81,22 @@ defmodule Resend.Client do |> handle_response(path, castable_module) end + @spec patch(t(), Castable.impl(), String.t()) :: response(any()) + @spec patch(t(), Castable.impl(), String.t(), map()) :: response(any()) + @spec patch(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) + def patch(client, castable_module, path, body \\ %{}, opts \\ []) do + client_module = client.client || Resend.Client.TeslaClient + + opts = + opts + |> Keyword.put(:method, :patch) + |> Keyword.put(:url, path) + |> Keyword.put(:body, body) + + client_module.request(client, opts) + |> handle_response(path, castable_module) + end + defp handle_response(response, path, castable_module) do case response do {:ok, %{body: "", status: status}} when status in 200..299 -> diff --git a/lib/resend/contacts.ex b/lib/resend/contacts.ex new file mode 100644 index 0000000..c8ae7d8 --- /dev/null +++ b/lib/resend/contacts.ex @@ -0,0 +1,231 @@ +defmodule Resend.Contacts do + @moduledoc """ + Manage contacts in Resend audiences. + + Contacts are individual email addresses within audiences. This module provides + functions to create, list, retrieve, update, and delete contacts within specific + audiences. Contacts can be referenced by either their ID or email address. + + ## Examples + + # Create a new contact + {:ok, contact} = Resend.Contacts.create("audience-id", + email: "steve@example.com", + first_name: "Steve", + last_name: "Wozniak" + ) + + # List all contacts in an audience + {:ok, contacts} = Resend.Contacts.list("audience-id") + + # Get a contact by ID + {:ok, contact} = Resend.Contacts.get("audience-id", id: "contact-id") + + # Get a contact by email + {:ok, contact} = Resend.Contacts.get("audience-id", email: "steve@example.com") + + # Update a contact + {:ok, contact} = Resend.Contacts.update("audience-id", + id: "contact-id", + unsubscribed: true + ) + + # Remove a contact + {:ok, removed} = Resend.Contacts.remove("audience-id", id: "contact-id") + """ + + alias Resend.Contacts.Contact + + @doc """ + Creates a new contact inside an audience. + + ## Parameters + + * `audience_id` - The Audience ID (required) + * `opts` - Keyword list of contact attributes: + * `:email` - The email address of the contact (required) + * `:first_name` - The first name of the contact (optional) + * `:last_name` - The last name of the contact (optional) + * `:unsubscribed` - The subscription status (optional) + + ## Examples + + iex> Resend.Contacts.create("78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> email: "steve@example.com", + ...> first_name: "Steve", + ...> last_name: "Wozniak", + ...> unsubscribed: false + ...> ) + {:ok, %Resend.Contacts.Contact{id: "479e3145-dd38-476b-932c-529ceb705947"}} + + """ + @spec create(String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + @spec create(Resend.Client.t(), String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + def create(client \\ Resend.client(), audience_id, opts) do + Resend.Client.post( + client, + Contact, + "/audiences/:audience_id/contacts", + %{ + email: opts[:email], + first_name: opts[:first_name], + last_name: opts[:last_name], + unsubscribed: opts[:unsubscribed] + }, + opts: [ + path_params: [audience_id: audience_id] + ] + ) + end + + @doc """ + Lists all contacts from an audience. + + ## Parameters + + * `audience_id` - The Audience ID (required) + + ## Examples + + iex> Resend.Contacts.list("78261eea-8f8b-4381-83c6-79fa7120f1cf") + {:ok, %Resend.List{data: [%Resend.Contacts.Contact{email: "steve@example.com"}]}} + + """ + @spec list(String.t()) :: Resend.Client.response(Resend.List.t(Contact.t())) + @spec list(Resend.Client.t(), String.t()) :: Resend.Client.response(Resend.List.t(Contact.t())) + def list(client \\ Resend.client(), audience_id) do + Resend.Client.get(client, Resend.List.of(Contact), "/audiences/:audience_id/contacts", + opts: [ + path_params: [audience_id: audience_id] + ] + ) + end + + @doc """ + Gets details of a specific contact by ID or email. + + ## Parameters + + * `audience_id` - The Audience ID (required) + * `opts` - Keyword list with one of: + * `:id` - The Contact ID + * `:email` - The Contact Email + + Either `:id` or `:email` must be provided. + + ## Examples + + # Get by contact ID + iex> Resend.Contacts.get("78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> id: "e169aa45-1ecf-4183-9955-b1499d5701d3" + ...> ) + {:ok, %Resend.Contacts.Contact{id: "e169aa45-1ecf-4183-9955-b1499d5701d3"}} + + # Get by contact email + iex> Resend.Contacts.get("78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> email: "steve@example.com" + ...> ) + {:ok, %Resend.Contacts.Contact{email: "steve@example.com"}} + + """ + @spec get(String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + @spec get(Resend.Client.t(), String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + def get(client \\ Resend.client(), audience_id, opts) do + contact_id = opts[:id] || opts[:email] + + Resend.Client.get(client, Contact, "/audiences/:audience_id/contacts/:id", + opts: [ + path_params: [audience_id: audience_id, id: contact_id] + ] + ) + end + + @doc """ + Updates an existing contact's information. + + ## Parameters + + * `audience_id` - The Audience ID (required) + * `opts` - Keyword list with: + * Either `:id` or `:email` to identify the contact (required) + * `:first_name` - The first name of the contact (optional) + * `:last_name` - The last name of the contact (optional) + * `:unsubscribed` - The subscription status (optional) + + ## Examples + + # Update by contact ID + iex> Resend.Contacts.update("78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + ...> unsubscribed: true + ...> ) + {:ok, %Resend.Contacts.Contact{id: "e169aa45-1ecf-4183-9955-b1499d5701d3"}} + + # Update by contact email + iex> Resend.Contacts.update("78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> email: "steve@example.com", + ...> first_name: "Steven" + ...> ) + {:ok, %Resend.Contacts.Contact{email: "steve@example.com"}} + + """ + @spec update(String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + @spec update(Resend.Client.t(), String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + def update(client \\ Resend.client(), audience_id, opts) do + contact_id = opts[:id] || opts[:email] + + body = + %{ + first_name: opts[:first_name], + last_name: opts[:last_name], + unsubscribed: opts[:unsubscribed] + } + |> Enum.filter(fn {_k, v} -> !is_nil(v) end) + |> Map.new() + + Resend.Client.patch(client, Contact, "/audiences/:audience_id/contacts/:id", body, + opts: [ + path_params: [audience_id: audience_id, id: contact_id] + ] + ) + end + + @doc """ + Removes a contact from an audience. + + ## Parameters + + * `audience_id` - The Audience ID (required) + * `opts` - Keyword list with one of: + * `:id` - The Contact ID + * `:email` - The Contact Email + + Either `:id` or `:email` must be provided. + + ## Examples + + # Remove by contact ID + iex> Resend.Contacts.remove("78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> id: "520784e2-887d-4c25-b53c-4ad46ad38100" + ...> ) + {:ok, %Resend.Contacts.Contact{id: "520784e2-887d-4c25-b53c-4ad46ad38100", deleted: true}} + + # Remove by contact email + iex> Resend.Contacts.remove("78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> email: "steve@example.com" + ...> ) + {:ok, %Resend.Contacts.Contact{deleted: true}} + + """ + @spec remove(String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + @spec remove(Resend.Client.t(), String.t(), Keyword.t()) :: Resend.Client.response(Contact.t()) + def remove(client \\ Resend.client(), audience_id, opts) do + contact_id = opts[:id] || opts[:email] + + Resend.Client.delete(client, Contact, "/audiences/:audience_id/contacts/:id", %{}, + opts: [ + path_params: [audience_id: audience_id, id: contact_id] + ] + ) + end +end diff --git a/lib/resend/contacts/contact.ex b/lib/resend/contacts/contact.ex new file mode 100644 index 0000000..0bf047e --- /dev/null +++ b/lib/resend/contacts/contact.ex @@ -0,0 +1,53 @@ +defmodule Resend.Contacts.Contact do + @moduledoc """ + Resend Contact struct. + + Represents an individual contact within an audience. Contacts have email addresses + and can have additional metadata like names and subscription status. + """ + + alias Resend.Util + + @behaviour Resend.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + email: String.t() | nil, + first_name: String.t() | nil, + last_name: String.t() | nil, + created_at: DateTime.t() | nil, + unsubscribed: boolean() | nil, + deleted: boolean() | nil + } + + @enforce_keys [:id] + defstruct [ + :id, + :email, + :first_name, + :last_name, + :created_at, + :unsubscribed, + :deleted + ] + + @doc """ + Casts a raw map from the API response into a Contact struct. + + This function is used internally by the Castable protocol to convert + JSON responses from the Resend API into properly typed Elixir structs. + """ + @impl true + @spec cast(map()) :: t() + def cast(map) do + %__MODULE__{ + id: map["id"] || map["contact"], + email: map["email"], + first_name: map["first_name"], + last_name: map["last_name"], + created_at: Util.parse_iso8601(map["created_at"]), + unsubscribed: map["unsubscribed"], + deleted: map["deleted"] + } + end +end diff --git a/mix.exs b/mix.exs index 84d3943..b02d536 100644 --- a/mix.exs +++ b/mix.exs @@ -77,12 +77,14 @@ defmodule Resend.MixProject do "Core API": [ Resend.ApiKeys, Resend.Audiences, + Resend.Contacts, Resend.Domains, Resend.Emails ], "Response Structs": [ Resend.ApiKeys.ApiKey, Resend.Audiences.Audience, + Resend.Contacts.Contact, Resend.Domains.Domain, Resend.Domains.Domain.Record, Resend.Emails.Email, diff --git a/test/resend/contacts/contact_test.exs b/test/resend/contacts/contact_test.exs new file mode 100644 index 0000000..d122961 --- /dev/null +++ b/test/resend/contacts/contact_test.exs @@ -0,0 +1,119 @@ +defmodule Resend.Contacts.ContactTest do + use ExUnit.Case, async: true + + alias Resend.Contacts.Contact + + describe "cast/1" do + test "casts a valid contact map to struct" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "steve.wozniak@gmail.com", + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "steve.wozniak@gmail.com", + first_name: "Steve", + last_name: "Wozniak", + created_at: ~U[2023-10-06 23:47:56.678Z], + unsubscribed: false, + deleted: nil + } = result + end + + test "casts a contact map with deleted flag" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "steve.wozniak@gmail.com", + "deleted" => true + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "steve.wozniak@gmail.com", + first_name: nil, + last_name: nil, + created_at: nil, + unsubscribed: nil, + deleted: true + } = result + end + + test "casts a minimal contact map" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3" + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: nil, + first_name: nil, + last_name: nil, + created_at: nil, + unsubscribed: nil, + deleted: nil + } = result + end + + test "handles contact field in delete response" do + contact_map = %{ + "contact" => "520784e2-887d-4c25-b53c-4ad46ad38100", + "deleted" => true + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "520784e2-887d-4c25-b53c-4ad46ad38100", + email: nil, + first_name: nil, + last_name: nil, + created_at: nil, + unsubscribed: nil, + deleted: true + } = result + end + + test "handles nil datetime" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "test@example.com", + "created_at" => nil + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "test@example.com", + created_at: nil + } = result + end + + test "handles unsubscribed status" do + contact_map = %{ + "id" => "e169aa45-1ecf-4183-9955-b1499d5701d3", + "email" => "test@example.com", + "unsubscribed" => true + } + + result = Contact.cast(contact_map) + + assert %Contact{ + id: "e169aa45-1ecf-4183-9955-b1499d5701d3", + email: "test@example.com", + unsubscribed: true + } = result + end + end +end diff --git a/test/resend/contacts_test.exs b/test/resend/contacts_test.exs new file mode 100644 index 0000000..ee4ea75 --- /dev/null +++ b/test/resend/contacts_test.exs @@ -0,0 +1,362 @@ +defmodule Resend.ContactsTest do + use Resend.TestCase, async: true + + alias Resend.Contacts.Contact + + setup :setup_env + + describe "/audiences/:audience_id/contacts" do + test "creates a contact", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "479e3145-dd38-476b-932c-529ceb705947" + email = "steve.wozniak@gmail.com" + first_name = "Steve" + last_name = "Wozniak" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["email"] == email + assert body["first_name"] == first_name + assert body["last_name"] == last_name + assert body["unsubscribed"] == false + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.create(audience_id, + email: email, + first_name: first_name, + last_name: last_name, + unsubscribed: false + ) + end + + test "lists all contacts", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve.wozniak@gmail.com" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "list", + "data" => [ + %{ + "id" => contact_id, + "email" => email, + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + ] + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Resend.List{data: [%Contact{id: ^contact_id, email: ^email}]}} = + Resend.Contacts.list(audience_id) + end + + test "gets a contact by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve.wozniak@gmail.com" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "id" => contact_id, + "email" => email, + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, email: ^email}} = + Resend.Contacts.get(audience_id, id: contact_id) + end + + test "gets a contact by email", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve.wozniak@gmail.com" + encoded_email = URI.encode_www_form(email) + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{encoded_email}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "id" => contact_id, + "email" => email, + "first_name" => "Steve", + "last_name" => "Wozniak", + "created_at" => "2023-10-06T23:47:56.678Z", + "unsubscribed" => false + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, email: ^email}} = + Resend.Contacts.get(audience_id, email: email) + end + + test "updates a contact by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["unsubscribed"] == true + assert Map.keys(body) == ["unsubscribed"] + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.update(audience_id, id: contact_id, unsubscribed: true) + end + + test "updates a contact by email", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "e169aa45-1ecf-4183-9955-b1499d5701d3" + email = "steve@example.com" + encoded_email = URI.encode_www_form(email) + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{encoded_email}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["first_name"] == "Steven" + assert body["last_name"] == "Jobs" + assert Map.keys(body) |> Enum.sort() == ["first_name", "last_name"] + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.update(audience_id, + email: email, + first_name: "Steven", + last_name: "Jobs" + ) + end + + test "removes a contact by ID", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "520784e2-887d-4c25-b53c-4ad46ad38100" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "contact" => contact_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, deleted: true}} = + Resend.Contacts.remove(audience_id, id: contact_id) + end + + test "removes a contact by email", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "520784e2-887d-4c25-b53c-4ad46ad38100" + email = "acme@example.com" + encoded_email = URI.encode_www_form(email) + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{encoded_email}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "contact", + "contact" => contact_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id, deleted: true}} = + Resend.Contacts.remove(audience_id, email: email) + end + + if not live_mode?() do + test "returns an error when creating contact fails", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Email is required", + "name" => "validation_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "validation_error", status_code: 400}} = + Resend.Contacts.create(audience_id, email: nil) + end + + test "returns an error when contact not found", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "non-existent-id" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + + assert request.url == + "https://api.resend.com/audiences/#{audience_id}/contacts/#{contact_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Contact not found", + "name" => "not_found", + "statusCode" => 404 + } + + %Tesla.Env{status: 404, body: error_body} + end) + + assert {:error, %Resend.Error{name: "not_found", status_code: 404}} = + Resend.Contacts.get(audience_id, id: contact_id) + end + end + end + + describe "with custom client" do + test "creates a contact with custom client", _context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + contact_id = "479e3145-dd38-476b-932c-529ceb705947" + email = "test@example.com" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :post + assert request.url == "https://api.resend.com/audiences/#{audience_id}/contacts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + body = Jason.decode!(request.body) + assert body["email"] == email + + success_body = %{ + "object" => "contact", + "id" => contact_id + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Contact{id: ^contact_id}} = + Resend.Contacts.create(custom_client, audience_id, email: email) + end + end +end From 6ef93b24172acb3d4bdc6ca0564b8da4117ca117 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Wed, 16 Jul 2025 06:59:04 -0400 Subject: [PATCH 3/4] feat(broadcasts): add Broadcasts module for managing email campaigns - Introduced `Resend.Broadcasts` module with functions to create, list, retrieve, update, send, and delete broadcasts. - Added `Resend.Broadcasts.Broadcast` struct to represent broadcast data. - Updated `mix.exs` to include the new Broadcasts module. - Implemented comprehensive tests for broadcast management functionalities. --- lib/resend/broadcasts.ex | 278 ++++++++ lib/resend/broadcasts/broadcast.ex | 70 ++ mix.exs | 2 + test/resend/broadcasts/broadcast_test.exs | 224 ++++++ test/resend/broadcasts_test.exs | 801 ++++++++++++++++++++++ 5 files changed, 1375 insertions(+) create mode 100644 lib/resend/broadcasts.ex create mode 100644 lib/resend/broadcasts/broadcast.ex create mode 100644 test/resend/broadcasts/broadcast_test.exs create mode 100644 test/resend/broadcasts_test.exs diff --git a/lib/resend/broadcasts.ex b/lib/resend/broadcasts.ex new file mode 100644 index 0000000..8f77fbf --- /dev/null +++ b/lib/resend/broadcasts.ex @@ -0,0 +1,278 @@ +defmodule Resend.Broadcasts do + @moduledoc """ + Manage broadcasts in Resend. + + Broadcasts are email campaigns that can be sent to an entire audience. This module + provides functions to create, list, retrieve, update, send, and delete broadcasts. + + ## Examples + + # Create a new broadcast + {:ok, broadcast} = Resend.Broadcasts.create( + audience_id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + name: "Monthly Newsletter", + from: "newsletter@example.com", + subject: "Our Monthly Update" + ) + + # List all broadcasts + {:ok, broadcasts} = Resend.Broadcasts.list() + + # Get a specific broadcast + {:ok, broadcast} = Resend.Broadcasts.get("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + + # Update a broadcast + {:ok, broadcast} = Resend.Broadcasts.update("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + subject: "Updated Subject Line" + ) + + # Send a broadcast immediately + {:ok, broadcast} = Resend.Broadcasts.send("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + + # Schedule a broadcast for later + {:ok, broadcast} = Resend.Broadcasts.send("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + scheduled_at: ~U[2024-12-25 09:00:00Z] + ) + + # Remove a broadcast + {:ok, removed_broadcast} = Resend.Broadcasts.remove("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + """ + + alias Resend.Broadcasts.Broadcast + + @doc """ + Creates a new broadcast. + + ## Parameters + + * `:audience_id` - The ID of the audience to send to (required) + * `:name` - The name of the broadcast (required) + * `:from` - The sender email address (required) + * `:subject` - The subject line of the broadcast (required) + * `:reply_to` - The reply-to email address(es). Can be a single string or list of strings (optional) + * `:preview_text` - The preview text that appears in email clients (optional) + * `:html` - The HTML content of the broadcast (optional) + * `:text` - The plain text content of the broadcast (optional) + * `:headers` - Map of custom headers to include (optional) + * `:attachments` - List of attachments to include (optional) + * `:tags` - List of tags for analytics (optional) + + ## Examples + + iex> Resend.Broadcasts.create( + ...> audience_id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> name: "Welcome Campaign", + ...> from: "hello@example.com", + ...> subject: "Welcome to our newsletter!", + ...> html: "

Thank you for subscribing!

" + ...> ) + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", status: "draft"}} + + iex> Resend.Broadcasts.create( + ...> audience_id: "78261eea-8f8b-4381-83c6-79fa7120f1cf", + ...> name: "Newsletter", + ...> from: "news@example.com", + ...> subject: "Monthly Update", + ...> reply_to: ["support@example.com", "help@example.com"], + ...> preview_text: "Check out our latest updates...", + ...> html: "

Newsletter

" + ...> ) + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e"}} + + """ + @spec create(Keyword.t()) :: Resend.Client.response(Broadcast.t()) + @spec create(Resend.Client.t(), Keyword.t()) :: Resend.Client.response(Broadcast.t()) + def create(client \\ Resend.client(), opts) do + Resend.Client.post(client, Broadcast, "/broadcasts", %{ + audience_id: opts[:audience_id], + name: opts[:name], + from: opts[:from], + subject: opts[:subject], + reply_to: opts[:reply_to], + preview_text: opts[:preview_text], + html: opts[:html], + text: opts[:text], + headers: opts[:headers], + attachments: opts[:attachments], + tags: opts[:tags] + }) + end + + @doc """ + Lists all broadcasts in your account. + + ## Examples + + iex> Resend.Broadcasts.list() + {:ok, %Resend.List{data: [%Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e"}]}} + + """ + @spec list() :: Resend.Client.response(Resend.List.t(Broadcast.t())) + @spec list(Resend.Client.t()) :: Resend.Client.response(Resend.List.t(Broadcast.t())) + def list(client \\ Resend.client()) do + Resend.Client.get(client, Resend.List.of(Broadcast), "/broadcasts") + end + + @doc """ + Gets details of a specific broadcast by its ID. + + ## Parameters + + * `broadcast_id` - The Broadcast ID (required) + + ## Examples + + iex> Resend.Broadcasts.get("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", name: "Monthly Newsletter"}} + + iex> Resend.Broadcasts.get("non-existent-id") + {:error, %Resend.Error{message: "Broadcast not found"}} + + """ + @spec get(String.t()) :: Resend.Client.response(Broadcast.t()) + @spec get(Resend.Client.t(), String.t()) :: Resend.Client.response(Broadcast.t()) + def get(client \\ Resend.client(), broadcast_id) do + Resend.Client.get(client, Broadcast, "/broadcasts/:id", + opts: [ + path_params: [id: broadcast_id] + ] + ) + end + + @doc """ + Updates an existing broadcast. + + Only broadcasts with status "draft" can be updated. Once a broadcast is sent or scheduled, + it cannot be modified. + + ## Parameters + + * `broadcast_id` - The Broadcast ID (required) + * `opts` - Keyword list with update parameters: + * `:name` - The name of the broadcast (optional) + * `:from` - The sender email address (optional) + * `:subject` - The subject line of the broadcast (optional) + * `:reply_to` - The reply-to email address(es). Can be a single string or list of strings (optional) + * `:preview_text` - The preview text that appears in email clients (optional) + * `:html` - The HTML content of the broadcast (optional) + * `:text` - The plain text content of the broadcast (optional) + * `:headers` - Map of custom headers to include (optional) + * `:attachments` - List of attachments to include (optional) + * `:tags` - List of tags for analytics (optional) + + ## Examples + + iex> Resend.Broadcasts.update("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + ...> subject: "Updated Newsletter Subject", + ...> preview_text: "New preview text" + ...> ) + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", subject: "Updated Newsletter Subject"}} + + iex> Resend.Broadcasts.update("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + ...> reply_to: "noreply@example.com" + ...> ) + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e"}} + + """ + @spec update(String.t(), Keyword.t()) :: Resend.Client.response(Broadcast.t()) + @spec update(Resend.Client.t(), String.t(), Keyword.t()) :: + Resend.Client.response(Broadcast.t()) + def update(client \\ Resend.client(), broadcast_id, opts) do + body = + %{ + name: opts[:name], + from: opts[:from], + subject: opts[:subject], + reply_to: opts[:reply_to], + preview_text: opts[:preview_text], + html: opts[:html], + text: opts[:text], + headers: opts[:headers], + attachments: opts[:attachments], + tags: opts[:tags] + } + |> Enum.filter(fn {_k, v} -> !is_nil(v) end) + |> Map.new() + + Resend.Client.patch(client, Broadcast, "/broadcasts/:id", body, + opts: [ + path_params: [id: broadcast_id] + ] + ) + end + + @doc """ + Sends or schedules a broadcast. + + ## Parameters + + * `broadcast_id` - The Broadcast ID (required) + * `opts` - Keyword list with optional parameters: + * `:scheduled_at` - DateTime to schedule the broadcast. If not provided, sends immediately (optional) + + ## Examples + + # Send immediately + iex> Resend.Broadcasts.send("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", status: "sending"}} + + # Schedule for later + iex> Resend.Broadcasts.send("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + ...> scheduled_at: ~U[2024-12-25 09:00:00Z] + ...> ) + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", status: "scheduled"}} + + # Cancel a scheduled broadcast by sending nil + iex> Resend.Broadcasts.send("b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + ...> scheduled_at: nil + ...> ) + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", status: "cancelled"}} + + """ + @spec send(String.t()) :: Resend.Client.response(Broadcast.t()) + @spec send(String.t(), Keyword.t()) :: Resend.Client.response(Broadcast.t()) + @spec send(Resend.Client.t(), String.t()) :: Resend.Client.response(Broadcast.t()) + @spec send(Resend.Client.t(), String.t(), Keyword.t()) :: Resend.Client.response(Broadcast.t()) + def send(client \\ Resend.client(), broadcast_id, opts \\ []) do + body = + case Keyword.has_key?(opts, :scheduled_at) do + true -> %{scheduled_at: opts[:scheduled_at]} + false -> %{} + end + + Resend.Client.post(client, Broadcast, "/broadcasts/:id/send", body, + opts: [ + path_params: [id: broadcast_id] + ] + ) + end + + @doc """ + Removes a broadcast permanently. This action cannot be undone. + + Only broadcasts with status "draft" or "cancelled" can be removed. + Broadcasts that have been sent or are currently sending cannot be deleted. + + ## Parameters + + * `broadcast_id` - The Broadcast ID (required) + + ## Examples + + iex> Resend.Broadcasts.remove("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + {:ok, %Resend.Broadcasts.Broadcast{id: "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", deleted: true}} + + iex> Resend.Broadcasts.remove("non-existent-id") + {:error, %Resend.Error{message: "Broadcast not found"}} + + """ + @spec remove(String.t()) :: Resend.Client.response(Broadcast.t()) + @spec remove(Resend.Client.t(), String.t()) :: Resend.Client.response(Broadcast.t()) + def remove(client \\ Resend.client(), broadcast_id) do + Resend.Client.delete(client, Broadcast, "/broadcasts/:id", %{}, + opts: [ + path_params: [id: broadcast_id] + ] + ) + end +end diff --git a/lib/resend/broadcasts/broadcast.ex b/lib/resend/broadcasts/broadcast.ex new file mode 100644 index 0000000..a4ee374 --- /dev/null +++ b/lib/resend/broadcasts/broadcast.ex @@ -0,0 +1,70 @@ +defmodule Resend.Broadcasts.Broadcast do + @moduledoc """ + Resend Broadcast struct. + + Represents a broadcast email campaign that can be sent to an audience. + Broadcasts support scheduling, drafts, and rich content including HTML and attachments. + """ + + alias Resend.Util + + @behaviour Resend.Castable + + @type t() :: %__MODULE__{ + id: String.t(), + audience_id: String.t() | nil, + name: String.t() | nil, + from: String.t() | nil, + subject: String.t() | nil, + reply_to: String.t() | list(String.t()) | nil, + preview_text: String.t() | nil, + status: String.t() | nil, + created_at: DateTime.t() | nil, + scheduled_at: DateTime.t() | nil, + sent_at: DateTime.t() | nil, + deleted: boolean() | nil + } + + @enforce_keys [:id] + defstruct [ + :id, + :audience_id, + :name, + :from, + :subject, + :reply_to, + :preview_text, + :status, + :created_at, + :scheduled_at, + :sent_at, + :deleted + ] + + @doc """ + Casts a raw map from the API response into a Broadcast struct. + + This function is used internally by the Castable protocol to convert + JSON responses from the Resend API into properly typed Elixir structs. + + Handles the reply_to field which can be either a string or list of strings. + """ + @impl true + @spec cast(map()) :: t() + def cast(map) do + %__MODULE__{ + id: map["id"], + audience_id: map["audience_id"], + name: map["name"], + from: map["from"], + subject: map["subject"], + reply_to: map["reply_to"], + preview_text: map["preview_text"], + status: map["status"], + created_at: Util.parse_iso8601(map["created_at"]), + scheduled_at: Util.parse_iso8601(map["scheduled_at"]), + sent_at: Util.parse_iso8601(map["sent_at"]), + deleted: map["deleted"] + } + end +end diff --git a/mix.exs b/mix.exs index b02d536..8b5b1a6 100644 --- a/mix.exs +++ b/mix.exs @@ -77,6 +77,7 @@ defmodule Resend.MixProject do "Core API": [ Resend.ApiKeys, Resend.Audiences, + Resend.Broadcasts, Resend.Contacts, Resend.Domains, Resend.Emails @@ -84,6 +85,7 @@ defmodule Resend.MixProject do "Response Structs": [ Resend.ApiKeys.ApiKey, Resend.Audiences.Audience, + Resend.Broadcasts.Broadcast, Resend.Contacts.Contact, Resend.Domains.Domain, Resend.Domains.Domain.Record, diff --git a/test/resend/broadcasts/broadcast_test.exs b/test/resend/broadcasts/broadcast_test.exs new file mode 100644 index 0000000..2d0ea61 --- /dev/null +++ b/test/resend/broadcasts/broadcast_test.exs @@ -0,0 +1,224 @@ +defmodule Resend.Broadcasts.BroadcastTest do + use Resend.TestCase, async: true + + alias Resend.Broadcasts.Broadcast + + describe "cast/1" do + test "casts a valid broadcast map to struct" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Monthly Newsletter", + "from" => "news@example.com", + "subject" => "Your Monthly Update", + "reply_to" => "support@example.com", + "preview_text" => "Check out what's new this month", + "status" => "draft", + "created_at" => "2023-10-06T22:59:55.977Z", + "scheduled_at" => "2023-10-10T09:00:00.000Z", + "sent_at" => "2023-10-10T09:01:15.123Z", + "deleted" => false + } + + broadcast = Broadcast.cast(map) + + assert broadcast.id == "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + assert broadcast.audience_id == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert broadcast.name == "Monthly Newsletter" + assert broadcast.from == "news@example.com" + assert broadcast.subject == "Your Monthly Update" + assert broadcast.reply_to == "support@example.com" + assert broadcast.preview_text == "Check out what's new this month" + assert broadcast.status == "draft" + assert broadcast.created_at == ~U[2023-10-06 22:59:55.977Z] + assert broadcast.scheduled_at == ~U[2023-10-10 09:00:00.000Z] + assert broadcast.sent_at == ~U[2023-10-10 09:01:15.123Z] + assert broadcast.deleted == false + end + + test "handles reply_to as string" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "reply_to" => "single@example.com" + } + + broadcast = Broadcast.cast(map) + + assert broadcast.reply_to == "single@example.com" + end + + test "handles reply_to as list of strings" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "reply_to" => ["support@example.com", "help@example.com", "info@example.com"] + } + + broadcast = Broadcast.cast(map) + + assert broadcast.reply_to == ["support@example.com", "help@example.com", "info@example.com"] + end + + test "handles minimal broadcast map" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + } + + broadcast = Broadcast.cast(map) + + assert broadcast.id == "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + assert is_nil(broadcast.audience_id) + assert is_nil(broadcast.name) + assert is_nil(broadcast.from) + assert is_nil(broadcast.subject) + assert is_nil(broadcast.reply_to) + assert is_nil(broadcast.preview_text) + assert is_nil(broadcast.status) + assert is_nil(broadcast.created_at) + assert is_nil(broadcast.scheduled_at) + assert is_nil(broadcast.sent_at) + assert is_nil(broadcast.deleted) + end + + test "handles nil datetime fields" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "created_at" => nil, + "scheduled_at" => nil, + "sent_at" => nil + } + + broadcast = Broadcast.cast(map) + + assert is_nil(broadcast.created_at) + assert is_nil(broadcast.scheduled_at) + assert is_nil(broadcast.sent_at) + end + + test "handles deleted flag" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "deleted" => true + } + + broadcast = Broadcast.cast(map) + + assert broadcast.deleted == true + end + + test "handles broadcast with various statuses" do + statuses = ["draft", "scheduled", "sending", "sent", "cancelled"] + + for status <- statuses do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "status" => status + } + + broadcast = Broadcast.cast(map) + + assert broadcast.status == status + end + end + + test "handles empty strings for optional fields" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "preview_text" => "", + "reply_to" => "" + } + + broadcast = Broadcast.cast(map) + + assert broadcast.preview_text == "" + assert broadcast.reply_to == "" + end + + test "handles valid datetime formats" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "created_at" => "2023-10-06T22:59:55.977Z", + "scheduled_at" => "2023-12-25T09:00:00.000Z", + "sent_at" => "2023-12-25T09:01:15.123Z" + } + + broadcast = Broadcast.cast(map) + + assert broadcast.created_at == ~U[2023-10-06 22:59:55.977Z] + assert broadcast.scheduled_at == ~U[2023-12-25 09:00:00.000Z] + assert broadcast.sent_at == ~U[2023-12-25 09:01:15.123Z] + end + + test "preserves fields not in struct definition" do + map = %{ + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "extra_field" => "should be ignored", + "another_field" => 123 + } + + broadcast = Broadcast.cast(map) + + assert broadcast.id == "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + # Extra fields should not be in the struct + refute Map.has_key?(broadcast, :extra_field) + refute Map.has_key?(broadcast, :another_field) + end + + test "handles broadcast response from API after creation" do + # Typical response after creating a broadcast + map = %{ + "object" => "broadcast", + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Welcome Campaign", + "from" => "hello@example.com", + "subject" => "Welcome!", + "status" => "draft", + "created_at" => "2023-10-06T22:59:55.977Z" + } + + broadcast = Broadcast.cast(map) + + assert broadcast.id == "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + assert broadcast.audience_id == "78261eea-8f8b-4381-83c6-79fa7120f1cf" + assert broadcast.name == "Welcome Campaign" + assert broadcast.from == "hello@example.com" + assert broadcast.subject == "Welcome!" + assert broadcast.status == "draft" + assert broadcast.created_at == ~U[2023-10-06 22:59:55.977Z] + end + + test "handles broadcast response from API after sending" do + # Typical response after sending a broadcast + map = %{ + "object" => "broadcast", + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Monthly Newsletter", + "from" => "news@example.com", + "subject" => "October Update", + "status" => "sending", + "created_at" => "2023-10-06T22:59:55.977Z", + "sent_at" => "2023-10-07T09:00:00.000Z" + } + + broadcast = Broadcast.cast(map) + + assert broadcast.status == "sending" + assert broadcast.sent_at == ~U[2023-10-07 09:00:00.000Z] + end + + test "handles broadcast response from API after deletion" do + # Typical response after deleting a broadcast + map = %{ + "object" => "broadcast", + "id" => "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "deleted" => true + } + + broadcast = Broadcast.cast(map) + + assert broadcast.id == "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + assert broadcast.deleted == true + end + end +end diff --git a/test/resend/broadcasts_test.exs b/test/resend/broadcasts_test.exs new file mode 100644 index 0000000..ea43019 --- /dev/null +++ b/test/resend/broadcasts_test.exs @@ -0,0 +1,801 @@ +defmodule Resend.BroadcastsTest do + use Resend.TestCase, async: true + + alias Resend.Broadcasts.Broadcast + + setup :setup_env + + describe "/broadcasts" do + test "creates a broadcast", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + name = "Monthly Newsletter" + from = "news@example.com" + subject = "Your Monthly Update" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["audience_id"] == audience_id + assert body["name"] == name + assert body["from"] == from + assert body["subject"] == subject + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => audience_id, + "name" => name, + "from" => from, + "subject" => subject, + "status" => "draft", + "created_at" => "2023-10-06T22:59:55.977Z" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, + %Broadcast{ + id: ^broadcast_id, + audience_id: ^audience_id, + name: ^name, + from: ^from, + subject: ^subject, + status: "draft" + }} = + Resend.Broadcasts.create( + audience_id: audience_id, + name: name, + from: from, + subject: subject + ) + end + + test "creates a broadcast with reply_to as string", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + reply_to = "support@example.com" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["reply_to"] == reply_to + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => audience_id, + "name" => "Test Broadcast", + "from" => "test@example.com", + "subject" => "Test Subject", + "reply_to" => reply_to, + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, reply_to: ^reply_to}} = + Resend.Broadcasts.create( + audience_id: audience_id, + name: "Test Broadcast", + from: "test@example.com", + subject: "Test Subject", + reply_to: reply_to + ) + end + + test "creates a broadcast with reply_to as list", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + reply_to = ["support@example.com", "help@example.com"] + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["reply_to"] == reply_to + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => audience_id, + "name" => "Test Broadcast", + "from" => "test@example.com", + "subject" => "Test Subject", + "reply_to" => reply_to, + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, reply_to: ^reply_to}} = + Resend.Broadcasts.create( + audience_id: audience_id, + name: "Test Broadcast", + from: "test@example.com", + subject: "Test Subject", + reply_to: reply_to + ) + end + + test "creates a broadcast with all optional fields", context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + preview_text = "Check out our latest updates" + html = "

Newsletter

Content here

" + text = "Newsletter\n\nContent here" + headers = %{"X-Custom" => "value"} + attachments = [%{filename: "report.pdf", content: "base64content"}] + tags = [%{name: "category", value: "newsletter"}] + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["preview_text"] == preview_text + assert body["html"] == html + assert body["text"] == text + assert body["headers"] == headers + # Attachments are sent as atoms but returned as strings in JSON + assert body["attachments"] == [ + %{"filename" => "report.pdf", "content" => "base64content"} + ] + + # Tags are sent as atoms but returned as strings in JSON + assert body["tags"] == [%{"name" => "category", "value" => "newsletter"}] + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => audience_id, + "name" => "Full Broadcast", + "from" => "test@example.com", + "subject" => "Test Subject", + "preview_text" => preview_text, + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, preview_text: ^preview_text}} = + Resend.Broadcasts.create( + audience_id: audience_id, + name: "Full Broadcast", + from: "test@example.com", + subject: "Test Subject", + preview_text: preview_text, + html: html, + text: text, + headers: headers, + attachments: attachments, + tags: tags + ) + end + + test "lists all broadcasts", context do + broadcast_id_1 = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + broadcast_id_2 = "a5d24b8e-af0b-4c3c-be0c-359bbd97382f" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "list", + "data" => [ + %{ + "id" => broadcast_id_1, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Monthly Newsletter", + "from" => "news@example.com", + "subject" => "October Update", + "status" => "sent", + "created_at" => "2023-10-06T22:59:55.977Z", + "sent_at" => "2023-10-07T09:00:00.000Z" + }, + %{ + "id" => broadcast_id_2, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Weekly Digest", + "from" => "digest@example.com", + "subject" => "This Week's Highlights", + "status" => "draft", + "created_at" => "2023-10-08T10:30:00.000Z" + } + ] + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, + %Resend.List{ + data: [ + %Broadcast{id: ^broadcast_id_1, status: "sent"}, + %Broadcast{id: ^broadcast_id_2, status: "draft"} + ] + }} = Resend.Broadcasts.list() + end + + test "gets a broadcast by ID", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + name = "Monthly Newsletter" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => audience_id, + "name" => name, + "from" => "news@example.com", + "subject" => "October Update", + "status" => "sent", + "created_at" => "2023-10-06T22:59:55.977Z", + "sent_at" => "2023-10-07T09:00:00.000Z" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, + %Broadcast{ + id: ^broadcast_id, + audience_id: ^audience_id, + name: ^name, + status: "sent" + }} = Resend.Broadcasts.get(broadcast_id) + end + + test "updates a broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + new_subject = "Updated Newsletter Subject" + new_preview_text = "New preview text" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["subject"] == new_subject + assert body["preview_text"] == new_preview_text + # Should not include nil values + refute Map.has_key?(body, "name") + refute Map.has_key?(body, "from") + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Monthly Newsletter", + "from" => "news@example.com", + "subject" => new_subject, + "preview_text" => new_preview_text, + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, + %Broadcast{ + id: ^broadcast_id, + subject: ^new_subject, + preview_text: ^new_preview_text + }} = + Resend.Broadcasts.update(broadcast_id, + subject: new_subject, + preview_text: new_preview_text + ) + end + + test "updates a broadcast with reply_to", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + new_reply_to = ["noreply@example.com", "support@example.com"] + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["reply_to"] == new_reply_to + assert Map.keys(body) == ["reply_to"] + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Monthly Newsletter", + "from" => "news@example.com", + "subject" => "October Update", + "reply_to" => new_reply_to, + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, reply_to: ^new_reply_to}} = + Resend.Broadcasts.update(broadcast_id, reply_to: new_reply_to) + end + + test "sends a broadcast immediately", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body == %{} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Monthly Newsletter", + "from" => "news@example.com", + "subject" => "October Update", + "status" => "sending", + "sent_at" => "2023-10-07T09:00:00.000Z" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, status: "sending"}} = + Resend.Broadcasts.send(broadcast_id) + end + + test "schedules a broadcast for later", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + scheduled_at = ~U[2024-12-25 09:00:00Z] + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["scheduled_at"] == DateTime.to_iso8601(scheduled_at) + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Christmas Newsletter", + "from" => "news@example.com", + "subject" => "Happy Holidays!", + "status" => "scheduled", + "scheduled_at" => DateTime.to_iso8601(scheduled_at) + } + + %Tesla.Env{status: 200, body: success_body} + end) + + # Fix: The function signature is send(client \\ Resend.client(), broadcast_id, opts \\ []) + # When called with 2 args, the second arg is interpreted as broadcast_id + # So we need to use the default client and pass broadcast_id and opts + assert {:ok, %Broadcast{id: ^broadcast_id, status: "scheduled"}} = + Resend.Broadcasts.send(Resend.client(), broadcast_id, scheduled_at: scheduled_at) + end + + test "cancels a scheduled broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + body = Jason.decode!(request.body) + assert body["scheduled_at"] == nil + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Cancelled Newsletter", + "from" => "news@example.com", + "subject" => "This was cancelled", + "status" => "cancelled" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + # Fix: Same issue here, need to use proper argument order + assert {:ok, %Broadcast{id: ^broadcast_id, status: "cancelled"}} = + Resend.Broadcasts.send(Resend.client(), broadcast_id, scheduled_at: nil) + end + + test "removes a broadcast by ID", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, deleted: true}} = + Resend.Broadcasts.remove(broadcast_id) + end + + if not live_mode?() do + test "returns an error when creating broadcast fails", context do + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Audience ID is required", + "name" => "validation_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "validation_error", status_code: 400}} = + Resend.Broadcasts.create( + audience_id: nil, + name: "Test", + from: "test@example.com", + subject: "Test" + ) + end + + test "returns an error when broadcast not found", context do + broadcast_id = "non-existent-id" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Broadcast not found", + "name" => "not_found", + "statusCode" => 404 + } + + %Tesla.Env{status: 404, body: error_body} + end) + + assert {:error, %Resend.Error{name: "not_found", status_code: 404}} = + Resend.Broadcasts.get(broadcast_id) + end + + test "returns an error when updating a sent broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Cannot update a broadcast that has been sent", + "name" => "invalid_request_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "invalid_request_error", status_code: 400}} = + Resend.Broadcasts.update(broadcast_id, subject: "New subject") + end + + test "returns an error when sending a broadcast that's already sent", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Broadcast has already been sent", + "name" => "invalid_request_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "invalid_request_error", status_code: 400}} = + Resend.Broadcasts.send(broadcast_id) + end + + test "returns an error when removing a sent broadcast", context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + + Tesla.Mock.mock(fn request -> + %{api_key: api_key} = context + + assert request.method == :delete + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer #{api_key}"} + + error_body = %{ + "message" => "Cannot delete a broadcast that has been sent", + "name" => "invalid_request_error", + "statusCode" => 400 + } + + %Tesla.Env{status: 400, body: error_body} + end) + + assert {:error, %Resend.Error{name: "invalid_request_error", status_code: 400}} = + Resend.Broadcasts.remove(broadcast_id) + end + end + end + + describe "with custom client" do + test "creates a broadcast with custom client", _context do + audience_id = "78261eea-8f8b-4381-83c6-79fa7120f1cf" + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + body = Jason.decode!(request.body) + assert body["audience_id"] == audience_id + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => audience_id, + "name" => "Test Broadcast", + "from" => "test@example.com", + "subject" => "Test Subject", + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.create(custom_client, + audience_id: audience_id, + name: "Test Broadcast", + from: "test@example.com", + subject: "Test Subject" + ) + end + + test "lists broadcasts with custom client", _context do + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "list", + "data" => [] + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Resend.List{data: []}} = Resend.Broadcasts.list(custom_client) + end + + test "gets a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :get + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Test Broadcast", + "from" => "test@example.com", + "subject" => "Test Subject", + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.get(custom_client, broadcast_id) + end + + test "updates a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :patch + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Updated Broadcast", + "from" => "test@example.com", + "subject" => "Updated Subject", + "status" => "draft" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id}} = + Resend.Broadcasts.update(custom_client, broadcast_id, subject: "Updated Subject") + end + + test "sends a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :post + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}/send" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "name" => "Test Broadcast", + "from" => "test@example.com", + "subject" => "Test Subject", + "status" => "sending" + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, status: "sending"}} = + Resend.Broadcasts.send(custom_client, broadcast_id) + end + + test "removes a broadcast with custom client", _context do + broadcast_id = "b6d24b8e-af0b-4c3c-be0c-359bbd97381e" + custom_client = Resend.client(api_key: "re_custom_key") + + Tesla.Mock.mock(fn request -> + assert request.method == :delete + assert request.url == "https://api.resend.com/broadcasts/#{broadcast_id}" + + assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == + {"Authorization", "Bearer re_custom_key"} + + success_body = %{ + "object" => "broadcast", + "id" => broadcast_id, + "deleted" => true + } + + %Tesla.Env{status: 200, body: success_body} + end) + + assert {:ok, %Broadcast{id: ^broadcast_id, deleted: true}} = + Resend.Broadcasts.remove(custom_client, broadcast_id) + end + end +end From dffd3b3108c369c1bcae0acd9c6bd071ccf4a321 Mon Sep 17 00:00:00 2001 From: Thomas Brewer Date: Wed, 16 Jul 2025 07:16:17 -0400 Subject: [PATCH 4/4] fix(tests): simplify success body assertions in Broadcasts tests - Removed redundant fields from success body assertions in `broadcasts_test.exs`. - Updated assertions to focus on the `id` field, enhancing clarity and maintainability of tests. --- test/resend/broadcasts_test.exs | 131 ++++++-------------------------- 1 file changed, 22 insertions(+), 109 deletions(-) diff --git a/test/resend/broadcasts_test.exs b/test/resend/broadcasts_test.exs index ea43019..780c921 100644 --- a/test/resend/broadcasts_test.exs +++ b/test/resend/broadcasts_test.exs @@ -29,14 +29,7 @@ defmodule Resend.BroadcastsTest do assert body["subject"] == subject success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => audience_id, - "name" => name, - "from" => from, - "subject" => subject, - "status" => "draft", - "created_at" => "2023-10-06T22:59:55.977Z" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} @@ -44,12 +37,7 @@ defmodule Resend.BroadcastsTest do assert {:ok, %Broadcast{ - id: ^broadcast_id, - audience_id: ^audience_id, - name: ^name, - from: ^from, - subject: ^subject, - status: "draft" + id: ^broadcast_id }} = Resend.Broadcasts.create( audience_id: audience_id, @@ -77,20 +65,13 @@ defmodule Resend.BroadcastsTest do assert body["reply_to"] == reply_to success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => audience_id, - "name" => "Test Broadcast", - "from" => "test@example.com", - "subject" => "Test Subject", - "reply_to" => reply_to, - "status" => "draft" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} end) - assert {:ok, %Broadcast{id: ^broadcast_id, reply_to: ^reply_to}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.create( audience_id: audience_id, name: "Test Broadcast", @@ -118,20 +99,13 @@ defmodule Resend.BroadcastsTest do assert body["reply_to"] == reply_to success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => audience_id, - "name" => "Test Broadcast", - "from" => "test@example.com", - "subject" => "Test Subject", - "reply_to" => reply_to, - "status" => "draft" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} end) - assert {:ok, %Broadcast{id: ^broadcast_id, reply_to: ^reply_to}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.create( audience_id: audience_id, name: "Test Broadcast", @@ -174,20 +148,13 @@ defmodule Resend.BroadcastsTest do assert body["tags"] == [%{"name" => "category", "value" => "newsletter"}] success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => audience_id, - "name" => "Full Broadcast", - "from" => "test@example.com", - "subject" => "Test Subject", - "preview_text" => preview_text, - "status" => "draft" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} end) - assert {:ok, %Broadcast{id: ^broadcast_id, preview_text: ^preview_text}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.create( audience_id: audience_id, name: "Full Broadcast", @@ -312,14 +279,7 @@ defmodule Resend.BroadcastsTest do refute Map.has_key?(body, "from") success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", - "name" => "Monthly Newsletter", - "from" => "news@example.com", - "subject" => new_subject, - "preview_text" => new_preview_text, - "status" => "draft" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} @@ -327,9 +287,7 @@ defmodule Resend.BroadcastsTest do assert {:ok, %Broadcast{ - id: ^broadcast_id, - subject: ^new_subject, - preview_text: ^new_preview_text + id: ^broadcast_id }} = Resend.Broadcasts.update(broadcast_id, subject: new_subject, @@ -355,20 +313,13 @@ defmodule Resend.BroadcastsTest do assert Map.keys(body) == ["reply_to"] success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", - "name" => "Monthly Newsletter", - "from" => "news@example.com", - "subject" => "October Update", - "reply_to" => new_reply_to, - "status" => "draft" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} end) - assert {:ok, %Broadcast{id: ^broadcast_id, reply_to: ^new_reply_to}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.update(broadcast_id, reply_to: new_reply_to) end @@ -388,20 +339,13 @@ defmodule Resend.BroadcastsTest do assert body == %{} success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", - "name" => "Monthly Newsletter", - "from" => "news@example.com", - "subject" => "October Update", - "status" => "sending", - "sent_at" => "2023-10-07T09:00:00.000Z" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} end) - assert {:ok, %Broadcast{id: ^broadcast_id, status: "sending"}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.send(broadcast_id) end @@ -422,14 +366,7 @@ defmodule Resend.BroadcastsTest do assert body["scheduled_at"] == DateTime.to_iso8601(scheduled_at) success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", - "name" => "Christmas Newsletter", - "from" => "news@example.com", - "subject" => "Happy Holidays!", - "status" => "scheduled", - "scheduled_at" => DateTime.to_iso8601(scheduled_at) + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} @@ -438,7 +375,7 @@ defmodule Resend.BroadcastsTest do # Fix: The function signature is send(client \\ Resend.client(), broadcast_id, opts \\ []) # When called with 2 args, the second arg is interpreted as broadcast_id # So we need to use the default client and pass broadcast_id and opts - assert {:ok, %Broadcast{id: ^broadcast_id, status: "scheduled"}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.send(Resend.client(), broadcast_id, scheduled_at: scheduled_at) end @@ -458,20 +395,14 @@ defmodule Resend.BroadcastsTest do assert body["scheduled_at"] == nil success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", - "name" => "Cancelled Newsletter", - "from" => "news@example.com", - "subject" => "This was cancelled", - "status" => "cancelled" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} end) # Fix: Same issue here, need to use proper argument order - assert {:ok, %Broadcast{id: ^broadcast_id, status: "cancelled"}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.send(Resend.client(), broadcast_id, scheduled_at: nil) end @@ -648,13 +579,7 @@ defmodule Resend.BroadcastsTest do assert body["audience_id"] == audience_id success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => audience_id, - "name" => "Test Broadcast", - "from" => "test@example.com", - "subject" => "Test Subject", - "status" => "draft" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} @@ -730,13 +655,7 @@ defmodule Resend.BroadcastsTest do {"Authorization", "Bearer re_custom_key"} success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", - "name" => "Updated Broadcast", - "from" => "test@example.com", - "subject" => "Updated Subject", - "status" => "draft" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} @@ -758,19 +677,13 @@ defmodule Resend.BroadcastsTest do {"Authorization", "Bearer re_custom_key"} success_body = %{ - "object" => "broadcast", - "id" => broadcast_id, - "audience_id" => "78261eea-8f8b-4381-83c6-79fa7120f1cf", - "name" => "Test Broadcast", - "from" => "test@example.com", - "subject" => "Test Subject", - "status" => "sending" + "id" => broadcast_id } %Tesla.Env{status: 200, body: success_body} end) - assert {:ok, %Broadcast{id: ^broadcast_id, status: "sending"}} = + assert {:ok, %Broadcast{id: ^broadcast_id}} = Resend.Broadcasts.send(custom_client, broadcast_id) end