├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .luacheckrc ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile-test-1.11 ├── Dockerfile-test-1.13 ├── Dockerfile-test-1.21 ├── LICENSE.txt ├── Makefile ├── README.md ├── dist.ini ├── docker-compose.yml ├── lib └── resty │ ├── mail.lua │ └── mail │ ├── headers.lua │ ├── message.lua │ ├── rfc2822_date.lua │ └── smtp.lua ├── lua-resty-mail-1.1.0-1.rockspec ├── lua-resty-mail-git-1.rockspec └── spec ├── bin └── resty-with-ssl-certs ├── mail_integration_external_spec.lua ├── mail_integration_external_spec_ssl_certs.lua ├── mail_integration_internal_spec.lua ├── mail_spec.lua ├── message_get_body_spec.lua ├── message_get_from_address_spec.lua ├── message_get_recipient_addresses_spec.lua └── rfc2822_date_spec.lua /.dockerignore: -------------------------------------------------------------------------------- 1 | spec/tmp 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | variant: 18 | - test-1.11 19 | - test-1.13 20 | - test-1.21 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Build Container 25 | run: docker compose build "${{ matrix.variant }}" 26 | 27 | - name: Lint 28 | run: docker compose run --rm "${{ matrix.variant }}" make lint 29 | 30 | - name: Test 31 | run: docker compose run --rm "${{ matrix.variant }}" make test 32 | 33 | - name: External SMTP Integration Test 34 | if: ${{ !github.event.repository.fork }} 35 | run: docker compose run --rm "${{ matrix.variant }}" make test-integration-external 36 | env: 37 | MAILGUN_USERNAME: ${{ secrets.MAILGUN_USERNAME }} 38 | MAILGUN_PASSWORD: ${{ secrets.MAILGUN_PASSWORD }} 39 | MAILGUN_RECIPIENT: ${{ secrets.MAILGUN_RECIPIENT }} 40 | 41 | - name: External SMTP Integration Test with SSL Certs 42 | # Don't run on OpenResty 1.11, since it's `resty` lacks support for the 43 | # `--http-conf` flag. 44 | if: ${{ !github.event.repository.fork && matrix.variant != 'test-1.11' }} 45 | run: docker compose run --rm "${{ matrix.variant }}" make test-integration-ssl-certs 46 | env: 47 | MAILGUN_USERNAME: ${{ secrets.MAILGUN_USERNAME }} 48 | MAILGUN_PASSWORD: ${{ secrets.MAILGUN_PASSWORD }} 49 | MAILGUN_RECIPIENT: ${{ secrets.MAILGUN_RECIPIENT }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore LuaRocks build artifacts 2 | /lua-resty-mail-* 3 | !/lua-resty-mail-*.rockspec 4 | 5 | /spec/tmp 6 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | std = "ngx_lua" 2 | max_line_length = false 3 | files["spec"] = { std = "ngx_lua+busted" } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # lua-resty-mail Change Log 2 | 3 | ## 1.1.0 - 2023-09-23 4 | 5 | ### Added 6 | - Add a `ssl_verify` option to turn on SSL certificate verification (defaults to `false`). 7 | - Add a `ssl_host` option to override the hostname used for SNI and TLS verification (instead of the default `host`). 8 | 9 | ### Fixed 10 | - Fixed potential bug if using the `ssl = true` option that could cause the connection to close early if the server also supported STARTLS. 11 | 12 | ### Changed 13 | - Upgraded test dependencies and moved CI testing to GitHub Actions. 14 | 15 | ## 1.0.2 - 2019-02-24 16 | 17 | ### Fixed 18 | - Fix sending authentication credentials when using the `login` option for `auth_type`. 19 | 20 | ## 1.0.1 - 2018-11-26 21 | 22 | ### Fixed 23 | - Fix compatibility with older versions of ngx_lua (pre v0.10.7) that lack `tcpsock:settimeouts` support. 24 | 25 | ## 1.0.0 - 2017-08-05 26 | 27 | - Initial release. 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openresty/openresty:1.21.4.2-rocky 2 | 3 | # Test dependencies. 4 | RUN yum -y install \ 5 | gcc \ 6 | glibc-langpack-fr \ 7 | make 8 | 9 | # Dependencies for the release process. 10 | RUN yum -y install git zip 11 | 12 | ENV SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt 13 | 14 | RUN mkdir /app 15 | WORKDIR /app 16 | 17 | COPY Makefile /app/Makefile 18 | RUN make install-test-deps 19 | 20 | COPY . /app 21 | -------------------------------------------------------------------------------- /Dockerfile-test-1.11: -------------------------------------------------------------------------------- 1 | FROM openresty/openresty:1.11.2.1-centos 2 | 3 | # Test dependencies. 4 | RUN yum -y install \ 5 | gcc \ 6 | make 7 | 8 | # Install locale data for date formatting locale tests. This requires 9 | # changing this yum setting and reinstalling: 10 | # https://serverfault.com/a/884562 11 | RUN sed -i '/override_install_langs/d' /etc/yum.conf && \ 12 | yum -y reinstall glibc-common || yum -y install glibc-common 13 | 14 | ENV SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt 15 | 16 | # Set PATH to pickup "luarocks" in this version of OpenResty container. 17 | ENV PATH=/usr/local/openresty/luajit/bin:/usr/local/openresty/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 18 | 19 | # Unset default entrypoint in this version of OpenResty container. 20 | ENTRYPOINT [] 21 | 22 | RUN mkdir /app 23 | WORKDIR /app 24 | 25 | COPY Makefile /app/Makefile 26 | RUN make install-test-deps 27 | 28 | COPY . /app 29 | -------------------------------------------------------------------------------- /Dockerfile-test-1.13: -------------------------------------------------------------------------------- 1 | FROM openresty/openresty:1.13.6.2-2-centos 2 | 3 | # Test dependencies. 4 | RUN yum -y install \ 5 | gcc \ 6 | make 7 | 8 | # Install locale data for date formatting locale tests. This requires 9 | # changing this yum setting and reinstalling: 10 | # https://serverfault.com/a/884562 11 | RUN sed -i '/override_install_langs/d' /etc/yum.conf && \ 12 | yum -y reinstall glibc-common || yum -y install glibc-common 13 | 14 | ENV SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt 15 | 16 | RUN mkdir /app 17 | WORKDIR /app 18 | 19 | COPY Makefile /app/Makefile 20 | RUN make install-test-deps 21 | 22 | COPY . /app 23 | -------------------------------------------------------------------------------- /Dockerfile-test-1.21: -------------------------------------------------------------------------------- 1 | FROM openresty/openresty:1.21.4.2-rocky 2 | 3 | # Test dependencies. 4 | RUN yum -y install \ 5 | gcc \ 6 | glibc-langpack-fr \ 7 | make 8 | 9 | ENV SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt 10 | 11 | RUN mkdir /app 12 | WORKDIR /app 13 | 14 | COPY Makefile /app/Makefile 15 | RUN make install-test-deps 16 | 17 | COPY . /app 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Nick Muerdter 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all lint test test-integration-external test-integration-ssl-certs install-test-deps release 2 | 3 | all: 4 | 5 | lint: 6 | luacheck . 7 | 8 | test: 9 | luarocks make --tree "${HOME}/.luarocks" lua-resty-mail-git-1.rockspec 10 | mkdir -p spec/tmp 11 | mailpit > spec/tmp/mailpit.log 2>&1 & echo $$! > spec/tmp/mailpit.pid 12 | wait-for-it localhost:1025 13 | env LUA_PATH="${HOME}/.luarocks/share/lua/5.1/?.lua;;" TZ="America/Denver" busted --shuffle --lua=resty --exclude-tags=integration_external,integration_ssl_certs spec 14 | kill `cat spec/tmp/mailpit.pid` && rm spec/tmp/mailpit.pid 15 | 16 | test-integration-external: 17 | luarocks make --tree "${HOME}/.luarocks" lua-resty-mail-git-1.rockspec 18 | env LUA_PATH="${HOME}/.luarocks/share/lua/5.1/?.lua;;" busted --shuffle --lua=resty --tags=integration_external spec 19 | 20 | test-integration-ssl-certs: 21 | luarocks make --tree "${HOME}/.luarocks" lua-resty-mail-git-1.rockspec 22 | env LUA_PATH="${HOME}/.luarocks/share/lua/5.1/?.lua;;" busted --shuffle --lua=./spec/bin/resty-with-ssl-certs --tags=integration_ssl_certs spec 23 | 24 | install-test-deps: 25 | luarocks install busted 2.1.2-3 26 | luarocks install luacheck 1.1.1-1 27 | luarocks install lua-resty-http 0.17.1-0 28 | arch="amd64"; \ 29 | if [ "$$(uname -m)" = "aarch64" ]; then \ 30 | arch="arm64"; \ 31 | fi; \ 32 | curl -fsSL "https://github.com/axllent/mailpit/releases/download/v1.9.0/mailpit-linux-$$arch.tar.gz" | tar -xvz -C /usr/local/bin/ --wildcards "mailpit" 33 | curl -fsSL -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/81b1373f17855a4dc21156cfe1694c31d7d1792e/wait-for-it.sh 34 | chmod +x /usr/local/bin/wait-for-it 35 | 36 | release: 37 | # Ensure the version number has been updated. 38 | grep -q -F 'VERSION = "${VERSION}"' lib/resty/mail.lua 39 | # Ensure the OPM version number has been updated. 40 | grep -q -F 'version = ${VERSION}' dist.ini 41 | # Ensure the rockspec has been renamed and updated. 42 | grep -q -F 'version = "${VERSION}-1"' "lua-resty-mail-${VERSION}-1.rockspec" 43 | grep -q -F 'tag = "v${VERSION}"' "lua-resty-mail-${VERSION}-1.rockspec" 44 | # Ensure the CHANGELOG has been updated. 45 | grep -q -F '## ${VERSION} -' CHANGELOG.md 46 | # Check for remote tag. 47 | git ls-remote -t | grep -F "refs/tags/v${VERSION}^{}" 48 | # Verify LuaRock and OPM can be built locally. 49 | docker-compose run --rm -v "${PWD}:/app" app luarocks pack "lua-resty-mail-${VERSION}-1.rockspec" 50 | docker-compose run --rm -v "${HOME}/.opmrc:/root/.opmrc" -v "${PWD}:/app" app opm build 51 | # Upload to LuaRocks and OPM. 52 | docker-compose run --rm -v "${HOME}/.luarocks/upload_config.lua:/root/.luarocks/upload_config.lua" -v "${PWD}:/app" app luarocks upload "lua-resty-mail-${VERSION}-1.rockspec" 53 | docker-compose run --rm -v "${HOME}/.opmrc:/root/.opmrc" -v "${PWD}:/app" app opm upload 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lua-resty-mail 2 | 3 | [![CircleCI](https://circleci.com/gh/GUI/lua-resty-mail.svg?style=svg)](https://circleci.com/gh/GUI/lua-resty-mail) 4 | 5 | A high-level, easy to use, and non-blocking email and SMTP library for OpenResty. 6 | 7 | # Features 8 | 9 | - SMTP authentication, STARTTLS, and SSL support. 10 | - Multipart plain text and HTML message bodies. 11 | - From, To, Cc, Bcc, Reply-To, and Subject fields (custom headers also supported). 12 | - Email addresses in "test@example.com" and "Name <test@example.com>" formats. 13 | - File attachments. 14 | 15 | # Installation 16 | 17 | Via [OPM](https://opm.openresty.org): 18 | 19 | ```sh 20 | opm get GUI/lua-resty-mail 21 | ``` 22 | 23 | Or via [LuaRocks](https://luarocks.org): 24 | 25 | ```sh 26 | luarocks install lua-resty-mail 27 | ``` 28 | 29 | # Usage 30 | 31 | ```lua 32 | local mail = require "resty.mail" 33 | 34 | local mailer, err = mail.new({ 35 | host = "smtp.gmail.com", 36 | port = 587, 37 | starttls = true, 38 | username = "example@gmail.com", 39 | password = "password", 40 | }) 41 | if err then 42 | ngx.log(ngx.ERR, "mail.new error: ", err) 43 | return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) 44 | end 45 | 46 | local ok, err = mailer:send({ 47 | from = "Master Splinter ", 48 | to = { "michelangelo@example.com" }, 49 | cc = { "leo@example.com", "Raphael ", "donatello@example.com" }, 50 | subject = "Pizza is here!", 51 | text = "There's pizza in the sewer.", 52 | html = "

