# Writing SMTP from the RFC instead of around it

> A Kotlin/Native service has two ways to send mail — shell out to sendmail, or wrap libcurl. Both hide the protocol exactly where it has to be visible.

Page: https://kotlin.website/blog/smtp-from-the-spec · Published: 2026-03-01

On the JVM this was solved twenty years ago — Jakarta Mail, Simple Java Mail, done. On
Kotlin/Native there is nothing, and the two workarounds both fail in the same way: shelling
out to `sendmail` and wrapping libcurl each hide the protocol at precisely the points where
you need to see it.

[smtpkn](https://github.com/youndie/smtpkn) implements RFC 5321 over sockets instead.
Here are the three places where reading the specification changed the design.

## Replies are matched by counting

The obvious way to match a reply to a command is by looking at it — the code, or the text.
The specification forbids this, and it is right to.

Codes repeat. Text is server-defined and changes between releases. The only thing that
holds is **order**: replies come back in the order the commands went out, one per command,
and pipelining does not change that. So the parser counts.

This sounds pedantic until `PIPELINING` is negotiated and five commands are in flight. A
matcher that reads codes will pair the wrong reply with the wrong command exactly when the
session is busiest, and it will do it silently.

The parser also has to stream: multiline replies, enhanced status codes, arbitrary text.
There is no reading until a blank line.

## A partial refusal is a result, not an exception

Send to five recipients. Three are accepted, two are rejected. What does the function
return?

If it throws, the caller has lost which three succeeded. And now the only safe options are
to give up or to retry all five — the second of which delivers three duplicates.

So a partial refusal is a value. The result carries who was accepted and who was not, and
the caller decides. That distinction — between a retry and a duplicate — is the entire
reason the type is shaped that way, and it is invisible until the first time it costs you.

## Refuse, don't work around

A line break inside an address is not a formatting problem. It is a way to append headers
of somebody else's choosing to your message. Same for a subject that carries a newline.

Every one of those is rejected rather than sanitised, along with credentials over a
cleartext channel. Sanitising means guessing what the caller meant; refusing means they
find out at the point they made the mistake.

## The rest of it

- Submission per RFC 6409: port 587 with `STARTTLS`, port 465 with implicit TLS.
- TLS with the certificate chain **and the host name** actually verified — OpenSSL on
  Kotlin/Native, `SSLEngine` on the JVM.
- Seven SASL mechanisms: `PLAIN`, `LOGIN`, `CRAM-MD5`, `SCRAM-SHA-1`, `SCRAM-SHA-256`,
  `XOAUTH2`, `OAUTHBEARER`.
- ESMTP: `PIPELINING`, `SIZE`, `8BITMIME`, `SMTPUTF8`, `DSN`, `ENHANCEDSTATUSCODES`,
  `CHUNKING`, plus punycode for internationalised domains.
- Message building: RFC 5322 headers, `multipart/alternative`, attachments, encoded words.
- Timeouts whose defaults are the RFC minimums rather than numbers I liked.

## One platform, claimed honestly

**`linuxX64` is the platform this is built for and the only one it is claimed to work on.**
That is where every milestone closes, where the whole suite runs, and where TLS is
exercised against a real server and end to end against Mailpit and Postfix.

Other targets compile. They are not claimed, because "it compiles" and "it delivers mail
through a real MTA" are different statements and only one of them is worth putting in a
README.

---

Source: [github.com/youndie/smtpkn](https://github.com/youndie/smtpkn)
