├── .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 | ![test](https://github.com/leafo/lua-mailgun/workflows/test/badge.svg) 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 | --------------------------------------------------------------------------------