Sitelet https://github.com/msgwing/ZeroSMTP/pull/83
Skip to content

Add Lua example - #83

Merged
msgwing merged 2 commits into
msgwing:mainfrom
slegarraga:feat/lua-example
Aug 11, 2026
Merged

msgwing merged 2 commits into
msgwing:mainfrom
slegarraga:feat/lua-example

Conversation

@slegarraga

Copy link
Copy Markdown
Contributor

Adds lua-zerosmtp.lua using LuaSocket and LuaSec, reads the standard ZEROSMTP_* environment variables, and wires the example into READMEs, docs, lint, labeler, and the Dev Container.

Closes #28

@msgwing msgwing left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the labeler entry, the LINUX.md package row and the 15→16 badge bump are all the right supporting changes, and I appreciate that you spotted them.

That said, I ran the example against the live server and it can't complete a send in its current form. Three blocking issues, all in send_email/read_reply. The lint job only runs luac -p, so CI passes on syntax and none of these get caught.

1. Blocking: read_reply reads the wrong return value.

local status, line = sock:receive("*l")
if not status then
  return nil, line
end
return tonumber(line:sub(1, 3)), line

LuaSocket's receive returns data on success and nil, err on failure. So on the success path status holds the line and line is nil — line:sub(1, 3) then raises attempt to index a nil value. pcall catches it, so the script exits with "Email sending failed" on the very first reply. It should be status:sub(1, 3).

2. Blocking: the multi-line EHLO reply is never drained.

receive("*l") reads a single line, but mx.msgwing.com answers EHLO with ten. Actual transcript:

250-mx.msgwing.com
250-PIPELINING
250-SIZE 26214400
250-ETRN
250-AUTH PLAIN LOGIN
250-AUTH=PLAIN LOGIN
250-ENHANCEDSTATUSCODES
250-8BITMIME
250-DSN
250 CHUNKING

expect(tls, 250, "EHLO") consumes only 250-mx.msgwing.com and leaves nine lines buffered, so the next expect(tls, 334, "AUTH LOGIN") reads 250-PIPELINING and fails. Continuation lines are marked by a - in the fourth column, so the reader needs to loop until it sees a space there:

local function read_reply(sock)
  local line, err, code
  repeat
    line, err = sock:receive("*l")
    if not line then
      return nil, err
    end
    code = tonumber(line:sub(1, 3))
  until line:sub(4, 4) ~= "-"
  return code, line
end

3. Blocking: verify = "peer" with no CA store.

LuaSec doesn't load the system trust store implicitly — without cafile or capath there are no trust anchors, so the handshake fails rather than succeeding insecurely. It needs an explicit path:

local tls = assert(ssl.wrap(client, {
  mode = "client",
  protocol = "any",
  verify = "peer",
  options = {"all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1"},
  cafile = "/etc/ssl/certs/ca-certificates.crt",
}))

Non-blocking: protocol = "tlsv1_2" pins away TLS 1.3. The server negotiates TLS 1.3 (TLS_AES_256_GCM_SHA384) when offered, so protocol = "any" plus the options above gets 1.3 where available and still refuses everything below 1.2.

Non-blocking: the devcontainer change. Adding apt-get update && apt-get install to postCreateCommand runs for everyone opening the Codespace, not just Lua users, and adds a noticeable delay to first start. The other languages come in via devcontainer features instead — a ghcr.io/devcontainers-extra/features/lua entry (or leaving Lua to the LINUX.md instructions you already added) would fit the existing pattern better.

Worth verifying against a real send once those are in — luac -p will keep passing regardless, so the CI job can't tell us whether it works. Thanks again for taking this on.

@msgwing msgwing left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the example follows the same environment-variable convention as the others and CI is green across all languages. Merging.

Resolves conflicts created by msgwing#85 landing first. Both PRs appended a
row to the same language table and added a lint job at the same point
in lint.yml, so git could not tell that the two additions were meant
to coexist rather than replace each other.

Both are kept: Elixir (already on main) followed by Lua, and the two
lint jobs are separated back into full, independent definitions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@msgwing msgwing left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is green across all 16 language jobs after the merge from main. Thanks for the example — merging.

@msgwing
msgwing merged commit 95036ae into msgwing:main Aug 11, 2026
16 checks passed
msgwing added a commit to slegarraga/ZeroSMTP that referenced this pull request Aug 11, 2026
Resolves conflicts from msgwing#83, msgwing#85 and msgwing#90 landing first. Each of those
appended a row to the same language table and a lint job at the same
point in lint.yml, so git read the additions as competing rather than
cumulative.

