├── .github
└── workflows
│ └── test.yml
├── Makefile
├── README.md
├── mailgun-dev-1.rockspec
├── mailgun.lua
├── mailgun.moon
├── mailgun
├── init.lua
├── init.moon
├── util.lua
└── util.moon
└── spec
└── mailgun_spec.moon
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on: [push]
4 |
5 | jobs:
6 | test:
7 | strategy:
8 | fail-fast: false
9 | matrix:
10 | luaVersion: ["5.1", "5.2", "5.3", "luajit", "luajit-openresty"]
11 |
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - uses: actions/checkout@master
16 |
17 | - uses: leafo/gh-actions-lua@master
18 | with:
19 | luaVersion: ${{ matrix.luaVersion }}
20 |
21 | - uses: leafo/gh-actions-luarocks@master
22 |
23 | - name: build
24 | run: |
25 | luarocks install https://raw.githubusercontent.com/leafo/lua-cjson/master/lua-cjson-dev-1.rockspec
26 | luarocks install busted
27 | luarocks install moonscript
28 | luarocks install luaossl
29 | luarocks make
30 |
31 | - name: test
32 | run: |
33 | busted -o utfTerminal
34 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: local lint build
2 |
3 | local: build
4 | luarocks --lua-version=5.1 make --local mailgun-dev-1.rockspec
5 |
6 | build:
7 | moonc mailgun
8 |
9 | lint:
10 | moonc -l mailgun
11 |
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # mailgun
2 |
3 | 
4 |
5 | A Lua library for sending emails and interacting with the
6 | [Mailgun](https://mailgun.com/) API. Compatible with OpenResty via Lapis HTTP
7 | API, or any other Lua script via LuaSocket.
8 |
9 | *At the moment this library only implements a subset of the API. If there's an
10 | missing API method feel free to open an issue.*
11 |
12 | ## Example
13 |
14 | ```lua
15 | local Mailgun = require("mailgun").Mailgun
16 |
17 | local m = Mailgun({
18 | domain = "leafo.net",
19 | api_key = "api:key-blah-blah-blahblah"
20 | })
21 |
22 | m:send_email({
23 | to = "you@example.com",
24 | subject = "Important message here",
25 | html = true,
26 | body = [[
27 |
Hello world
28 | Here is my email to you.
29 |
30 |
31 | Unsubscribe
32 |
33 | ]]
34 | })
35 | ```
36 |
37 | ## Install
38 |
39 | ```
40 | luarocks install mailgun
41 | ```
42 |
43 | ## Reference
44 |
45 | The `Mailgun` constructor can be used to create a new client to Mailgun. It's
46 | found in the `mailgun` module.
47 |
48 | ```lua
49 | local Mailgun = require("mailgun").Mailgun
50 |
51 | local m = Mailgun({
52 | domain = "leafo.net",
53 | api_key = "api:key-blah-blah-blahblah"
54 | })
55 | ```
56 |
57 | The following options are valid:
58 |
59 | * `domain` - the domain to use for API requests (**required**)
60 | * `api_key` - the API key to authenticate requests (**required**)
61 | * `webhook_signing_key` - key used for webhook signature verification, defaults to api key without username (*optional*)
62 | * `default_sender` - the sender to use for `send_email` when a sender is not provided (*optional*)
63 | * `http` - set the HTTP client (*optional*)
64 |
65 | The value of `default_sender` has a default created from the `domain` like
66 | this: `{domain} `.
67 |
68 | ### HTTP Client
69 |
70 | If a HTTP client is not specified, this library will pick `lapis.nginx.http`
71 | when inside of Nginx (OpenResty), otherwise it will fall back on `ssl.https`
72 | (LuaSocket & LuaSec)
73 |
74 | The client can be changed by providing an `http` option to the constructor. If
75 | a string is passed, it will be required as a module name. For example, you can
76 | use [lua-http](https://github.com/daurnimator/lua-http) by passing in `http = "http.compat.socket"`
77 |
78 | Alternatively, a function can be passed in. The function will be called once
79 | and the return value will be used as the http module. (It should be a table
80 | with a request function that works like LuaSocket)
81 |
82 | ### Methods
83 |
84 | #### `mailgun:send_email(opts={})`
85 |
86 | The following are required options:
87 |
88 | * `to` - the recipient(s) of the email. Pass an array table to send to multiple recipients
89 | * `subject` - the subject line of the email
90 | * `body` - the body of the email
91 |
92 | Optional fields:
93 |
94 | * `from` - the sender of the email (default: `{domain} `)
95 | * `html` - set to `true` to send email as HTML (default `false`)
96 | * `domain` - use a different domain than the default
97 | * `cc` - recipients to cc to, same format as `to`
98 | * `bcc` - recipients to bcc to, same format as `to`
99 | * `track_opens` - track the open rate fo the email (default `false`)
100 | * `tags` - an array table of tags to apply to message
101 | * `vars` - table of recipient specific variables where the key is the recipient and value is a table of vars
102 | * `headers` - a table of additional headers to provide
103 | * `campaign` - the campaign id of the campaign the email is part of (see `get_or_create_campaign_id`)
104 | * `v:{NAME}` - add any number of user variables with the name `{NAME}`, ie. `v:user_id`
105 |
106 | ##### Recipient variables
107 |
108 | Using recipient variables you can bulk send many emails in a single API call.
109 | You can parameterize your email address with different variables for each
110 | recipient:
111 |
112 |
113 | ```lua
114 | local vars = {
115 | ["leafo@example.com"] = {
116 | username = "L.E.A.F.",
117 | profile_url = "http://example.com/leafo",
118 | },
119 | ["adam@example.com"] = {
120 | username = "Adumb",
121 | profile_url = "http://example.com/adam",
122 | }
123 | }
124 |
125 | mailgun:send_email({
126 | to = {"leafo@example.com", "adam@example.com"},
127 | vars = vars,
128 | subject = "Hey check it out!",
129 | body = [[
130 | Hello %recipient.username%,
131 | We just updated your profile page. Check it out: %recipient.profile_url%
132 | ]]
133 | })
134 | ```
135 |
136 | ##### Setting reply-to email
137 |
138 | Pass the `Reply-To` header:
139 |
140 | ```lua
141 | mailgun:send_email({
142 | to = "you@example.com",
143 | subject = "Hey check it out!",
144 | from = "Postmaster ",
145 | headers = {
146 | ["Reply-To"] = "leafo@leaf.zone"
147 | },
148 | body = [[
149 | Thanks for downloading our game, reply if you have any questions!
150 | ]]
151 | })
152 | ```
153 |
154 | #### `mailgun:create_campaign(name)`
155 |
156 | Creates a new campaign named `name`. Returns the campaign object
157 |
158 | #### `campaigns = mailgun:get_campaigns()`
159 |
160 | Gets all the campaigns that are available
161 |
162 | #### `mailgun:get_or_create_campaign_id(name)`
163 |
164 | Gets a campaign id for a campaign by name. If it doesn't exist yet a new one is created.
165 |
166 | #### `messages, paging = mailgun:get_messages()`
167 |
168 | Gets the first page of stored messages (this uses the events API). The paging
169 | object includes the urls for fetching subsequent pages.
170 |
171 | #### `unsubscribes, paging = mailgun:get_unsubscribes(opts={})`
172 |
173 | https://documentation.mailgun.com/api-suppressions.html#unsubscribes
174 |
175 | Gets the first page of unsubscribes messages. `opts` is passed as query string
176 | parameters.
177 |
178 | #### `iter = mailgun:each_event(filter_params={})`
179 |
180 | https://documentation.mailgun.com/en/latest/api-events.html
181 |
182 | Iterates through each event, lazily fetching pages of events as needed. In
183 | order to stop processing events before all of them have been traversed use
184 | `break` to exit the loop.
185 |
186 | ```
187 | for e in mailgun:each_unsubscribe() do
188 | print(e.event)
189 | end
190 | ```
191 |
192 | Each event is a plain Lua table with the same format provided by the API :
193 |
194 |
195 | Uses `limit` of 300 by default, which will fetch 300 events at a time for each page.
196 |
197 | #### `result = mailgun:get_events(params={})`
198 |
199 | https://documentation.mailgun.com/en/latest/api-events.html
200 |
201 | Issues API call to `GET //events` with provided parameters. If you want
202 | to iterate over events see `each_event`.
203 |
204 | #### `iter = mailgun:each_unsubscribe()`
205 |
206 | Iterates through each message (fetching each page as needed)
207 |
208 | ```lua
209 | for unsub in mailgun:each_unsubscribe() do
210 | print(unsub.address)
211 | end
212 | ```
213 |
214 | #### `bounces, paging = mailgun:get_bounces(opts={})`
215 |
216 | https://documentation.mailgun.com/api-suppressions.html#bounces
217 |
218 | Gets the first page of unsubscribes bounces. `opts` is passed as query string
219 | parameters.
220 |
221 | #### `iter = mailgun:each_bounce()`
222 |
223 | Iterates through each bounce (fetching each page as needed). Similar to
224 | `get_unsubscribes`.
225 |
226 | #### `complaints, paging = mailgun:get_complaints(opts={})`
227 |
228 | https://documentation.mailgun.com/api-suppressions.html#view-all-complaints
229 |
230 | Gets the first page of complaints messages. `opts` is passed as query string
231 | parameters.
232 |
233 | #### `iter = mailgun:each_complaint()`
234 |
235 | Iterates through each complaint (fetching each page as needed). Similar to
236 | `get_unsubscribes`.
237 |
238 |
239 | #### `new_mailgun = mailgun:for_domain(domain)`
240 |
241 | Returns a new instance of the API client configured the same way, but with the
242 | domain replaced with the provided domain. If you have multiple domains on your
243 | account you can use this to switch to them for any of the `get_` methods.
244 |
245 | #### `mailgun:verify_webhook_signature(timestamp, token, signature)`
246 |
247 | Verify signature of a webhook call using the stored API key as described here:
248 |
249 | Returns `true` if the signature is validated, otherwise returns `nil` and an error message.
250 |
251 | If any of the arguments aren't provided, an error is thrown.
252 |
253 | #### `mailgun:validate_email(email_address)`
254 |
255 | Look up email using the email validation service described here:
256 |
257 | Returns a Lua object with results of validation
258 |
259 | # Changelog
260 |
261 | Changelog now available on GitHub releases: https://github.com/leafo/lua-mailgun/releases
262 |
263 | ## License (MIT)
264 |
265 | Copyright (C) 2022 by Leaf Corcoran
266 |
267 | Permission is hereby granted, free of charge, to any person obtaining a copy
268 | of this software and associated documentation files (the "Software"), to deal
269 | in the Software without restriction, including without limitation the rights
270 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
271 | copies of the Software, and to permit persons to whom the Software is
272 | furnished to do so, subject to the following conditions:
273 |
274 | The above copyright notice and this permission notice shall be included in
275 | all copies or substantial portions of the Software.
276 |
277 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
278 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
279 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
280 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
281 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
282 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
283 | THE SOFTWARE.
284 |
285 |
--------------------------------------------------------------------------------
/mailgun-dev-1.rockspec:
--------------------------------------------------------------------------------
1 | package = "mailgun"
2 | version = "dev-1"
3 |
4 | source = {
5 | url = "git://github.com/leafo/lua-mailgun.git",
6 | }
7 |
8 | description = {
9 | summary = "Send email with Mailgun",
10 | homepage = "https://github.com/leafo/lua-mailgun",
11 | license = "MIT"
12 | }
13 |
14 | dependencies = {
15 | "lua >= 5.1",
16 | "lpeg",
17 | "luasocket",
18 | "lua-cjson",
19 | "luasec",
20 | }
21 |
22 | build = {
23 | type = "builtin",
24 | modules = {
25 | ["mailgun"] = "mailgun/init.lua",
26 | ["mailgun.util"] = "mailgun/util.lua",
27 | }
28 | }
29 |
30 |
--------------------------------------------------------------------------------
/mailgun.lua:
--------------------------------------------------------------------------------
1 | return require("mailgun.init")
2 |
--------------------------------------------------------------------------------
/mailgun.moon:
--------------------------------------------------------------------------------
1 | require "mailgun.init"
2 |
--------------------------------------------------------------------------------
/mailgun/init.lua:
--------------------------------------------------------------------------------
1 | local ltn12 = require("ltn12")
2 | local encode_base64, encode_query_string, parse_query_string
3 | do
4 | local _obj_0 = require("mailgun.util")
5 | encode_base64, encode_query_string, parse_query_string = _obj_0.encode_base64, _obj_0.encode_query_string, _obj_0.parse_query_string
6 | end
7 | local concat
8 | concat = table.concat
9 | local json = require("cjson")
10 | local add_recipients
11 | add_recipients = function(data, field, emails)
12 | if not (emails) then
13 | return
14 | end
15 | if type(emails) == "table" then
16 | for _index_0 = 1, #emails do
17 | local email = emails[_index_0]
18 | table.insert(data, {
19 | field,
20 | email
21 | })
22 | end
23 | else
24 | data[field] = emails
25 | end
26 | end
27 | local items_method
28 | items_method = function(path, items_field, paging_field)
29 | if items_field == nil then
30 | items_field = "items"
31 | end
32 | if paging_field == nil then
33 | paging_field = "paging"
34 | end
35 | return function(self, opts)
36 | if opts == nil then
37 | opts = { }
38 | end
39 | local res, err = self:api_request(tostring(path) .. "?" .. tostring(encode_query_string(opts)))
40 | if res then
41 | return res[items_field], res[paging_field]
42 | else
43 | return nil, err
44 | end
45 | end
46 | end
47 | local to_hex
48 | do
49 | local hex_c
50 | hex_c = function(c)
51 | return string.format("%02x", string.byte(c))
52 | end
53 | to_hex = function(str)
54 | return (str:gsub(".", hex_c))
55 | end
56 | end
57 | local Mailgun
58 | do
59 | local _class_0
60 | local _base_0 = {
61 | api_prefix = "https://api.mailgun.net",
62 | api_version = "v3",
63 | for_domain = function(self, domain)
64 | return Mailgun({
65 | domain = domain,
66 | api_key = self.api_key,
67 | http = self.http_provider
68 | })
69 | end,
70 | http = function(self)
71 | if not (self._http) then
72 | self.http_provider = self.http_provider or (function()
73 | if ngx then
74 | return "lapis.nginx.http"
75 | else
76 | return "ssl.https"
77 | end
78 | end)()
79 | if type(self.http_provider) == "function" then
80 | self._http = self:http_provider()
81 | else
82 | self._http = require(self.http_provider)
83 | end
84 | end
85 | return self._http
86 | end,
87 | api_request = function(self, path, data, domain)
88 | if domain == nil then
89 | domain = self.domain
90 | end
91 | local url
92 | if path:match("^https?:") then
93 | url = path
94 | else
95 | local prefix = tostring(self.api_prefix) .. "/" .. tostring(self.api_version) .. "/" .. tostring(domain)
96 | url = prefix .. path
97 | end
98 | local body = data and encode_query_string(data)
99 | local out = { }
100 | local req = {
101 | url = url,
102 | source = body and ltn12.source.string(body) or nil,
103 | method = data and "POST" or "GET",
104 | headers = {
105 | ["Host"] = "api.mailgun.net",
106 | ["Content-type"] = body and "application/x-www-form-urlencoded" or nil,
107 | ["Content-length"] = body and #body or nil,
108 | ["Authorization"] = "Basic " .. encode_base64(self.api_key)
109 | },
110 | sink = ltn12.sink.table(out),
111 | protocol = not ngx and "sslv23" or nil
112 | }
113 | local _, status = self:http().request(req)
114 | return self:format_response(concat(out), status)
115 | end,
116 | format_response = function(self, res, status)
117 | pcall(function()
118 | res = json.decode(res)
119 | end)
120 | if res == "" or not res then
121 | res = "invalid response"
122 | end
123 | if status ~= 200 then
124 | return nil, res.message or res
125 | end
126 | return res
127 | end,
128 | send_email = function(self, opts)
129 | if opts == nil then
130 | opts = { }
131 | end
132 | local to, subject, body, domain
133 | to, subject, body, domain = opts.to, opts.subject, opts.body, opts.domain
134 | assert(to, "missing recipients")
135 | assert(subject, "missing subject")
136 | assert(body, "missing body")
137 | domain = domain or self.domain
138 | local data = {
139 | from = opts.from or self.default_sender,
140 | subject = subject,
141 | [opts.html and "html" or "text"] = body
142 | }
143 | add_recipients(data, "to", to)
144 | add_recipients(data, "cc", opts.cc)
145 | add_recipients(data, "bcc", opts.bcc)
146 | if opts.tags then
147 | local _list_0 = opts.tags
148 | for _index_0 = 1, #_list_0 do
149 | local t = _list_0[_index_0]
150 | table.insert(data, {
151 | "o:tag",
152 | t
153 | })
154 | end
155 | end
156 | if opts.vars then
157 | data["recipient-variables"] = json.encode(opts.vars)
158 | end
159 | if opts.headers then
160 | for h, v in pairs(opts.headers) do
161 | data["h:" .. tostring(h)] = v
162 | end
163 | end
164 | if opts.track_opens then
165 | data["o:tracking-opens"] = "yes"
166 | end
167 | do
168 | local c = opts.campaign
169 | if c then
170 | data["o:campaign"] = c
171 | end
172 | end
173 | for k, v in pairs(opts) do
174 | if k:match("^[%w]+:") then
175 | data[k] = v
176 | end
177 | end
178 | return self:api_request("/messages", data, domain)
179 | end,
180 | create_campaign = function(self, name)
181 | local res, err = self:api_request("/campaigns", {
182 | name = name
183 | })
184 | if res then
185 | return res.campaign
186 | else
187 | return res, err
188 | end
189 | end,
190 | get_campaigns = function(self)
191 | local res, err = self:api_request("/campaigns")
192 | if res then
193 | return res.items, res
194 | else
195 | return res, err
196 | end
197 | end,
198 | get_events = items_method("/events"),
199 | each_event = function(self, opts)
200 | if opts == nil then
201 | opts = { }
202 | end
203 | opts.limit = opts.limit or 300
204 | return self:_each_item(self.get_events, opts)
205 | end,
206 | get_unsubscribes = items_method("/unsubscribes"),
207 | each_unsubscribe = function(self)
208 | return self:_each_item(self.get_unsubscribes)
209 | end,
210 | get_unsubscribe = function(self, email)
211 | return self:api_request("/unsubscribes/" .. tostring(email))
212 | end,
213 | get_bounces = items_method("/bounces"),
214 | each_bounce = function(self)
215 | return self:_each_item(self.get_bounces)
216 | end,
217 | get_bounce = function(self, email)
218 | return self:api_request("/bounces/" .. tostring(email))
219 | end,
220 | get_complaints = items_method("/complaints"),
221 | each_complaint = function(self)
222 | return self:_each_item(self.get_complaints)
223 | end,
224 | get_complaint = function(self, email)
225 | return self:api_request("/complaints/" .. tostring(email))
226 | end,
227 | _each_item = function(self, getter, params)
228 | local parse_url = require("socket.url").parse
229 | local after_value
230 | return coroutine.wrap(function()
231 | local page_params = {
232 | limit = 1000
233 | }
234 | if params then
235 | for k, v in pairs(params) do
236 | page_params[k] = v
237 | end
238 | end
239 | local page, paging = getter(self, page_params)
240 | while true do
241 | if not (page) then
242 | return
243 | end
244 | if not (next(page)) then
245 | return
246 | end
247 | for _index_0 = 1, #page do
248 | local item = page[_index_0]
249 | coroutine.yield(item)
250 | end
251 | if not (paging and paging.next) then
252 | return
253 | end
254 | local res, err = self:api_request(paging.next)
255 | if not (res) then
256 | return
257 | end
258 | page = res.items
259 | paging = res.paging
260 | end
261 | end)
262 | end,
263 | get_or_create_campaign_id = function(self, campaign_name)
264 | local campaign_id
265 | local _list_0 = assert(self:get_campaigns())
266 | for _index_0 = 1, #_list_0 do
267 | local c = _list_0[_index_0]
268 | if c.name == campaign_name then
269 | campaign_id = c.id
270 | break
271 | end
272 | end
273 | if not (campaign_id) then
274 | campaign_id = assert(self:create_campaign(campaign_name)).id
275 | end
276 | return campaign_id
277 | end,
278 | verify_webhook_signature = function(self, timestamp, token, signature)
279 | assert(type(timestamp) == "string", "invalid timestamp")
280 | assert(type(token) == "string", "invalid token")
281 | assert(type(signature) == "string", "invalid signature")
282 | local secret = self.webhook_signing_key or self.api_key:gsub("^api:", "")
283 | local to_verify = tostring(timestamp) .. tostring(token)
284 | local openssl_hmac = require("openssl.hmac")
285 | local hmac = openssl_hmac.new(secret, "sha256")
286 | local expected = to_hex((hmac:final(to_verify)))
287 | if not (expected == signature) then
288 | return nil, "signature mismatch"
289 | end
290 | return true
291 | end,
292 | validate_email = function(self, address)
293 | assert(type(address) == "string", "invalid address")
294 | return self:api_request(tostring(self.api_prefix) .. "/v4/address/validate?" .. tostring(encode_query_string({
295 | address = address
296 | })))
297 | end
298 | }
299 | _base_0.__index = _base_0
300 | _class_0 = setmetatable({
301 | __init = function(self, opts)
302 | if opts == nil then
303 | opts = { }
304 | end
305 | assert(opts.domain, "missing `domain` from opts")
306 | assert(opts.api_key, "missing `api_key` from opts")
307 | self.http_provider = opts.http
308 | self.domain = opts.domain
309 | self.api_key = opts.api_key
310 | self.webhook_signing_key = opts.webhook_signing_key
311 | self.default_sender = opts.default_sender or tostring(opts.domain) .. " "
312 | end,
313 | __base = _base_0,
314 | __name = "Mailgun"
315 | }, {
316 | __index = _base_0,
317 | __call = function(cls, ...)
318 | local _self_0 = setmetatable({}, _base_0)
319 | cls.__init(_self_0, ...)
320 | return _self_0
321 | end
322 | })
323 | _base_0.__class = _class_0
324 | Mailgun = _class_0
325 | end
326 | return {
327 | Mailgun = Mailgun,
328 | VERSION = "1.2.0"
329 | }
330 |
--------------------------------------------------------------------------------
/mailgun/init.moon:
--------------------------------------------------------------------------------
1 |
2 | ltn12 = require "ltn12"
3 |
4 | import encode_base64, encode_query_string, parse_query_string from require "mailgun.util"
5 | import concat from table
6 |
7 | json = require "cjson"
8 |
9 | add_recipients = (data, field, emails) ->
10 | return unless emails
11 |
12 | if type(emails) == "table"
13 | for email in *emails
14 | table.insert data, {field, email}
15 | else
16 | data[field] = emails
17 |
18 | items_method = (path, items_field="items", paging_field="paging") ->
19 | (opts={}) =>
20 | res, err = @api_request "#{path}?#{encode_query_string opts}"
21 |
22 | if res
23 | res[items_field], res[paging_field]
24 | else
25 | nil, err
26 |
27 | to_hex = do
28 | hex_c = (c) -> string.format "%02x", string.byte c
29 | (str) -> (str\gsub ".", hex_c)
30 |
31 | class Mailgun
32 | api_prefix: "https://api.mailgun.net"
33 | api_version: "v3"
34 |
35 | new: (opts={}) =>
36 | assert opts.domain, "missing `domain` from opts"
37 | assert opts.api_key, "missing `api_key` from opts"
38 |
39 | @http_provider = opts.http
40 | @domain = opts.domain
41 | @api_key = opts.api_key
42 | @webhook_signing_key = opts.webhook_signing_key
43 | @default_sender = opts.default_sender or "#{opts.domain} "
44 |
45 | -- create a new instance on another domain
46 | for_domain: (domain) =>
47 | Mailgun {
48 | domain: domain
49 | api_key: @api_key
50 | http: @http_provider
51 | }
52 |
53 | http: =>
54 | unless @_http
55 | @http_provider or= if ngx
56 | "lapis.nginx.http"
57 | else
58 | "ssl.https"
59 |
60 | @_http = if type(@http_provider) == "function"
61 | @http_provider!
62 | else
63 | require @http_provider
64 |
65 | @_http
66 |
67 | api_request: (path, data, domain=@domain) =>
68 | url = if path\match "^https?:"
69 | path
70 | else
71 | prefix = "#{@api_prefix}/#{@api_version}/#{domain}"
72 | prefix .. path
73 |
74 | body = data and encode_query_string data
75 |
76 | out = {}
77 | req = {
78 | :url
79 | source: body and ltn12.source.string(body) or nil
80 | method: data and "POST" or "GET"
81 | headers: {
82 | "Host": "api.mailgun.net"
83 | "Content-type": body and "application/x-www-form-urlencoded" or nil
84 | "Content-length": body and #body or nil
85 | "Authorization": "Basic " .. encode_base64 @api_key
86 | }
87 | sink: ltn12.sink.table out
88 | protocol: not ngx and "sslv23" or nil -- for luasec
89 | }
90 |
91 | _, status = @http!.request req
92 | @format_response concat(out), status
93 |
94 | format_response: (res, status) =>
95 | pcall ->
96 | res = json.decode res
97 |
98 | if res == "" or not res
99 | res = "invalid response"
100 |
101 | if status != 200
102 | return nil, res.message or res
103 |
104 | res
105 |
106 | send_email: (opts={}) =>
107 | {:to, :subject, :body, :domain} = opts
108 |
109 | assert to, "missing recipients"
110 | assert subject, "missing subject"
111 | assert body, "missing body"
112 |
113 | domain or= @domain
114 |
115 | data = {
116 | from: opts.from or @default_sender
117 | subject: subject
118 | [opts.html and "html" or "text"]: body
119 | }
120 |
121 | add_recipients data, "to", to
122 | add_recipients data, "cc", opts.cc
123 | add_recipients data, "bcc", opts.bcc
124 |
125 | if opts.tags
126 | for t in *opts.tags
127 | table.insert data, {"o:tag", t}
128 |
129 | if opts.vars
130 | data["recipient-variables"] = json.encode opts.vars
131 |
132 | if opts.headers
133 | for h, v in pairs opts.headers
134 | data["h:#{h}"] = v
135 |
136 | if opts.track_opens
137 | data["o:tracking-opens"] = "yes"
138 |
139 | if c = opts.campaign
140 | data["o:campaign"] = c
141 |
142 | for k, v in pairs opts
143 | if k\match "^[%w]+:"
144 | data[k] = v
145 |
146 | @api_request "/messages", data, domain
147 |
148 | create_campaign: (name) =>
149 | res, err = @api_request "/campaigns", { :name }
150 |
151 | if res
152 | res.campaign
153 | else
154 | res, err
155 |
156 | get_campaigns: =>
157 | res, err = @api_request "/campaigns"
158 |
159 | if res
160 | res.items, res
161 | else
162 | res, err
163 |
164 | get_events: items_method "/events"
165 | each_event: (opts={}) =>
166 | opts.limit or= 300
167 | @_each_item @get_events, opts
168 |
169 | get_unsubscribes: items_method "/unsubscribes"
170 | each_unsubscribe: => @_each_item @get_unsubscribes
171 | get_unsubscribe: (email) => @api_request "/unsubscribes/#{email}"
172 |
173 | get_bounces: items_method "/bounces"
174 | each_bounce: => @_each_item @get_bounces
175 | get_bounce: (email) => @api_request "/bounces/#{email}"
176 |
177 | get_complaints: items_method "/complaints"
178 | each_complaint: => @_each_item @get_complaints
179 | get_complaint: (email) => @api_request "/complaints/#{email}"
180 |
181 | -- iterate through every item in basic paging api endpoint
182 | _each_item: (getter, params) =>
183 | parse_url = require("socket.url").parse
184 |
185 | local after_value
186 |
187 | coroutine.wrap ->
188 | page_params = { limit: 1000 }
189 | if params
190 | for k,v in pairs params
191 | page_params[k] = v
192 |
193 | page, paging = getter @, page_params
194 |
195 | while true
196 | return unless page
197 | return unless next page
198 |
199 | for item in *page
200 | coroutine.yield item
201 |
202 | return unless paging and paging.next
203 | res, err = @api_request paging.next
204 | return unless res
205 |
206 | page = res.items
207 | paging = res.paging
208 |
209 | get_or_create_campaign_id: (campaign_name) =>
210 | local campaign_id
211 |
212 | for c in *assert @get_campaigns!
213 | if c.name == campaign_name
214 | campaign_id = c.id
215 | break
216 |
217 | unless campaign_id
218 | campaign_id = assert(@create_campaign(campaign_name)).id
219 |
220 | campaign_id
221 |
222 | verify_webhook_signature: (timestamp, token, signature) =>
223 | assert type(timestamp) == "string", "invalid timestamp"
224 | assert type(token) == "string", "invalid token"
225 | assert type(signature) == "string", "invalid signature"
226 |
227 | secret = @webhook_signing_key or @api_key\gsub "^api:", "" -- username baked into api key
228 | to_verify = "#{timestamp}#{token}"
229 |
230 | openssl_hmac = require "openssl.hmac"
231 |
232 | hmac = openssl_hmac.new secret, "sha256"
233 | expected = to_hex (hmac\final to_verify)
234 |
235 | unless expected == signature
236 | return nil, "signature mismatch"
237 |
238 | true
239 |
240 | validate_email: (address) =>
241 | assert type(address) == "string", "invalid address"
242 | @api_request "#{@api_prefix}/v4/address/validate?#{encode_query_string(:address)}"
243 |
244 | { :Mailgun, VERSION: "1.2.0" }
245 |
--------------------------------------------------------------------------------
/mailgun/util.lua:
--------------------------------------------------------------------------------
1 | local url = require("socket.url")
2 | local concat = table.concat
3 | local escape
4 | do
5 | local e = url.escape
6 | escape = function(str)
7 | return (e(str))
8 | end
9 | end
10 | local unescape
11 | do
12 | local u = url.unescape
13 | unescape = function(str)
14 | return (u(str))
15 | end
16 | end
17 | local encode_base64, decode_base64
18 | if ngx then
19 | local hmac_sha1
20 | do
21 | local _obj_0 = ngx
22 | encode_base64, decode_base64, hmac_sha1 = _obj_0.encode_base64, _obj_0.decode_base64, _obj_0.hmac_sha1
23 | end
24 | else
25 | local mime = require("mime")
26 | local b64, unb64
27 | b64, unb64 = mime.b64, mime.unb64
28 | encode_base64 = function(...)
29 | return (b64(...))
30 | end
31 | decode_base64 = function(...)
32 | return (unb64(...))
33 | end
34 | end
35 | local inject_tuples
36 | inject_tuples = function(tbl)
37 | for _index_0 = 1, #tbl do
38 | local tuple = tbl[_index_0]
39 | tbl[tuple[1]] = tuple[2] or true
40 | end
41 | end
42 | local parse_query_string
43 | do
44 | local C, P, S, Ct
45 | do
46 | local _obj_0 = require("lpeg")
47 | C, P, S, Ct = _obj_0.C, _obj_0.P, _obj_0.S, _obj_0.Ct
48 | end
49 | local char = (P(1) - S("=&"))
50 | local chunk = C(char ^ 1)
51 | local chunk_0 = C(char ^ 0)
52 | local tuple = Ct(chunk / unescape * "=" * (chunk_0 / unescape) + chunk)
53 | local query = S("?#") ^ -1 * Ct(tuple * (P("&") * tuple) ^ 0)
54 | parse_query_string = function(str)
55 | do
56 | local out = query:match(str)
57 | if out then
58 | inject_tuples(out)
59 | end
60 | return out
61 | end
62 | end
63 | end
64 | local encode_query_string
65 | encode_query_string = function(t, sep)
66 | if sep == nil then
67 | sep = "&"
68 | end
69 | local _escape = ngx and ngx.escape_uri or escape
70 | local i = 0
71 | local buf = { }
72 | for k, v in pairs(t) do
73 | if type(k) == "number" and type(v) == "table" then
74 | k, v = v[1], v[2]
75 | end
76 | buf[i + 1] = _escape(k)
77 | buf[i + 2] = "="
78 | buf[i + 3] = _escape(v)
79 | buf[i + 4] = sep
80 | i = i + 4
81 | end
82 | buf[i] = nil
83 | return concat(buf)
84 | end
85 | return {
86 | parse_query_string = parse_query_string,
87 | encode_query_string = encode_query_string,
88 | encode_base64 = encode_base64
89 | }
90 |
--------------------------------------------------------------------------------
/mailgun/util.moon:
--------------------------------------------------------------------------------
1 | -- TODO: this is ripped from lapis, turn this into library?
2 |
3 | url = require "socket.url"
4 | concat = table.concat
5 |
6 | escape = do
7 | e = url.escape
8 | (str) -> (e str)
9 |
10 | unescape = do
11 | u = url.unescape
12 | (str) -> (u str)
13 |
14 |
15 | local encode_base64, decode_base64
16 |
17 | if ngx
18 | {:encode_base64, :decode_base64, :hmac_sha1} = ngx
19 | else
20 | mime = require "mime"
21 | { :b64, :unb64 } = mime
22 | encode_base64 = (...) -> (b64 ...)
23 | decode_base64 = (...) -> (unb64 ...)
24 |
25 | inject_tuples = (tbl) ->
26 | for tuple in *tbl
27 | tbl[tuple[1]] = tuple[2] or true
28 |
29 | parse_query_string = do
30 | import C, P, S, Ct from require "lpeg"
31 |
32 | char = (P(1) - S("=&"))
33 |
34 | chunk = C char^1
35 | chunk_0 = C char^0
36 |
37 | tuple = Ct(chunk / unescape * "=" * (chunk_0 / unescape) + chunk)
38 | query = S"?#"^-1 * Ct tuple * (P"&" * tuple)^0
39 |
40 | (str) ->
41 | with out = query\match str
42 | inject_tuples out if out
43 |
44 |
45 | -- todo: handle nested tables
46 | -- takes either { hello: "world"} or { {"hello", "world"} }
47 | encode_query_string = (t, sep="&") ->
48 | _escape = ngx and ngx.escape_uri or escape
49 |
50 | i = 0
51 | buf = {}
52 | for k,v in pairs t
53 | if type(k) == "number" and type(v) == "table"
54 | {k,v} = v
55 |
56 | buf[i + 1] = _escape k
57 | buf[i + 2] = "="
58 | buf[i + 3] = _escape v
59 | buf[i + 4] = sep
60 | i += 4
61 |
62 | buf[i] = nil
63 | concat buf
64 |
65 | {:parse_query_string, :encode_query_string, :encode_base64}
66 |
--------------------------------------------------------------------------------
/spec/mailgun_spec.moon:
--------------------------------------------------------------------------------
1 |
2 | ltn12 = require "ltn12"
3 |
4 | unpack = table.unpack or unpack
5 |
6 | describe "mailgun", ->
7 | local http, http_requests, http_responses
8 |
9 | send_success = ->
10 | 200, [[{"id": "123", "message": "Queued. Thank you." }]]
11 |
12 | send_fail = ->
13 | 400, [[{"message": "'from' parameter is missing" }]]
14 |
15 | stub_http = (...) ->
16 | table.insert http_responses, {...}
17 |
18 | before_each ->
19 | http_requests = {}
20 | http_responses = {}
21 | http = -> {
22 | request: (opts) ->
23 | table.insert http_requests, opts
24 | for {pattern, response} in *http_responses
25 | if (opts.url or "")\match pattern
26 | status, body = response!
27 |
28 | if sink = body and opts.sink
29 | sink body
30 |
31 | return 1, status
32 | }
33 |
34 | parse_body = (req) ->
35 | return unless req.source
36 |
37 | out = {}
38 | while true
39 | part = req.source!
40 | break unless part
41 | table.insert out, part
42 |
43 | body = table.concat out
44 | import parse_query_string from require "mailgun.util"
45 |
46 | out = {}
47 | for {key, val} in *parse_query_string body
48 | if out[key]
49 | if type(out[key]) == "table"
50 | table.insert out[key], val
51 | else
52 | out[key] = {out[key], val}
53 | else
54 | out[key] = val
55 | out
56 |
57 | it "creates a mailgun object", ->
58 | import Mailgun from require "mailgun"
59 | Mailgun {
60 | domain: "leafo.net"
61 | api_key: "hello-world"
62 | }
63 |
64 | describe "verify_webhook_signature", ->
65 | local client
66 |
67 | before_each ->
68 | import Mailgun from require "mailgun"
69 | client = Mailgun {
70 | domain: "leafo.net"
71 | api_key: "api:hello-world"
72 | }
73 |
74 | it "valid signature", ->
75 | assert client\verify_webhook_signature "1564705897", "mytoken",
76 | "18ced557f769caaab4676366036594dc2dae9d0dca9871290e872b24c6dc6aff"
77 |
78 | it "signature mismatch", ->
79 | assert.same {
80 | nil, "signature mismatch"
81 | }, {
82 | client\verify_webhook_signature "1564705897", "mytoken",
83 | "18ced557f769caaab4676366036594dc2dae9d0dca9871290e872b24c6dc6afg"
84 | }
85 |
86 |
87 | describe "with mailgun", ->
88 | local mailgun
89 | before_each ->
90 | import Mailgun from require "mailgun"
91 | mailgun = Mailgun {
92 | domain: "leafo.net"
93 | api_key: "hello-world"
94 | http: http
95 | }
96 |
97 | it "performs GET api request", ->
98 | mailgun\api_request "/hello"
99 | assert.same 1, #http_requests
100 | req = unpack http_requests
101 | assert.same "GET", req.method
102 | assert.same "https://api.mailgun.net/v3/leafo.net/hello", req.url
103 | assert.same req.headers, {
104 | Host: "api.mailgun.net"
105 | Authorization: "Basic aGVsbG8td29ybGQ="
106 | }
107 |
108 | it "performs POST api request", ->
109 | mailgun\api_request "/world", some: "data"
110 | assert.same 1, #http_requests
111 | req = unpack http_requests
112 |
113 | assert.same "POST", req.method
114 | assert.same "https://api.mailgun.net/v3/leafo.net/world", req.url
115 | assert.same req.headers, {
116 | Host: "api.mailgun.net"
117 | Authorization: "Basic aGVsbG8td29ybGQ="
118 | "Content-length": 9
119 | "Content-type": "application/x-www-form-urlencoded"
120 | }
121 |
122 |
123 | describe "send_email", ->
124 | it "sends an email", ->
125 | stub_http ".", send_success
126 |
127 | email_html = [[
128 | Hello world
129 | Here is my email to you.
130 |
131 |
132 | Unsubscribe
133 |
134 | ]]
135 |
136 | assert mailgun\send_email {
137 | to: "you@example.com"
138 | subject: "Important message here"
139 | html: true
140 | body: email_html
141 | }
142 |
143 | assert.same 1, #http_requests
144 | req = unpack http_requests
145 |
146 | assert.same "POST", req.method
147 | assert.same "https://api.mailgun.net/v3/leafo.net/messages", req.url
148 | assert.same req.headers, {
149 | "Authorization": "Basic aGVsbG8td29ybGQ="
150 | "Content-length": 522
151 | "Content-type": "application/x-www-form-urlencoded"
152 | "Host": "api.mailgun.net"
153 | }
154 |
155 | assert.same {
156 | from: "leafo.net "
157 | to: "you@example.com"
158 | subject: "Important message here"
159 | html: email_html
160 | }, parse_body req
161 |
162 | it "sends an email to many people", ->
163 | stub_http ".", send_success
164 |
165 | assert mailgun\send_email {
166 | to: { "you2@example.com", "you3@example.com" }
167 | subject: "Howdy"
168 | body: "okay sure"
169 | }
170 |
171 | req = unpack http_requests
172 |
173 | assert.same {
174 | from: "leafo.net "
175 | to: { "you2@example.com", "you3@example.com" }
176 | subject: "Howdy"
177 | text: "okay sure"
178 | }, parse_body req
179 |
180 | it "sends an email with recipient vars and other options", ->
181 | stub_http ".", send_success
182 |
183 | assert mailgun\send_email {
184 | to: { "you2@example.com", "you3@example.com" }
185 | bcc: "cool@example.com"
186 | cc: { "a@itch.zone", "b@itch.zone" }
187 | from: "dad@itch.zone"
188 | subject: "Howdy"
189 | body: "okay sure %recipient.name%"
190 | track_opens: true
191 | tags: {"hello", "world"}
192 | campaign: 123
193 | headers: {
194 | "Reply-To": "leaf@leafo.zone"
195 | }
196 |
197 | "v:test": "world"
198 | }
199 |
200 | req = unpack http_requests
201 |
202 | assert.same {
203 | to: { "you2@example.com", "you3@example.com" }
204 | bcc: "cool@example.com"
205 | cc: { "a@itch.zone", "b@itch.zone" }
206 | from: "dad@itch.zone"
207 | subject: "Howdy"
208 | text: "okay sure %recipient.name%"
209 | "h:Reply-To": "leaf@leafo.zone"
210 | "o:campaign": "123"
211 | "o:tracking-opens": "yes"
212 | "o:tag": {
213 | "hello", "world"
214 | }
215 | "v:test": "world"
216 | }, parse_body req
217 |
218 |
219 | it "handles server error", ->
220 | stub_http ".", send_fail
221 |
222 | res, err = mailgun\send_email {
223 | to: { "you2@example.com", "you3@example.com" }
224 | subject: "Howdy"
225 | body: "this email will fail"
226 | }
227 |
228 | assert.same {nil, "'from' parameter is missing"}, {res, err}
229 |
230 | it "creates campaign", ->
231 | stub_http ".", ->
232 | 200, [[{"campaign": {"id": 123}}]]
233 |
234 | assert mailgun\create_campaign "hello"
235 |
236 | it "gets campaigns", ->
237 | stub_http ".", ->
238 | 200, [[ { "items": [{"id": 123}] } ]]
239 |
240 | res = assert mailgun\get_campaigns!
241 | assert.same {
242 | { id: 123 }
243 | }, res
244 |
245 | it "gets messages", ->
246 | stub_http ".", ->
247 | 200, [[ { "items": [{"id": 123}] } ]]
248 |
249 | assert mailgun\get_events!
250 |
251 | it "gets or creates campaign", ->
252 | stub_http ".", ->
253 | 200, [[ {
254 | "items": [{"name": "cool", "id": 123}]
255 | } ]]
256 |
257 | res = assert mailgun\get_or_create_campaign_id "cool"
258 | assert.same 123, res
259 |
260 | it "get unsubscribes", ->
261 | stub_http "/unsubscribes", ->
262 | 200, [[ { "items": [{"id": 123}] } ]]
263 |
264 | assert.same { {id: 123} }, mailgun\get_unsubscribes!
265 |
266 | it "get bounces", ->
267 | stub_http "/bounces", ->
268 | 200, [[ { "items": [{"id": 123}] } ]]
269 |
270 | assert.same { {id: 123} }, mailgun\get_bounces!
271 |
272 | it "get complaints", ->
273 | stub_http "/complaints", ->
274 | 200, [[ { "items": [{"id": 123}] } ]]
275 |
276 | assert.same { {id: 123} }, mailgun\get_complaints!
277 |
278 | it "iterates unsubscribes with one page", ->
279 | stub_http "/unsubscribes", ->
280 | 200, [[ { "items": [{"id": 123}, {"id": 999}] } ]]
281 |
282 | assert.same {
283 | {id: 123}
284 | {id: 999}
285 | },[u for u in mailgun\each_unsubscribe!]
286 |
287 | it "iterates unsubscribes with two pages", ->
288 | -- second page
289 | stub_http "/unsubscribes.-page=next", ->
290 | 200, [[ {
291 | "items": [{"id": 22}, {"id": 23}]
292 | } ]]
293 |
294 | -- first page
295 | stub_http "/unsubscribes", ->
296 | 200, [[ {
297 | "items": [{"id": 12}, {"id": 13}],
298 | "paging": {"next": "/unsubscribes?page=next&address=next@email.com"}
299 | } ]]
300 |
301 | assert.same {
302 | {id: 12}
303 | {id: 13}
304 | {id: 22}
305 | {id: 23}
306 | },[u for u in mailgun\each_unsubscribe!]
307 |
308 | it "validates email", ->
309 | stub_http ".", -> 200, [[{}]]
310 | mailgun\validate_email "leafo@example.com"
311 |
312 | assert.same 1, #http_requests
313 | req = unpack http_requests
314 | assert.same "GET", req.method
315 | assert.same "https://api.mailgun.net/v4/address/validate?address=leafo%40example%2ecom", req.url
316 |
317 |
318 |
--------------------------------------------------------------------------------