├── test ├── test_helper.exs └── mailgun_test.exs ├── .travis.yml ├── .gitignore ├── fixture └── sample.png ├── lib ├── mailgun.ex └── client.ex ├── mix.lock ├── mix.exs ├── LICENSE ├── config └── config.exs └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | otp_release: 3 | - 17.4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /fixture/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrismccord/mailgun/HEAD/fixture/sample.png -------------------------------------------------------------------------------- /lib/mailgun.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailgun do 2 | 3 | def start do 4 | ensure_started :inets 5 | ensure_started :ssl 6 | :ok 7 | end 8 | 9 | defp ensure_started(module) do 10 | case module.start do 11 | :ok -> :ok 12 | {:error, {:already_started, _module}} -> :ok 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"exactor": {:hex, :exactor, "2.0.1"}, 2 | "exjsx": {:hex, :exjsx, "3.1.0"}, 3 | "exvcr": {:hex, :exvcr, "0.4.0"}, 4 | "hackney": {:hex, :hackney, "1.0.6"}, 5 | "httpoison": {:hex, :httpoison, "0.6.2"}, 6 | "idna": {:hex, :idna, "1.0.2"}, 7 | "jsex": {:hex, :jsex, "2.0.0"}, 8 | "jsx": {:hex, :jsx, "2.4.0"}, 9 | "meck": {:hex, :meck, "0.8.2"}, 10 | "poison": {:hex, :poison, "1.4.0"}, 11 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.1"}} 12 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailgun.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :mailgun, 6 | version: "0.1.3", 7 | elixir: "~> 1.0", 8 | deps: deps, 9 | package: [ 10 | contributors: ["Chris McCord"], 11 | licenses: ["MIT"], 12 | links: %{github: "https://github.com/chrismccord/mailgun"} 13 | ], 14 | description: """ 15 | Elixir Mailgun Client 16 | """] 17 | end 18 | 19 | # Configuration for the OTP application 20 | # 21 | # Type `mix help compile.app` for more information 22 | def application do 23 | [applications: [:logger, :inets, :ssl]] 24 | end 25 | 26 | # Dependencies can be Hex packages: 27 | # 28 | # {:mydep, "~> 0.3.0"} 29 | # 30 | # Or git/path repositories: 31 | # 32 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 33 | # 34 | # Type `mix help deps` for more examples and options 35 | defp deps do 36 | [{:exvcr, "~> 0.4.0", only: [:test]}, 37 | {:poison, "~> 1.4 or ~> 2.0"} 38 | ] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Chris McCord 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elixir Mailgun Client [![Build Status](https://travis-ci.org/chrismccord/mailgun.svg)](https://travis-ci.org/chrismccord/mailgun) 2 | 3 | 4 | ```elixir 5 | # config/config.exs 6 | 7 | config :my_app, mailgun_domain: "https://api.mailgun.net/v3/mydomain.com", 8 | mailgun_key: "key-##############" 9 | 10 | 11 | # lib/mailer.ex 12 | defmodule MyApp.Mailer do 13 | @config domain: Application.get_env(:my_app, :mailgun_domain), 14 | key: Application.get_env(:my_app, :mailgun_key) 15 | use Mailgun.Client, @config 16 | 17 | 18 | @from "info@example.com" 19 | 20 | def send_welcome_text_email(user) do 21 | send_email to: user.email, 22 | from: @from, 23 | subject: "hello!", 24 | text: "Welcome!" 25 | end 26 | 27 | def send_welcome_html_email(user) do 28 | send_email to: user.email, 29 | from: @from, 30 | subject: "hello!", 31 | html: "Welcome!" 32 | end 33 | 34 | # attachments expect a list of maps. Each map should have a filename and path/content 35 | 36 | def send_greetings(user, file_path) do 37 | send_email to: user.email, 38 | from: @from, 39 | subject: "Happy b'day", 40 | html: "Cheers!", 41 | attachments: [%{path: file_path, filename: "greetings.png"}] 42 | end 43 | 44 | def send_invoice(user) do 45 | pdf = Invoice.create_for(user) # a string 46 | send_email to: user.email, 47 | from: @from, 48 | subject: "Invoice", 49 | html: "Your Invoice", 50 | attachments: [%{content: pdf, filename: "invoice.pdf"}] 51 | end 52 | end 53 | 54 | 55 | iex> MyApp.Mailer.send_welcome_text_email(user) 56 | {:ok, ...} 57 | ``` 58 | 59 | ### Installation 60 | 61 | Add mailgun to your `mix.exs` dependencies: 62 | 63 | ```elixir 64 | def deps do 65 | [ {:mailgun, "~> 0.1.2"} ] 66 | end 67 | ``` 68 | 69 | ### Test mode 70 | For testing purposes mailgun can output emails to a local file instead of 71 | actually sending them. Just set the `mode` configuration key to `:test` 72 | and the `test_file_path` to where you want that file to appear. 73 | 74 | ```elixir 75 | # lib/mailer.ex 76 | defmodule MyApp.Mailer do 77 | @config domain: Application.get_env(:my_app, :mailgun_domain), 78 | key: Application.get_env(:my_app, :mailgun_key), 79 | mode: :test, 80 | test_file_path: "/tmp/mailgun.json" 81 | use Mailgun.Client, @config 82 | 83 | ... 84 | end 85 | ``` 86 | 87 | ### httpc options 88 | Under the hood the client uses [`httpc`](http://erlang.org/doc/man/httpc.html) 89 | to call Mailgun REST API. You can inject any valid `httpc` options to your 90 | outbound requests by defining them within `httpc_opts` config entry: 91 | 92 | ```elixir 93 | # lib/mailer.ex 94 | defmodule MyApp.Mailer do 95 | @config domain: Application.get_env(:my_app, :mailgun_domain), 96 | key: Application.get_env(:my_app, :mailgun_key), 97 | httpc_opts: [connect_timeout: 2000, timeout: 3000] 98 | use Mailgun.Client, @config 99 | ... 100 | ``` 101 | -------------------------------------------------------------------------------- /test/mailgun_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MailgunTest do 2 | use ExUnit.Case, async: false 3 | use ExVCR.Mock, adapter: ExVCR.Adapter.Httpc 4 | 5 | @success_json "{\n \"message\": \"Queued. Thank you.\",\n \"id\": \"\"\n}" 6 | @error_json "{\n \"message\": \"'to' parameter is not a valid address. please check documentation\"\n}" 7 | 8 | setup_all do 9 | Mailgun.start 10 | end 11 | 12 | test "url returns the full url joined with the path and domain config" do 13 | assert Mailgun.Client.url("/messages", "https://api.mailgun.net/v3/mydomain.com") == 14 | "https://api.mailgun.net/v3/mydomain.com/messages" 15 | end 16 | 17 | test "mailers can use Client for configuration automation" do 18 | defmodule Mailer do 19 | use Mailgun.Client, domain: "https://api.mailgun.net/v3/mydomain.test", key: "my-key" 20 | end 21 | 22 | assert Mailer.__info__(:functions) |> Enum.member?({:send_email, 1}) 23 | 24 | end 25 | 26 | test "send_email returns {:ok, response} if sent successfully" do 27 | config = [domain: "https://api.mailgun.net/v3/mydomain.test", key: "my-key"] 28 | use_cassette :stub, [url: "https://api.mailgun.net/v3/mydomain.test/messages", 29 | method: "post", 30 | status_code: ["HTTP/1.1", 200, "OK"], 31 | body: @success_json] do 32 | 33 | {:ok, body} = Mailgun.Client.send_email config, 34 | to: "foo@bar.test", 35 | from: "foo@bar.test", 36 | subject: "hello!", 37 | text: "How goes it?" 38 | 39 | assert body == @success_json 40 | end 41 | end 42 | 43 | test "send_email with attachment returns {:ok, response} if sent successfully" do 44 | config = [domain: "https://api.mailgun.net/v3/mydomain.test", key: "my-key"] 45 | use_cassette :stub, [url: "https://api.mailgun.net/v3/mydomain.test/messages", 46 | method: "post", 47 | status_code: ["HTTP/1.1", 200, "OK"], 48 | body: @success_json] do 49 | 50 | file_path = Path.join("fixture", "sample.png") 51 | file_content = file_path |> File.read! 52 | {:ok, body} = Mailgun.Client.send_email config, 53 | to: "foo@bar.test", 54 | from: "foo@bar.test", 55 | subject: "hello!", 56 | text: "How goes it?", 57 | attachments: [%{content: file_content, filename: "sample.png"}] 58 | assert body == @success_json 59 | 60 | {:ok, body} = Mailgun.Client.send_email config, 61 | to: "foo@bar.test", 62 | from: "foo@bar.test", 63 | subject: "hello!", 64 | text: "How goes it?", 65 | attachments: [%{path: file_path, filename: "sample.png"}] 66 | assert body == @success_json 67 | end 68 | end 69 | 70 | test "send_email returns {:error, reason} if send failed" do 71 | config = [domain: "https://api.mailgun.net/v3/mydomain.test", key: "my-key"] 72 | use_cassette :stub, [url: "https://api.mailgun.net/v3/mydomain.test/messages", 73 | method: "post", 74 | status_code: ["HTTP/1.1", 400, "BAD REQUEST"], 75 | body: @error_json] do 76 | 77 | {:error, status, body} = Mailgun.Client.send_email config, 78 | to: "foo@bar.test", 79 | from: "foo@bar.test", 80 | subject: "hello!", 81 | text: "How goes it?" 82 | 83 | assert status == 400 84 | assert body == @error_json 85 | end 86 | end 87 | 88 | test "sending in test mode writes the mail fields to a file" do 89 | file_path = "/tmp/mailgun.json" 90 | config = [domain: "https://api.mailgun.net/v3/mydomain.test", key: "my-key", mode: :test, test_file_path: file_path] 91 | {:ok, _} = Mailgun.Client.send_email config, 92 | to: "foo@bar.test", 93 | from: "foo@bar.test", 94 | subject: "hello!", 95 | text: "How goes it?" 96 | 97 | file_contents = File.read!(file_path) 98 | assert file_contents == "{\"to\":\"foo@bar.test\",\"text\":\"How goes it?\",\"subject\":\"hello!\",\"from\":\"foo@bar.test\"}" 99 | end 100 | 101 | end 102 | -------------------------------------------------------------------------------- /lib/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailgun.Client do 2 | @moduledoc """ 3 | Module to interact with Mailgun and send emails. 4 | 5 | ## Configuration 6 | 7 | # config/config.exs 8 | config :my_app, 9 | mailgun_domain: "https://api.mailgun.net/v3/mydomain.com", 10 | mailgun_key: "key-##############" 11 | 12 | # lib/user_mailer.ex 13 | defmodule MyApp.UserMailer do 14 | @config domain: Application.get_env(:my_app, :mailgun_domain), 15 | key: Application.get_env(:my_app, :mailgun_key), 16 | mode: Mix.env 17 | Mailgun.Client, @config 18 | end 19 | 20 | ## Sending Emails 21 | 22 | Invoke `send_email/1` method with a keyword list of `:from`, `:to`, `:subject`, 23 | `:text`, `:html`, `:attachments`. 24 | 25 | # lib/user_mailer.ex 26 | defmodule MyApp.UserMailer do 27 | @config domain: Application.get_env(:my_app, :mailgun_domain), 28 | key: Application.get_env(:my_app, :mailgun_key), 29 | mode: Mix.env 30 | use Mailgun.Client, @config 31 | 32 | def send_welcome_text_email(email) do 33 | send_email to: email, 34 | from: "info@example.com", 35 | subject: "hello!", 36 | text: "Welcome!" 37 | end 38 | 39 | def send_welcome_html_email(user) do 40 | send_email to: user.email, 41 | from: "info@example.com", 42 | subject: "hello!", 43 | html: "Welcome!" 44 | end 45 | end 46 | 47 | $ iex -S mix 48 | iex> MyApp.UserMailer.send_welcome_text_email("us@example.com") 49 | 50 | ## Send an attachment in the email 51 | 52 | Pass the `attachments` option which is a list of maps. Each map 53 | (attachment) should have a `filename` and a `path` or `content`. 54 | 55 | Options for each attachment: 56 | * `filename` - a string eg: "sample.png" 57 | * `path` - a string eg: "/tmp/sample.png" 58 | * `content` - a string eg: File.read!("/tmp/sample.png") 59 | 60 | If there is a file_path in the storage that needs to sent in the email, 61 | pass that as a map with `path` and `filename`. 62 | 63 | def send_greetings(user, file_path) do 64 | send_email to: user.email, 65 | from: @from, 66 | subject: "Happy b'day", 67 | html: "Cheers!", 68 | attachments: [%{path: file_path, filename: "greetings.png"}] 69 | end 70 | 71 | If a file content is created on the fly using some generator. That file content 72 | can be passed(without being written on to the disk) in the map with 73 | `content` and `filename`. 74 | 75 | def send_invoice(user) do 76 | pdf = Invoice.create_for(user) # a string 77 | send_email to: user.email, 78 | from: @from, 79 | subject: "Invoice", 80 | html: "Your Invoice", 81 | attachments: [%{content: pdf, filename: "invoice.pdf"}] 82 | end 83 | """ 84 | 85 | defmacro __using__(config) do 86 | quote do 87 | @conf unquote(config) 88 | def conf, do: @conf 89 | def send_email(email) do 90 | unquote(__MODULE__).send_email(conf(), email) 91 | end 92 | end 93 | end 94 | 95 | def get_attachment(mailer, url) do 96 | config = mailer.conf 97 | request config, :get, url, "api", config[:key], [], "", "" 98 | end 99 | 100 | def send_email(conf, email) do 101 | do_send_email(conf[:mode], conf, email) 102 | end 103 | defp do_send_email(:test, conf, email) do 104 | log_email(conf, email) 105 | {:ok, "OK"} 106 | end 107 | defp do_send_email(_, conf, email) do 108 | case email[:attachments] do 109 | atts when atts in [nil, []] -> 110 | send_without_attachments(conf, email) 111 | atts when is_list(atts) -> 112 | send_with_attachments(conf, Dict.delete(email, :attachments), atts) 113 | end 114 | end 115 | defp send_without_attachments(conf, email) do 116 | attrs = Dict.merge(email, %{ 117 | to: Dict.fetch!(email, :to), 118 | from: Dict.fetch!(email, :from), 119 | text: Dict.get(email, :text, ""), 120 | html: Dict.get(email, :html, ""), 121 | subject: Dict.get(email, :subject, ""), 122 | }) 123 | ctype = 'application/x-www-form-urlencoded' 124 | body = URI.encode_query(Dict.drop(attrs, [:attachments])) 125 | 126 | request(conf, :post, url("/messages", conf[:domain]), "api", conf[:key], [], ctype, body) 127 | end 128 | defp send_with_attachments(conf, email, attachments) do 129 | attrs = 130 | email 131 | |> Dict.merge(%{ 132 | to: Dict.fetch!(email, :to), 133 | from: Dict.fetch!(email, :from), 134 | text: Dict.get(email, :text, ""), 135 | html: Dict.get(email, :html, ""), 136 | subject: Dict.get(email, :subject, "")}) 137 | |> Enum.map(fn 138 | {k, v} when is_binary(v) -> {k, String.to_char_list(v)} 139 | {k, v} -> {k, v} 140 | end) 141 | |> Enum.into(%{}) 142 | 143 | headers = [] 144 | boundary = '------------a450glvjfEoqerAc1p431paQlfDac152cadADfd' 145 | ctype = :lists.concat(['multipart/form-data; boundary=', boundary]) 146 | 147 | attachments = 148 | Enum.reduce(attachments, [], fn upload, acc -> 149 | data = parse_attachment(upload) |> :erlang.binary_to_list 150 | [{:attachment, String.to_char_list(upload.filename), data} | acc] 151 | end) 152 | 153 | body = format_multipart_formdata(boundary, attrs, attachments) 154 | 155 | headers = [{'Content-Length', :erlang.integer_to_list(:erlang.length(attachments))} | headers] 156 | 157 | request(conf, :post, url("/messages", conf[:domain]), "api", conf[:key], headers, ctype, body) 158 | end 159 | 160 | defp parse_attachment(%{content: content}), do: content 161 | defp parse_attachment(%{path: path}), do: File.read!(path) 162 | 163 | def log_email(conf, email) do 164 | json = email 165 | |> Enum.into(%{}) 166 | |> Poison.encode! 167 | File.write(conf[:test_file_path], json) 168 | end 169 | 170 | defp format_multipart_formdata(boundary, fields, files) do 171 | field_parts = Enum.map(fields, fn {field_name, field_content} -> 172 | [:lists.concat(['--', boundary]), 173 | :lists.concat(['Content-Disposition: form-data; name=\"', :erlang.atom_to_list(field_name),'\"']), 174 | '', 175 | field_content] 176 | end) 177 | field_parts2 = :lists.append(field_parts) 178 | file_parts = Enum.map(files, fn {field_name, file_name, file_content} -> 179 | [:lists.concat(['--', boundary]), 180 | :lists.concat(['Content-Disposition: format-data; name=\"', :erlang.atom_to_list(field_name), '\"; filename=\"', file_name, '\"']), 181 | :lists.concat(['Content-Type: ', 'application/octet-stream']), 182 | '', 183 | file_content] 184 | end) 185 | file_parts2 = :lists.append(file_parts) 186 | ending_parts = [:lists.concat(['--', boundary, '--']), ''] 187 | parts = :lists.append([field_parts2, file_parts2, ending_parts]) 188 | 189 | :string.join(parts, '\r\n') 190 | end 191 | 192 | def url(path, domain), do: Path.join([domain, path]) 193 | 194 | def request(conf, method, url, user, pass, headers, ctype, body) do 195 | url = String.to_char_list(url) 196 | opts = conf[:httpc_opts] || [] 197 | 198 | case method do 199 | :get -> 200 | headers = headers ++ [auth_header(user, pass)] 201 | :httpc.request(:get, {url, headers}, opts, body_format: :binary) 202 | _ -> 203 | headers = headers ++ [auth_header(user, pass), {'Content-Type', ctype}] 204 | :httpc.request(method, {url, headers, ctype, body}, opts, body_format: :binary) 205 | end 206 | |> normalize_response 207 | end 208 | 209 | defp auth_header(user, pass) do 210 | {'Authorization', 'Basic ' ++ String.to_char_list(Base.encode64("#{user}:#{pass}"))} 211 | end 212 | 213 | defp normalize_response(response) do 214 | case response do 215 | {:ok, {{_httpvs, 200, _status_phrase}, json_body}} -> 216 | {:ok, json_body} 217 | {:ok, {{_httpvs, 200, _status_phrase}, _headers, json_body}} -> 218 | {:ok, json_body} 219 | {:ok, {{_httpvs, status, _status_phrase}, json_body}} -> 220 | {:error, status, json_body} 221 | {:ok, {{_httpvs, status, _status_phrase}, _headers, json_body}} -> 222 | {:error, status, json_body} 223 | {:error, reason} -> {:error, :bad_fetch, reason} 224 | end 225 | end 226 | end 227 | --------------------------------------------------------------------------------