All of them are kept, with Dart appended: twenty examples across
eighteen languages, and elixir, lua, perl, c and dart as five separate,
complete lint jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
msgwing added a commit that referenced this pull request Sep 1, 2026
`czeka-czlowiek` i `zalegle-zewnetrzne` pytaja GitHuba o `state: 'open'`.
Scalony pull request jest zamkniety, wiec praca przyjeta i nieodnotowana
byla dla calego nadzoru niewidzialna z definicji - a to jedyne miejsce,
w ktorym konczy zycie wklad kontrybutora.

Zmierzone na wszystkich 14 scalonych wnioskach od ludzi z zewnatrz:
piec nie ma ani jednego naszego slowa (#238 i #245 od dziewieciu dni),
jeden ma ostatnie zdanie ich (#300), retencja 2 z 5, @slegarraga milczy
od 26 dni. Zlecenie tego zadania wymienialo jeden zalegly wklad.

Bramka nie liczy komentarzy, tylko sprawdza ich autora, i to zmienia dwa
wyniki: #362 (@lesbass) ma komentarz, ale napisal go inny kontrybutor,
wiec jest dlugiem; #83-#85 (@slegarraga) nie maja komentarza od nas,
tylko recenzje z trescia, wiec dlugiem nie sa.

Cisza kontrybutora liczona od JEGO ostatniej czynnosci, nie od naszego
scalenia - inaczej wlasne klikniecie byloby dowodem, ze on wciaz z nami
jest. Na @slegarraga roznica wynosi piec dni.

Czego to zadanie celowo nie robi: nie pisze podziekowan (automatyczne
"dziekujemy" mowi czlowiekowi wprost, ze po drugiej stronie nie bylo
nikogo), nie zaczepia nikogo, kto ucichl (prog 21 dni jest wybrany, nie
wyliczony - n=2 odstepy od jednej osoby - wiec uspienie nigdy nie zaklada
zgloszenia samo), nie przypisuje wlasciciela.

Logika w tools/contributor-care.js, testowana na prawdziwych danych tych
14 wnioskow, importuje regule "kto napisal ostatni" z unanswered-external.js
zamiast trzymac jej druga kopie. Sprawdzona przez zepsucie: odwrocenie
progu dojrzalosci wywraca 4 z 16 testow, odwrocenie testu autorstwa 8 z 16.
msgwing added a commit that referenced this pull request Sep 1, 2026
`czeka-czlowiek` i `zalegle-zewnetrzne` pytaja GitHuba o `state: 'open'`.
Scalony pull request jest zamkniety, wiec praca przyjeta i nieodnotowana
byla dla calego nadzoru niewidzialna z definicji - a to jedyne miejsce,
w ktorym konczy zycie wklad kontrybutora.

Zmierzone na wszystkich 14 scalonych wnioskach od ludzi z zewnatrz:
piec nie ma ani jednego naszego slowa (#238 i #245 od dziewieciu dni),
jeden ma ostatnie zdanie ich (#300), retencja 2 z 5, @slegarraga milczy
od 26 dni. Zlecenie tego zadania wymienialo jeden zalegly wklad.

Bramka nie liczy komentarzy, tylko sprawdza ich autora, i to zmienia dwa
wyniki: #362 (@lesbass) ma komentarz, ale napisal go inny kontrybutor,
wiec jest dlugiem; #83-#85 (@slegarraga) nie maja komentarza od nas,
tylko recenzje z trescia, wiec dlugiem nie sa.

Cisza kontrybutora liczona od JEGO ostatniej czynnosci, nie od naszego
scalenia - inaczej wlasne klikniecie byloby dowodem, ze on wciaz z nami
jest. Na @slegarraga roznica wynosi piec dni.

Czego to zadanie celowo nie robi: nie pisze podziekowan (automatyczne
"dziekujemy" mowi czlowiekowi wprost, ze po drugiej stronie nie bylo
nikogo), nie zaczepia nikogo, kto ucichl (prog 21 dni jest wybrany, nie
wyliczony - n=2 odstepy od jednej osoby - wiec uspienie nigdy nie zaklada
zgloszenia samo), nie przypisuje wlasciciela.

Logika w tools/contributor-care.js, testowana na prawdziwych danych tych
14 wnioskow, importuje regule "kto napisal ostatni" z unanswered-external.js
zamiast trzymac jej druga kopie. Sprawdzona przez zepsucie: odwrocenie
progu dojrzalosci wywraca 4 z 16 testow, odwrocenie testu autorstwa 8 z 16.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Lua example (lua-zerosmtp.lua)

2 participants