Production-ready SMTP email library for EZ. Supports plain, HTML, attachments, CC/BCC, and multiple recipients over pure libcurl FFI.
ez install email
| Package | Range |
|---|---|
| No dependencies. | |
SMTP email for EZ — pure libcurl FFI, no C++ glue.
email is an SMTP client library for the EZ programming language that lets you send plain-text, HTML, and multipart emails with attachments, directly over libcurl. There is no compiled C++ extension in this package — every call into the operating system goes through EZ's built-in FFI (os_load_lib / os_get_func / os_call), all funneled through a single file (src/ffi.ez) so the rest of the library stays pure EZ.
version: 2.0
.from(), .to(), .subject(), .html(), .text(), .attach(), and more.multipart/alternative-style content depending on what you set.multipart/mixed MIME parts, wrapped at the RFC-mandated 76 characters per line.SmtpConnectionError, SmtpAuthError, AttachmentError, EmailError), never a raw string.libcurl via FFI.os_load_lib, os_get_func, os_call) available.PATH:libcurl-4.dll (tried first — shipped with most EZ distributions), orlibcurl.dll (fallback name used by some Windows installs).msvcrt.dll (present on any standard Windows install) — used for temp-file fopen/fclose/remove.Platform note: the current FFI layer loads Windows DLLs (libcurl-4.dll,libcurl.dll,msvcrt.dll) by name. This release targets Windows; a POSIX build would needffi.ezto load a shared object (libcurl.so) instead.
If neither libcurl DLL can be found, the library throws a clear error at import time rather than failing silently later.
Place the package where your EZ project can reach it and import the public entry point:
use "email"
This pulls in main.ez, which internally wires up src/ffi.ez, src/errors.ez, src/message.ez, src/mime.ez, and src/client.ez. You only ever need to use "email" — the src/ files are implementation details.
use "email"
client = connect("smtp.gmail.com", 587, "me@gmail.com", "app-password")
client.send(
Message()
.from("me@gmail.com")
.to("alice@example.com")
.subject("Hello!")
.html("<h1>Hi Alice!</h1>")
.attach("report.pdf")
)
Or, for a single throwaway email without holding onto a client:
use "email"
send_quick("smtp.gmail.com", 587, "me@gmail.com", "app-password",
"me@gmail.com", "alice@example.com", "Hi", "Hello!")
connect() returns a configured SmtpClient you can reuse for multiple sends:
client = connect(server, port, username, password)
| Argument | Description |
|---|---|
server | SMTP hostname, e.g. "smtp.gmail.com" |
port | 465 (implicit SSL/SMTPS), 587 (STARTTLS), or 25 (plain) |
username | Login / email address |
password | Password or App Password |
SmtpClient picks smtps:// vs smtp:// automatically based on whether port == 465.
Message is a fluent builder — every setter returns self:
msg = Message()
.from("me@gmail.com")
.to("alice@example.com")
.cc("bob@example.com")
.bcc("audit@company.com")
.subject("Hello!")
.html("<h1>Hi Alice!</h1>")
.attach("report.pdf")
.priority("high")
.header("X-Campaign", "summer25")
Only from, to, and subject are required, plus at least one of text() / html(). SmtpClient.send() calls msg.validate() first and throws EmailError if anything required is missing — before any network activity happens.
client.send(msg)
send():
true on success, or re-throws a structured exception on failure.For scripts that only need to send a single plain-text email:
send_quick(server, port, username, password, fromAddr, toAddr, subject, body)
This is sugar for connect() + Message().from(...).to(...).subject(...).text(...) + send().
msg.attach("report.pdf")
msg.attach("C:\\Users\\me\\Documents\\invoice.pdf")
Content-Disposition header is taken from the last path segment (both \ and / separators are handled).AttachmentError is thrown with the file path and reason.multipart/mixed.msg.cc("bob@example.com")
msg.cc(["bob@example.com", "carol@example.com"]) // array form also accepted
msg.bcc("audit@company.com")
msg.replyTo("support@example.com")
to(), cc(), and bcc() all accept either a single string or an array of strings.RCPT TO list sent to the SMTP server via CURLOPT_MAIL_RCPT, but are never written into the message headers — so recipients can't see each other's BCC status by inspecting the raw message.msg.priority("high") // X-Priority: 1, Importance: High
msg.priority("normal") // X-Priority: 3 (no Importance header)
msg.priority("low") // X-Priority: 5, Importance: Low
msg.header("X-Campaign", "summer25")
msg.header("X-Mailer", "email/2.0")
Custom headers are appended after the standard headers and before the body.
SmtpClient is secure by default. These fields can be set directly on the client instance before calling send():
| Field | Default | Meaning |
|---|---|---|
useSsl | true | Enables CURLUSESSL_ALL — require SSL for the entire session |
verifyCert | true | Verifies the server's TLS certificate and hostname |
useNativeCa | true | Uses the Windows native certificate store (CURLSSLOPT_NATIVE_CA) |
caBundlePath | "" | Optional path to a custom ca-bundle.crt, used if verifyCert is on |
minTls | CURL_SSLVERSION_TLSv1_2 | Minimum negotiated TLS version |
verbose | false | Prints the full curl handshake to stdout for debugging |
client = connect("smtp.internal.corp", 25, "svc@corp", "pass")
client.verifyCert = false // ⚠ insecure — internal/self-signed servers only
client.send(msg)
Disabling verifyCert disables both peer and host verification. Only do this against trusted internal infrastructure — never over the public internet.
client.verbose = true
Prints the raw curl handshake (CURLOPT_VERBOSE) to stdout, useful for diagnosing connection or auth issues.
You can also check which libcurl DLL was actually loaded:
out backend() // "libcurl-4.dll" or "libcurl.dll"
All exceptions raised by this library are typed models, never raw strings:
| Exception | Thrown When |
|---|---|
EmailError | Generic/base failure — missing required fields, curl init failure, unmapped curl error codes |
SmtpConnectionError | libcurl can't reach the server (DNS resolution, TCP connect, or TLS handshake failure) |
SmtpAuthError | The server rejects the credentials (curl code 67) |
AttachmentError | An attached file can't be read from disk |
Every model implements toString() for easy logging:
try {
client.send(msg)
} catch (e) {
out e.toString()
// e.g. "SmtpAuthError: Authentication failed for user 'me@gmail.com'. Check your password or App Password."
}
mapCurlError() in errors.ez is what translates raw curl integer codes into the right exception type after curl_easy_perform fails.
main.ez is the public surface only. All real work lives under src/, with a strict one-directional dependency chain and a single point of contact with the operating system:
┌───────────────┐
│ main.ez │ connect() / send_quick() / backend()
└───────┬───────┘
│ use
┌────────────────┼────────────────┬───────────────┐
▼ ▼ ▼ ▼
src/client.ez src/message.ez src/mime.ez src/errors.ez
│ │ ▲
│ └───────use──────┘
│
└──────────────use────────────────► src/ffi.ez
│
▼
libcurl-4.dll / libcurl.dll
msvcrt.dll
src/ffi.ez is the only file allowed to call os_load_lib, os_get_func, or os_call. If libcurl's C API shape ever changes, this is the only file that needs to change.src/mime.ez owns all payload string-building. It never touches curl or file I/O.src/client.ez owns the curl handle and temp-file lifecycle. It never touches payload formatting.src/message.ez is a pure data builder with no I/O and no FFI at all.src/errors.ez defines the exception hierarchy every other file throws into.main.connect() constructs an SmtpClient.client.send(msg) calls msg.validate().mime.buildPayload(msg) renders the RFC 2822 string.ez_email_<clock>.tmp).easy handle is initialized and configured: URL, auth, envelope MAIL FROM / RCPT TO, TLS options, upload source.curl_easy_perform runs the SMTP conversation.CURLE_OK result is mapped to a structured exception via mapCurlError().slist freed, curl handle cleaned up, temp file deleted — regardless of success or failure.true is returned.| File | Responsibility |
|---|---|
main.ez | Public API: connect(), send_quick(), backend(). Imports and re-exports everything else. |
src/ffi.ez | Loads libcurl-4.dll/libcurl.dll and msvcrt.dll; exposes function tables (__CurlFFI, __MsvcrtFFI) and libcurl option/result constants. |
src/errors.ez | Exception models: EmailError, SmtpConnectionError, SmtpAuthError, AttachmentError; mapCurlError() translator. |
src/message.ez | Message fluent builder model: recipients, subject, body, attachments, priority, custom headers, validation. |
src/mime.ez | buildPayload() — turns a Message into an RFC 2822 wire payload, including base64 attachment encoding and MIME boundaries. |
src/client.ez | SmtpClient model — TLS configuration, curl handle lifecycle, temp-file lifecycle, error mapping on send. |
SmtpClient.send() follows a manual "always cleanup" pattern rather than relying on finally, because EZ does not run finally after give. Instead:
curlHandle, rcptSlist, and fileHandle are initialized to 0 before the try block.try is caught and stored in sendErr, not re-thrown immediately.try/catch, cleanup runs unconditionally for every handle that was actually opened.sendErr re-thrown (if one occurred).
This guarantees no leaked curl handles, no leaked slist memory, and no orphaned temp files, even when a send fails partway through.
buildPayload() supports three content strategies depending on what's set on the Message:
| Scenario | Content-Type |
|---|---|
| Plain text only | text/plain; charset=UTF-8 |
| HTML only, no attachments | text/html; charset=UTF-8 |
| Any attachment present | multipart/mixed; boundary="..." — one text/html body part, plus one application/octet-stream part per attachment |
Additional details:
Sat, 19 Jul 2026 17:05:50 +0500) using locale-independent day/month name tables, since strftime's %a/%b are locale-dependent.b64url_encode, then converted to the standard base64 alphabet (-→+, _→/) with = padding added, and wrapped at 76 characters per line.RCPT TO).libcurl-4.dll, msvcrt.dll) and the native CA store option assume a Windows target for this release.msvcrt's fopen/fread through curl's CURLOPT_READDATA. The temp filename includes a clock-based value to reduce collision risk between concurrent sends.client.verifyCert = false disables both peer and hostname verification — use only against trusted infrastructure you control.priority() only recognizes the literal strings "high", "normal", and "low"; any other value is silently ignored (no exception is thrown).MIT License
| Version | Size | Downloads | Published |
|---|---|---|---|
1.0.0 |
15.1 KB | 0 | 1 hour ago |
sha256 a39366d618657a3f572259e28badd973ceb2140c2024fd1a1c37d31a79bdff36