├── 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 [](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 |
--------------------------------------------------------------------------------