There's pizza in the sewer.

", 53 | attachments = { 54 | { 55 | filename = "toppings.txt", 56 | content_type = "text/plain", 57 | content = "1. Cheese\n2. Pepperoni", 58 | }, 59 | }, 60 | }) 61 | if err then 62 | ngx.log(ngx.ERR, "mailer:send error: ", err) 63 | return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) 64 | end 65 | ``` 66 | 67 | # API 68 | 69 | ## new 70 | 71 | **syntax:** `mailer, err = mail.new(options)` 72 | 73 | Create and return a new mail object. In case of errors, returns `nil` and a string describing the error. 74 | 75 | The `options` table accepts the following fields: 76 | 77 | - `host`: The host of the SMTP server to connect to. (default: `localhost`) 78 | - `port`: The port number on the SMTP server to connect to. (default: `25`) 79 | - `starttls`: Set to `true` to ensure [STARTTLS](https://en.wikipedia.org/wiki/STARTTLS) is always used to encrypt communication with the SMTP server. If not set, STARTTLS will automatically be enabled if the server supports it (but explicitly setting this to true if your server supports it is preferable to prevent STRIPTLS attacks). This is usually used in conjunction with port 587. (default: `nil`) 80 | - `ssl`: Set to `true` to use [SMTPS](https://en.wikipedia.org/wiki/SMTPS) to encrypt communication with the SMTP server (not needed if STARTTLS is being used instead). This is usually used in conjunction with port 465. (default: `nil`) 81 | - `username`: Username to use for SMTP authentication. (default: `nil`) 82 | - `password`: Password to use for SMTP authentication. (default: `nil`) 83 | - `auth_type`: The type of SMTP authentication to perform. Can either be `plain` or `login`. (default: `plain` if username and password are present) 84 | - `domain`: The domain name presented to the SMTP server during the `EHLO` connection and used as part of the Message-ID header. (default: `localhost.localdomain`) 85 | - `ssl_verify`: Whether or not to perform verification of the server's certificate when either `ssl` or `starttls` is enabled. If this is enabled then configuring the [`lua_ssl_trusted_certificate`](https://github.com/openresty/lua-nginx-module#lua_ssl_trusted_certificate) setting will be required. (default: `false`) 86 | - `ssl_host`: If the hostname of the server's certificate is different than the `host` option, this setting can be used to specify a different host used for SNI and TLS verification when either `ssl` or `starttls` is enabled. (default: the `host` option's value) 87 | - `timeout_connect`: The timeout (in milliseconds) for connecting to the SMTP server. (default: OpenResty's global `lua_socket_connect_timeout` timeout, which defaults to 60s) 88 | - `timeout_send`: The timeout (in milliseconds) for sending data to the SMTP server. (default: OpenResty's global `lua_socket_send_timeout` timeout, which defaults to 60s) 89 | - `timeout_read`: The timeout (in milliseconds) for reading data from the SMTP server. (default: OpenResty's global `lua_socket_read_timeout` timeout, which defaults to 60s) 90 | 91 | ## mailer:send 92 | 93 | **syntax:** `ok, err = mailer:send(data)` 94 | 95 | Send an email via the SMTP server. This function returns `true` on success. In case of errors, returns `nil` and a string describing the error. 96 | 97 | The `data` table accepts the following fields: 98 | 99 | - `from`: Email address for the `From` header. 100 | - `reply_to`: Email address for the `Reply-To` header. 101 | - `to`: A table (list-like) of email addresses for the `To` recipients. 102 | - `cc`: A table (list-like) of email addresses for the `Cc` recipients. 103 | - `bcc`: A table (list-like) of email addresses for the `Bcc` recipients. 104 | - `subject`: Message subject. 105 | - `text`: Body of the message (plain text version). 106 | - `html`: Body of the message (HTML verion). 107 | - `headers`: A table of additional headers to set on the message. 108 | - `attachments`: A table (list-like) of file attachments for the message. Each attachment must be an table (map-like) with the following fields: 109 | - `filename`: The filename of the attachment. 110 | - `content_type`: The `Content-Type` of the file attachment. 111 | - `content`: The contents of the file attachment as a string. 112 | - `disposition`: The `Content-Disposition` of the file attachment. Can either be `attachment` or `inline`. (default: `attachment`) 113 | - `content_id`: The `Content-ID` of the file attachment. (default: randomly generated ID) 114 | 115 | # Development 116 | 117 | After checking out the repo, Docker can be used to run the test suite: 118 | 119 | ```sh 120 | docker-compose run --rm app make test 121 | ``` 122 | 123 | ## Release Process 124 | 125 | To release a new version to LuaRocks and OPM: 126 | 127 | - Ensure `CHANGELOG.md` is up to date. 128 | - Update the `_VERSION` in `lib/resty/mail.lua`. 129 | - Update the `version` in `dist.ini`. 130 | - Move the rockspec file to the new version number (`git mv lua-resty-mail-X.X.X-1.rockspec lua-resty-mail-X.X.X-1.rockspec`), and update the `version` and `tag` variables in the rockspec file. 131 | - Commit and tag the release (`git tag -a vX.X.X -m "Tagging vX.X.X" && git push origin vX.X.X`). 132 | - Run `make release VERSION=X.X.X`. 133 | -------------------------------------------------------------------------------- /dist.ini: -------------------------------------------------------------------------------- 1 | name = lua-resty-mail 2 | abstract = A high-level, easy to use, and non-blocking email and SMTP library for OpenResty. 3 | author = Nick Muerdter 4 | version = 1.1.0 5 | license = mit 6 | repo_link=https://github.com/GUI/lua-resty-mail 7 | is_original = yes 8 | lib_dir = lib 9 | main_module = lib/resty/mail.lua 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - .:/app 9 | environment: 10 | - MAILGUN_USERNAME 11 | - MAILGUN_PASSWORD 12 | - MAILGUN_RECIPIENT 13 | test-1.11: 14 | build: 15 | context: . 16 | dockerfile: Dockerfile-test-1.11 17 | volumes: 18 | - .:/app 19 | environment: 20 | - MAILGUN_USERNAME 21 | - MAILGUN_PASSWORD 22 | - MAILGUN_RECIPIENT 23 | test-1.13: 24 | build: 25 | context: . 26 | dockerfile: Dockerfile-test-1.13 27 | volumes: 28 | - .:/app 29 | environment: 30 | - MAILGUN_USERNAME 31 | - MAILGUN_PASSWORD 32 | - MAILGUN_RECIPIENT 33 | test-1.21: 34 | build: 35 | context: . 36 | dockerfile: Dockerfile-test-1.21 37 | volumes: 38 | - .:/app 39 | environment: 40 | - MAILGUN_USERNAME 41 | - MAILGUN_PASSWORD 42 | - MAILGUN_RECIPIENT 43 | -------------------------------------------------------------------------------- /lib/resty/mail.lua: -------------------------------------------------------------------------------- 1 | local message = require "resty.mail.message" 2 | local smtp = require "resty.mail.smtp" 3 | 4 | local _M = { 5 | _VERSION = "1.1.0", 6 | } 7 | 8 | function _M.new(options) 9 | if not options then 10 | options = {} 11 | end 12 | 13 | if not options["host"] then 14 | options["host"] = "localhost" 15 | end 16 | 17 | if not options["port"] then 18 | options["port"] = 25 19 | end 20 | 21 | if not options["domain"] then 22 | options["domain"] = "localhost.localdomain" 23 | end 24 | 25 | if options["username"] or options["password"] or options["auth_type"] then 26 | if not options["username"] then 27 | return nil, "authentication requested, but missing username" 28 | end 29 | if not options["password"] then 30 | return nil, "authentication requested, but missing password" 31 | end 32 | 33 | if not options["auth_type"] then 34 | options["auth_type"] = "plain" 35 | end 36 | 37 | if options["auth_type"] ~= "plain" and options["auth_type"] ~= "login" then 38 | return nil, "invalid auth_type: " .. options["auth_type"] 39 | end 40 | end 41 | 42 | return setmetatable({ options = options }, { __index = _M }) 43 | end 44 | 45 | function _M.send(self, data) 46 | local smtp_conn, smtp_err = smtp.new(self) 47 | if not smtp_conn then 48 | return false, smtp_err 49 | end 50 | 51 | local msg, msg_err = message.new(self, data) 52 | if not msg then 53 | return false, msg_err 54 | end 55 | 56 | return smtp_conn:send(msg) 57 | end 58 | 59 | return _M 60 | -------------------------------------------------------------------------------- /lib/resty/mail/headers.lua: -------------------------------------------------------------------------------- 1 | -- Case-insenstive access to headers. Based on lua-resty-http's headers module: 2 | -- https://github.com/pintsized/lua-resty-http/blob/v0.11/lib/resty/http_headers.lua 3 | 4 | local str_lower = string.lower 5 | 6 | local _M = {} 7 | 8 | function _M.new() 9 | local mt = { 10 | normalized = {}, 11 | } 12 | 13 | mt.__index = function(self, k) 14 | return rawget(self, mt.normalized[str_lower(k)]) 15 | end 16 | 17 | mt.__newindex = function(self, key, value) 18 | local key_normalized = str_lower(key) 19 | if not mt.normalized[key_normalized] then 20 | mt.normalized[key_normalized] = key 21 | rawset(self, key, value) 22 | else 23 | rawset(self, mt.normalized[key_normalized], value) 24 | end 25 | end 26 | 27 | return setmetatable({}, mt) 28 | end 29 | 30 | return _M 31 | -------------------------------------------------------------------------------- /lib/resty/mail/message.lua: -------------------------------------------------------------------------------- 1 | local mail_headers = require "resty.mail.headers" 2 | local rfc2822_date = require "resty.mail.rfc2822_date" 3 | local resty_random = require "resty.random" 4 | local str = require "resty.string" 5 | 6 | local random_bytes = resty_random.bytes 7 | local encode_base64 = ngx.encode_base64 8 | local to_hex = str.to_hex 9 | local match = ngx.re.match 10 | local CRLF = "\r\n" 11 | 12 | local _M = {} 13 | 14 | local function random_tag() 15 | local num_bytes = 20 16 | local random = random_bytes(num_bytes, true) 17 | if not random then 18 | random = random_bytes(num_bytes, false) 19 | end 20 | 21 | return math.floor(ngx.now()) .. "." .. to_hex(random) 22 | end 23 | 24 | local function generate_message_id(mailer) 25 | local host = mailer.options["domain"] 26 | return random_tag() .. "@" .. host 27 | end 28 | 29 | local function wrapped_base64(value) 30 | local line_length = 76 31 | local encoded = encode_base64(value) 32 | local lines = {} 33 | local index = 1 34 | while index <= #encoded do 35 | local end_index = index + line_length - 1 36 | local line = string.sub(encoded, index, end_index) 37 | index = end_index + 1 38 | table.insert(lines, line) 39 | end 40 | 41 | return table.concat(lines, CRLF) 42 | end 43 | 44 | local function body_insert_header(body, name, value) 45 | if value then 46 | table.insert(body, name) 47 | table.insert(body, ": ") 48 | table.insert(body, value) 49 | table.insert(body, CRLF) 50 | end 51 | end 52 | 53 | local function body_insert_boundary(body, boundary) 54 | table.insert(body, "--") 55 | table.insert(body, boundary) 56 | table.insert(body, CRLF) 57 | end 58 | 59 | local function body_insert_boundary_final(body, boundary) 60 | table.insert(body, "--") 61 | table.insert(body, boundary) 62 | table.insert(body, "--") 63 | table.insert(body, CRLF) 64 | table.insert(body, CRLF) 65 | end 66 | 67 | local function body_insert_attachment(body, attachment, mailer) 68 | assert(attachment["filename"]) 69 | assert(attachment["content_type"]) 70 | assert(attachment["content"]) 71 | 72 | local encoded_filename = "=?utf-8?B?" .. encode_base64(attachment["filename"]) .. "?=" 73 | local content_type = attachment["content_type"] 74 | local disposition = attachment["disposition"] or "attachment" 75 | local content_id = attachment["content_id"] or generate_message_id(mailer) 76 | 77 | body_insert_header(body, "Content-Type", content_type) 78 | body_insert_header(body, "Content-Transfer-Encoding", "base64") 79 | body_insert_header(body, "Content-Disposition", disposition .. '; filename="' .. encoded_filename .. '"') 80 | body_insert_header(body, "Content-ID", "<" .. content_id .. ">") 81 | table.insert(body, CRLF) 82 | table.insert(body, wrapped_base64(attachment["content"])) 83 | table.insert(body, CRLF) 84 | end 85 | 86 | local function extract_address(string) 87 | local captures, err = match(string, [[<\s*(.+?@.+?)\s*>]], "jo") 88 | if captures then 89 | return captures[1] 90 | else 91 | if err then 92 | ngx.log(ngx.ERR, "lua-resty-mail: regex error: ", err) 93 | end 94 | 95 | return string 96 | end 97 | end 98 | 99 | local function generate_boundary() 100 | return "--==_mimepart_" .. random_tag() 101 | end 102 | 103 | function _M.new(mailer, data) 104 | if not data then 105 | data = {} 106 | end 107 | 108 | local headers = mail_headers.new() 109 | if data["headers"] then 110 | for name, value in pairs(data["headers"]) do 111 | headers[name] = value 112 | end 113 | end 114 | 115 | if data["from"] then 116 | headers["From"] = data["from"] 117 | end 118 | 119 | if data["reply_to"] then 120 | headers["Reply-To"] = data["reply_to"] 121 | end 122 | 123 | if data["to"] then 124 | headers["To"] = table.concat(data["to"], ",") 125 | end 126 | 127 | if data["cc"] then 128 | headers["Cc"] = table.concat(data["cc"], ",") 129 | end 130 | 131 | if data["bcc"] then 132 | headers["Bcc"] = table.concat(data["bcc"], ",") 133 | end 134 | 135 | if data["subject"] then 136 | headers["Subject"] = data["subject"] 137 | end 138 | 139 | if not headers["Message-ID"] then 140 | headers["Message-ID"] = "<" .. generate_message_id(mailer) .. ">" 141 | end 142 | 143 | if not headers["Date"] then 144 | headers["Date"] = rfc2822_date(ngx.now()) 145 | end 146 | 147 | if not headers["MIME-Version"] then 148 | headers["MIME-Version"] = "1.0" 149 | end 150 | 151 | data["headers"] = headers 152 | 153 | return setmetatable({ mailer = mailer, data = data }, { __index = _M }) 154 | end 155 | 156 | function _M.get_from_address(self) 157 | local from 158 | if self.data["from"] then 159 | from = extract_address(self.data["from"]) 160 | end 161 | 162 | return from 163 | end 164 | 165 | function _M.get_recipient_addresses(self) 166 | local fields = { "to", "cc", "bcc" } 167 | local uniq_addresses = {} 168 | for _, field in ipairs(fields) do 169 | if self.data[field] then 170 | for _, string in ipairs(self.data[field]) do 171 | uniq_addresses[extract_address(string)] = 1 172 | end 173 | end 174 | end 175 | 176 | local list = {} 177 | for address, _ in pairs(uniq_addresses) do 178 | table.insert(list, address) 179 | end 180 | 181 | table.sort(list) 182 | 183 | return list 184 | end 185 | 186 | function _M.get_body_list(self) 187 | local data = self.data 188 | local headers = data["headers"] 189 | local body = {} 190 | 191 | local mixed_boundary 192 | if data["text"] or data["html"] or data["attachments"] then 193 | mixed_boundary = generate_boundary() 194 | headers["Content-Type"] = 'multipart/mixed; boundary="' .. mixed_boundary .. '"' 195 | end 196 | 197 | for name, value in pairs(headers) do 198 | body_insert_header(body, name, value) 199 | end 200 | 201 | table.insert(body, CRLF) 202 | 203 | if data["text"] or data["html"] or data["attachments"] then 204 | table.insert(body, "This is a multi-part message in MIME format.") 205 | table.insert(body, CRLF) 206 | body_insert_boundary(body, mixed_boundary) 207 | 208 | local alternative_boundary = generate_boundary() 209 | body_insert_header(body, "Content-Type", 'multipart/alternative; boundary="' .. alternative_boundary .. '"') 210 | table.insert(body, CRLF) 211 | 212 | if data["text"] then 213 | body_insert_boundary(body, alternative_boundary) 214 | body_insert_header(body, "Content-Type", "text/plain; charset=utf-8") 215 | body_insert_header(body, "Content-Transfer-Encoding", "base64") 216 | table.insert(body, CRLF) 217 | table.insert(body, wrapped_base64(data["text"])) 218 | table.insert(body, CRLF) 219 | end 220 | 221 | if data["html"] then 222 | body_insert_boundary(body, alternative_boundary) 223 | body_insert_header(body, "Content-Type", "text/html; charset=utf-8") 224 | body_insert_header(body, "Content-Transfer-Encoding", "base64") 225 | table.insert(body, CRLF) 226 | table.insert(body, wrapped_base64(data["html"])) 227 | table.insert(body, CRLF) 228 | end 229 | 230 | body_insert_boundary_final(body, alternative_boundary) 231 | 232 | if data["attachments"] then 233 | for _, attachment in ipairs(data["attachments"]) do 234 | body_insert_boundary(body, mixed_boundary) 235 | body_insert_attachment(body, attachment, self.mailer) 236 | end 237 | end 238 | 239 | body_insert_boundary_final(body, mixed_boundary) 240 | end 241 | 242 | return body 243 | end 244 | 245 | function _M.get_body_string(self) 246 | return table.concat(self:get_body_list(), "") 247 | end 248 | 249 | return _M 250 | -------------------------------------------------------------------------------- /lib/resty/mail/rfc2822_date.lua: -------------------------------------------------------------------------------- 1 | local MONTHS = { 2 | "Jan", 3 | "Feb", 4 | "Mar", 5 | "Apr", 6 | "May", 7 | "Jun", 8 | "Jul", 9 | "Aug", 10 | "Sep", 11 | "Oct", 12 | "Nov", 13 | "Dec", 14 | } 15 | 16 | local WDAYS = { 17 | "Sun", 18 | "Mon", 19 | "Tue", 20 | "Wed", 21 | "Thu", 22 | "Fri", 23 | "Sat", 24 | } 25 | 26 | -- Output a date and time in RFC 2822 compliant format. 27 | -- 28 | -- This ensures the month and day of week are output in the compliant English 29 | -- format (instead of relying on os.date, which can be affected by the current 30 | -- system's locale). 31 | return function(time) 32 | local data = os.date("*t", time) 33 | local month = MONTHS[data["month"]] 34 | local wday = WDAYS[data["wday"]] 35 | return os.date(wday .. ", %d " .. month .. " %Y %H:%M:%S %z", time) 36 | end 37 | -------------------------------------------------------------------------------- /lib/resty/mail/smtp.lua: -------------------------------------------------------------------------------- 1 | local encode_base64 = ngx.encode_base64 2 | local ngx_socket_tcp = ngx.socket.tcp 3 | 4 | local CRLF = "\r\n" 5 | 6 | local _M = {} 7 | 8 | local function receive_response(sock) 9 | local status 10 | local lines = {} 11 | while true do 12 | local line, receive_err = sock:receive() 13 | if not line then 14 | return nil, receive_err 15 | end 16 | 17 | table.insert(lines, line) 18 | status = tonumber(string.sub(line, 1, 3)) 19 | 20 | -- Response lines where a dash ("-") follows the status code indicate there 21 | -- are more lines to this response. So keep reading lines until there's no 22 | -- dash after the status. 23 | if string.sub(line, 4, 4) ~= "-" then 24 | break 25 | end 26 | end 27 | 28 | return { 29 | status = status, 30 | lines = lines, 31 | } 32 | end 33 | 34 | local function send_data(sock, data) 35 | local bytes, send_err = sock:send(data) 36 | if not bytes then 37 | return false, send_err 38 | end 39 | 40 | return receive_response(sock) 41 | end 42 | 43 | local function send_line(sock, line) 44 | -- Prevent SMTP injections, by ensuring recipients addresses don't contain 45 | -- line breaks or are so long that they could potentially cause line breaks. 46 | -- 47 | -- See http://www.mbsd.jp/Whitepaper/smtpi.pdf 48 | if #line > 2000 then 49 | return false, "may not exceed 2kB" 50 | end 51 | if ngx.re.match(line, "[\r\n]", "jo") then 52 | return false, "may not contain CR or LF line breaks" 53 | end 54 | 55 | return send_data(sock, { line, CRLF }) 56 | end 57 | 58 | local function assert_response_status(min_status, max_status, response, err) 59 | if err then 60 | return error(err) 61 | end 62 | 63 | if not response or not response["status"] then 64 | return error("Unknown SMTP response: " .. table.concat(response["lines"] or {}, "\n")) 65 | end 66 | 67 | if response["status"] < min_status or response["status"] > max_status then 68 | return error("SMTP response was not successful: " .. table.concat(response["lines"] or {}, "\n")) 69 | end 70 | end 71 | 72 | local function assert_response_ok(response, err) 73 | return assert_response_status(200, 299, response, err) 74 | end 75 | 76 | local function assert_response_continue(response, err) 77 | return assert_response_status(300, 399, response, err) 78 | end 79 | 80 | local function ehlo(self, sock) 81 | local options = self.mailer["options"] 82 | local response, err = send_line(sock, "EHLO " .. options["domain"]) 83 | assert_response_ok(response, err) 84 | 85 | -- Read the extensions from the EHLO response. 86 | self.extensions = {} 87 | for index, line in ipairs(response["lines"]) do 88 | -- Skip the first response line, since it's just the greeting. Further 89 | -- lines list extensions. 90 | if index > 1 then 91 | -- Extract the rest of the line after the status code. Lowercase the 92 | -- line, so everything is treated case-insensitively. 93 | local ehlo_line = string.lower(string.sub(line, 5)) 94 | 95 | -- Extract the space-delimited keywords from the line. 96 | local keywords = {} 97 | for keyword in string.gmatch(ehlo_line, "%S+") do 98 | table.insert(keywords, keyword) 99 | end 100 | 101 | local extension = keywords[1] 102 | local params = {} 103 | for i = 2,#keywords do 104 | local param = keywords[i] 105 | table.insert(params, param) 106 | end 107 | 108 | self.extensions[extension] = params 109 | end 110 | end 111 | end 112 | 113 | local function sslhandshake(self) 114 | local sock = self.sock 115 | local options = self.mailer["options"] 116 | local session, err = sock:sslhandshake(nil, options["ssl_host"] or options["host"], options["ssl_verify"] or false) 117 | 118 | if not session then 119 | return error("sslhandshake error: " .. (err or "")) 120 | end 121 | end 122 | 123 | local function authenticate(self) 124 | local sock = self.sock 125 | local options = self.mailer["options"] 126 | local auth_type = options["auth_type"] 127 | local username = options["username"] 128 | local password = options["password"] 129 | if auth_type == "plain" then 130 | assert_response_ok(send_line(sock, "AUTH PLAIN " .. encode_base64("\0" .. username .. "\0" .. password))) 131 | elseif auth_type == "login" then 132 | assert_response_continue(send_line(sock, "AUTH LOGIN")) 133 | assert_response_continue(send_line(sock, encode_base64(username))) 134 | assert_response_ok(send_line(sock, encode_base64(password))) 135 | else 136 | return error("unknown auth_type: " .. (auth_type or "")) 137 | end 138 | end 139 | 140 | local function send_message(self, message) 141 | local options = self.mailer["options"] 142 | local sock = self.sock 143 | 144 | -- Open connection 145 | if sock.settimeouts then 146 | sock:settimeouts(options["timeout_connect"], options["timeout_send"], options["timeout_read"]) 147 | else 148 | -- Fallback to settimeout for older versions of ngx_lua (pre v0.10.7) where 149 | -- settimeouts isn't available. 150 | sock:settimeout(options["timeout_connect"]) 151 | end 152 | local ok, err = sock:connect(options["host"], options["port"]) 153 | if not ok then 154 | return error("connect failure: " .. err) 155 | end 156 | 157 | -- If SSL is explicitly enabled (SMTPS), establish a secure connection first. 158 | if options["ssl"] then 159 | sslhandshake(self) 160 | end 161 | 162 | assert_response_ok(receive_response(sock)) 163 | 164 | -- EHLO 165 | ehlo(self, sock) 166 | 167 | -- If STARTTLS is explicitly enabled, or it's detected as supported, then try 168 | -- to establish a secure connection. 169 | if (options["starttls"] or self.extensions["starttls"]) and not options["ssl"] then 170 | assert_response_ok(send_line(sock, "STARTTLS")) 171 | sslhandshake(self) 172 | 173 | -- Re-send the EHLO, which re-reads the extensions, since they might 174 | -- differ over the secure connection. 175 | ehlo(self, sock) 176 | end 177 | 178 | if options["auth_type"] then 179 | authenticate(self) 180 | end 181 | 182 | -- From 183 | local from = message:get_from_address() 184 | assert_response_ok(send_line(sock, "MAIL FROM:<" .. from .. ">")) 185 | 186 | -- Recpients (includes all To, Cc, Bcc addresses). 187 | local recipients = message:get_recipient_addresses() 188 | for _, address in ipairs(recipients) do 189 | assert_response_ok(send_line(sock, "RCPT TO:<" .. address .. ">")) 190 | end 191 | 192 | -- Send the message body along with the data terminator. 193 | assert_response_continue(send_line(sock, "DATA")) 194 | local body = message:get_body_list() 195 | table.insert(body, CRLF) 196 | table.insert(body, ".") 197 | table.insert(body, CRLF) 198 | assert_response_ok(send_data(sock, body)) 199 | end 200 | 201 | function _M.new(mailer) 202 | local sock, err = ngx_socket_tcp() 203 | if not sock then 204 | return nil, err 205 | end 206 | 207 | return setmetatable({ 208 | mailer = mailer, 209 | sock = sock, 210 | extensions = {}, 211 | }, { __index = _M }) 212 | end 213 | 214 | function _M.send(self, message) 215 | local sock = self.sock 216 | 217 | -- Try to send the message, catching any errors. 218 | local send_ok, send_err = pcall(send_message, self, message) 219 | 220 | -- Always try to quit the connection, regardless of whether or not the send 221 | -- succeeded. 222 | local quit_ok, quit_err = send_line(sock, "QUIT") 223 | 224 | -- Always close the socket. 225 | sock:close() 226 | 227 | -- Return any errors that happened. 228 | if not send_ok then 229 | return false, send_err 230 | end 231 | if not quit_ok then 232 | return false, quit_err 233 | end 234 | 235 | return true 236 | end 237 | 238 | return _M 239 | -------------------------------------------------------------------------------- /lua-resty-mail-1.1.0-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "lua-resty-mail" 2 | version = "1.1.0-1" 3 | source = { 4 | url = "git://github.com/GUI/lua-resty-mail.git", 5 | tag = "v1.1.0", 6 | } 7 | description = { 8 | summary = "Email and SMTP library for OpenResty", 9 | detailed = "A high-level, easy to use, and non-blocking email and SMTP library for OpenResty.", 10 | homepage = "https://github.com/GUI/lua-resty-mail", 11 | license = "MIT", 12 | } 13 | build = { 14 | type = "builtin", 15 | modules = { 16 | ["resty.mail"] = "lib/resty/mail.lua", 17 | ["resty.mail.message"] = "lib/resty/mail/message.lua", 18 | ["resty.mail.smtp"] = "lib/resty/mail/smtp.lua", 19 | ["resty.mail.headers"] = "lib/resty/mail/headers.lua", 20 | ["resty.mail.rfc2822_date"] = "lib/resty/mail/rfc2822_date.lua", 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /lua-resty-mail-git-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "lua-resty-mail" 2 | version = "git-1" 3 | source = { 4 | url = "git://github.com/GUI/lua-resty-mail.git", 5 | } 6 | description = { 7 | summary = "Email and SMTP library for OpenResty", 8 | detailed = "A high-level, easy to use, and non-blocking email and SMTP library for OpenResty.", 9 | homepage = "https://github.com/GUI/lua-resty-mail", 10 | license = "MIT", 11 | } 12 | build = { 13 | type = "builtin", 14 | modules = { 15 | ["resty.mail"] = "lib/resty/mail.lua", 16 | ["resty.mail.message"] = "lib/resty/mail/message.lua", 17 | ["resty.mail.smtp"] = "lib/resty/mail/smtp.lua", 18 | ["resty.mail.headers"] = "lib/resty/mail/headers.lua", 19 | ["resty.mail.rfc2822_date"] = "lib/resty/mail/rfc2822_date.lua", 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /spec/bin/resty-with-ssl-certs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | exec resty --http-conf "lua_ssl_trusted_certificate $SSL_CERT_FILE;" "$@" 3 | -------------------------------------------------------------------------------- /spec/mail_integration_external_spec.lua: -------------------------------------------------------------------------------- 1 | local mail = require "resty.mail" 2 | 3 | describe("mail integration external #integration_external", function() 4 | local username, password, recipient 5 | setup(function() 6 | username = os.getenv("MAILGUN_USERNAME") or error("Must set MAILGUN_USERNAME environment variable") 7 | password = os.getenv("MAILGUN_PASSWORD") or error("Must set MAILGUN_PASSWORD environment variable") 8 | recipient = os.getenv("MAILGUN_RECIPIENT") or error("Must set MAILGUN_RECIPIENT environment variable") 9 | end) 10 | 11 | it("sends starttls enabled mail", function() 12 | local mailer, mailer_err = mail.new({ 13 | host = "smtp.mailgun.org", 14 | port = 587, 15 | username = username, 16 | password = password, 17 | starttls = true, 18 | }) 19 | assert.equal(nil, mailer_err) 20 | local ok, err = mailer:send({ 21 | from = "foo@example.com", 22 | to = { recipient }, 23 | subject = "Subject", 24 | text = "Message", 25 | headers = { 26 | ["X-Mailgun-Drop-Message"] = "yes", 27 | }, 28 | }) 29 | assert.equal(nil, err) 30 | assert.equal(true, ok) 31 | end) 32 | 33 | it("sends ssl enabled mail", function() 34 | local mailer, mailer_err = mail.new({ 35 | host = "smtp.mailgun.org", 36 | port = 465, 37 | username = username, 38 | password = password, 39 | ssl = true, 40 | }) 41 | assert.equal(nil, mailer_err) 42 | local ok, err = mailer:send({ 43 | from = "foo@example.com", 44 | to = { recipient }, 45 | subject = "Subject", 46 | text = "Message", 47 | headers = { 48 | ["X-Mailgun-Drop-Message"] = "yes", 49 | }, 50 | }) 51 | assert.equal(nil, err) 52 | assert.equal(true, ok) 53 | end) 54 | 55 | it("sends login authenticated mail", function() 56 | local mailer, mailer_err = mail.new({ 57 | host = "smtp.mailgun.org", 58 | port = 587, 59 | username = username, 60 | password = password, 61 | auth_type = "login", 62 | }) 63 | assert.equal(nil, mailer_err) 64 | local ok, err = mailer:send({ 65 | from = "foo@example.com", 66 | to = { recipient }, 67 | subject = "Subject", 68 | text = "Message", 69 | headers = { 70 | ["X-Mailgun-Drop-Message"] = "yes", 71 | }, 72 | }) 73 | assert.equal(nil, err) 74 | assert.equal(true, ok) 75 | end) 76 | 77 | it("rejects starttls when ssl_verify is enabled and no system certs are configured", function() 78 | local mailer, mailer_err = mail.new({ 79 | host = "smtp.mailgun.org", 80 | port = 587, 81 | username = username, 82 | password = password, 83 | starttls = true, 84 | ssl_verify = true, 85 | }) 86 | assert.equal(nil, mailer_err) 87 | local ok, err = mailer:send({ 88 | from = "foo@example.com", 89 | to = { recipient }, 90 | subject = "Subject", 91 | text = "Message", 92 | headers = { 93 | ["X-Mailgun-Drop-Message"] = "yes", 94 | }, 95 | }) 96 | assert.equal(false, ok) 97 | assert.matches("sslhandshake error: 20: unable to get local issuer certificate", err, 1, true) 98 | end) 99 | 100 | it("rejects ssl when ssl_verify is enabled and no system certs are configured", function() 101 | local mailer, mailer_err = mail.new({ 102 | host = "smtp.mailgun.org", 103 | port = 465, 104 | username = username, 105 | password = password, 106 | ssl = true, 107 | ssl_verify = true, 108 | }) 109 | assert.equal(nil, mailer_err) 110 | local ok, err = mailer:send({ 111 | from = "foo@example.com", 112 | to = { recipient }, 113 | subject = "Subject", 114 | text = "Message", 115 | headers = { 116 | ["X-Mailgun-Drop-Message"] = "yes", 117 | }, 118 | }) 119 | assert.equal(false, ok) 120 | assert.matches("sslhandshake error: 20: unable to get local issuer certificate", err, 1, true) 121 | end) 122 | end) 123 | -------------------------------------------------------------------------------- /spec/mail_integration_external_spec_ssl_certs.lua: -------------------------------------------------------------------------------- 1 | local mail = require "resty.mail" 2 | 3 | describe("mail integration external with ssl certs #integration_ssl_certs", function() 4 | local username, password, recipient 5 | setup(function() 6 | username = os.getenv("MAILGUN_USERNAME") or error("Must set MAILGUN_USERNAME environment variable") 7 | password = os.getenv("MAILGUN_PASSWORD") or error("Must set MAILGUN_PASSWORD environment variable") 8 | recipient = os.getenv("MAILGUN_RECIPIENT") or error("Must set MAILGUN_RECIPIENT environment variable") 9 | end) 10 | 11 | it("sends starttls enabled mail with ssl verification", function() 12 | local mailer, mailer_err = mail.new({ 13 | host = "smtp.mailgun.org", 14 | port = 587, 15 | username = username, 16 | password = password, 17 | starttls = true, 18 | ssl_verify = true, 19 | }) 20 | assert.equal(nil, mailer_err) 21 | local ok, err = mailer:send({ 22 | from = "foo@example.com", 23 | to = { recipient }, 24 | subject = "Subject", 25 | text = "Message", 26 | headers = { 27 | ["X-Mailgun-Drop-Message"] = "yes", 28 | }, 29 | }) 30 | assert.equal(nil, err) 31 | assert.equal(true, ok) 32 | end) 33 | 34 | it("sends ssl enabled mail", function() 35 | local mailer, mailer_err = mail.new({ 36 | host = "smtp.mailgun.org", 37 | port = 465, 38 | username = username, 39 | password = password, 40 | ssl = true, 41 | ssl_verify = true, 42 | }) 43 | assert.equal(nil, mailer_err) 44 | local ok, err = mailer:send({ 45 | from = "foo@example.com", 46 | to = { recipient }, 47 | subject = "Subject", 48 | text = "Message", 49 | headers = { 50 | ["X-Mailgun-Drop-Message"] = "yes", 51 | }, 52 | }) 53 | assert.equal(nil, err) 54 | assert.equal(true, ok) 55 | end) 56 | 57 | it("rejects starttls when ssl_verify is ssl_host does not match", function() 58 | local mailer, mailer_err = mail.new({ 59 | host = "smtp.mailgun.org", 60 | port = 587, 61 | username = username, 62 | password = password, 63 | starttls = true, 64 | ssl_verify = true, 65 | ssl_host = "example.com", 66 | }) 67 | assert.equal(nil, mailer_err) 68 | local ok, err = mailer:send({ 69 | from = "foo@example.com", 70 | to = { recipient }, 71 | subject = "Subject", 72 | text = "Message", 73 | headers = { 74 | ["X-Mailgun-Drop-Message"] = "yes", 75 | }, 76 | }) 77 | assert.equal(false, ok) 78 | assert.matches("sslhandshake error: certificate host mismatch", err, 1, true) 79 | end) 80 | 81 | it("rejects ssl when ssl_verify is ssl_host does not match", function() 82 | local mailer, mailer_err = mail.new({ 83 | host = "smtp.mailgun.org", 84 | port = 465, 85 | username = username, 86 | password = password, 87 | ssl = true, 88 | ssl_verify = true, 89 | ssl_host = "example.com", 90 | }) 91 | assert.equal(nil, mailer_err) 92 | local ok, err = mailer:send({ 93 | from = "foo@example.com", 94 | to = { recipient }, 95 | subject = "Subject", 96 | text = "Message", 97 | headers = { 98 | ["X-Mailgun-Drop-Message"] = "yes", 99 | }, 100 | }) 101 | assert.equal(false, ok) 102 | assert.matches("sslhandshake error: certificate host mismatch", err, 1, true) 103 | end) 104 | end) 105 | -------------------------------------------------------------------------------- /spec/mail_integration_internal_spec.lua: -------------------------------------------------------------------------------- 1 | local cjson = require "cjson" 2 | local http = require "resty.http" 3 | local mail = require "resty.mail" 4 | 5 | describe("mail integration internal #integration_internal", function() 6 | before_each(function() 7 | local httpc = http.new() 8 | local res, err = httpc:request_uri("http://127.0.0.1:8025/api/v1/messages", { 9 | method = "DELETE", 10 | }) 11 | assert.equal(nil, err) 12 | assert.equal(200, res.status) 13 | end) 14 | 15 | it("sends example mail", function() 16 | local mailer = mail.new({ 17 | host = "127.0.0.1", 18 | port = 1025, 19 | }) 20 | local ok, err = mailer:send({ 21 | from = "From ", 22 | reply_to = "Reply ", 23 | to = { "To ", "to2@example.com" }, 24 | cc = { "Cc ", "cc2@example.com" }, 25 | bcc = { "Bcc ", "bcc2@example.com" }, 26 | subject = "Subject", 27 | text = "Plain Text", 28 | html = "

