tracker
All repositories: gitoria
51.8 KB
// hl:smtp plugin — an SMTP submission client (libsmtp.so, dlopen'd by the runtime)//// Mission 137. Sending mail, and nothing else: there is no receiving (IMAP/POP),// no DKIM signing and no queue-with-retry here — see plugins/smtp/README.md for// why each of those is somewhere else's job.//// SHAPE// hl_smtp_open(host, port, options) → Number mailer id// hl_smtp_send(id, message) → Number job id, or an hl_error REFUSAL// hl_smtp_results(id) → the result source (iterator + wake fd)// hl_smtp_close(id) → null//// THE SPLIT BETWEEN THE THREADS is the whole design://// • `send()` runs on the Hybriel thread and does everything that can FAIL// LOCALLY — validating the addresses, refusing a header injection, rendering// the RFC 5322 message. Those are located errors at the caller's own line,// which they can only be if they happen before the job leaves.// • the WORKER thread does the conversation (connect, EHLO, STARTTLS, AUTH,// MAIL/RCPT/DATA, QUIT). Nothing on the Hybriel thread waits for it: the// result goes onto a queue and the queue's eventfd is rung, which is mission// 125's bell — the event loop is parked in one `epoll_wait` over every// source's fd and comes back with the answer.//// So a server that sends a mail keeps serving while the mail is in flight, and a// script that only sends mail drains the same source with `for (r of m.results())`.// One source, the two consumptions the runtime already gives every plugin source// (`hl:http1`'s server iterator has been both since mission 012/052).//// TLS rides plugins/http/tls_common.zig — the SAME OpenSSL layer the servers use,// through the client half mission 137 added to it. There is no second TLS here.const std = @import("std");const api = @import("plugin_api");const http = @import("http_common");const tls = @import("tls_common");const HlValue = api.HlValue;const HlObject = api.HlObject;const HlField = api.HlField;const HlIterator = api.HlIterator;const HlString = api.HlString;const linux = std.os.linux;const c = std.c;const PthreadMutex = c.pthread_mutex_t;const PthreadCond = c.pthread_cond_t;// Per-mailer state is touched by two threads, so the thread-safe production// allocator is the only correct choice — the same reasoning as the HTTP plugins.const allocator = std.heap.smp_allocator;fn mutexLock(m: *PthreadMutex) void {_ = c.pthread_mutex_lock(m);}fn mutexUnlock(m: *PthreadMutex) void {_ = c.pthread_mutex_unlock(m);}fn condSignal(cnd: *PthreadCond) void {_ = c.pthread_cond_signal(cnd);}fn condBroadcast(cnd: *PthreadCond) void {_ = c.pthread_cond_broadcast(cnd);}fn condWait(cnd: *PthreadCond, m: *PthreadMutex) void {_ = c.pthread_cond_wait(cnd, m);}/// Direct syscall for stderr — std.debug.print pulls in std.Progress, whose/// global state is ABI-incompatible when a .so is loaded into a differently/// built binary (the same reason tls_common.zig has its own).fn logMsg(msg: []const u8) void {_ = linux.write(2, msg.ptr, msg.len);}// ── REFUSALS ────────────────────────────────────────────────────────────────//// An hl_error returned at the top level of a plugin call becomes a LOCATED// runtime error at the `__native` call site and is offered to `on Error(e)`// first (plugin_loader / native_bridge). That is the channel every local refusal// here uses, so `m.send(…)` with a newline in the subject points at the line the// author wrote and is catchable exactly like any other plugin failure.//// The text is rendered into a module buffer: a plugin call never yields, and the// runtime dupes the string into its own tracker the instant the call returns.var err_buf: [1024]u8 = undefined;fn refuse(comptime fmt: []const u8, args: anytype) HlValue {const s = std.fmt.bufPrint(&err_buf, fmt, args) catch "hl:smtp refused the message";return api.makeError(s);}// ── ARGUMENT READING ────────────────────────────────────────────────────────fn fieldOf(v: HlValue, key: []const u8) ?HlValue {if (v.type != .hl_object) return null;const obj = v.data.object;for (obj.fields[0..obj.field_count]) |f| {if (std.mem.eql(u8, f.key.ptr[0..f.key.len], key)) return f.value;}return null;}fn strField(v: HlValue, key: []const u8) ?[]const u8 {const f = fieldOf(v, key) orelse return null;if (f.type != .hl_string) return null;return f.data.string.ptr[0..f.data.string.len];}fn boolField(v: HlValue, key: []const u8, dflt: bool) bool {const f = fieldOf(v, key) orelse return dflt;return switch (f.type) {.hl_bool => f.data.boolean,else => dflt,};}fn numField(v: HlValue, key: []const u8, dflt: f64) f64 {const f = fieldOf(v, key) orelse return dflt;if (f.type != .hl_number) return dflt;return f.data.number;}/// THE SENDER'S ADDRESS, under either name.////// `from` is a RESERVED WORD in Hybriel (`import X from './x.hl'`), so/// `{ from = "a@b" }` does not parse and `msg.from` is not a member access./// Rather than force every caller to write the quoted-key form, both spellings/// are read here: `"from" = …` (the quoted key, which parses fine and is what/// mission 137's brief names) and `sender = …` (the bare-identifier form, for a/// caller who would rather not quote). Same field, one meaning; the quoted one/// wins if a message somehow carries both.fn senderField(v: HlValue) ?[]const u8 {if (strField(v, "from")) |s| return s;return strField(v, "sender");}fn dupOpt(s: ?[]const u8) ?[]u8 {const src = s orelse return null;if (src.len == 0) return null;return allocator.dupe(u8, src) catch null;}// ── CONFIGURATION ───────────────────────────────────────────────────────────const TlsMode = enum { starttls, implicit, none };const AuthMode = enum { auto, plain, login, none };// ── THE JOB AND ITS RESULT ──────────────────────────────────────────────────const Job = struct {id: u32,from: []u8,/// Envelope recipients — every RCPT TO the conversation issues.rcpt: [][]u8,/// The rendered RFC 5322 message: CRLF line endings, dot-stuffed, WITHOUT/// the terminating ".". Built on the Hybriel thread by `send`.data: []u8,fn deinit(self: *Job) void {allocator.free(self.from);for (self.rcpt) |r| allocator.free(r);allocator.free(self.rcpt);allocator.free(self.data);}};const Result = struct {id: u32,ok: bool,/// The last SMTP reply code the conversation saw (0 = never got one).code: u16,/// Which step answered: "connect", "greeting", "ehlo", "starttls", "auth",/// "mail", "rcpt", "data", "body", "quit", "done".stage: []const u8,/// The server's own text, or this plugin's reason for giving up.message: []u8,/// Did the conversation run inside TLS by the time DATA was sent?secure: bool,rcpt: [][]u8,};// ── THE MAILER ──────────────────────────────────────────────────────────────const Mailer = struct {id: u32,host: []u8,port: u16,user: ?[]u8,pass: ?[]u8,from: []u8,helo: []u8,tls_mode: TlsMode,auth_mode: AuthMode,ca_file: ?[]u8,verify: bool,/// Send AUTH over a connection that never became TLS. OFF by default: the/// credentials would be on the wire in the clear, and a default that leaks/// them is not a default, it is a bug with a setting.allow_insecure_auth: bool,timeout_ms: u32,mutex: PthreadMutex,/// The worker parks here for the next job; the drain parks here for the next/// result. One lock covers both queues and `outstanding`, which is what makes/// "no results and nothing outstanding" a single consistent observation.condvar: PthreadCond,jobs: std.ArrayListUnmanaged(Job) = .empty,results: std.ArrayListUnmanaged(Result) = .empty,/// Jobs accepted and not yet answered. The blocking drain ends when this is/// zero and the result queue is empty — that is what lets a script that only/// sends mail terminate.outstanding: usize = 0,next_job: u32 = 1,stop: bool = false,/// Mission 125's bell for the event loop. Rung whenever a result becomes/// visible; over-ringing is free, a missed ring is the only bug there is.wake_fd: i32 = -1,worker: ?std.Thread = null,/// The source object is made ONCE: two iterators over one queue would each/// steal half the results.source: ?*HlIterator = null,registered: bool = false,fn enqueue(self: *Mailer, job: Job) void {mutexLock(&self.mutex);defer mutexUnlock(&self.mutex);self.jobs.append(allocator, job) catch return;self.outstanding += 1;condBroadcast(&self.condvar);}fn takeJob(self: *Mailer) ?Job {mutexLock(&self.mutex);defer mutexUnlock(&self.mutex);while (self.jobs.items.len == 0 and !self.stop) {condWait(&self.condvar, &self.mutex);}if (self.jobs.items.len == 0) return null;return self.jobs.orderedRemove(0);}fn publish(self: *Mailer, result: Result) void {mutexLock(&self.mutex);self.results.append(allocator, result) catch {};if (self.outstanding > 0) self.outstanding -= 1;condBroadcast(&self.condvar);// Rung while the append is still under the lock — the loop drains the// eventfd counter only immediately AFTER a wake, never before blocking,// so a bell rung any time after that drain leaves the level up and the// next epoll_wait returns at once (native/src/loop_wait.zig).http.ringWake(self.wake_fd);mutexUnlock(&self.mutex);}fn tryTake(self: *Mailer) ?Result {mutexLock(&self.mutex);defer mutexUnlock(&self.mutex);if (self.results.items.len == 0) return null;return self.results.orderedRemove(0);}/// Blocking take for the drain form. `null` means DONE, not "nothing yet":/// every accepted job has been answered and its answer taken.fn take(self: *Mailer) ?Result {mutexLock(&self.mutex);defer mutexUnlock(&self.mutex);while (self.results.items.len == 0 and self.outstanding > 0 and !self.stop) {condWait(&self.condvar, &self.mutex);}if (self.results.items.len == 0) return null;return self.results.orderedRemove(0);}};var mailers: std.ArrayListUnmanaged(*Mailer) = .empty;var mailers_mutex: PthreadMutex = c.PTHREAD_MUTEX_INITIALIZER;var next_mailer_id: u32 = 1;fn mailerById(id: u32) ?*Mailer {mutexLock(&mailers_mutex);defer mutexUnlock(&mailers_mutex);for (mailers.items) |m| {if (m.id == id) return m;}return null;}// ── HEADER INJECTION IS A REFUSAL, NOT AN ESCAPE ────────────────────────────//// A CR or an LF inside a `to`, a `from` or a `subject` ends the header and// starts a new one — that is the whole of SMTP header injection, and it is how a// contact form becomes an open relay (`subject = "hi\r\nBcc: everyone@…"`).// Stripping the characters silently would deliver a mail the author did not// write; escaping them is not defined for a header field. So it is a located// refusal, at the caller's line, naming the field and the character.fn checkHeaderValue(field: []const u8, value: []const u8) ?HlValue {for (value, 0..) |ch, i| {if (ch == '\r' or ch == '\n') {return refuse("hl:smtp refused the message: {s} contains a {s} at byte {d} — a line break in a header field is how mail headers are injected, so it is never sent and never stripped",.{ field, if (ch == '\r') "carriage return" else "line feed", i },);}if (ch == 0) {return refuse("hl:smtp refused the message: {s} contains a NUL byte at byte {d}",.{ field, i },);}}return null;}/// An envelope address additionally may not carry the delimiters the envelope/// itself is made of: `MAIL FROM:<a@b>` is a command, and a `<`, `>` or a space/// inside the address would rewrite it.fn checkAddress(field: []const u8, value: []const u8) ?HlValue {if (checkHeaderValue(field, value)) |e| return e;if (value.len == 0) {return refuse("hl:smtp refused the message: {s} is empty", .{field});}for (value) |ch| {if (ch == '<' or ch == '>' or ch == ' ' or ch == '\t') {return refuse("hl:smtp refused the message: the address '{s}' in {s} contains '{c}' — an envelope address is written between angle brackets and may not carry them, or whitespace",.{ value, field, ch },);}}if (std.mem.indexOfScalar(u8, value, '@') == null) {return refuse("hl:smtp refused the message: the address '{s}' in {s} has no '@'",.{ value, field },);}return null;}// ── MESSAGE RENDERING ───────────────────────────────────────────────────────const b64 = std.base64.standard.Encoder;fn b64Alloc(src: []const u8) ![]u8 {const out = try allocator.alloc(u8, b64.calcSize(src.len));_ = b64.encode(out, src);return out;}fn isAscii(s: []const u8) bool {for (s) |ch| {if (ch >= 0x80) return false;}return true;}const day_names = [_][]const u8{ "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed" };const month_names = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };/// RFC 5322 §3.3 date-time, always +0000. A mail without a Date is not a mail —/// receivers add their own and it reads as a forgery.fn writeDate(w: *std.ArrayListUnmanaged(u8)) !void {var ts: linux.timespec = undefined;_ = linux.clock_gettime(linux.CLOCK.REALTIME, &ts);const secs: u64 = @intCast(@max(@as(i64, ts.sec), 0));const es = std.time.epoch.EpochSeconds{ .secs = secs };const yd = es.getEpochDay().calculateYearDay();const md = yd.calculateMonthDay();const ds = es.getDaySeconds();// 1970-01-01 was a Thursday, which is why day_names starts there.const dow = day_names[@intCast(es.getEpochDay().day % 7)];var buf: [64]u8 = undefined;const line = try std.fmt.bufPrint(&buf, "Date: {s}, {d:0>2} {s} {d} {d:0>2}:{d:0>2}:{d:0>2} +0000\r\n", .{dow,@as(u32, md.day_index) + 1,month_names[md.month.numeric() - 1],yd.year,ds.getHoursIntoDay(),ds.getMinutesIntoHour(),ds.getSecondsIntoMinute(),});try w.appendSlice(allocator, line);}fn randomHex(out: []u8) void {var raw: [32]u8 = undefined;const need = (out.len + 1) / 2;if (linux.getrandom(&raw, need, 0) != need) {@memset(out, '0');return;}const hex = "0123456789abcdef";for (out, 0..) |*ch, i| {const byte = raw[i / 2];const nib: u8 = if (i % 2 == 0) (byte >> 4) else (byte & 0x0f);ch.* = hex[nib];}}/// A header value that is not pure ASCII becomes an RFC 2047 encoded-word./// Writing raw UTF-8 into a header is only legal with SMTPUTF8 negotiated, which/// this client does not do — so an umlaut in a subject would arrive as mojibake.fn appendHeader(w: *std.ArrayListUnmanaged(u8), name: []const u8, value: []const u8) !void {try w.appendSlice(allocator, name);try w.appendSlice(allocator, ": ");if (isAscii(value)) {try w.appendSlice(allocator, value);} else {const enc = try b64Alloc(value);defer allocator.free(enc);try w.appendSlice(allocator, "=?UTF-8?B?");try w.appendSlice(allocator, enc);try w.appendSlice(allocator, "?=");}try w.appendSlice(allocator, "\r\n");}/// CRLF DISCIPLINE AND DOT-STUFFING, in one pass over the assembled message.////// SMTP's line terminator is CRLF and its end-of-data marker is a line holding/// exactly ".", so a body line that legitimately begins with "." must be sent as/// ".." (RFC 5321 §4.5.2) or it truncates the mail — and a lone LF, which every/// text a program builds is full of, is not a line ending at all on the wire.fn appendDotStuffed(w: *std.ArrayListUnmanaged(u8), body: []const u8) !void {var at_line_start = true;var i: usize = 0;while (i < body.len) : (i += 1) {const ch = body[i];if (ch == '\r') {// Normalise CR and CRLF alike to one CRLF; a bare CR is a line// ending in nothing that talks SMTP.if (i + 1 < body.len and body[i + 1] == '\n') i += 1;try w.appendSlice(allocator, "\r\n");at_line_start = true;continue;}if (ch == '\n') {try w.appendSlice(allocator, "\r\n");at_line_start = true;continue;}if (at_line_start and ch == '.') try w.append(allocator, '.');try w.append(allocator, ch);at_line_start = false;}if (!at_line_start) try w.appendSlice(allocator, "\r\n");}// ── THE CONNECTION ──────────────────────────────────────────────────────────const Conn = struct {fd: i32 = -1,ssl: ?*tls.SSL = null,ctx: ?tls.TlsContext = null,buf: [4096]u8 = undefined,len: usize = 0,pos: usize = 0,fn writeAll(self: *Conn, bytes: []const u8) bool {if (self.ssl) |ssl| {const ctx = &(self.ctx orelse return false);return ctx.writeAll(ssl, bytes) == bytes.len;}var off: usize = 0;while (off < bytes.len) {const rc = linux.write(self.fd, bytes.ptr + off, bytes.len - off);const n: isize = @bitCast(rc);if (n <= 0) return false;off += @intCast(n);}return true;}fn fill(self: *Conn) bool {if (self.ssl) |ssl| {const ctx = &(self.ctx orelse return false);const n = ctx.read(ssl, self.buf[0..]);if (n <= 0) return false;self.len = @intCast(n);self.pos = 0;return true;}const rc = linux.read(self.fd, &self.buf, self.buf.len);const n: isize = @bitCast(rc);if (n <= 0) return false;self.len = @intCast(n);self.pos = 0;return true;}/// One CRLF-terminated reply line, without its terminator, appended into/// `out` (which is cleared first). `false` = the peer went away or timed out.fn readLine(self: *Conn, out: *std.ArrayListUnmanaged(u8)) bool {out.clearRetainingCapacity();while (true) {if (self.pos >= self.len) {if (!self.fill()) return false;}const ch = self.buf[self.pos];self.pos += 1;if (ch == '\n') {if (out.items.len > 0 and out.items[out.items.len - 1] == '\r') {_ = out.pop();}return true;}out.append(allocator, ch) catch return false;if (out.items.len > 65536) return false;}}fn close(self: *Conn) void {if (self.ssl) |ssl| {if (self.ctx) |*ctx| ctx.shutdownAndFree(ssl);self.ssl = null;}if (self.ctx) |*ctx| {ctx.deinit();self.ctx = null;}if (self.fd >= 0) {_ = linux.close(self.fd);self.fd = -1;}}};/// A complete SMTP reply: the code, and every line's text joined with "; ".const Reply = struct {code: u16 = 0,text: std.ArrayListUnmanaged(u8) = .empty,fn deinit(self: *Reply) void {self.text.deinit(allocator);}};/// Read a reply, following RFC 5321 §4.2's multiline form (`250-…` continues,/// `250 …` ends). Returns false if the connection died mid-reply.fn readReply(conn: *Conn, reply: *Reply) bool {reply.text.clearRetainingCapacity();reply.code = 0;var line: std.ArrayListUnmanaged(u8) = .empty;defer line.deinit(allocator);var first = true;while (true) {if (!conn.readLine(&line)) return false;const s = line.items;if (s.len < 3) return false;const code = std.fmt.parseInt(u16, s[0..3], 10) catch return false;if (first) {reply.code = code;first = false;}const rest = if (s.len > 4) s[4..] else "";if (reply.text.items.len > 0) reply.text.appendSlice(allocator, "; ") catch {};reply.text.appendSlice(allocator, rest) catch {};if (s.len == 3 or s[3] != '-') return true;}}fn command(conn: *Conn, reply: *Reply, parts: []const []const u8) bool {for (parts) |p| {if (!conn.writeAll(p)) return false;}if (!conn.writeAll("\r\n")) return false;return readReply(conn, reply);}/// Does the EHLO capability list advertise `name`? Case-insensitive, and/// anchored on a word boundary so "AUTH" does not match "AUTHOR".fn advertises(caps: []const u8, name: []const u8) bool {var i: usize = 0;while (i + name.len <= caps.len) : (i += 1) {if (std.ascii.eqlIgnoreCase(caps[i .. i + name.len], name)) {const before_ok = i == 0 or !std.ascii.isAlphanumeric(caps[i - 1]);const after = i + name.len;const after_ok = after >= caps.len or !std.ascii.isAlphanumeric(caps[after]);if (before_ok and after_ok) return true;}}return false;}// ── DIALLING ────────────────────────────────────────────────────────────────/// Connect to host:port with `timeout_ms` bounding BOTH the connect and every/// later read/write. A blocking connect(2) ignores SO_SNDTIMEO, so the connect/// is made non-blocking and polled; the socket goes back to blocking afterwards/// and the two socket timeouts carry the rest of the conversation.fn dial(host: []const u8, port: u16, timeout_ms: u32, reason: *std.ArrayListUnmanaged(u8)) ?i32 {// libc's resolver, not a hand-rolled one: /etc/hosts, /etc/resolv.conf,// nsswitch and IPv6 are the host's configuration, and a mail client that// answers differently from every other program on the machine is a bug.var host_z_buf: [256]u8 = undefined;if (host.len >= host_z_buf.len) {reason.appendSlice(allocator, "the host name is too long") catch {};return null;}@memcpy(host_z_buf[0..host.len], host);host_z_buf[host.len] = 0;var port_buf: [8]u8 = undefined;const port_s = std.fmt.bufPrintZ(&port_buf, "{d}", .{port}) catch {reason.appendSlice(allocator, "bad port") catch {};return null;};const hints = c.addrinfo{.flags = .{},.family = c.AF.UNSPEC,.socktype = c.SOCK.STREAM,.protocol = 0,.addrlen = 0,.canonname = null,.addr = null,.next = null,};var res: ?*c.addrinfo = null;const rc_ai = c.getaddrinfo(@ptrCast(&host_z_buf), port_s.ptr, &hints, &res);if (rc_ai != @as(c.EAI, @enumFromInt(0)) or res == null) {reason.appendSlice(allocator, "could not resolve the host") catch {};return null;}defer c.freeaddrinfo(res.?);var last: []const u8 = "connection refused";var cursor: ?*c.addrinfo = res;while (cursor) |ai| : (cursor = ai.next) {const sa = ai.addr orelse continue;const rc = linux.socket(@intCast(ai.family), linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0);const fd: i32 = @intCast(@as(isize, @bitCast(rc)));if (fd < 0) continue;const flags_rc = linux.fcntl(fd, linux.F.GETFL, @as(usize, 0));var oflags: linux.O = @bitCast(@as(u32, @truncate(flags_rc)));oflags.NONBLOCK = true;_ = linux.fcntl(fd, linux.F.SETFL, @as(usize, @as(u32, @bitCast(oflags))));const crc = linux.connect(fd, @ptrCast(sa), ai.addrlen);const cerr: isize = @bitCast(crc);var connected = cerr == 0;if (!connected) {// A negative return is -errno; INPROGRESS is the non-blocking// connect's ordinary answer, not a failure.const e: linux.E = @enumFromInt(@as(u16, @intCast(-cerr)));if (e != .INPROGRESS and e != .INTR) {last = "connection refused";_ = linux.close(fd);continue;}var pfd = [_]linux.pollfd{.{ .fd = fd, .events = linux.POLL.OUT, .revents = 0 }};const prc = linux.poll(&pfd, 1, @intCast(timeout_ms));const pn: isize = @bitCast(prc);if (pn <= 0) {last = "the connection attempt timed out";_ = linux.close(fd);continue;}var soerr: i32 = 0;var slen: linux.socklen_t = @sizeOf(i32);_ = linux.getsockopt(fd, linux.SOL.SOCKET, linux.SO.ERROR, @ptrCast(&soerr), &slen);if (soerr != 0) {last = "connection refused";_ = linux.close(fd);continue;}connected = true;}oflags.NONBLOCK = false;_ = linux.fcntl(fd, linux.F.SETFL, @as(usize, @as(u32, @bitCast(oflags))));const tv = linux.timeval{.sec = @intCast(timeout_ms / 1000),.usec = @intCast((timeout_ms % 1000) * 1000),};_ = linux.setsockopt(fd, linux.SOL.SOCKET, linux.SO.RCVTIMEO, @ptrCast(&tv), @sizeOf(linux.timeval));_ = linux.setsockopt(fd, linux.SOL.SOCKET, linux.SO.SNDTIMEO, @ptrCast(&tv), @sizeOf(linux.timeval));return fd;}reason.appendSlice(allocator, last) catch {};return null;}// ── THE CONVERSATION ────────────────────────────────────────────────────────const Outcome = struct {ok: bool,code: u16,stage: []const u8,message: []u8,secure: bool,};fn own(text: []const u8) []u8 {return allocator.dupe(u8, text) catch allocator.alloc(u8, 0) catch unreachable;}fn fail(stage: []const u8, code: u16, secure: bool, text: []const u8) Outcome {return .{ .ok = false, .code = code, .stage = stage, .message = own(text), .secure = secure };}fn runJob(m: *Mailer, job: *const Job) Outcome {var conn = Conn{};defer conn.close();var reason: std.ArrayListUnmanaged(u8) = .empty;defer reason.deinit(allocator);const fd = dial(m.host, m.port, m.timeout_ms, &reason) orelsereturn fail("connect", 0, false, reason.items);conn.fd = fd;var secure = false;if (m.tls_mode == .implicit) {const ctx = tls.TlsContext.initClient(.{.ca_file = if (m.ca_file) |ca| ca else null,.verify = m.verify,.tag = "smtp",}) orelse return fail("connect", 0, false, "TLS is unavailable (no usable libssl on this host, or the CA file could not be loaded)");conn.ctx = ctx;conn.ssl = conn.ctx.?.connect(fd, m.host, "smtp") orelsereturn fail("connect", 0, false, "the implicit TLS handshake failed (see the smtp: lines on stderr)");secure = true;}var reply = Reply{};defer reply.deinit();if (!readReply(&conn, &reply)) return fail("greeting", 0, secure, "the server sent no greeting");if (reply.code != 220) return fail("greeting", reply.code, secure, reply.text.items);// EHLO, and keep the capability list — STARTTLS and AUTH are both read off it.var caps: std.ArrayListUnmanaged(u8) = .empty;defer caps.deinit(allocator);if (!command(&conn, &reply, &.{ "EHLO ", m.helo })) return fail("ehlo", 0, secure, "the connection closed during EHLO");if (reply.code != 250) return fail("ehlo", reply.code, secure, reply.text.items);caps.appendSlice(allocator, reply.text.items) catch {};if (m.tls_mode == .starttls and !secure) {if (!advertises(caps.items, "STARTTLS")) {return fail("starttls", 0, secure, "the server does not advertise STARTTLS (set tls = \"none\" to accept a plaintext session deliberately)");}if (!command(&conn, &reply, &.{"STARTTLS"})) return fail("starttls", 0, secure, "the connection closed during STARTTLS");if (reply.code != 220) return fail("starttls", reply.code, secure, reply.text.items);const ctx = tls.TlsContext.initClient(.{.ca_file = if (m.ca_file) |ca| ca else null,.verify = m.verify,.tag = "smtp",}) orelse return fail("starttls", 0, secure, "TLS is unavailable (no usable libssl on this host, or the CA file could not be loaded)");conn.ctx = ctx;// The SAME socket, mid-protocol: this is what STARTTLS is, and the// client half of tls_common.zig takes it as it stands.conn.ssl = conn.ctx.?.connect(fd, m.host, "smtp") orelsereturn fail("starttls", 0, secure, "the STARTTLS handshake failed (see the smtp: lines on stderr)");secure = true;conn.len = 0;conn.pos = 0;// RFC 3207 §4.2: everything learned before the upgrade is discarded and// EHLO is re-issued inside TLS — a capability list from the plaintext// half is exactly what an attacker in the middle would have written.caps.clearRetainingCapacity();if (!command(&conn, &reply, &.{ "EHLO ", m.helo })) return fail("ehlo", 0, secure, "the connection closed during the post-STARTTLS EHLO");if (reply.code != 250) return fail("ehlo", reply.code, secure, reply.text.items);caps.appendSlice(allocator, reply.text.items) catch {};}// AUTHif (m.user != null and m.pass != null and m.auth_mode != .none) {if (!secure and !m.allow_insecure_auth) {return fail("auth", 0, secure, "refusing to send credentials over a plaintext connection — use tls = \"starttls\" (the default) or \"implicit\", or set allowInsecureAuth = true to say the clear text is intended");}const wants_login = switch (m.auth_mode) {.login => true,.plain => false,else => !advertises(caps.items, "PLAIN") and advertises(caps.items, "LOGIN"),};const outcome = if (wants_login) authLogin(m, &conn, &reply, secure) else authPlain(m, &conn, &reply, secure);if (outcome) |o| return o;}if (!command(&conn, &reply, &.{ "MAIL FROM:<", job.from, ">" })) return fail("mail", 0, secure, "the connection closed during MAIL FROM");if (reply.code != 250) return fail("mail", reply.code, secure, reply.text.items);for (job.rcpt) |r| {if (!command(&conn, &reply, &.{ "RCPT TO:<", r, ">" })) return fail("rcpt", 0, secure, "the connection closed during RCPT TO");// 251 = "will forward"; anything else non-2xx is a rejected recipient,// and one rejected recipient fails the whole send rather than silently// delivering to the rest (the app decides what to do about it).if (reply.code != 250 and reply.code != 251) return fail("rcpt", reply.code, secure, reply.text.items);}if (!command(&conn, &reply, &.{"DATA"})) return fail("data", 0, secure, "the connection closed during DATA");if (reply.code != 354) return fail("data", reply.code, secure, reply.text.items);if (!conn.writeAll(job.data)) return fail("body", 0, secure, "the connection closed while the message was being written");if (!conn.writeAll(".\r\n")) return fail("body", 0, secure, "the connection closed at the end-of-data marker");if (!readReply(&conn, &reply)) return fail("body", 0, secure, "the server never acknowledged the message");if (reply.code != 250) return fail("body", reply.code, secure, reply.text.items);const accepted = own(reply.text.items);// QUIT is courtesy: the message is accepted the moment DATA answered 250, so// a server that drops the socket instead of saying 221 has not lost it._ = command(&conn, &reply, &.{"QUIT"});return .{ .ok = true, .code = 250, .stage = "done", .message = accepted, .secure = secure };}/// AUTH PLAIN — RFC 4616: base64 of "\0user\0pass", in the command itself.fn authPlain(m: *Mailer, conn: *Conn, reply: *Reply, secure: bool) ?Outcome {const user = m.user.?;const pass = m.pass.?;const raw = allocator.alloc(u8, 2 + user.len + pass.len) catch return fail("auth", 0, secure, "out of memory");defer allocator.free(raw);raw[0] = 0;@memcpy(raw[1 .. 1 + user.len], user);raw[1 + user.len] = 0;@memcpy(raw[2 + user.len ..], pass);const enc = b64Alloc(raw) catch return fail("auth", 0, secure, "out of memory");defer allocator.free(enc);if (!command(conn, reply, &.{ "AUTH PLAIN ", enc })) return fail("auth", 0, secure, "the connection closed during AUTH PLAIN");if (reply.code != 235) return fail("auth", reply.code, secure, reply.text.items);return null;}/// AUTH LOGIN — the de-facto challenge/response form: base64 username, then/// base64 password, each answering a 334.fn authLogin(m: *Mailer, conn: *Conn, reply: *Reply, secure: bool) ?Outcome {if (!command(conn, reply, &.{"AUTH LOGIN"})) return fail("auth", 0, secure, "the connection closed during AUTH LOGIN");if (reply.code != 334) return fail("auth", reply.code, secure, reply.text.items);const user_enc = b64Alloc(m.user.?) catch return fail("auth", 0, secure, "out of memory");defer allocator.free(user_enc);if (!command(conn, reply, &.{user_enc})) return fail("auth", 0, secure, "the connection closed after the username");if (reply.code != 334) return fail("auth", reply.code, secure, reply.text.items);const pass_enc = b64Alloc(m.pass.?) catch return fail("auth", 0, secure, "out of memory");defer allocator.free(pass_enc);if (!command(conn, reply, &.{pass_enc})) return fail("auth", 0, secure, "the connection closed after the password");if (reply.code != 235) return fail("auth", reply.code, secure, reply.text.items);return null;}fn workerLoop(m: *Mailer) void {while (true) {var job = m.takeJob() orelse break;const outcome = runJob(m, &job);const rcpt: [][]u8 = allocator.alloc([]u8, job.rcpt.len) catch &.{};for (job.rcpt, 0..) |r, i| {if (i < rcpt.len) rcpt[i] = allocator.dupe(u8, r) catch own("");}m.publish(.{.id = job.id,.ok = outcome.ok,.code = outcome.code,.stage = outcome.stage,.message = outcome.message,.secure = outcome.secure,.rcpt = rcpt,});job.deinit();}}// ── THE RESULT SOURCE ───────────────────────────────────────────────────────/// The loader converts a nested `hl_object` by recursing and running THAT/// object's own `deinit_fn` before it runs this one, so the `to` list is already/// gone by the time this is called — touching it here is a double free (measured,/// mission 137). Only the string values this object owns are freed here.fn resultObjDeinit(obj: *HlObject) callconv(.c) void {const fields = obj.fields[0..obj.field_count];for (fields) |f| {if (f.value.type == .hl_string) {allocator.free(@constCast(f.value.data.string.ptr[0..f.value.data.string.len]));}}allocator.free(fields);allocator.destroy(obj);}fn rcptObjDeinit(obj: *HlObject) callconv(.c) void {const fields = obj.fields[0..obj.field_count];for (fields) |f| {allocator.free(@constCast(f.key.ptr[0..f.key.len]));if (f.value.type == .hl_string) {allocator.free(@constCast(f.value.data.string.ptr[0..f.value.data.string.len]));}}allocator.free(fields);allocator.destroy(obj);}/// Contiguous "0".."n-1" keys is how the loader recognises an ordered hybrid, so/// `res.to` arrives in Hybriel as a list however many recipients there were.fn rcptValue(rcpt: [][]u8) HlValue {const fields = allocator.alloc(HlField, rcpt.len) catch return api.makeNull();var built: usize = 0;for (rcpt, 0..) |r, i| {var key_buf: [24]u8 = undefined;const key_src = std.fmt.bufPrint(&key_buf, "{d}", .{i}) catch break;const key = allocator.dupe(u8, key_src) catch break;fields[built] = .{ .key = .{ .ptr = key.ptr, .len = key.len }, .value = api.makeString(r) };built += 1;}const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{ .fields = fields.ptr, .field_count = built, .deinit_fn = &rcptObjDeinit };return api.makeObject(obj);}fn resultValue(r: Result) HlValue {const fields = allocator.alloc(HlField, 7) catch return api.makeNull();fields[0] = .{ .key = http.hlStr("id"), .value = api.makeNumber(@floatFromInt(r.id)) };fields[1] = .{ .key = http.hlStr("ok"), .value = api.makeBool(r.ok) };fields[2] = .{ .key = http.hlStr("code"), .value = api.makeNumber(@floatFromInt(r.code)) };// `stage` names a compile-time constant, so it is DUPED here: the deinit// below frees every string field it finds, and handing it a `.rodata`// pointer is a segfault (measured, mission 137). One rule for all fields// beats a per-field exception nobody will remember.fields[3] = .{ .key = http.hlStr("stage"), .value = api.makeString(own(r.stage)) };fields[4] = .{ .key = http.hlStr("message"), .value = api.makeString(r.message) };fields[5] = .{ .key = http.hlStr("secure"), .value = api.makeBool(r.secure) };fields[6] = .{ .key = http.hlStr("to"), .value = rcptValue(r.rcpt) };const obj = allocator.create(HlObject) catch return api.makeNull();obj.* = .{ .fields = fields.ptr, .field_count = 7, .deinit_fn = &resultObjDeinit };allocator.free(r.rcpt);return api.makeObject(obj);}fn resultsTryNext(ctx: ?*anyopaque) callconv(.c) HlValue {const m: *Mailer = @ptrCast(@alignCast(ctx orelse return api.makeNull()));const r = m.tryTake() orelse return api.makeNull();return resultValue(r);}fn resultsNext(ctx: ?*anyopaque) callconv(.c) HlValue {const m: *Mailer = @ptrCast(@alignCast(ctx orelse return api.makeNull()));const r = m.take() orelse return api.makeNull();return resultValue(r);}fn resultsDeinit(_: ?*anyopaque) callconv(.c) void {}// ── EXPORTS ─────────────────────────────────────────────────────────────────/// __native("smtp.open", host, port, options) → Number mailer idexport fn hl_smtp_open(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_string or argv[1].type != .hl_number) {return refuse("hl:smtp open: host must be a String and port a Number", .{});}const host = argv[0].data.string.ptr[0..argv[0].data.string.len];const port_f = argv[1].data.number;if (port_f < 1 or port_f > 65535) {return refuse("hl:smtp open: {d} is not a port number", .{port_f});}const opts: HlValue = if (argc >= 3) argv[2] else api.makeNull();const tls_name = strField(opts, "tls") orelse "starttls";const tls_mode: TlsMode = if (std.ascii.eqlIgnoreCase(tls_name, "starttls")).starttlselse if (std.ascii.eqlIgnoreCase(tls_name, "implicit")).implicitelse if (std.ascii.eqlIgnoreCase(tls_name, "none")).noneelsereturn refuse("hl:smtp open: tls = \"{s}\" is not one of \"starttls\", \"implicit\", \"none\"", .{tls_name});const auth_name = strField(opts, "auth") orelse "auto";const auth_mode: AuthMode = if (std.ascii.eqlIgnoreCase(auth_name, "auto")).autoelse if (std.ascii.eqlIgnoreCase(auth_name, "plain")).plainelse if (std.ascii.eqlIgnoreCase(auth_name, "login")).loginelse if (std.ascii.eqlIgnoreCase(auth_name, "none")).noneelsereturn refuse("hl:smtp open: auth = \"{s}\" is not one of \"auto\", \"plain\", \"login\", \"none\"", .{auth_name});// A MAILER WITHOUT A SENDER OPENS (ticket #13): an app that never sends// must not abort at load for want of one. A message that ends up with no// sender at all is refused at send(), located at that call.const from = senderField(opts) orelse "";if (from.len > 0) {if (checkAddress("the mailer's \"from\" (or sender)", from)) |e| return e;}const helo = strField(opts, "helo") orelse "localhost";if (checkHeaderValue("helo", helo)) |e| return e;const m = allocator.create(Mailer) catch return refuse("hl:smtp open: out of memory", .{});m.* = .{.id = 0,.host = allocator.dupe(u8, host) catch return refuse("hl:smtp open: out of memory", .{}),.port = @intFromFloat(port_f),.user = dupOpt(strField(opts, "user")),.pass = dupOpt(strField(opts, "pass")),.from = allocator.dupe(u8, from) catch return refuse("hl:smtp open: out of memory", .{}),.helo = allocator.dupe(u8, helo) catch return refuse("hl:smtp open: out of memory", .{}),.tls_mode = tls_mode,.auth_mode = auth_mode,.ca_file = dupOpt(strField(opts, "caFile")),.verify = boolField(opts, "verify", true),.allow_insecure_auth = boolField(opts, "allowInsecureAuth", false),.timeout_ms = @intFromFloat(@max(1000, numField(opts, "timeout", 30000))),.mutex = c.PTHREAD_MUTEX_INITIALIZER,.condvar = c.PTHREAD_COND_INITIALIZER,.wake_fd = http.makeWakeFd(),};mutexLock(&mailers_mutex);m.id = next_mailer_id;next_mailer_id += 1;mailers.append(allocator, m) catch {};mutexUnlock(&mailers_mutex);m.worker = std.Thread.spawn(.{}, workerLoop, .{m}) catch {return refuse("hl:smtp open: could not start the sender thread", .{});};return api.makeNumber(@floatFromInt(m.id));}/// __native("smtp.send", id, message) → Number job id, or a located refusal.////// Everything this function does happens on the HYBRIEL thread, before the job/// exists: that is deliberate, because a refusal is only located if it is raised/// at the call site and a worker thread has no call site.export fn hl_smtp_send(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 2 or argv[0].type != .hl_number) {return refuse("hl:smtp send: expected a mailer id and a message", .{});}const m = mailerById(@intFromFloat(argv[0].data.number)) orelsereturn refuse("hl:smtp send: this Mailer is closed", .{});const msg = argv[1];if (msg.type != .hl_object) {return refuse("hl:smtp send: the message must be a hybrid with to, subject and text (or html)", .{});}const from = senderField(msg) orelse m.from;if (from.len == 0) {return refuse("hl:smtp refused the message: it has no \"from\" and neither has the mailer — give one in the message or in the Mailer's options", .{});}if (checkAddress("\"from\"", from)) |e| return e;// `to` is one address or a list of them. A single String is the common case// and a list is the same message with more RCPT TO lines — never several// messages, so every recipient sees the same To: header.var rcpt: std.ArrayListUnmanaged([]u8) = .empty;var rcpt_failed = false;defer if (rcpt_failed) {for (rcpt.items) |r| allocator.free(r);rcpt.deinit(allocator);};const to_field = fieldOf(msg, "to") orelse return refuse("hl:smtp send: the message has no 'to'", .{});switch (to_field.type) {.hl_string => {const one = to_field.data.string.ptr[0..to_field.data.string.len];if (checkAddress("to", one)) |e| {rcpt_failed = true;return e;}rcpt.append(allocator, allocator.dupe(u8, one) catch "") catch {};},.hl_object => {const list = to_field.data.object;for (list.fields[0..list.field_count]) |f| {if (f.value.type != .hl_string) continue;const one = f.value.data.string.ptr[0..f.value.data.string.len];if (checkAddress("to", one)) |e| {rcpt_failed = true;return e;}rcpt.append(allocator, allocator.dupe(u8, one) catch "") catch {};}},else => return refuse("hl:smtp send: 'to' must be a String or a list of Strings", .{}),}if (rcpt.items.len == 0) {rcpt_failed = true;return refuse("hl:smtp send: 'to' names no recipient", .{});}const subject = strField(msg, "subject") orelse "";if (checkHeaderValue("subject", subject)) |e| {rcpt_failed = true;return e;}const text = strField(msg, "text");const html = strField(msg, "html");if (text == null and html == null) {rcpt_failed = true;return refuse("hl:smtp send: the message has neither 'text' nor 'html'", .{});}var body: std.ArrayListUnmanaged(u8) = .empty;var body_failed = false;defer if (body_failed) body.deinit(allocator);renderMessage(&body, m, from, rcpt.items, subject, text, html) catch {body_failed = true;rcpt_failed = true;return refuse("hl:smtp send: out of memory rendering the message", .{});};mutexLock(&m.mutex);const job_id = m.next_job;m.next_job += 1;mutexUnlock(&m.mutex);m.enqueue(.{.id = job_id,.from = allocator.dupe(u8, from) catch "",.rcpt = rcpt.toOwnedSlice(allocator) catch &[_][]u8{},.data = body.toOwnedSlice(allocator) catch &[_]u8{},});return api.makeNumber(@floatFromInt(job_id));}fn renderMessage(out: *std.ArrayListUnmanaged(u8),m: *Mailer,from: []const u8,rcpt: []const []u8,subject: []const u8,text: ?[]const u8,html: ?[]const u8,) !void {var head: std.ArrayListUnmanaged(u8) = .empty;defer head.deinit(allocator);try appendHeader(&head, "From", from);var to_join: std.ArrayListUnmanaged(u8) = .empty;defer to_join.deinit(allocator);for (rcpt, 0..) |r, i| {if (i > 0) try to_join.appendSlice(allocator, ", ");try to_join.appendSlice(allocator, r);}try appendHeader(&head, "To", to_join.items);try appendHeader(&head, "Subject", subject);try writeDate(&head);var mid: [32]u8 = undefined;randomHex(&mid);try head.appendSlice(allocator, "Message-ID: <");try head.appendSlice(allocator, &mid);try head.append(allocator, '@');try head.appendSlice(allocator, m.helo);try head.appendSlice(allocator, ">\r\n");try head.appendSlice(allocator, "MIME-Version: 1.0\r\n");if (text != null and html != null) {var boundary: [24]u8 = undefined;randomHex(&boundary);try head.appendSlice(allocator, "Content-Type: multipart/alternative; boundary=\"hl-");try head.appendSlice(allocator, &boundary);try head.appendSlice(allocator, "\"\r\n\r\n");// The plain part first: RFC 2046 §5.1.4 orders alternatives worst-first,// so a reader that understands HTML picks the LAST one it can render.try head.appendSlice(allocator, "--hl-");try head.appendSlice(allocator, &boundary);try head.appendSlice(allocator, "\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n");try out.appendSlice(allocator, head.items);try appendDotStuffed(out, text.?);try out.appendSlice(allocator, "--hl-");try out.appendSlice(allocator, &boundary);try out.appendSlice(allocator, "\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n");try appendDotStuffed(out, html.?);try out.appendSlice(allocator, "--hl-");try out.appendSlice(allocator, &boundary);try out.appendSlice(allocator, "--\r\n");return;}const single = text orelse html.?;const ctype = if (text != null) "text/plain" else "text/html";try head.appendSlice(allocator, "Content-Type: ");try head.appendSlice(allocator, ctype);try head.appendSlice(allocator, "; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n");try out.appendSlice(allocator, head.items);try appendDotStuffed(out, single);}/// __native("smtp.results", id) → the mailer's ONE result source.export fn hl_smtp_results(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) return api.makeNull();const m = mailerById(@intFromFloat(argv[0].data.number)) orelsereturn refuse("hl:smtp results: this Mailer is closed", .{});if (m.source == null) {const iter = allocator.create(HlIterator) catch return api.makeNull();iter.* = .{.context = @ptrCast(m),.next_fn = &resultsNext,.deinit_fn = &resultsDeinit,.try_next_fn = &resultsTryNext,.wake_fd = m.wake_fd,};m.source = iter;}return api.makeIterator(m.source.?);}/// __native("smtp.mark_registered", id) → refuses a SECOND consumer./// The drain (`for (r of m.results())`) and the event loop take results off the/// same queue, so a program doing both would see each result exactly once, in/// one of two places, at random. Saying so is cheaper than debugging it.export fn hl_smtp_mark_registered(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) return api.makeNull();const m = mailerById(@intFromFloat(argv[0].data.number)) orelsereturn refuse("hl:smtp deliver: this Mailer is closed", .{});if (m.registered) {return refuse("hl:smtp deliver: this Mailer already delivers its results to the event loop", .{});}m.registered = true;return api.makeNull();}/// __native("smtp.pending", id) → how many sends have not been answered yet.export fn hl_smtp_pending(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) return api.makeNumber(0);const m = mailerById(@intFromFloat(argv[0].data.number)) orelse return api.makeNumber(0);mutexLock(&m.mutex);defer mutexUnlock(&m.mutex);return api.makeNumber(@floatFromInt(m.outstanding));}/// __native("smtp.close", id) — stop the worker. Queued jobs still in hand are/// dropped; a job already in conversation finishes, because abandoning a socket/// between DATA and its 250 is how a message gets delivered twice.export fn hl_smtp_close(argc: u32, argv: [*]const HlValue) callconv(.c) HlValue {if (argc < 1 or argv[0].type != .hl_number) return api.makeNull();const m = mailerById(@intFromFloat(argv[0].data.number)) orelse return api.makeNull();mutexLock(&m.mutex);m.stop = true;condBroadcast(&m.condvar);mutexUnlock(&m.mutex);http.ringWake(m.wake_fd);if (m.worker) |t| {t.join();m.worker = null;}mutexLock(&mailers_mutex);for (mailers.items, 0..) |candidate, i| {if (candidate == m) {_ = mailers.orderedRemove(i);break;}}mutexUnlock(&mailers_mutex);return api.makeNull();}
Branches
- mainmain branch
Latest commits
- 3251488atracker#7: /my/shows (followed shows, newest follow first, poster, title, last watched SxxEyy); gate can take screenshots (TRACKER_GATE_SHOTS)mre
- 44b7d9f9tracker#6: /schedule — upcoming episodes of followed shows, soonest firstmre
- 91c9fc8ctracker#5: /unwatched — unwatched released episodes of followed shows, newest firstmre
- a97c0295tracker#4: show page /shows/:slug (header, seasons, episodes, watch checks) + tools/relink-episode-seasons.hlmre
- 05f407c5tracker: no border on any button except inverted ones (Log out, ident status and identities too); header brand weight 100mre
- 17375427tracker#2: tools/migrate.hl + tools/verify.hl — old MongoDB data into mpackdb with new idsmre
- 31aac936tracker#3: no border on the header and on filled buttons; inverted buttons keep theirsmre
- 2ad9d29ctracker#1: login exactly like calendar (identity selector in the header, empty homepage)mre
- 3691e176tracker#1: empty tracker with the ident login (state of 2026-09-27)mre