2025-06-19 09:52:15 +02:00
|
|
|
-- furt-lua/src/smtp.lua
|
2025-06-23 08:27:59 +02:00
|
|
|
-- Universal SMTP implementation with SSL compatibility
|
|
|
|
|
-- Supports both luaossl (Arch/karl) and luasec (OpenBSD/walter)
|
2025-06-19 09:52:15 +02:00
|
|
|
-- Dragons@Work Digital Sovereignty Project
|
|
|
|
|
|
|
|
|
|
local socket = require("socket")
|
|
|
|
|
|
|
|
|
|
local SMTP = {}
|
|
|
|
|
|
2025-06-23 08:27:59 +02:00
|
|
|
-- SSL Compatibility Layer - Auto-detect available SSL library
|
|
|
|
|
local SSLCompat = {}
|
|
|
|
|
|
|
|
|
|
function SSLCompat:detect_ssl_library()
|
|
|
|
|
-- Try luaossl first (more feature-complete)
|
|
|
|
|
local success, ssl_lib = pcall(require, "ssl")
|
|
|
|
|
if success and ssl_lib and ssl_lib.wrap then
|
|
|
|
|
-- Check if it's luaossl (has more comprehensive API)
|
|
|
|
|
if ssl_lib.newcontext or type(ssl_lib.wrap) == "function" then
|
|
|
|
|
return "luaossl", ssl_lib
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Try luasec
|
|
|
|
|
local success, ssl_lib = pcall(require, "ssl")
|
|
|
|
|
if success and ssl_lib then
|
|
|
|
|
-- luasec typically has ssl.wrap function but different API
|
|
|
|
|
if ssl_lib.wrap and not ssl_lib.newcontext then
|
|
|
|
|
return "luasec", ssl_lib
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return nil, "No compatible SSL library found (tried luaossl, luasec)"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function SSLCompat:wrap_socket(sock, options)
|
|
|
|
|
local ssl_type, ssl_lib = self:detect_ssl_library()
|
|
|
|
|
|
|
|
|
|
if not ssl_type then
|
|
|
|
|
return nil, ssl_lib -- ssl_lib contains error message
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if ssl_type == "luaossl" then
|
|
|
|
|
return self:wrap_luaossl(sock, options, ssl_lib)
|
|
|
|
|
elseif ssl_type == "luasec" then
|
|
|
|
|
return self:wrap_luasec(sock, options, ssl_lib)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return nil, "Unknown SSL library type: " .. ssl_type
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function SSLCompat:wrap_luaossl(sock, options, ssl_lib)
|
|
|
|
|
-- luaossl API
|
|
|
|
|
local ssl_sock, err = ssl_lib.wrap(sock, {
|
|
|
|
|
mode = "client",
|
|
|
|
|
protocol = "tlsv1_2",
|
|
|
|
|
verify = "none" -- For self-signed certs
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if not ssl_sock then
|
|
|
|
|
return nil, "luaossl wrap failed: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- luaossl typically does handshake automatically, but explicit is safer
|
|
|
|
|
local success, err = pcall(function() return ssl_sock:dohandshake() end)
|
|
|
|
|
if not success then
|
|
|
|
|
-- Some luaossl versions don't need explicit handshake
|
|
|
|
|
-- Continue if dohandshake doesn't exist
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return ssl_sock, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function SSLCompat:wrap_luasec(sock, options, ssl_lib)
|
|
|
|
|
-- luasec API
|
|
|
|
|
local ssl_sock, err = ssl_lib.wrap(sock, {
|
|
|
|
|
protocol = "tlsv1_2",
|
|
|
|
|
mode = "client",
|
|
|
|
|
verify = "none",
|
|
|
|
|
options = "all"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if not ssl_sock then
|
|
|
|
|
return nil, "luasec wrap failed: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- luasec requires explicit handshake
|
|
|
|
|
local success, err = ssl_sock:dohandshake()
|
|
|
|
|
if not success then
|
|
|
|
|
return nil, "luasec handshake failed: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return ssl_sock, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Create SMTP instance
|
2025-06-19 09:52:15 +02:00
|
|
|
function SMTP:new(config)
|
|
|
|
|
local instance = {
|
|
|
|
|
server = config.smtp_server or "mail.dragons-at-work.de",
|
|
|
|
|
port = config.smtp_port or 465,
|
|
|
|
|
username = config.username,
|
|
|
|
|
password = config.password,
|
|
|
|
|
from_address = config.from_address or "noreply@dragons-at-work.de",
|
|
|
|
|
use_ssl = config.use_ssl or true,
|
2025-06-23 08:27:59 +02:00
|
|
|
debug = config.debug or false,
|
|
|
|
|
ssl_compat = SSLCompat
|
2025-06-19 09:52:15 +02:00
|
|
|
}
|
|
|
|
|
setmetatable(instance, self)
|
|
|
|
|
self.__index = self
|
|
|
|
|
return instance
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Base64 encoding for SMTP AUTH
|
|
|
|
|
function SMTP:base64_encode(str)
|
|
|
|
|
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
|
|
|
return ((str:gsub('.', function(x)
|
|
|
|
|
local r, b = '', x:byte()
|
|
|
|
|
for i = 8, 1, -1 do
|
|
|
|
|
r = r .. (b % 2^i - b % 2^(i-1) > 0 and '1' or '0')
|
|
|
|
|
end
|
|
|
|
|
return r;
|
|
|
|
|
end) .. '0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
|
|
|
|
|
if (#x < 6) then return '' end
|
|
|
|
|
local c = 0
|
|
|
|
|
for i = 1, 6 do
|
|
|
|
|
c = c + (x:sub(i,i) == '1' and 2^(6-i) or 0)
|
|
|
|
|
end
|
|
|
|
|
return b:sub(c+1,c+1)
|
|
|
|
|
end) .. ({ '', '==', '=' })[#str % 3 + 1])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Send SMTP command and read response
|
|
|
|
|
function SMTP:send_command(sock, command, expected_code)
|
|
|
|
|
if self.debug then
|
|
|
|
|
print("SMTP CMD: " .. (command or ""):gsub("\r\n", "\\r\\n"))
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Only send if command is not nil (for server greeting, command is nil)
|
|
|
|
|
if command then
|
|
|
|
|
local success, err = sock:send(command .. "\r\n")
|
|
|
|
|
if not success then
|
|
|
|
|
return false, "Failed to send command: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local response, err = sock:receive()
|
|
|
|
|
if not response then
|
|
|
|
|
return false, "Failed to receive response: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if self.debug then
|
|
|
|
|
print("SMTP RSP: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Handle multi-line responses (like EHLO)
|
|
|
|
|
local full_response = response
|
|
|
|
|
while response:match("^%d%d%d%-") do
|
|
|
|
|
response, err = sock:receive()
|
|
|
|
|
if not response then
|
|
|
|
|
return false, "Failed to receive multi-line response: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
if self.debug then
|
|
|
|
|
print("SMTP RSP: " .. response)
|
|
|
|
|
end
|
|
|
|
|
full_response = full_response .. "\n" .. response
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local code = response:match("^(%d+)")
|
|
|
|
|
if expected_code and code ~= tostring(expected_code) then
|
|
|
|
|
return false, "Unexpected response: " .. full_response
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return true, full_response
|
|
|
|
|
end
|
|
|
|
|
|
2025-06-23 08:27:59 +02:00
|
|
|
-- Connect to SMTP server with universal SSL support
|
2025-06-19 09:52:15 +02:00
|
|
|
function SMTP:connect()
|
|
|
|
|
-- Create socket
|
|
|
|
|
local sock, err = socket.tcp()
|
|
|
|
|
if not sock then
|
|
|
|
|
return false, "Failed to create socket: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Set timeout
|
|
|
|
|
sock:settimeout(30)
|
|
|
|
|
|
|
|
|
|
-- Connect to server
|
|
|
|
|
local success, err = sock:connect(self.server, self.port)
|
|
|
|
|
if not success then
|
|
|
|
|
return false, "Failed to connect to " .. self.server .. ":" .. self.port .. " - " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
|
2025-06-23 08:27:59 +02:00
|
|
|
-- Wrap with SSL for port 465 using compatibility layer
|
2025-06-19 09:52:15 +02:00
|
|
|
if self.use_ssl and self.port == 465 then
|
2025-06-23 08:27:59 +02:00
|
|
|
local ssl_sock, err = self.ssl_compat:wrap_socket(sock, {
|
2025-06-19 09:52:15 +02:00
|
|
|
mode = "client",
|
2025-06-23 08:27:59 +02:00
|
|
|
protocol = "tlsv1_2"
|
2025-06-19 09:52:15 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if not ssl_sock then
|
|
|
|
|
sock:close()
|
|
|
|
|
return false, "Failed to establish SSL connection: " .. (err or "unknown error")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
sock = ssl_sock
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Read server greeting
|
|
|
|
|
local success, response = self:send_command(sock, nil, 220)
|
|
|
|
|
if not success then
|
|
|
|
|
sock:close()
|
|
|
|
|
return false, "SMTP server greeting failed: " .. response
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return sock, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Send email
|
|
|
|
|
function SMTP:send_email(to_address, subject, message, from_name)
|
|
|
|
|
if not self.username or not self.password then
|
|
|
|
|
return false, "SMTP username or password not configured"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Connect to server
|
|
|
|
|
local sock, err = self:connect()
|
|
|
|
|
if not sock then
|
|
|
|
|
return false, err
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local function cleanup_and_fail(error_msg)
|
|
|
|
|
sock:close()
|
|
|
|
|
return false, error_msg
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- EHLO command
|
|
|
|
|
local success, response = self:send_command(sock, "EHLO furt-lua", 250)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("EHLO failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- AUTH LOGIN
|
|
|
|
|
local success, response = self:send_command(sock, "AUTH LOGIN", 334)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("AUTH LOGIN failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Send username (base64 encoded)
|
|
|
|
|
local username_b64 = self:base64_encode(self.username)
|
|
|
|
|
local success, response = self:send_command(sock, username_b64, 334)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("Username authentication failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Send password (base64 encoded)
|
|
|
|
|
local password_b64 = self:base64_encode(self.password)
|
|
|
|
|
local success, response = self:send_command(sock, password_b64, 235)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("Password authentication failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- MAIL FROM
|
|
|
|
|
local mail_from = "MAIL FROM:<" .. self.from_address .. ">"
|
|
|
|
|
local success, response = self:send_command(sock, mail_from, 250)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("MAIL FROM failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- RCPT TO
|
|
|
|
|
local rcpt_to = "RCPT TO:<" .. to_address .. ">"
|
|
|
|
|
local success, response = self:send_command(sock, rcpt_to, 250)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("RCPT TO failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- DATA command
|
|
|
|
|
local success, response = self:send_command(sock, "DATA", 354)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("DATA command failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Build email message
|
|
|
|
|
local display_name = from_name or "Furt Contact Form"
|
|
|
|
|
local email_content = string.format(
|
|
|
|
|
"From: %s <%s>\r\n" ..
|
|
|
|
|
"To: <%s>\r\n" ..
|
|
|
|
|
"Subject: %s\r\n" ..
|
|
|
|
|
"Date: %s\r\n" ..
|
|
|
|
|
"Content-Type: text/plain; charset=UTF-8\r\n" ..
|
|
|
|
|
"\r\n" ..
|
|
|
|
|
"%s\r\n" ..
|
|
|
|
|
".",
|
|
|
|
|
display_name,
|
|
|
|
|
self.from_address,
|
|
|
|
|
to_address,
|
|
|
|
|
subject,
|
|
|
|
|
os.date("%a, %d %b %Y %H:%M:%S %z"),
|
|
|
|
|
message
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
-- Send email content
|
|
|
|
|
local success, response = self:send_command(sock, email_content, 250)
|
|
|
|
|
if not success then
|
|
|
|
|
return cleanup_and_fail("Email sending failed: " .. response)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- QUIT
|
|
|
|
|
self:send_command(sock, "QUIT", 221)
|
|
|
|
|
sock:close()
|
|
|
|
|
|
|
|
|
|
return true, "Email sent successfully"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return SMTP
|
|
|
|
|
|