Something went wrong. Try again.
SMTP client for Zig forked from karlseguin.tngl.sh/smtp_client.zig
Something went wrong. Try again.
18 kB · 543 lines
Zig
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544const std = @import("std");
const Config = @import("smtp.zig").Config;const Reader = @import("reader.zig").Reader;const Message = @import("message.zig").Message;
pub const AuthMode = enum { PLAIN, LOGIN, CRAM_MD5,};
pub fn Client(comptime S: type) type { return struct { stream: S,
reader: Reader(*S),
// maximum reply length is 512 buf: [512]u8 = undefined,
random: std.Random.DefaultPrng,
config: Config,
// whether the server supports the 8BITMIME, SMTPUTF8 and AUTH extensions ext_8_bit_mine: bool = false, ext_smtp_utf8: bool = false, ext_auth: ?AuthMode = null,
const Self = @This();
pub fn init(stream: S, config: Config) !Self { var seed: u64 = undefined; try std.posix.getrandom(std.mem.asBytes(&seed)); return .{ .stream = stream, .config = config, .reader = undefined, .random = std.Random.DefaultPrng.init(seed), }; }
pub fn deinit(self: *Self) void { self.stream.deinit(); }
pub fn hello(self: *Self) !void { const config = &self.config; if (config.encryption == .tls) { try self.stream.toTLS(config); }
self.reader = try Reader(*S).init(&self.stream, config.timeout);
// server should send the first message const code = (try self.reader.read()).code; if (code != 220) { return errorFromCode(code); } return self._hello(); }
// When StartSSL is used, we send 2 hellos - one after we've upgraded // to TLS. This 2nd hello doesn't have to setup things (like the reader) // like the first one. fn _hello(self: *Self) !void { const buf = &self.buf; var reader = &self.reader;
const msg = try std.fmt.bufPrint(buf, "EHLO {s}\r\n", .{self.config.local_name}); try self.stream.directWrite(msg);
while (true) { const reply = try reader.read(); const code = reply.code; if (code != 250) { return errorFromCode(code); }
const reply_data = reply.data; if (std.ascii.eqlIgnoreCase(reply_data, "8BITMIME")) { self.ext_8_bit_mine = true; } else if (std.ascii.eqlIgnoreCase(reply_data, "SMTPUTF8")) { self.ext_smtp_utf8 = true; } else { if (reply_data.len > 4 and std.ascii.eqlIgnoreCase(reply_data[0..4], "AUTH")) { self.ext_auth = findSupportedAuth(reply_data[5..]); } } if (reply.more == false) { return; } } }
pub fn startTLS(self: *Self) !void { var buf = &self.buf; @memcpy(buf[0..10], "STARTTLS\r\n"); try self.stream.directWrite(buf[0..10]); const code = (try self.reader.read()).code; if (code != 220) { return errorFromCode(code); } try self.stream.toTLS(&self.config); return self._hello(); }
pub fn auth(self: *Self) !void { if (self.config.encryption == .start_tls) { try self.startTLS(); }
if (self.config.username == null) { return; }
const auth_mode = self.ext_auth orelse return error.NoSupportedAuth; switch (auth_mode) { .PLAIN => try self.authPlain(), .LOGIN => try self.authLogin(), .CRAM_MD5 => try self.authCRAMMD5(), } }
pub fn from(self: *Self, frm: Message.Address) !void { var buf = &self.buf; const address = frm.address;
@memcpy(buf[0..11], "MAIL FROM:<"); var pos: usize = 11; var end = pos + address.len; @memcpy(buf[pos..end], address);
buf[end] = '>'; pos = end + 1;
if (self.ext_8_bit_mine) { end = pos + 14; @memcpy(buf[pos..end], " BODY=8BITMIME"); pos = end; }
if (self.ext_smtp_utf8) { end = pos + 11; @memcpy(buf[pos..end], " SMTPUTF8\r\n"); } else { buf[pos] = '\r'; buf[pos + 1] = '\n'; end = pos + 2; }
try self.stream.directWrite(buf[0..end]); const code = (try self.reader.read()).code; if (code != 250) { return errorFromCode(code); } }
pub fn to(self: *Self, recepients: []const Message.Address) !void { var buf = &self.buf; var reader = &self.reader; const stream = &self.stream;
@memcpy(buf[0..9], "RCPT TO:<"); for (recepients) |recepient| { const address = recepient.address; const recepient_end = 9 + address.len; @memcpy(buf[9..recepient_end], address);
const end = recepient_end + 3; @memcpy(buf[recepient_end..end], ">\r\n"); try stream.directWrite(buf[0..end]);
const code = (try reader.read()).code; if (code != 250 and code != 251) { return errorFromCode(code); } } }
pub fn data(self: *Self, d: []const u8) !void { try self.prepareFordata(); try self.stream.directWrite(d); try self.verifyData(); }
pub fn sendMessage(self: *Self, message: Message) !void { try self.from(message.from); if (message.to) |message_to| { try self.to(message_to); }
if (message.data) |d| { return self.data(d); }
try self.prepareFordata();
try message.write(&self.stream, self.random.random(), .{ .message_id_host = self.config.message_id_host, }); try self.stream.writeAll(".\r\n"); try self.stream.flush(); try self.verifyData(); }
pub fn quit(self: *Self) !void { try self.stream.directWrite("QUIT\r\n"); }
fn prepareFordata(self: *Self) !void { try self.stream.directWrite("DATA\r\n"); const code = (try self.reader.read()).code; if (code != 354) { return errorFromCode(code); } }
fn verifyData(self: *Self) !void { const code = (try self.reader.read()).code; if (code != 250) { return errorFromCode(code); } }
fn authPlain(self: *Self) !void { if (self.config.encryption == .none) { return error.InsecureAuth; }
const config = &self.config; const encoder = std.base64.standard.Encoder;
// our final result has to fit in our 512 byte buffer + some command overhead // (+ a big of extra padding, incase...) var temp: [366]u8 = undefined; const plain = try std.fmt.bufPrint(&temp, "\x00{s}\x00{s}", .{ config.username.?, config.password.? }); const encoded_length = encoder.calcSize(plain.len);
// "AUTH PLAIN " + \r + \n // 11 + 1 + 1 == 13 var buf = self.buf[0 .. encoded_length + 13]; @memcpy(buf[0..11], "AUTH PLAIN "); _ = encoder.encode(buf[11..], plain);
buf[buf.len - 2] = '\r'; buf[buf.len - 1] = '\n';
try self.stream.directWrite(buf); const code = (try self.reader.read()).code; if (code != 235) { return errorFromCode(code); } }
fn authLogin(self: *Self) !void { if (self.config.encryption == .none) { return error.InsecureAuth; }
var buf = &self.buf; var reader = &self.reader; const config = &self.config; const encoder = std.base64.standard.Encoder;
{ @memcpy(buf[0..11], "AUTH LOGIN "); const encoded = encoder.encode(buf[11..], config.username.?); const end = 11 + encoded.len + 2;
buf[end - 2] = '\r'; buf[end - 1] = '\n';
try self.stream.directWrite(buf[0..end]); }
{ const reply = try reader.read(); const code = reply.code; if (code != 334) { return errorFromCode(code); }
// base64 encoded "Password:" if (std.mem.eql(u8, reply.data, "UGFzc3dvcmQ6") == false) { return error.UnexpectedServerResponse; }
const password = config.password orelse return error.PasswordRequired; const encoded = encoder.encode(buf[0..], password); const end = encoded.len + 2; buf[end - 2] = '\r'; buf[end - 1] = '\n'; try self.stream.directWrite(buf[0..end]); }
const code = (try reader.read()).code; if (code != 235) { return errorFromCode(code); } }
fn authCRAMMD5(self: *Self) !void { const config = &self.config;
try self.stream.directWrite("AUTH CRAM-MD5\r\n");
const secret = blk: { const reply = try self.reader.read(); const code = reply.code; if (code != 235) { return errorFromCode(code); } break :blk reply.data; };
var temp: [16]u8 = undefined; std.crypto.auth.hmac.HmacMd5.create(&temp, config.password.?, secret); const hex = std.fmt.bytesToHex(&temp, .lower);
const answer = try std.fmt.bufPrint(&self.buf, "{s} {s}\r\n", .{ config.username.?, hex }); try self.stream.directWrite(answer); const code = (try self.reader.read()).code; if (code != 235) { return errorFromCode(code); } } };}
fn findSupportedAuth(data: []const u8) ?AuthMode { var maybe_auth: ?AuthMode = null; var it = std.mem.splitScalar(u8, data, ' ');
while (it.next()) |value| { if (std.ascii.eqlIgnoreCase(value, "CRAM-MD5")) { return .CRAM_MD5; }
if (std.ascii.eqlIgnoreCase(value, "PLAIN") and maybe_auth == null) { maybe_auth = .PLAIN; } if (std.ascii.eqlIgnoreCase(value, "LOGIN")) { maybe_auth = .LOGIN; } }
return maybe_auth;}
fn errorFromCode(code: u16) anyerror { return switch (code) { 421 => error.ServiceNotAvailable, 454 => error.TemporaryAuthFailure, 450 => error.TemporaryMailboxNotAvailable, 451 => error.ErrorInProcessing, 452 => error.InsufficientStorage, 455 => error.UnableToAccomodateParameter, 500 => error.SyntaxErrorOrCommandNotFound, 501 => error.InvalidParameter, 502 => error.CommandNotImplemented, 503 => error.InvalidCommandSequence, 504 => error.ParameterNotImplemented, 530 => error.AuthenticationRequired, 534 => error.AuthMethodTooWeak, 535 => error.InvalidCredentials, 538 => error.EncryptionRequiredForAuthMethod, 550 => error.MailboxNotAvailable, 551 => error.UserNotLocal, 552 => error.ExceededStorageAllocation, 553 => error.MailboxNotAllowed, 554 => error.TransactionFailed, 555 => error.InvalidFromOrRecptParameter, else => if (code < 400) error.UnexpectedServerResponse else error.UnknownServerResponse, };}
const t = @import("t.zig");test "client: helo" { const config = testConfig(.{});
{ // wrong server init reply var stream = t.Stream.init(); stream.add("421 go away\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try t.expectError(error.ServiceNotAvailable, client.hello()); }
{ // wrong server reply var stream = t.Stream.init(); stream.add("220\r\n502 nope\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try t.expectError(error.CommandNotImplemented, client.hello()); }
{ // no exstensions var stream = t.Stream.init(); stream.add("220\r\n250\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try client.hello(); try t.expectEqual(false, client.ext_smtp_utf8); try t.expectEqual(false, client.ext_8_bit_mine); try t.expectEqual(null, client.ext_auth); }
{ // SMTPUTF8 var stream = t.Stream.init(); stream.add("220\r\n250-\r\n250 SMTPUTF8\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try client.hello(); try t.expectEqual(true, client.ext_smtp_utf8); try t.expectEqual(false, client.ext_8_bit_mine); try t.expectEqual(null, client.ext_auth); }
{ // 8BITMIME var stream = t.Stream.init(); stream.add("220\r\n250-\r\n250 8BITMIME\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try client.hello(); try t.expectEqual(false, client.ext_smtp_utf8); try t.expectEqual(true, client.ext_8_bit_mine); try t.expectEqual(null, client.ext_auth); }
{ // CRAM-MD5 var stream = t.Stream.init(); stream.add("220\r\n250-\r\n250 AUTH LOGIN PLAIN CRAM-MD5\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try client.hello(); try t.expectEqual(false, client.ext_smtp_utf8); try t.expectEqual(false, client.ext_8_bit_mine); try t.expectEqual(.CRAM_MD5, client.ext_auth); }
{ // Login var stream = t.Stream.init(); stream.add("220\r\n250-\r\n250 AUTH LOGIN PLAIN\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try client.hello(); try t.expectEqual(.LOGIN, client.ext_auth); }
{ // Plain var stream = t.Stream.init(); stream.add("220\r\n250 Auth PlAIN\r\n"); var client = try TestClient.init(stream, config); defer client.deinit();
try client.hello(); try t.expectEqual(.PLAIN, client.ext_auth); }}
test "client: auth" { { // cannot use PLAIN auth with unencrypted connection var stream = t.Stream.init(); stream.add("220\r\n250 AUTH PLAIN\r\n"); var client = try TestClient.init(stream, testConfig(.{ .encryption = .none, .username = "leto" })); defer client.deinit();
try client.hello(); try t.expectError(error.InsecureAuth, client.auth()); }
{ // cannot use LOGIN auth with unencrypted connection var stream = t.Stream.init(); stream.add("220\r\n250 AUTH LOGIN\r\n"); var client = try TestClient.init(stream, testConfig(.{ .encryption = .none, .username = "leto" })); defer client.deinit();
try client.hello(); try t.expectError(error.InsecureAuth, client.auth()); }
{ // cannot use LOGIN auth with unencrypted connection var stream = t.Stream.init(); stream.add("220\r\n250 AUTH LOGIN\r\n"); stream.add("334 UGFzc3dvcmQ6\r\n"); stream.add("235 Auth Successful\r\n"); var client = try TestClient.init(stream, testConfig(.{ .username = "leto", .password = "ghanima", .encryption = .tls })); defer client.deinit();
try client.hello(); try client.auth(); try client.quit();
const received = client.stream.received(); try t.expectEqual(4, received.len); try t.expectString("EHLO localhost\r\n", received[0]); try t.expectString("AUTH LOGIN bGV0bw==\r\n", received[1]); try t.expectString("Z2hhbmltYQ==\r\n", received[2]); try t.expectString("QUIT\r\n", received[3]); }
{ // wrong command var stream = t.Stream.init(); stream.add("invalid data\r\n"); var client = try TestClient.init(stream, testConfig(.{})); defer client.deinit();
try t.expectError(error.SyntaxErrorOrCommandNotFound, client.hello()); }}
const TestClient = Client(t.Stream);fn testConfig(config: anytype) Config { const T = @TypeOf(config); return .{ .port = 1025, .host = "127.0.0.1", .encryption = if (@hasField(T, "encryption")) config.encryption else .none, .username = if (@hasField(T, "username")) config.username else null, .password = if (@hasField(T, "password")) config.password else null, };}