350 lines
12 KiB
Lua
350 lines
12 KiB
Lua
-- smtp.lua -- sending mail, as a script.
|
|
--
|
|
-- The same shape as examples/httpd.lua and for the same reason: the systems primitives are in C
|
|
-- (tcp*, tcpStartTls, crypto*) and the protocol lives here, where it can be read and patched. It
|
|
-- needs an engine with binary-safe strings.
|
|
--
|
|
-- local smtp = load(fsRead("examples/smtp.lua"))()
|
|
-- smtp.send({
|
|
-- host = "smtp.example.com", port = 587, tls = "starttls",
|
|
-- user = "apikey", password = "...",
|
|
-- from = "Singe <no-reply@example.com>",
|
|
-- to = "player@example.com", -- or a list of addresses
|
|
-- subject = "Verify your account",
|
|
-- text = "Open this link ...",
|
|
-- })
|
|
--
|
|
-- Returns true, or nil plus a message. Nothing here retries: a caller that must not lose a message
|
|
-- keeps it in a queue and calls again, because "did it send" is a question only the caller's own
|
|
-- durable state can answer.
|
|
|
|
local smtp = {}
|
|
|
|
local CRLF = "\r\n"
|
|
local LINE_MAX = 998 -- RFC 5322's limit, excluding the CRLF
|
|
local B64_WRAP = 76 -- RFC 2045's limit for base64 body lines
|
|
local RECV_CHUNK = 4096
|
|
local REPLY_MAX = 65536 -- a server that will not finish a reply is not a server
|
|
|
|
|
|
-- Every address and header value the caller supplies is checked for CR and LF before it goes near
|
|
-- the wire. Without this, a display name or an address out of a database could carry "\r\nBcc: ..."
|
|
-- and add headers, or "\r\n.\r\n" and end the message early. This is the single most important
|
|
-- function in the file.
|
|
local function guard(what, value)
|
|
value = tostring(value or "")
|
|
if value:find("[\r\n]") then
|
|
return nil, what .. " must not contain a line break"
|
|
end
|
|
return value
|
|
end
|
|
|
|
|
|
-- The address inside "Display Name <box@example.com>", or the whole string when it is bare. The
|
|
-- envelope wants only the address; the header keeps whatever the caller wrote.
|
|
local function addressOf(value)
|
|
local inner = value:match("<([^>]*)>")
|
|
return inner or value
|
|
end
|
|
|
|
|
|
local function isAscii(value)
|
|
return not value:find("[\128-\255]")
|
|
end
|
|
|
|
|
|
-- A header value that is not plain ASCII becomes an RFC 2047 encoded word, so a subject with an
|
|
-- accent or an emoji survives instead of arriving as mojibake.
|
|
local function encodeHeader(value)
|
|
if isAscii(value) then
|
|
return value
|
|
end
|
|
return "=?UTF-8?B?" .. cryptoBase64Encode(value) .. "?="
|
|
end
|
|
|
|
|
|
-- Days since the epoch to a civil date (Howard Hinnant's algorithm, shifted so the era starts in
|
|
-- March and leap day lands at the end of it).
|
|
local function civilFromDays(days)
|
|
local z = days + 719468
|
|
local era = math.floor(z / 146097)
|
|
local doe = z - era * 146097
|
|
local yoe = math.floor((doe - math.floor(doe / 1460) + math.floor(doe / 36524) - math.floor(doe / 146096)) / 365)
|
|
local y = yoe + era * 400
|
|
local doy = doe - (365 * yoe + math.floor(yoe / 4) - math.floor(yoe / 100))
|
|
local mp = math.floor((5 * doy + 2) / 153)
|
|
local d = doy - math.floor((153 * mp + 2) / 5) + 1
|
|
local m = mp + (mp < 10 and 3 or -9)
|
|
if m <= 2 then
|
|
y = y + 1
|
|
end
|
|
return y, m, d
|
|
end
|
|
|
|
|
|
-- An RFC 5322 date in UTC. The zone is written "-0000" rather than "+0000" because that is the
|
|
-- form meaning "UTC, and the sender's own zone is not being disclosed", which is the truth here.
|
|
local function rfc5322Date(epoch)
|
|
local DAY_NAMES = { "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed" } -- epoch day 0 was a Thursday
|
|
local MONTH_NAMES = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }
|
|
local seconds = math.floor(epoch)
|
|
local days = math.floor(seconds / 86400)
|
|
local rest = seconds - days * 86400
|
|
local year, month, day = civilFromDays(days)
|
|
|
|
return string.format("%s, %02d %s %04d %02d:%02d:%02d -0000",
|
|
DAY_NAMES[(days % 7) + 1], day, MONTH_NAMES[month], year,
|
|
math.floor(rest / 3600), math.floor((rest % 3600) / 60), rest % 60)
|
|
end
|
|
|
|
|
|
-- A reader over one connection. tcpRecv hands back whatever has arrived, which is not the same
|
|
-- shape as a line, so what is left over is kept for the next call.
|
|
local function reader(conn)
|
|
return { conn = conn, buffer = "" }
|
|
end
|
|
|
|
|
|
local function readLine(r)
|
|
while true do
|
|
local at = r.buffer:find(CRLF, 1, true)
|
|
if at then
|
|
local line = r.buffer:sub(1, at - 1)
|
|
r.buffer = r.buffer:sub(at + 2)
|
|
return line
|
|
end
|
|
if #r.buffer > REPLY_MAX then
|
|
return nil, "the server sent an unreasonably long reply"
|
|
end
|
|
local chunk = tcpRecv(r.conn, RECV_CHUNK)
|
|
if not chunk or #chunk == 0 then
|
|
return nil, "the server closed the connection"
|
|
end
|
|
r.buffer = r.buffer .. chunk
|
|
end
|
|
end
|
|
|
|
|
|
-- An SMTP reply is one or more lines; every line but the last has a hyphen after its code
|
|
-- ("250-STARTTLS" then "250 HELP"). Returns the code and the joined text.
|
|
local function readReply(r)
|
|
local lines = {}
|
|
while true do
|
|
local line, err = readLine(r)
|
|
if not line then
|
|
return nil, err
|
|
end
|
|
local code, sep, text = line:match("^(%d%d%d)([ -]?)(.*)$")
|
|
if not code then
|
|
return nil, "malformed reply: " .. line
|
|
end
|
|
lines[#lines + 1] = text
|
|
if sep ~= "-" then
|
|
return tonumber(code), table.concat(lines, "\n")
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function say(conn, text)
|
|
return tcpSend(conn, text .. CRLF)
|
|
end
|
|
|
|
|
|
-- Send a command and require one of the expected codes, so every step fails at the step that
|
|
-- actually went wrong rather than three steps later.
|
|
local function expect(conn, r, command, ok, what)
|
|
if command then
|
|
say(conn, command)
|
|
end
|
|
local code, text = readReply(r)
|
|
if not code then
|
|
return nil, what .. ": " .. tostring(text)
|
|
end
|
|
for _, wanted in ipairs(ok) do
|
|
if code == wanted then
|
|
return code, text
|
|
end
|
|
end
|
|
return nil, string.format("%s: server said %d %s", what, code, (text or ""):gsub("\n.*", ""))
|
|
end
|
|
|
|
|
|
local function wrapBase64(text)
|
|
local out = {}
|
|
for i = 1, #text, B64_WRAP do
|
|
out[#out + 1] = text:sub(i, i + B64_WRAP - 1)
|
|
end
|
|
return table.concat(out, CRLF)
|
|
end
|
|
|
|
|
|
-- The message itself. The body is always base64: it makes the message binary-safe and 8-bit clean,
|
|
-- and it cannot produce a line starting with "." or one longer than the limit, so neither
|
|
-- dot-stuffing nor folding has to be got right separately.
|
|
local function buildMessage(opts, recipients)
|
|
local headers = {}
|
|
local body = tostring(opts.text or "")
|
|
|
|
headers[#headers + 1] = "From: " .. encodeHeader(opts.from)
|
|
headers[#headers + 1] = "To: " .. encodeHeader(table.concat(recipients, ", "))
|
|
headers[#headers + 1] = "Subject: " .. encodeHeader(opts.subject or "")
|
|
headers[#headers + 1] = "Date: " .. rfc5322Date(timeNow())
|
|
headers[#headers + 1] = "Message-ID: <" .. cryptoUuid() .. "@" .. (opts.messageIdDomain or addressOf(opts.from):match("@(.+)") or "localhost") .. ">"
|
|
headers[#headers + 1] = "MIME-Version: 1.0"
|
|
headers[#headers + 1] = "Content-Type: text/plain; charset=utf-8"
|
|
headers[#headers + 1] = "Content-Transfer-Encoding: base64"
|
|
for _, extra in ipairs(opts.headers or {}) do
|
|
headers[#headers + 1] = extra
|
|
end
|
|
return table.concat(headers, CRLF) .. CRLF .. CRLF .. wrapBase64(cryptoBase64Encode(body))
|
|
end
|
|
|
|
|
|
local function authenticate(conn, r, opts, extensions)
|
|
if not opts.user then
|
|
return true
|
|
end
|
|
local mechanisms = extensions:match("AUTH ([^\n]*)") or ""
|
|
if mechanisms:find("PLAIN") then
|
|
local token = cryptoBase64Encode("\0" .. opts.user .. "\0" .. opts.password)
|
|
return expect(conn, r, "AUTH PLAIN " .. token, { 235 }, "authentication")
|
|
end
|
|
if mechanisms:find("LOGIN") then
|
|
local ok, err = expect(conn, r, "AUTH LOGIN", { 334 }, "authentication")
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
ok, err = expect(conn, r, cryptoBase64Encode(opts.user), { 334 }, "authentication (user)")
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
return expect(conn, r, cryptoBase64Encode(opts.password), { 235 }, "authentication (password)")
|
|
end
|
|
return nil, "the relay offers no authentication mechanism this understands (saw: " .. mechanisms .. ")"
|
|
end
|
|
|
|
|
|
-- Send one message. opts.tls is "starttls" (default), "implicit", or "none"; "none" also needs
|
|
-- allowPlain, because sending a password or a recovery link over a plain connection should take
|
|
-- more than a typo.
|
|
function smtp.send(opts)
|
|
local mode = opts.tls or "starttls"
|
|
local port = opts.port or (mode == "implicit" and 465 or 587)
|
|
local ehloName = opts.ehlo or "localhost"
|
|
local recipients = type(opts.to) == "table" and opts.to or { opts.to }
|
|
local envelope = {}
|
|
|
|
if mode == "none" and not opts.allowPlain then
|
|
return nil, "refusing to send over a plain connection: set tls, or allowPlain if the relay is reached some other safe way"
|
|
end
|
|
for _, field in ipairs({ "host", "from", "subject" }) do
|
|
local value, err = guard(field, opts[field])
|
|
if not value then
|
|
return nil, err
|
|
end
|
|
end
|
|
for index, address in ipairs(recipients) do
|
|
local value, err = guard("recipient", address)
|
|
if not value then
|
|
return nil, err
|
|
end
|
|
recipients[index] = value
|
|
envelope[index] = addressOf(value)
|
|
end
|
|
if #recipients == 0 then
|
|
return nil, "no recipient"
|
|
end
|
|
|
|
local conn = tcpConnect(opts.host, port)
|
|
if not conn then
|
|
return nil, "could not reach " .. opts.host .. ":" .. port
|
|
end
|
|
|
|
local function fail(message)
|
|
tcpClose(conn)
|
|
return nil, message
|
|
end
|
|
|
|
-- An implicit-TLS relay expects the handshake before it says anything at all.
|
|
if mode == "implicit" then
|
|
local ok, err = pcall(tcpStartTls, conn, opts.host, { insecure = opts.insecure })
|
|
if not ok then
|
|
return fail("TLS: " .. tostring(err))
|
|
end
|
|
end
|
|
|
|
local r = reader(conn)
|
|
local code, text = expect(conn, r, nil, { 220 }, "greeting")
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
code, text = expect(conn, r, "EHLO " .. ehloName, { 250 }, "EHLO")
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
|
|
if mode == "starttls" then
|
|
if not text:find("STARTTLS") then
|
|
return fail("the relay does not offer STARTTLS, and downgrading is not on offer here")
|
|
end
|
|
code, text = expect(conn, r, "STARTTLS", { 220 }, "STARTTLS")
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
local ok, err = pcall(tcpStartTls, conn, opts.host, { insecure = opts.insecure })
|
|
if not ok then
|
|
return fail("TLS: " .. tostring(err))
|
|
end
|
|
-- The extension list from before the upgrade is not to be trusted, and AUTH is usually only
|
|
-- advertised afterwards, so EHLO is asked again over the protected connection.
|
|
code, text = expect(conn, r, "EHLO " .. ehloName, { 250 }, "EHLO after STARTTLS")
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
end
|
|
|
|
if opts.user and mode == "none" then
|
|
return fail("refusing to send credentials over a plain connection")
|
|
end
|
|
local authed, authErr = authenticate(conn, r, opts, text)
|
|
if not authed then
|
|
return fail(authErr)
|
|
end
|
|
|
|
code, text = expect(conn, r, "MAIL FROM:<" .. addressOf(opts.from) .. ">", { 250 }, "MAIL FROM")
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
for _, address in ipairs(envelope) do
|
|
code, text = expect(conn, r, "RCPT TO:<" .. address .. ">", { 250, 251 }, "RCPT TO " .. address)
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
end
|
|
code, text = expect(conn, r, "DATA", { 354 }, "DATA")
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
tcpSend(conn, buildMessage(opts, recipients) .. CRLF .. "." .. CRLF)
|
|
code, text = expect(conn, r, nil, { 250 }, "message body")
|
|
if not code then
|
|
return fail(text)
|
|
end
|
|
say(conn, "QUIT")
|
|
tcpClose(conn)
|
|
return true
|
|
end
|
|
|
|
|
|
-- Exposed for testing: the pieces above are pure functions of their arguments and are worth
|
|
-- checking without a relay in the loop.
|
|
smtp.internal = {
|
|
addressOf = addressOf,
|
|
encodeHeader = encodeHeader,
|
|
guard = guard,
|
|
rfc5322Date = rfc5322Date,
|
|
buildMessage = buildMessage,
|
|
}
|
|
|
|
return smtp
|