HTML Text

", 29 | headers = { 30 | ["X-Foo"] = "bar", 31 | ["X-Bar"] = "baz", 32 | }, 33 | attachments = { 34 | { 35 | filename = "foo.txt", 36 | content_type = "text/plain", 37 | content = "Hello, World (attachment).", 38 | }, 39 | { 40 | filename = "foo.txt", 41 | content_type = "text/plain", 42 | content = "Hello, World (inline).", 43 | disposition = "inline", 44 | content_id = "custom_content_id", 45 | }, 46 | }, 47 | }) 48 | assert.equal(nil, err) 49 | assert.equal(true, ok) 50 | 51 | local httpc = http.new() 52 | local res, http_err = httpc:request_uri("http://127.0.0.1:8025/api/v1/messages") 53 | assert.equal(nil, http_err) 54 | assert.equal(200, res.status) 55 | local data = cjson.decode(res.body) 56 | assert.equal(1, #data["messages"]) 57 | local msg = data["messages"][1] 58 | 59 | res, http_err = httpc:request_uri("http://127.0.0.1:8025/api/v1/message/" .. msg["ID"]) 60 | assert.equal(nil, http_err) 61 | assert.equal(200, res.status) 62 | msg = cjson.decode(res.body) 63 | 64 | res, http_err = httpc:request_uri("http://127.0.0.1:8025/api/v1/message/" .. msg["ID"] .. "/headers") 65 | assert.equal(nil, http_err) 66 | assert.equal(200, res.status) 67 | local headers = cjson.decode(res.body) 68 | 69 | res, http_err = httpc:request_uri("http://127.0.0.1:8025/api/v1/message/" .. msg["ID"] .. "/raw") 70 | assert.equal(nil, http_err) 71 | assert.equal(200, res.status) 72 | local raw = res.body 73 | 74 | local attachments = {} 75 | for _, item in ipairs(msg["Attachments"]) do 76 | res, http_err = httpc:request_uri("http://127.0.0.1:8025/api/v1/message/" .. msg["ID"] .. "/part/" .. item["PartID"]) 77 | assert.equal(nil, http_err) 78 | assert.equal(200, res.status) 79 | local attachment = res.body 80 | table.insert(attachments, attachment) 81 | end 82 | 83 | local inlines = {} 84 | for _, item in ipairs(msg["Inline"]) do 85 | res, http_err = httpc:request_uri("http://127.0.0.1:8025/api/v1/message/" .. msg["ID"] .. "/part/" .. item["PartID"]) 86 | assert.equal(nil, http_err) 87 | assert.equal(200, res.status) 88 | local inline = res.body 89 | table.insert(inlines, inline) 90 | end 91 | 92 | -- From 93 | assert.equal("From", msg["From"]["Name"]) 94 | assert.equal("from@example.com", msg["From"]["Address"]) 95 | 96 | -- Recipients 97 | assert.equal("To", msg["To"][1]["Name"]) 98 | assert.equal("to@example.com", msg["To"][1]["Address"]) 99 | assert.equal("", msg["To"][2]["Name"]) 100 | assert.equal("to2@example.com", msg["To"][2]["Address"]) 101 | assert.equal("Cc", msg["Cc"][1]["Name"]) 102 | assert.equal("cc@example.com", msg["Cc"][1]["Address"]) 103 | assert.equal("", msg["Cc"][2]["Name"]) 104 | assert.equal("cc2@example.com", msg["Cc"][2]["Address"]) 105 | assert.equal("Bcc", msg["Bcc"][1]["Name"]) 106 | assert.equal("bcc@example.com", msg["Bcc"][1]["Address"]) 107 | assert.equal("", msg["Bcc"][2]["Name"]) 108 | assert.equal("bcc2@example.com", msg["Bcc"][2]["Address"]) 109 | 110 | -- Headers 111 | assert.same({ "Bcc ,bcc2@example.com" }, headers["Bcc"]) 112 | assert.same({ "Cc ,cc2@example.com" }, headers["Cc"]) 113 | assert.equal(1, #headers["Content-Type"]) 114 | assert.matches("multipart/mixed; boundary=\"", headers["Content-Type"][1], 1, true) 115 | assert.equal(1, #headers["Date"]) 116 | assert.same({ "From " }, headers["From"]) 117 | assert.same({ "1.0" }, headers["Mime-Version"]) 118 | assert.equal(1, #headers["Message-Id"]) 119 | assert.equal(1, #headers["Received"]) 120 | assert.same({ "Reply " }, headers["Reply-To"]) 121 | assert.same({ "" }, headers["Return-Path"]) 122 | assert.same({ "Subject" }, headers["Subject"]) 123 | assert.same({ "To ,to2@example.com" }, headers["To"]) 124 | assert.same({ "baz" }, headers["X-Bar"]) 125 | assert.same({ "bar" }, headers["X-Foo"]) 126 | 127 | -- Content 128 | assert.equal("Plain Text\n--\nHello, World (inline).", msg["Text"]) 129 | assert.equal("

