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 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,
SSLEngineon 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