defmodule Bamboo.SendGridAdapter do @moduledoc """ Sends email using SendGrid's JSON API. Use this adapter to send emails through SendGrid's API. Requires that an API key is set in the config. If you would like to add a replyto header to your email, then simply pass it in using the header property or put_header function like so: put_header("reply-to", "foo@bar.com") To set arbitrary email headers, set them in the `headers` property of the [Bamboo.Email](Bamboo.Email) struct. Note that some header names are reserved for use by Sendgrid. See [here](https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html) for full list. ## Example config # In config/config.exs, or config.prod.exs, etc. config :my_app, MyApp.Mailer, adapter: Bamboo.SendGridAdapter, api_key: "my_api_key", # or {:system, "SENDGRID_API_KEY"}, # or {ModuleName, :method_name, []} hackney_opts: [ recv_timeout: :timer.minutes(1) ] # To enable sandbox mode (e.g. in development or staging environments), # in config/dev.exs or config/prod.exs etc config :my_app, MyApp.Mailer, sandbox: true # Define a Mailer. Maybe in lib/my_app/mailer.ex defmodule MyApp.Mailer do use Bamboo.Mailer, otp_app: :my_app end """ @service_name "SendGrid" @default_base_uri "https://api.sendgrid.com/v3/" @send_message_path "/mail/send" @behaviour Bamboo.Adapter alias Bamboo.{Email, AdapterHelper, Formatter} import Bamboo.ApiError def deliver(email, config) do api_key = get_key(config) try do body = email |> to_sendgrid_body(config) |> Bamboo.json_library().encode!() url = [base_uri(), @send_message_path] case :hackney.post(url, headers(api_key), body, AdapterHelper.hackney_opts(config)) do {:ok, status, _headers, response} when status > 299 -> filtered_params = body |> Bamboo.json_library().decode!() |> Map.put("key", "[FILTERED]") {:error, build_api_error(@service_name, response, filtered_params)} {:ok, status, headers, response} -> {:ok, %{status_code: status, headers: headers, body: response}} {:error, reason} -> {:error, build_api_error(inspect(reason))} end catch :throw, {:error, _} = error -> error end end @doc false def handle_config(config) do # build the api key - will raise if there are errors Map.merge(config, %{api_key: get_key(config)}) end @doc false def supports_attachments?, do: true defp get_key(config) do api_key = case Map.get(config, :api_key) do {:system, var} -> System.get_env(var) {module_name, method_name, args} -> apply(module_name, method_name, args) fun when is_function(fun) -> fun.() key -> key end if api_key in [nil, ""] do raise_api_key_error(config) else api_key end end defp raise_api_key_error(config) do raise ArgumentError, """ There was no API key set for the SendGrid adapter. * Here are the config options that were passed in: #{inspect(config)} """ end defp headers(api_key) do [ {"Accept", "application/json"}, {"Content-Type", "application/json"}, {"Authorization", "Bearer #{api_key}"} ] end defp to_sendgrid_body(%Email{} = email, config) do %{} |> put_from(email) |> put_personalizations(email) |> put_reply_to(email) |> put_headers(email) |> put_subject(email) |> put_content(email) |> put_template_id(email) |> put_attachments(email) |> put_categories(email) |> put_send_at(email) |> put_settings(config) |> put_asm_group_id(email) |> put_bypass_list_management(email) |> put_google_analytics(email) |> put_click_tracking(email) |> put_ip_pool_name(email) |> put_unique_args(email) end defp put_from(body, %Email{from: from}) do Map.put(body, :from, to_address(from)) end defp put_personalizations(body, email) do Map.put(body, :personalizations, personalizations(email)) end defp personalizations(email) do base_personalization = %{} |> put_to(email) |> put_cc(email) |> put_bcc(email) |> put_custom_args(email) |> put_template_substitutions(email) |> put_dynamic_template_data(email) |> put_send_at(email) additional_personalizations = email.private |> Map.get(:additional_personalizations, []) |> Enum.map(&build_personalization/1) if base_personalization == %{} do additional_personalizations else [base_personalization] ++ additional_personalizations end end defp build_personalization(personalization = %{to: to}) do %{to: cast_addresses(to, :to)} |> map_put_if(personalization, :cc, &cast_addresses(&1, :cc)) |> map_put_if(personalization, :bcc, &cast_addresses(&1, :bcc)) |> map_put_if(personalization, :custom_args) |> map_put_if(personalization, :substitutions) |> map_put_if(personalization, :subject) |> map_put_if(personalization, :headers) |> map_put_if(personalization, :send_at, &cast_time/1) end defp build_personalization(_personalization) do throw({:error, "Each personalization requires a 'to' field"}) end defp map_put_if(map_out, map_in, key, mapper \\ & &1) do case Map.fetch(map_in, key) do {:ok, value} -> Map.put(map_out, key, mapper.(value)) :error -> map_out end end defp put_to(body, %Email{to: to}) do put_addresses(body, :to, to) end defp put_cc(body, %Email{cc: []}), do: body defp put_cc(body, %Email{cc: cc}) do put_addresses(body, :cc, cc) end defp put_bcc(body, %Email{bcc: []}), do: body defp put_bcc(body, %Email{bcc: bcc}) do put_addresses(body, :bcc, bcc) end defp put_reply_to(body, %Email{headers: %{"reply-to" => {name, email}}}) do Map.put(body, :reply_to, %{email: email, name: name}) end defp put_reply_to(body, %Email{headers: %{"reply-to" => reply_to}}) do Map.put(body, :reply_to, %{email: reply_to}) end defp put_reply_to(body, %Email{headers: %{"Reply-To" => {name, email}}}) do Map.put(body, :reply_to, %{email: email, name: name}) end defp put_reply_to(body, %Email{headers: %{"Reply-To" => reply_to}}) do Map.put(body, :reply_to, %{email: reply_to}) end defp put_reply_to(body, _), do: body defp put_headers(body, %Email{headers: headers}) when is_map(headers) do headers_without_tuple_values = headers |> Map.delete("reply-to") |> Map.delete("Reply-To") Map.put(body, :headers, headers_without_tuple_values) end defp put_headers(body, _), do: body defp put_subject(body, %Email{subject: subject}) when not is_nil(subject), do: Map.put(body, :subject, subject) defp put_subject(body, _), do: body defp put_content(body, email) do email_content = content(email) if not Enum.empty?(email_content) do Map.put(body, :content, content(email)) else body end end defp put_settings(body, %{sandbox: true}), do: Map.put(body, :mail_settings, %{sandbox_mode: %{enable: true}}) defp put_settings(body, _), do: body defp content(email) do [] |> put_html_body(email) |> put_text_body(email) end defp put_html_body(list, %Email{html_body: nil}), do: list defp put_html_body(list, %Email{html_body: html_body}) do [%{type: "text/html", value: html_body} | list] end defp put_text_body(list, %Email{text_body: nil}), do: list defp put_text_body(list, %Email{text_body: text_body}) do [%{type: "text/plain", value: text_body} | list] end defp put_template_id(body, %Email{private: %{send_grid_template: %{template_id: template_id}}}) do Map.put(body, :template_id, template_id) end defp put_template_id(body, _), do: body defp put_template_substitutions(body, %Email{ private: %{send_grid_template: %{substitutions: substitutions}} }) do Map.put(body, :substitutions, substitutions) end defp put_template_substitutions(body, _), do: body defp put_dynamic_template_data(body, %Email{ private: %{send_grid_template: %{dynamic_template_data: dynamic_template_data}} }) do Map.put(body, :dynamic_template_data, dynamic_template_data) end defp put_dynamic_template_data(body, _), do: body defp put_custom_args(body, %Email{private: %{custom_args: custom_args}}) when is_nil(custom_args) or length(custom_args) == 0, do: body defp put_custom_args(body, %Email{ private: %{custom_args: custom_args} }) do Map.put(body, :custom_args, custom_args) end defp put_custom_args(body, _), do: body defp put_categories(body, %Email{private: %{categories: categories}}) when is_list(categories) and length(categories) <= 10 do body |> Map.put(:categories, categories) end defp put_categories(body, _), do: body defp put_send_at(body, %Email{private: %{sendgrid_send_at: send_at_timestamp}}) do body |> Map.put(:send_at, send_at_timestamp) end defp put_send_at(body, _), do: body defp put_asm_group_id(body, %Email{private: %{asm_group_id: asm_group_id}}) when is_integer(asm_group_id) do body |> Map.put(:asm, %{group_id: asm_group_id}) end defp put_asm_group_id(body, _), do: body defp put_bypass_list_management(body, %Email{private: %{bypass_list_management: enabled}}) when is_boolean(enabled) do mail_settings = body |> Map.get(:mail_settings, %{}) |> Map.put(:bypass_list_management, %{enable: enabled}) body |> Map.put(:mail_settings, mail_settings) end defp put_bypass_list_management(body, _), do: body defp put_google_analytics(body, %Email{ private: %{google_analytics_enabled: enabled, google_analytics_utm_params: utm_params} }) do ganalytics = %{enable: enabled} |> Map.merge(utm_params) tracking_settings = body |> Map.get(:tracking_settings, %{}) |> Map.put(:ganalytics, ganalytics) body |> Map.put(:tracking_settings, tracking_settings) end defp put_google_analytics(body, _), do: body defp put_click_tracking(body, %Email{private: %{click_tracking_enabled: enabled}}) do tracking_settings = body |> Map.get(:tracking_settings, %{}) |> Map.put(:click_tracking, %{enable: enabled, enable_text: enabled}) body |> Map.put(:tracking_settings, tracking_settings) end defp put_click_tracking(body, _), do: body defp put_attachments(body, %Email{attachments: []}), do: body defp put_attachments(body, %Email{attachments: attachments}) do transformed = attachments |> Enum.reverse() |> Enum.map(fn attachment -> %{ filename: attachment.filename, type: attachment.content_type, content: Base.encode64(attachment.data) } end) Map.put(body, :attachments, transformed) end defp put_addresses(body, _, []), do: body defp put_addresses(body, field, addresses), do: Map.put(body, field, Enum.map(addresses, &to_address/1)) defp to_address({nil, address}), do: %{email: address} defp to_address({"", address}), do: %{email: address} defp to_address({name, address}), do: %{email: address, name: name} defp base_uri do Application.get_env(:bamboo, :sendgrid_base_uri) || @default_base_uri end defp cast_time(%DateTime{} = date_time), do: DateTime.to_unix(date_time) defp cast_time(unix_timestamp) when is_integer(unix_timestamp), do: unix_timestamp defp cast_time(_other) do throw({:error, "expected 'send_at' time parameter to be a DateTime or unix timestamp"}) end defp cast_addresses(addresses, type) when is_list(addresses) do Enum.map(addresses, &cast_address(&1, type)) end defp cast_addresses(address, type), do: cast_addresses([address], type) # SendGrid wants emails as a map defp cast_address(%_{} = address, type) do case Formatter.impl_for(address) do nil -> cast_address_as_map(address) _ -> cast_address_with_formatter(address, type) end end defp cast_address(address, _type) when is_map(address) do cast_address_as_map(address) end defp cast_address(address, type) do cast_address_with_formatter(address, type) end defp cast_address_as_map(address) do case {Map.get(address, :name, Map.get(address, "name")), Map.get(address, :email, Map.get(address, "email"))} do {_name, nil} -> throw({:error, "Must specify at least an 'email' field in map #{inspect(address)}"}) {nil, address} -> %{email: address} {name, address} -> %{email: address, name: name} end end defp cast_address_with_formatter(address, type) do {name, address} = Formatter.format_email_address(address, type) case {name, address} do {nil, address} -> %{email: address} {name, address} -> %{email: address, name: name} end end defp put_ip_pool_name(body, %Email{private: %{ip_pool_name: ip_pool_name}}), do: Map.put(body, :ip_pool_name, ip_pool_name) defp put_ip_pool_name(body, _), do: body defp put_unique_args(body, %Email{private: %{unique_args: unique_args}}) when is_map(unique_args) do body |> Map.put(:unique_args, unique_args) end defp put_unique_args(body, _), do: body end