HTML Text

", msg["HTML"]) 130 | 131 | -- MIME content 132 | assert.matches("This is a multi-part message in MIME format.", raw, 1, true) 133 | assert.matches("multipart/mixed; boundary=\"", raw, 1, true) 134 | assert.matches("multipart/alternative; boundary=\"", raw, 1, true) 135 | assert.matches("Content-Transfer-Encoding: base64", raw, 1, true) 136 | assert.matches("Content-Type: text/plain; charset=utf-8", raw, 1, true) 137 | assert.matches("Content-Type: text/html; charset=utf-8", raw, 1, true) 138 | assert.matches("UGxhaW4gVGV4dA==", raw, 1, true) 139 | 140 | -- Attachments 141 | assert.equal(1, #msg["Attachments"]) 142 | assert.equal(1, #attachments) 143 | local part = msg["Attachments"][1] 144 | assert.matches("@localhost.localdomain", part["ContentID"], 1, true) 145 | assert.same("foo.txt", part["FileName"]) 146 | assert.same("text/plain", part["ContentType"]) 147 | assert.equal("Hello, World (attachment).", attachments[1]) 148 | 149 | assert.equal(1, #msg["Inline"]) 150 | assert.equal(1, #inlines) 151 | part = msg["Inline"][1] 152 | assert.matches("custom_content_id", part["ContentID"], 1, true) 153 | assert.same("foo.txt", part["FileName"]) 154 | assert.same("text/plain", part["ContentType"]) 155 | assert.equal("Hello, World (inline).", inlines[1]) 156 | end) 157 | end) 158 | -------------------------------------------------------------------------------- /spec/mail_spec.lua: -------------------------------------------------------------------------------- 1 | local mail = require "resty.mail" 2 | 3 | describe("mail", function() 4 | it("returns connection error (port)", function() 5 | local mailer = mail.new({ 6 | host = "127.0.0.1", 7 | port = 1026, 8 | }) 9 | local ok, err = mailer:send({ 10 | from = "from@example.com", 11 | to = { "to@example.com" }, 12 | subject = "Subject", 13 | text = "Message", 14 | }) 15 | assert.matches("connect failure: connection refused", err, 1, true) 16 | assert.equal(false, ok) 17 | end) 18 | 19 | it("returns starttls error (unsupported)", function() 20 | local mailer = mail.new({ 21 | host = "127.0.0.1", 22 | port = 1025, 23 | starttls = true, 24 | }) 25 | local ok, err = mailer:send({ 26 | from = "from@example.com", 27 | to = { "to@example.com" }, 28 | subject = "Subject", 29 | text = "Message", 30 | }) 31 | assert.matches("SMTP response was not successful: 502 5.5.1 Command not implemented", err, 1, true) 32 | assert.equal(false, ok) 33 | end) 34 | 35 | it("returns ssl error (unsupported)", function() 36 | local mailer = mail.new({ 37 | host = "127.0.0.1", 38 | port = 1025, 39 | ssl = true, 40 | }) 41 | local ok, err = mailer:send({ 42 | from = "from@example.com", 43 | to = { "to@example.com" }, 44 | subject = "Subject", 45 | text = "Message", 46 | }) 47 | assert.matches("sslhandshake error: handshake failed", err, 1, true) 48 | assert.equal(false, ok) 49 | end) 50 | 51 | it("returns missing username error", function() 52 | local mailer, err = mail.new({ 53 | host = "127.0.0.1", 54 | port = 1025, 55 | password = "foo", 56 | }) 57 | assert.equal("authentication requested, but missing username", err) 58 | assert.equal(nil, mailer) 59 | end) 60 | 61 | it("returns missing password error", function() 62 | local mailer, err = mail.new({ 63 | host = "127.0.0.1", 64 | port = 1025, 65 | username = "foo", 66 | }) 67 | assert.equal("authentication requested, but missing password", err) 68 | assert.equal(nil, mailer) 69 | end) 70 | 71 | it("returns missing username and password error", function() 72 | local mailer, err = mail.new({ 73 | host = "127.0.0.1", 74 | port = 1025, 75 | auth_type = "plain", 76 | }) 77 | assert.equal("authentication requested, but missing username", err) 78 | assert.equal(nil, mailer) 79 | end) 80 | end) 81 | -------------------------------------------------------------------------------- /spec/message_get_body_spec.lua: -------------------------------------------------------------------------------- 1 | local mail = require "resty.mail" 2 | local message = require "resty.mail.message" 3 | 4 | describe("message get_body", function() 5 | it("contains message-id, which uses 'from' host", function() 6 | local msg = message.new(mail.new({ domain = "example.com" })) 7 | assert.matches("Message%-ID: <.+@example.com>", msg:get_body_string()) 8 | end) 9 | 10 | it("contains message-id, which defaults to localhost", function() 11 | local msg = message.new(mail.new()) 12 | assert.matches("Message%-ID: <.+@localhost.localdomain>", msg:get_body_string()) 13 | end) 14 | 15 | it("contains date, which always uses rfc 2822 english locale", function() 16 | local orig_locale = os.setlocale() 17 | assert(os.setlocale("fr_FR")) 18 | assert.equal("ven., 28 juil. 2017", os.date("!%a, %d %b %Y", 1501211178)) 19 | 20 | local msg = message.new(mail.new()) 21 | -- Since the date output will be the current date, check to make sure the 22 | -- format matches the expected English output (which we can test for, since 23 | -- the french locale will have extra characters). 24 | assert.matches("Date: [A-Z][a-z][a-z], %d%d [A-Z][a-z][a-z] %d%d%d%d %d%d:%d%d:%d%d [+-]%d%d%d%d", msg:get_body_string()) 25 | 26 | assert(os.setlocale(orig_locale)) 27 | end) 28 | 29 | it("wraps the lines in base64 encoded bodies", function() 30 | local msg = message.new(mail.new(), { 31 | text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789", 32 | html = "

abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789

", 33 | }) 34 | local body = msg:get_body_string() 35 | local boundary = ngx.re.match(body, 'Content-Type: multipart/alternative; boundary="([^"]+)"')[1] 36 | assert.matches("--" .. boundary .. "\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\nYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1\r\ndnd4eXowMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5YWJjZGVm\r\nZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5\r\n--" .. boundary .. "\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\nPHA+YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFy\r\nc3R1dnd4eXowMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5YWJj\r\nZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5PC9wPg==\r\n--" .. boundary .. "--", body, 1, true) 37 | end) 38 | 39 | it("supports custom headers", function() 40 | local msg = message.new(mail.new(), { 41 | headers = { 42 | ["X-Foo"] = "bar", 43 | }, 44 | }) 45 | assert.matches("X-Foo: bar\r\n", msg:get_body_string(), 1, true) 46 | end) 47 | 48 | it("supports attachments", function() 49 | local msg = message.new(mail.new(), { 50 | attachments = { 51 | { 52 | filename = "foobar.txt", 53 | content_type = "text/plain", 54 | content = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789", 55 | }, 56 | }, 57 | }) 58 | local body = msg:get_body_string() 59 | local boundary = ngx.re.match(body, 'Content-Type: multipart/mixed; boundary="([^"]+)"')[1] 60 | local content_id = ngx.re.match(body, "Content-ID: (<[^>]+>)")[1] 61 | assert.matches("--" .. boundary .. "\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"=?utf-8?B?Zm9vYmFyLnR4dA==?=\"\r\nContent-ID: " .. content_id .. "\r\n\r\nYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1\r\ndnd4eXowMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5YWJjZGVm\r\nZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5\r\n--" .. boundary .. "--", body, 1, true) 62 | end) 63 | 64 | it("supports attachments with custom content ids", function() 65 | local msg = message.new(mail.new(), { 66 | attachments = { 67 | { 68 | filename = "foobar.txt", 69 | content_type = "text/plain", 70 | content = "abc", 71 | disposition = "inline", 72 | content_id = "foobar", 73 | }, 74 | }, 75 | }) 76 | local body = msg:get_body_string() 77 | local boundary = ngx.re.match(body, 'Content-Type: multipart/mixed; boundary="([^"]+)"')[1] 78 | local content_id = "" 79 | assert.matches("--" .. boundary .. "\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: inline; filename=\"=?utf-8?B?Zm9vYmFyLnR4dA==?=\"\r\nContent-ID: " .. content_id .. "\r\n\r\nYWJj\r\n--" .. boundary .. "--", body, 1, true) 80 | end) 81 | end) 82 | -------------------------------------------------------------------------------- /spec/message_get_from_address_spec.lua: -------------------------------------------------------------------------------- 1 | local mail = require "resty.mail" 2 | local message = require "resty.mail.message" 3 | 4 | describe("message get_from_address", function() 5 | it("returns address", function() 6 | local msg = message.new(mail.new(), { 7 | from = "foo@example.com", 8 | }) 9 | assert.equal("foo@example.com", msg:get_from_address()) 10 | end) 11 | 12 | it("extracts address from name", function() 13 | local msg = message.new(mail.new(), { 14 | from = "Foobar ", 15 | }) 16 | assert.equal("foo@example.com", msg:get_from_address()) 17 | end) 18 | 19 | it("extracts address from name and spaces", function() 20 | local msg = message.new(mail.new(), { 21 | from = "Foobar < foo@example.com > ", 22 | }) 23 | assert.equal("foo@example.com", msg:get_from_address()) 24 | end) 25 | 26 | it("returns nil when not present", function() 27 | local msg = message.new(mail.new()) 28 | assert.equal(nil, msg:get_from_address()) 29 | end) 30 | end) 31 | -------------------------------------------------------------------------------- /spec/message_get_recipient_addresses_spec.lua: -------------------------------------------------------------------------------- 1 | local mail = require "resty.mail" 2 | local message = require "resty.mail.message" 3 | 4 | describe("message get_recipient_addresses", function() 5 | it("returns to, cc, bcc addresses", function() 6 | local msg = message.new(mail.new(), { 7 | to = { "a@example.com", "b@example.com" }, 8 | cc = { "c@example.com", "d@example.com" }, 9 | bcc = { "e@example.com", "f@example.com" }, 10 | }) 11 | assert.same({ 12 | "a@example.com", 13 | "b@example.com", 14 | "c@example.com", 15 | "d@example.com", 16 | "e@example.com", 17 | "f@example.com", 18 | }, msg:get_recipient_addresses()) 19 | end) 20 | 21 | it("returns only unique addresses", function() 22 | local msg = message.new(mail.new(), { 23 | to = { "a@example.com", "a@example.com" }, 24 | cc = { "a@example.com", "a@example.com" }, 25 | bcc = { "a@example.com", "a@example.com" }, 26 | }) 27 | assert.same({ "a@example.com" }, msg:get_recipient_addresses()) 28 | end) 29 | 30 | it("extracts addresses from name and spaces", function() 31 | local msg = message.new(mail.new(), { 32 | to = { "Foo " }, 33 | cc = { "Bar < b@example.com >" }, 34 | bcc = { "Baz " }, 35 | }) 36 | assert.same({ 37 | "a@example.com", 38 | "b@example.com", 39 | "c@example.com", 40 | }, msg:get_recipient_addresses()) 41 | end) 42 | 43 | it("returns empty list when not present", function() 44 | local msg = message.new(mail.new()) 45 | assert.same({}, msg:get_recipient_addresses()) 46 | end) 47 | end) 48 | -------------------------------------------------------------------------------- /spec/rfc2822_date_spec.lua: -------------------------------------------------------------------------------- 1 | local rfc2822_date = require "resty.mail.rfc2822_date" 2 | 3 | -- Tests assume TZ="America/Denver" environment variable set. 4 | describe("rfc2822_date", function() 5 | it("ignores system locale", function() 6 | local orig_locale = os.setlocale() 7 | assert(os.setlocale("fr_FR")) 8 | assert.equal("jeu., 27 juil. 2017", os.date("%a, %d %b %Y", 1501211178)) 9 | 10 | local date = rfc2822_date(1501211178) 11 | assert.equal("Thu, 27 Jul 2017 21:06:18 -0600", date) 12 | 13 | assert(os.setlocale(orig_locale)) 14 | end) 15 | end) 16 | --------------------------------------------------------------------------------