├── .formatter.exs ├── .gitignore ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── resend.ex └── resend │ ├── api_keys.ex │ ├── api_keys │ └── api_key.ex │ ├── castable.ex │ ├── client.ex │ ├── client │ └── tesla_client.ex │ ├── domains.ex │ ├── domains │ ├── domain.ex │ └── domain │ │ └── record.ex │ ├── emails.ex │ ├── emails │ ├── attachment.ex │ └── email.ex │ ├── empty.ex │ ├── error.ex │ ├── list.ex │ ├── swoosh │ └── adapter.ex │ └── util.ex ├── mix.exs ├── mix.lock ├── resend_elixir.livemd └── test ├── resend └── emails_test.exs ├── resend_test.exs ├── support ├── client_mock.ex └── test_case.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | resend-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Resend 2 | 3 | [![Run in Livebook](https://livebook.dev/badge/v1/black.svg)](https://livebook.dev/run?url=https%3A%2F%2Fraw.githubusercontent.com%2Felixir-saas%2Fresend-elixir%2Fmain%2Fresend_elixir.livemd) 4 | 5 | API client for [Resend](https://resend.com/), the new email API for developers. 6 | 7 | ## API 8 | 9 | * [`Resend.Emails`](https://hexdocs.pm/resend/Resend.Emails.html) 10 | * [`Resend.Domains`](https://hexdocs.pm/resend/Resend.Domains.html) 11 | * [`Resend.ApiKeys`](https://hexdocs.pm/resend/Resend.ApiKeys.html) 12 | 13 | ## Installation 14 | 15 | Install by adding `resend` to your list of dependencies in `mix.exs`: 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:resend, "~> 0.4.4"} 21 | ] 22 | end 23 | ``` 24 | 25 | ## Getting Started 26 | 27 | Send your first email in two steps: 28 | 29 | ```ex 30 | # Configure your Resend API key 31 | config :resend, Resend.Client, api_key: "re_123456789" 32 | ``` 33 | 34 | ```ex 35 | # Send an email 36 | Resend.Emails.send(%{ 37 | to: "me@example.com", 38 | from: "myapp@example.com", 39 | subject: "Hello!", 40 | text: "👋🏻" 41 | }) 42 | ``` 43 | 44 | Elixir script example: 45 | 46 | ```ex 47 | # Save this file as `resend.exs`, run it with `elixir resend.exs` 48 | Mix.install([ 49 | {:resend, "~> 0.4.1"} 50 | ]) 51 | 52 | # Replace with your API key 53 | client = Resend.client(api_key: "re_123456789") 54 | 55 | # Replace `:to` and `:from` with valid emails 56 | Resend.Emails.send(client, %{ 57 | to: "me@example.com", 58 | from: "myapp@example.com", 59 | subject: "Hello!", 60 | text: "👋🏻" 61 | }) 62 | ``` 63 | 64 | View additional documentation at . 65 | 66 | ## Swoosh Adapter 67 | 68 | This library includes a Swoosh adapter to make using Resend with a new Phoenix project as easy as 69 | possible. All you have to do is configure your Mailer: 70 | 71 | ```ex 72 | config :my_app, MyApp.Mailer, 73 | adapter: Resend.Swoosh.Adapter, 74 | api_key: "re_123456789" 75 | ``` 76 | 77 | View additional documentation at . 78 | 79 | ## Testing 80 | 81 | By default, calls to Resend are mocked in tests. To send live emails while running 82 | the test suite, set the following environment variables: 83 | 84 | ```sh 85 | RESEND_KEY="re_123456789" \ 86 | RECIPIENT_EMAIL="" \ 87 | SENDER_EMAIL="" \ 88 | SENT_EMAIL_ID="" \ 89 | mix test 90 | ``` 91 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :resend, Resend.Client, api_key: System.get_env("RESEND_KEY") 4 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-saas/resend-elixir/e227a39ebd66924f025569e86298645821809d78/config/prod.exs -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if resend_key = System.get_env("RESEND_KEY") do 4 | config :resend, live_mode: true 5 | config :resend, Resend.Client, api_key: resend_key 6 | else 7 | config :tesla, adapter: Tesla.Mock 8 | 9 | config :resend, live_mode: false 10 | config :resend, Resend.Client, api_key: "re_123456789" 11 | end 12 | -------------------------------------------------------------------------------- /lib/resend.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend do 2 | @moduledoc """ 3 | Documentation for `Resend`. 4 | """ 5 | 6 | @config_module Resend.Client 7 | 8 | @type config() :: 9 | list( 10 | {:api_key, String.t()} 11 | | {:base_url, String.t()} 12 | | {:client, atom()} 13 | ) 14 | 15 | @doc """ 16 | Returns a Resend client. 17 | 18 | Accepts a keyword list of config opts, though if omitted then it will attempt to load 19 | them from the application environment. 20 | """ 21 | @spec client() :: Resend.Client.t() 22 | @spec client(config()) :: Resend.Client.t() 23 | def client(config \\ config()) do 24 | Resend.Client.new(config) 25 | end 26 | 27 | @doc """ 28 | Loads config values from the application environment. 29 | 30 | Config options are as follows: 31 | 32 | ```ex 33 | config :resend, Resend.Client 34 | api_key: "re_1234567", 35 | base_url: "https://api.resend.com", 36 | client: Resend.Client.TeslaClient 37 | ``` 38 | 39 | The only required config option is `:api_key`. If you would like to replace the 40 | HTTP client used by Resend, configure the `:client` option. By default, this library 41 | uses [Tesla](https://github.com/elixir-tesla/tesla), but changing it is as easy as 42 | defining your own client module. See the `Resend.Client` module docs for more info. 43 | """ 44 | @spec config() :: config() 45 | def config() do 46 | config = 47 | Application.get_env(:resend, @config_module) || 48 | raise """ 49 | Missing client configuration for Resend. 50 | 51 | Configure your Resend API key in one of your config files, for example: 52 | 53 | config :resend, #{inspect(@config_module)}, api_key: "re_1234567" 54 | """ 55 | 56 | validate_config!(config) 57 | end 58 | 59 | @doc false 60 | @spec validate_config!(Resend.config()) :: Resend.config() | no_return() 61 | def validate_config!(config) do 62 | api_key = 63 | Keyword.get(config, :api_key) || 64 | raise "Missing required config key for #{@config_module}: :api_key" 65 | 66 | String.starts_with?(api_key, "re_") || 67 | raise "Resend API key should start with 're_', please check your configuration" 68 | 69 | config 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/resend/api_keys.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.ApiKeys do 2 | @moduledoc """ 3 | Manage API keys in Resend. 4 | """ 5 | 6 | alias Resend.ApiKeys.ApiKey 7 | 8 | @doc """ 9 | Creates a new API key. 10 | 11 | Parameter options: 12 | 13 | * `:name` - The API key name (required) 14 | * `:permission` - Access scope to assign to this key, one of: `["full_access", "sending_access"]` 15 | * `:domain_id` - Restrict sending to a specific domain. Only used when permission is set to `"sending_access"` 16 | 17 | The `:token` field in the response struct is the only time you will see the token, keep it somewhere safe. 18 | 19 | """ 20 | @spec create(Keyword.t()) :: Resend.Client.response(ApiKey.t()) 21 | @spec create(Resend.Client.t(), Keyword.t()) :: Resend.Client.response(ApiKey.t()) 22 | def create(client \\ Resend.client(), opts) do 23 | Resend.Client.post(client, ApiKey, "/api-keys", %{ 24 | name: opts[:name], 25 | permission: opts[:permission], 26 | domain_id: opts[:domain_id] 27 | }) 28 | end 29 | 30 | @doc """ 31 | Lists all API keys. 32 | """ 33 | @spec list() :: Resend.Client.response(Resend.List.t(ApiKey.t())) 34 | @spec list(Resend.Client.t()) :: Resend.Client.response(Resend.List.t(ApiKey.t())) 35 | def list(client \\ Resend.client()) do 36 | Resend.Client.get(client, Resend.List.of(ApiKey), "/api-keys") 37 | end 38 | 39 | @doc """ 40 | Removes an API key. Caution: This can't be undone! 41 | """ 42 | @spec remove(String.t()) :: Resend.Client.response(ApiKey.t()) 43 | @spec remove(Resend.Client.t(), String.t()) :: Resend.Client.response(ApiKey.t()) 44 | def remove(client \\ Resend.client(), api_key_id) do 45 | Resend.Client.delete(client, Resend.Empty, "/api-keys/:id", %{}, 46 | opts: [ 47 | path_params: [id: api_key_id] 48 | ] 49 | ) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/resend/api_keys/api_key.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.ApiKeys.ApiKey do 2 | @moduledoc """ 3 | Resend API Key struct. 4 | """ 5 | 6 | alias Resend.Util 7 | 8 | @behaviour Resend.Castable 9 | 10 | @type t() :: %__MODULE__{ 11 | id: String.t(), 12 | name: String.t() | nil, 13 | token: String.t() | nil, 14 | created_at: DateTime.t() | nil 15 | } 16 | 17 | @enforce_keys [:id] 18 | defstruct [ 19 | :id, 20 | :name, 21 | :token, 22 | :created_at 23 | ] 24 | 25 | @impl true 26 | def cast(map) do 27 | %__MODULE__{ 28 | id: map["id"], 29 | name: map["name"], 30 | token: map["token"], 31 | created_at: Util.parse_iso8601(map["created_at"]) 32 | } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/resend/castable.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Castable do 2 | @moduledoc false 3 | 4 | @type impl :: module() | {module(), module()} | :raw 5 | @type generic_map :: %{String.t() => any()} 6 | 7 | @callback cast(generic_map() | {module(), generic_map()} | nil) :: struct() | nil 8 | 9 | @spec cast(impl(), generic_map() | nil) :: struct() | generic_map() | nil 10 | def cast(_implementation, nil) do 11 | nil 12 | end 13 | 14 | def cast(:raw, generic_map) do 15 | generic_map 16 | end 17 | 18 | def cast({implementation, inner}, generic_map) when is_map(generic_map) do 19 | implementation.cast({inner, generic_map}) 20 | end 21 | 22 | def cast(implementation, generic_map) when is_map(generic_map) do 23 | implementation.cast(generic_map) 24 | end 25 | 26 | @spec cast_list(module(), [generic_map()] | nil) :: [struct()] | nil 27 | def cast_list(_implementation, nil) do 28 | nil 29 | end 30 | 31 | def cast_list(implementation, list_of_generic_maps) when is_list(list_of_generic_maps) do 32 | Enum.map(list_of_generic_maps, &cast(implementation, &1)) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/resend/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Client do 2 | @moduledoc """ 3 | Resend API client. 4 | """ 5 | 6 | require Logger 7 | 8 | alias Resend.Castable 9 | 10 | @callback request(t(), Keyword.t()) :: 11 | {:ok, %{body: map(), status: pos_integer()}} | {:error, any()} 12 | 13 | @type response(type) :: {:ok, type} | {:error, Resend.Error.t() | :client_error} 14 | 15 | @type t() :: %__MODULE__{ 16 | api_key: String.t(), 17 | base_url: String.t() | nil, 18 | client: module() | nil 19 | } 20 | 21 | @enforce_keys [:api_key, :base_url, :client] 22 | defstruct [:api_key, :base_url, :client] 23 | 24 | @default_opts [ 25 | base_url: "https://api.resend.com", 26 | client: __MODULE__.TeslaClient 27 | ] 28 | 29 | @doc """ 30 | Creates a new Resend client struct given a keyword list of config opts. 31 | """ 32 | @spec new(Resend.config()) :: t() 33 | def new(config) do 34 | config = Keyword.take(config, [:api_key, :base_url, :client]) 35 | struct!(__MODULE__, Keyword.merge(@default_opts, config)) 36 | end 37 | 38 | @spec get(t(), Castable.impl(), String.t()) :: response(any()) 39 | @spec get(t(), Castable.impl(), String.t(), Keyword.t()) :: response(any()) 40 | def get(client, castable_module, path, opts \\ []) do 41 | client_module = client.client || Resend.Client.TeslaClient 42 | 43 | opts = 44 | opts 45 | |> Keyword.put(:method, :get) 46 | |> Keyword.put(:url, path) 47 | 48 | client_module.request(client, opts) 49 | |> handle_response(path, castable_module) 50 | end 51 | 52 | @spec post(t(), Castable.impl(), String.t()) :: response(any()) 53 | @spec post(t(), Castable.impl(), String.t(), map()) :: response(any()) 54 | @spec post(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) 55 | def post(client, castable_module, path, body \\ %{}, opts \\ []) do 56 | client_module = client.client || Resend.Client.TeslaClient 57 | 58 | opts = 59 | opts 60 | |> Keyword.put(:method, :post) 61 | |> Keyword.put(:url, path) 62 | |> Keyword.put(:body, body) 63 | 64 | client_module.request(client, opts) 65 | |> handle_response(path, castable_module) 66 | end 67 | 68 | @spec delete(t(), Castable.impl(), String.t()) :: response(any()) 69 | @spec delete(t(), Castable.impl(), String.t(), map()) :: response(any()) 70 | @spec delete(t(), Castable.impl(), String.t(), map(), Keyword.t()) :: response(any()) 71 | def delete(client, castable_module, path, body \\ %{}, opts \\ []) do 72 | client_module = client.client || Resend.Client.TeslaClient 73 | 74 | opts = 75 | opts 76 | |> Keyword.put(:method, :delete) 77 | |> Keyword.put(:url, path) 78 | |> Keyword.put(:body, body) 79 | 80 | client_module.request(client, opts) 81 | |> handle_response(path, castable_module) 82 | end 83 | 84 | defp handle_response(response, path, castable_module) do 85 | case response do 86 | {:ok, %{body: "", status: status}} when status in 200..299 -> 87 | {:ok, Castable.cast(castable_module, %{})} 88 | 89 | {:ok, %{body: body, status: status}} when status in 200..299 -> 90 | {:ok, Castable.cast(castable_module, body)} 91 | 92 | {:ok, %{body: body}} when is_map(body) -> 93 | Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(body)}") 94 | {:error, Castable.cast(Resend.Error, body)} 95 | 96 | {:ok, %{body: body}} when is_binary(body) -> 97 | Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{body}") 98 | {:error, body} 99 | 100 | {:error, reason} -> 101 | Logger.error("#{inspect(__MODULE__)} error when calling #{path}: #{inspect(reason)}") 102 | {:error, :client_error} 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/resend/client/tesla_client.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Client.TeslaClient do 2 | @moduledoc """ 3 | Tesla client for Resend. This is the default HTTP client used. 4 | """ 5 | @behaviour Resend.Client 6 | 7 | @doc """ 8 | Sends a request to a Resend API endpoint, given list of request opts. 9 | """ 10 | @spec request(Resend.Client.t(), Keyword.t()) :: 11 | {:ok, %{body: map(), status: pos_integer()}} | {:error, any()} 12 | def request(client, opts) do 13 | opts = Keyword.take(opts, [:method, :url, :query, :headers, :body, :opts]) 14 | Tesla.request(new(client), opts) 15 | end 16 | 17 | @doc """ 18 | Returns a new `Tesla.Client`, configured for calling the Resend API. 19 | """ 20 | @spec new(Resend.Client.t()) :: Tesla.Client.t() 21 | def new(client) do 22 | Tesla.client([ 23 | Tesla.Middleware.Logger, 24 | {Tesla.Middleware.BaseUrl, client.base_url}, 25 | Tesla.Middleware.PathParams, 26 | Tesla.Middleware.JSON, 27 | {Tesla.Middleware.Headers, [{"Authorization", "Bearer #{client.api_key}"}]} 28 | ]) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/resend/domains.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Domains do 2 | @moduledoc """ 3 | Manage domains in Resend. 4 | """ 5 | 6 | alias Resend.Domains.Domain 7 | 8 | @doc """ 9 | Creates a new domain. 10 | 11 | Parameter options: 12 | 13 | * `:name` - The domain name (required) 14 | * `:region` - Region to deliver emails from, on of: `["us-east-1", "eu-west-1", "sa-east-1"]` 15 | 16 | """ 17 | @spec create(Keyword.t()) :: Resend.Client.response(Domain.t()) 18 | @spec create(Resend.Client.t(), Keyword.t()) :: Resend.Client.response(Domain.t()) 19 | def create(client \\ Resend.client(), opts) do 20 | Resend.Client.post(client, Domain, "/domains", %{ 21 | name: opts[:name], 22 | region: opts[:region] 23 | }) 24 | end 25 | 26 | @doc """ 27 | Gets a domain given an ID. 28 | """ 29 | @spec get(String.t()) :: Resend.Client.response(Domain.t()) 30 | @spec get(Resend.Client.t(), String.t()) :: Resend.Client.response(Domain.t()) 31 | def get(client \\ Resend.client(), domain_id) do 32 | Resend.Client.get(client, Domain, "/domains/:id", 33 | opts: [ 34 | path_params: [id: domain_id] 35 | ] 36 | ) 37 | end 38 | 39 | @doc """ 40 | Begins the verification process for a domain. 41 | """ 42 | @spec verify(String.t()) :: Resend.Client.response(Domain.t()) 43 | @spec verify(Resend.Client.t(), String.t()) :: Resend.Client.response(Domain.t()) 44 | def verify(client \\ Resend.client(), domain_id) do 45 | Resend.Client.post(client, Domain, "/domains/:id/verify", %{}, 46 | opts: [ 47 | path_params: [id: domain_id] 48 | ] 49 | ) 50 | end 51 | 52 | @doc """ 53 | Lists all domains. 54 | """ 55 | @spec list() :: Resend.Client.response(Resend.List.t(Domain.t())) 56 | @spec list(Resend.Client.t()) :: Resend.Client.response(Resend.List.t(Domain.t())) 57 | def list(client \\ Resend.client()) do 58 | Resend.Client.get(client, Resend.List.of(Domain), "/domains") 59 | end 60 | 61 | @doc """ 62 | Removes a domain. Caution: This can't be undone! 63 | """ 64 | @spec remove(String.t()) :: Resend.Client.response(Domain.t()) 65 | @spec remove(Resend.Client.t(), String.t()) :: Resend.Client.response(Domain.t()) 66 | def remove(client \\ Resend.client(), domain_id) do 67 | Resend.Client.delete(client, Domain, "/domains/:id", %{}, 68 | opts: [ 69 | path_params: [id: domain_id] 70 | ] 71 | ) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/resend/domains/domain.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Domains.Domain do 2 | @moduledoc """ 3 | Resend Domain struct. 4 | """ 5 | 6 | alias Resend.Util 7 | alias Resend.Castable 8 | alias Resend.Domains.Domain.Record 9 | 10 | @behaviour Resend.Castable 11 | 12 | @type t() :: %__MODULE__{ 13 | id: String.t(), 14 | name: String.t() | nil, 15 | status: list(String.t()) | nil, 16 | region: list(String.t()) | nil, 17 | records: list(Record.t()) | nil, 18 | created_at: DateTime.t() | nil, 19 | deleted: boolean() | nil 20 | } 21 | 22 | @enforce_keys [:id] 23 | defstruct [ 24 | :id, 25 | :name, 26 | :status, 27 | :region, 28 | :records, 29 | :created_at, 30 | :deleted 31 | ] 32 | 33 | @impl true 34 | def cast(map) do 35 | %__MODULE__{ 36 | id: map["id"], 37 | name: map["name"], 38 | status: map["status"], 39 | region: map["region"], 40 | records: Castable.cast_list(Record, map["records"]), 41 | created_at: Util.parse_iso8601(map["created_at"]), 42 | deleted: map["deleted"] 43 | } 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/resend/domains/domain/record.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Domains.Domain.Record do 2 | @moduledoc """ 3 | Resend Domain Record struct. 4 | """ 5 | 6 | @behaviour Resend.Castable 7 | 8 | @type t() :: %__MODULE__{ 9 | name: String.t(), 10 | record: String.t(), 11 | status: list(String.t()), 12 | ttl: list(String.t()), 13 | type: list(String.t()), 14 | value: list(String.t()) 15 | } 16 | 17 | @enforce_keys [:name, :record, :status, :ttl, :type, :value] 18 | defstruct [ 19 | :name, 20 | :record, 21 | :status, 22 | :ttl, 23 | :type, 24 | :value 25 | ] 26 | 27 | @impl true 28 | def cast(map) do 29 | %__MODULE__{ 30 | name: map["name"], 31 | record: map["record"], 32 | status: map["status"], 33 | ttl: map["ttl"], 34 | type: map["type"], 35 | value: map["value"] 36 | } 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/resend/emails.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Emails do 2 | @moduledoc """ 3 | Send emails via Resend. 4 | """ 5 | 6 | alias Resend.Emails.Email 7 | 8 | @doc """ 9 | Sends an email given a map of parameters. 10 | 11 | Parameter options: 12 | 13 | * `:to` - Recipient email address (required) 14 | * `:from` - Sender email address (required) 15 | * `:cc` - Additional email addresses to copy, may be a single address or a list of addresses 16 | * `:bcc` - Additional email addresses to blind-copy, may be a single address or a list of addresses 17 | * `:reply_to` - Specify the email that recipients will reply to 18 | * `:subject` - Subject line of the email 19 | * `:headers` - Map of headers to add to the email with corresponding string values 20 | * `:html` - The HTML-formatted body of the email 21 | * `:text` - The text-formatted body of the email 22 | * `:attachments` - List of attachments to include in the email 23 | 24 | You must include one or both of the `:html` and `:text` options. 25 | 26 | """ 27 | @spec send(map()) :: Resend.Client.response(Email.t()) 28 | @spec send(Resend.Client.t(), map()) :: Resend.Client.response(Email.t()) 29 | def send(client \\ Resend.client(), opts) do 30 | Resend.Client.post(client, Email, "/emails", %{ 31 | subject: opts[:subject], 32 | to: opts[:to], 33 | from: opts[:from], 34 | cc: opts[:cc], 35 | bcc: opts[:bcc], 36 | reply_to: opts[:reply_to], 37 | headers: opts[:headers], 38 | html: opts[:html], 39 | text: opts[:text], 40 | attachments: opts[:attachments] 41 | }) 42 | end 43 | 44 | @doc """ 45 | Gets an email given an ID. 46 | 47 | """ 48 | @spec get(String.t()) :: Resend.Client.response(Email.t()) 49 | @spec get(Resend.Client.t(), String.t()) :: Resend.Client.response(Email.t()) 50 | def get(client \\ Resend.client(), email_id) do 51 | Resend.Client.get(client, Email, "/emails/:id", 52 | opts: [ 53 | path_params: [id: email_id] 54 | ] 55 | ) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/resend/emails/attachment.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Emails.Attachment do 2 | @moduledoc """ 3 | Resend Email Attachment struct. 4 | """ 5 | @behaviour Resend.Castable 6 | @derive Jason.Encoder 7 | 8 | @type t() :: map() 9 | 10 | defstruct [ 11 | :content, 12 | :content_type, 13 | :filename, 14 | path: "" 15 | ] 16 | 17 | @impl true 18 | def cast(map) do 19 | %__MODULE__{ 20 | content: map["content"], 21 | content_type: map["content_type"], 22 | filename: map["filename"], 23 | path: map["path"] 24 | } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/resend/emails/email.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Emails.Email do 2 | @moduledoc """ 3 | Resend Email struct. 4 | """ 5 | 6 | alias Resend.Emails.Attachment 7 | alias Resend.Util 8 | 9 | @behaviour Resend.Castable 10 | 11 | @type t() :: %__MODULE__{ 12 | id: String.t(), 13 | from: String.t() | nil, 14 | to: list(String.t()) | nil, 15 | bcc: list(String.t()) | nil, 16 | cc: list(String.t()) | nil, 17 | reply_to: String.t() | nil, 18 | subject: String.t() | nil, 19 | headers: map(), 20 | text: String.t() | nil, 21 | html: String.t() | nil, 22 | attachments: list(Attachment.t()) | nil, 23 | last_event: String.t() | nil, 24 | created_at: DateTime.t() | nil 25 | } 26 | 27 | @enforce_keys [:id] 28 | defstruct [ 29 | :id, 30 | :from, 31 | :to, 32 | :bcc, 33 | :cc, 34 | :reply_to, 35 | :subject, 36 | :headers, 37 | :text, 38 | :html, 39 | :attachments, 40 | :last_event, 41 | :created_at 42 | ] 43 | 44 | @impl true 45 | def cast(map) do 46 | %__MODULE__{ 47 | id: map["id"], 48 | from: map["from"], 49 | to: map["to"], 50 | bcc: map["bcc"], 51 | cc: map["cc"], 52 | reply_to: map["reply_to"], 53 | subject: map["subject"], 54 | headers: map["headers"], 55 | text: map["text"], 56 | html: map["html"], 57 | attachments: map["attachments"], 58 | last_event: map["last_event"], 59 | created_at: Util.parse_iso8601(map["created_at"]) 60 | } 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/resend/empty.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Empty do 2 | @moduledoc """ 3 | Empty response. 4 | """ 5 | 6 | @behaviour Resend.Castable 7 | 8 | @type t() :: %__MODULE__{} 9 | 10 | defstruct [] 11 | 12 | @impl true 13 | def cast(_map), do: %__MODULE__{} 14 | end 15 | -------------------------------------------------------------------------------- /lib/resend/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Error do 2 | @moduledoc """ 3 | Castable module for returning structured errors from the Resend API. 4 | """ 5 | 6 | @behaviour Resend.Castable 7 | 8 | @type t() :: %__MODULE__{ 9 | name: String.t(), 10 | message: String.t(), 11 | status_code: integer() 12 | } 13 | 14 | defstruct [ 15 | :name, 16 | :message, 17 | :status_code 18 | ] 19 | 20 | @impl true 21 | def cast(error) when is_map(error) do 22 | %__MODULE__{ 23 | name: error["name"], 24 | message: error["message"], 25 | status_code: error["statusCode"] 26 | } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/resend/list.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.List do 2 | @moduledoc """ 3 | Casts a response to a `%Resend.List{}` of structs. 4 | """ 5 | 6 | alias Resend.Castable 7 | 8 | @behaviour Resend.Castable 9 | 10 | @type t(g) :: %__MODULE__{ 11 | data: list(g) 12 | } 13 | 14 | @enforce_keys [:data] 15 | defstruct [:data] 16 | 17 | @impl true 18 | def cast({implementation, map}) do 19 | %__MODULE__{ 20 | data: Castable.cast_list(implementation, map["data"]) 21 | } 22 | end 23 | 24 | def of(implementation) do 25 | {__MODULE__, implementation} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/resend/swoosh/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Swoosh.Adapter do 2 | @moduledoc """ 3 | Adapter module to configure Swoosh to send emails via Resend. 4 | 5 | Using this adapter, we can configure a new Phoenix application to send mail 6 | via Resend by default. If the project generated authentication with `phx.gen.auth`, 7 | then all auth communication will work with Resend out of the box. 8 | 9 | To configure your Mailer, specify the adapter and a Resend API key: 10 | 11 | ```ex 12 | config :my_app, MyApp.Mailer, 13 | adapter: Resend.Swoosh.Adapter, 14 | api_key: "re_1234567" 15 | ``` 16 | 17 | If you're configuring your app for production, configure your adapter in `prod.exs`, and 18 | your API key from the environment in `runtime.exs`: 19 | 20 | ```ex 21 | # prod.exs 22 | config :my_app, MyApp.Mailer, adapter: Resend.Swoosh.Adapter 23 | ``` 24 | 25 | ```ex 26 | # runtime.exs 27 | config :my_app, MyApp.Mailer, api_key: "re_1234567" 28 | ``` 29 | 30 | And just like that, you should be all set to send emails with Resend! 31 | """ 32 | 33 | @behaviour Swoosh.Adapter 34 | 35 | @impl true 36 | def deliver(%Swoosh.Email{} = email, config) do 37 | Resend.Emails.send(Resend.client(config), %{ 38 | subject: email.subject, 39 | from: format_sender(email.from), 40 | to: format_recipients(email.to), 41 | bcc: format_recipients(email.bcc), 42 | cc: format_recipients(email.cc), 43 | reply_to: format_recipients(email.reply_to), 44 | headers: email.headers, 45 | html: email.html_body, 46 | text: email.text_body, 47 | attachments: format_attachments(email.attachments) 48 | }) 49 | end 50 | 51 | @impl true 52 | def deliver_many(list, config) do 53 | Enum.reduce_while(list, {:ok, []}, fn email, {:ok, acc} -> 54 | case deliver(email, config) do 55 | {:ok, email} -> 56 | {:cont, {:ok, acc ++ [email]}} 57 | 58 | {:error, _reason} = error -> 59 | {:halt, error} 60 | end 61 | end) 62 | end 63 | 64 | @impl true 65 | def validate_config(config) do 66 | Resend.validate_config!(config) 67 | :ok 68 | end 69 | 70 | defp format_attachment(%Swoosh.Attachment{} = attachment) do 71 | %Resend.Emails.Attachment{ 72 | content: Swoosh.Attachment.get_content(attachment, :base64), 73 | content_type: attachment.content_type, 74 | filename: attachment.filename 75 | } 76 | end 77 | 78 | defp format_attachments(nil), do: nil 79 | defp format_attachments(attachments), do: Enum.map(attachments, &format_attachment/1) 80 | 81 | defp format_sender(nil), do: nil 82 | defp format_sender(from) when is_binary(from), do: from 83 | defp format_sender({"", from}), do: from 84 | defp format_sender({from_name, from}), do: "#{from_name} <#{from}>" 85 | 86 | defp format_recipients(nil), do: nil 87 | defp format_recipients(to) when is_binary(to), do: to 88 | defp format_recipients({_ignore, to}), do: to 89 | defp format_recipients(xs) when is_list(xs), do: Enum.map(xs, &format_recipients/1) 90 | end 91 | -------------------------------------------------------------------------------- /lib/resend/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.Util do 2 | @moduledoc """ 3 | Module for utility functions. 4 | """ 5 | 6 | @doc """ 7 | Parses a iso8601 data string to a `DateTime`. Returns nil if argument is nil. 8 | """ 9 | @spec parse_iso8601(String.t() | nil) :: DateTime.t() | nil 10 | def parse_iso8601(nil), do: nil 11 | 12 | def parse_iso8601(date_string) do 13 | {:ok, date_time, 0} = DateTime.from_iso8601(date_string) 14 | date_time 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Resend.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.4.4" 5 | @source_url "https://github.com/elixir-saas/resend-elixir" 6 | 7 | def project do 8 | [ 9 | app: :resend, 10 | version: @version, 11 | elixir: "~> 1.14", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | description: description(), 15 | package: package(), 16 | docs: docs(), 17 | deps: deps() 18 | ] 19 | end 20 | 21 | defp description() do 22 | "API client for Resend, the new email API for developers." 23 | end 24 | 25 | defp package() do 26 | [ 27 | description: description(), 28 | licenses: ["Apache-2.0"], 29 | maintainers: [ 30 | "Justin Tormey" 31 | ], 32 | links: %{ 33 | "GitHub" => @source_url, 34 | "Resend" => "https://resend.com/", 35 | "Elixir Example" => "https://github.com/resendlabs/resend-elixir-example", 36 | "Phoenix Example" => "https://github.com/resendlabs/resend-phoenix-example" 37 | } 38 | ] 39 | end 40 | 41 | # Run "mix help compile.app" to learn about applications. 42 | def application do 43 | [ 44 | extra_applications: [:logger] 45 | ] 46 | end 47 | 48 | # Specifies which paths to compile per environment. 49 | defp elixirc_paths(:test), do: ["lib", "test/support"] 50 | defp elixirc_paths(_), do: ["lib"] 51 | 52 | # Run "mix help deps" to learn about dependencies. 53 | defp deps do 54 | [ 55 | {:swoosh, "~> 1.3"}, 56 | {:tesla, "~> 1.5"}, 57 | {:hackney, "~> 1.9"}, 58 | {:dialyxir, "~> 1.2", only: :dev, runtime: false}, 59 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 60 | ] 61 | end 62 | 63 | defp docs() do 64 | [ 65 | extras: [ 66 | "README.md": [title: "Overview"] 67 | ], 68 | main: "readme", 69 | source_url: @source_url, 70 | source_ref: "v#{@version}", 71 | groups_for_modules: groups_for_modules() 72 | ] 73 | end 74 | 75 | defp groups_for_modules() do 76 | [ 77 | "Core API": [ 78 | Resend.ApiKeys, 79 | Resend.Domains, 80 | Resend.Emails 81 | ], 82 | "Response Structs": [ 83 | Resend.ApiKeys.ApiKey, 84 | Resend.Domains.Domain, 85 | Resend.Domains.Domain.Record, 86 | Resend.Emails.Email, 87 | Resend.Empty, 88 | Resend.Error, 89 | Resend.List 90 | ], 91 | Swoosh: [ 92 | Resend.Swoosh.Adapter 93 | ], 94 | "API Client": [ 95 | Resend.Client, 96 | Resend.Client.TeslaClient 97 | ], 98 | Utilities: [ 99 | Resend.Util 100 | ] 101 | ] 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 3 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 5 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 6 | "ex_doc": {:hex, :ex_doc, "0.32.2", "f60bbeb6ccbe75d005763e2a328e6f05e0624232f2393bc693611c2d3ae9fa0e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "a4480305cdfe7fdfcbb77d1092c76161626d9a7aa4fb698aee745996e34602df"}, 7 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 8 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 9 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 10 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 13 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 14 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 15 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 17 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 18 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 19 | "swoosh": {:hex, :swoosh, "1.16.7", "9dd0c172b4519a023f58e94d3ea79480b469dd4c0cd5369fabfbfd2e39bf5545", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.1.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21073982816cff3410e90c0d80ebfd5a0bf4839c7b39db20bc69a6df123bbf35"}, 20 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 21 | "tesla": {:hex, :tesla, "1.9.0", "8c22db6a826e56a087eeb8cdef56889731287f53feeb3f361dec5d4c8efb6f14", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "7c240c67e855f7e63e795bf16d6b3f5115a81d1f44b7fe4eadbf656bae0fef8a"}, 22 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 23 | } 24 | -------------------------------------------------------------------------------- /resend_elixir.livemd: -------------------------------------------------------------------------------- 1 | # Resend + Elixir 2 | 3 | ```elixir 4 | Mix.install([ 5 | {:resend, "~> 0.4.0"}, 6 | {:kino, "~> 0.9.4"} 7 | ]) 8 | ``` 9 | 10 | ## Create a client 11 | 12 | To start using Resend, create a `client` with the API key you created in your web dashboard: 13 | 14 | ```elixir 15 | client = Resend.client(api_key: System.fetch_env!("LB_RESEND_KEY")) 16 | ``` 17 | 18 | Note that if you choose to configure Resend in your app config, passing a client struct is always optional. 19 | 20 | ## Sending email 21 | 22 | We've created some inputs to make sending emails easy. Feel free to replace these as needed! 23 | 24 | ```elixir 25 | to = Kino.Input.text("To") |> Kino.render() 26 | from = Kino.Input.text("From", default: "onboarding@resend.dev") |> Kino.render() 27 | subject = Kino.Input.text("Subject", default: "Hello World") |> Kino.render() 28 | text = Kino.Input.textarea("Text", default: "It works!") |> Kino.render() 29 | 30 | Kino.nothing() 31 | ``` 32 | 33 | Read the input values and send our first email with Resend: 34 | 35 | ```elixir 36 | {:ok, email} = 37 | Resend.Emails.send( 38 | client, 39 | %{ 40 | to: Kino.Input.read(to), 41 | from: Kino.Input.read(from), 42 | subject: Kino.Input.read(subject), 43 | text: Kino.Input.read(text) 44 | } 45 | ) 46 | ``` 47 | 48 | We can get details about this email by looking it up with its ID: 49 | 50 | ```elixir 51 | Resend.Emails.get(client, email.id) 52 | ``` 53 | 54 | ## Managing domains 55 | 56 | Lets create a new domain, we'll just use a random string: 57 | 58 | ```elixir 59 | {:ok, domain} = 60 | Resend.Domains.create( 61 | client, 62 | name: Base.encode16(:rand.bytes(8), case: :lower) <> ".com" 63 | ) 64 | ``` 65 | 66 | We can list all our domains, to see that our new one has been added: 67 | 68 | ```elixir 69 | Resend.Domains.list(client) 70 | ``` 71 | 72 | Now that we've demonstrated that we can create domains, lets remove the random one we created: 73 | 74 | ```elixir 75 | {:ok, _deleted} = Resend.Domains.remove(client, domain.id) 76 | ``` 77 | 78 | ## Managing API keys 79 | 80 | We can manage API keys too. Let's create a new one that can only send to our new domain. Note that we must also apply the `"sending_access"` permission when specifying a `:domain_id`. 81 | 82 | ```elixir 83 | {:ok, api_key} = 84 | Resend.ApiKeys.create(client, 85 | name: "For random domain", 86 | permission: "sending_access", 87 | domain_id: domain.id 88 | ) 89 | ``` 90 | 91 | The `:token` field we get back contains our new API token. It's the only time it's show, so make sure to save it some place where it won't get lost. 92 | 93 | Let's take a look at our new API key in the list of all API keys: 94 | 95 | ```elixir 96 | Resend.ApiKeys.list(client) 97 | ``` 98 | 99 | Nice! 100 | 101 | Lastly, we'll remove the API key we created to keep our account nice and tidy: 102 | 103 | ```elixir 104 | {:ok, _deleted} = Resend.ApiKeys.remove(client, api_key.id) 105 | ``` 106 | 107 | 108 | -------------------------------------------------------------------------------- /test/resend/emails_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Resend.EmailsTest do 2 | use Resend.TestCase 3 | 4 | alias Resend.ClientMock 5 | 6 | setup :setup_env 7 | 8 | describe "/emails" do 9 | test "Sends an email with a text body", context do 10 | to = context.to 11 | from = context.from 12 | subject = "Test Email" 13 | text = "Testing Resend" 14 | headers = %{"x-test-header" => "resend-elixir"} 15 | 16 | opts = [ 17 | to: to, 18 | subject: subject, 19 | from: from, 20 | text: text, 21 | headers: headers 22 | ] 23 | 24 | ClientMock.mock_send_email(context, assert_fields: opts) 25 | 26 | assert {:ok, %Resend.Emails.Email{}} = Resend.Emails.send(Map.new(opts)) 27 | end 28 | 29 | test "Gets an email by ID", context do 30 | email_id = context.sent_email_id 31 | to = context.to 32 | from = context.from 33 | 34 | ClientMock.mock_get_email(context) 35 | 36 | assert {:ok, %Resend.Emails.Email{id: ^email_id, to: [^to], from: ^from}} = 37 | Resend.Emails.get(email_id) 38 | end 39 | 40 | if not live_mode?() do 41 | test "Returns an error", context do 42 | email_id = context.sent_email_id 43 | 44 | ClientMock.mock_get_email(context, 45 | respond_with: 46 | {401, 47 | %{ 48 | "message" => "This API key is restricted to only send emails", 49 | "name" => "restricted_api_key", 50 | "statusCode" => 401 51 | }} 52 | ) 53 | 54 | assert {:error, %Resend.Error{name: "restricted_api_key", status_code: 401}} = 55 | Resend.Emails.get(email_id) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/resend_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ResendTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /test/support/client_mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.ClientMock do 2 | use ExUnit.Case 3 | 4 | def mock_send_email(context, opts \\ []) do 5 | Tesla.Mock.mock(fn request -> 6 | %{api_key: api_key} = context 7 | 8 | assert request.method == :post 9 | assert request.url == "https://api.resend.com/emails" 10 | 11 | assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == 12 | {"Authorization", "Bearer #{api_key}"} 13 | 14 | body = Jason.decode!(request.body) 15 | 16 | for {field, value} <- Keyword.get(opts, :assert_fields, []) do 17 | assert body[to_string(field)] == value 18 | end 19 | 20 | success_body = %{ 21 | "id" => context.sent_email_id 22 | } 23 | 24 | {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) 25 | %Tesla.Env{status: status, body: body} 26 | end) 27 | end 28 | 29 | def mock_get_email(context, opts \\ []) do 30 | Tesla.Mock.mock(fn request -> 31 | %{api_key: api_key} = context 32 | 33 | assert request.method == :get 34 | assert request.url == "https://api.resend.com/emails/#{context.sent_email_id}" 35 | 36 | assert Enum.find(request.headers, &(elem(&1, 0) == "Authorization")) == 37 | {"Authorization", "Bearer #{api_key}"} 38 | 39 | success_body = %{ 40 | "bcc" => nil, 41 | "cc" => nil, 42 | "created_at" => "2023-06-01T00:00:00.000Z", 43 | "from" => context.from, 44 | "html" => nil, 45 | "id" => context.sent_email_id, 46 | "last_event" => "delivered", 47 | "object" => "email", 48 | "reply_to" => nil, 49 | "subject" => "Test Email", 50 | "text" => "Testing Resend", 51 | "to" => [context.to] 52 | } 53 | 54 | {status, body} = Keyword.get(opts, :respond_with, {200, success_body}) 55 | %Tesla.Env{status: status, body: body} 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Resend.TestCase do 2 | defmacro __using__(_opts) do 3 | quote do 4 | use ExUnit.Case 5 | import Resend.TestCase 6 | end 7 | end 8 | 9 | defmacro live_mode?() do 10 | quote do 11 | Application.compile_env!(:resend, :live_mode) 12 | end 13 | end 14 | 15 | def setup_env(_context) do 16 | %{ 17 | to: System.get_env("RECIPIENT_EMAIL", "test@example.com"), 18 | from: System.get_env("SENDER_EMAIL", "sender@example.com"), 19 | sent_email_id: System.get_env("SENT_EMAIL_ID", "f524bc41-316b-45c6-99f3-c5d3bc193d12"), 20 | api_key: System.get_env("RESEND_KEY", "re_123456789") 21 | } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------