An SMTP client and server library for Zig implementing RFC 5321.
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! An SMTP client session over any `Io.Reader`/`Io.Writer` pair, which keeps
5//! it transport-agnostic: wrap a TCP stream for real use, or fixed buffers
6//! for testing. TLS can be layered in the same way once the transport
7//! supports it.
8//!
9//! Typical use:
10//! ```
11//! var client: Client = .init(&stream_reader, &stream_writer, &reply_buf);
12//! _ = try client.greet();
13//! _ = try client.hello("my-host.example.com");
14//! try client.sendMail("me@example.com", &.{"you@example.net"}, message);
15//! try client.quit();
16//! ```
17
18const Client = @This();
19
20const std = @import("std");
21const Io = std.Io;
22const protocol = @import("protocol.zig");
23const Reply = protocol.Reply;
24
25reader: *Io.Reader,
26writer: *Io.Writer,
27/// Backing storage for reply text; `last_reply.text` points into it.
28reply_buffer: []u8,
29/// The most recent reply read from the server. Useful for reporting the
30/// server's actual response after an `error.UnexpectedReply`.
31last_reply: ?Reply = null,
32
33pub const Error = error{
34 WriteFailed,
35 ReadFailed,
36 EndOfStream,
37 LineTooLong,
38 InvalidReply,
39 ReplyTooLong,
40 /// The server answered with an unexpected code; see `last_reply`.
41 UnexpectedReply,
42};
43
44/// Extensions advertised in the server's EHLO response.
45pub const Extensions = struct {
46 pipelining: bool = false,
47 eight_bit_mime: bool = false,
48 starttls: bool = false,
49 smtputf8: bool = false,
50 enhanced_status_codes: bool = false,
51 /// AUTH mechanisms advertised by the server.
52 auth: Auth = .{},
53 /// Value of the SIZE extension, if advertised with a value.
54 max_size: ?u64 = null,
55
56 pub const Auth = struct {
57 plain: bool = false,
58 login: bool = false,
59 cram_md5: bool = false,
60
61 pub fn any(a: Auth) bool {
62 return a.plain or a.login or a.cram_md5;
63 }
64
65 fn parse(arg: []const u8) Auth {
66 var auth: Auth = .{};
67 var it = std.mem.tokenizeScalar(u8, arg, ' ');
68 while (it.next()) |mechanism| {
69 if (ieql(mechanism, "PLAIN")) {
70 auth.plain = true;
71 } else if (ieql(mechanism, "LOGIN")) {
72 auth.login = true;
73 } else if (ieql(mechanism, "CRAM-MD5")) {
74 auth.cram_md5 = true;
75 }
76 }
77 return auth;
78 }
79 };
80
81 fn parse(reply: Reply) Extensions {
82 var ext: Extensions = .{};
83 var it = reply.lines();
84 _ = it.next(); // The first line is the server's greeting, not a keyword.
85 while (it.next()) |line| {
86 const kw_end = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
87 const kw = line[0..kw_end];
88 const arg = if (kw_end < line.len) line[kw_end + 1 ..] else "";
89 if (ieql(kw, "PIPELINING")) {
90 ext.pipelining = true;
91 } else if (ieql(kw, "8BITMIME")) {
92 ext.eight_bit_mime = true;
93 } else if (ieql(kw, "STARTTLS")) {
94 ext.starttls = true;
95 } else if (ieql(kw, "SMTPUTF8")) {
96 ext.smtputf8 = true;
97 } else if (ieql(kw, "ENHANCEDSTATUSCODES")) {
98 ext.enhanced_status_codes = true;
99 } else if (ieql(kw, "AUTH")) {
100 ext.auth = Auth.parse(arg);
101 } else if (kw.len > 5 and ieql(kw[0..5], "AUTH=")) {
102 // Some legacy servers advertise "AUTH=PLAIN LOGIN".
103 var legacy_arg_buf: [128]u8 = undefined;
104 const joined = std.fmt.bufPrint(&legacy_arg_buf, "{s} {s}", .{ kw[5..], arg }) catch kw[5..];
105 ext.auth = Auth.parse(joined);
106 } else if (ieql(kw, "SIZE")) {
107 ext.max_size = std.fmt.parseInt(u64, arg, 10) catch null;
108 }
109 }
110 return ext;
111 }
112
113 fn ieql(a: []const u8, b: []const u8) bool {
114 return std.ascii.eqlIgnoreCase(a, b);
115 }
116};
117
118/// `reply_buffer` must be large enough for the largest expected reply text
119/// (the EHLO response is usually the largest); 512 bytes is plenty in
120/// practice.
121pub fn init(reader: *Io.Reader, writer: *Io.Writer, reply_buffer: []u8) Client {
122 return .{ .reader = reader, .writer = writer, .reply_buffer = reply_buffer };
123}
124
125/// Reads the server's 220 greeting. Call once, right after connecting.
126pub fn greet(c: *Client) Error!Reply {
127 return c.expect(220);
128}
129
130/// Sends EHLO and returns the extensions the server advertised, falling back
131/// to plain HELO for servers that do not speak ESMTP.
132pub fn hello(c: *Client, client_name: []const u8) Error!Extensions {
133 try c.send("EHLO {s}", .{client_name});
134 const reply = try c.readReply();
135 if (reply.isPositiveCompletion()) return Extensions.parse(reply);
136 if (reply.code == 500 or reply.code == 502) {
137 try c.send("HELO {s}", .{client_name});
138 _ = try c.expectClass(2);
139 return .{};
140 }
141 return error.UnexpectedReply;
142}
143
144/// Sends STARTTLS (RFC 3207) and reads the server's 220 go-ahead. On
145/// success, perform a TLS handshake over the underlying stream (see `Tls`),
146/// switch to the encrypted transport with `setTransport`, and then call
147/// `hello` again — the server discards everything it learned before the
148/// handshake, including the EHLO state.
149pub fn starttls(c: *Client) Error!void {
150 try c.send("STARTTLS", .{});
151 _ = try c.expect(220);
152}
153
154/// Replaces the session's transport, typically with a TLS reader/writer
155/// after `starttls`.
156pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void {
157 c.reader = reader;
158 c.writer = writer;
159}
160
161pub const AuthError = Error || error{
162 CredentialsTooLong,
163 /// The server rejected the credentials; see `last_reply`.
164 AuthenticationFailed,
165 /// The server's CRAM-MD5 challenge was not valid base64.
166 InvalidChallenge,
167 /// The server advertised none of the supported mechanisms.
168 NoSupportedMechanism,
169};
170
171/// Authenticates with the best mechanism the server advertised (PLAIN,
172/// then LOGIN, then CRAM-MD5). Note that PLAIN and LOGIN send credentials
173/// unprotected: use TLS on real networks.
174pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void {
175 if (extensions.auth.plain) return c.authPlain("", username, password);
176 if (extensions.auth.login) return c.authLogin(username, password);
177 if (extensions.auth.cram_md5) return c.authCramMd5(username, password);
178 return error.NoSupportedMechanism;
179}
180
181/// Authenticates with AUTH PLAIN (RFC 4616). Pass an empty `authzid` unless
182/// you need to act on behalf of another identity. Note that sending
183/// credentials over an unencrypted connection exposes them to the network.
184pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void {
185 var plain_buf: [512]u8 = undefined;
186 var plain: Io.Writer = .fixed(&plain_buf);
187 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch
188 return error.CredentialsTooLong;
189 var b64_buf: [std.base64.standard.Encoder.calcSize(plain_buf.len)]u8 = undefined;
190 const b64 = std.base64.standard.Encoder.encode(&b64_buf, plain.buffered());
191 try c.send("AUTH PLAIN {s}", .{b64});
192 try c.expectAuthSuccess();
193}
194
195/// Authenticates with AUTH LOGIN, the legacy two-step username/password
196/// exchange still required by some servers.
197pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void {
198 try c.send("AUTH LOGIN", .{});
199 _ = try c.expect(334); // Username: prompt
200 try c.sendBase64(username);
201 _ = try c.expect(334); // Password: prompt
202 try c.sendBase64(password);
203 try c.expectAuthSuccess();
204}
205
206/// Authenticates with AUTH CRAM-MD5 (RFC 2195): the password never crosses
207/// the wire, only an HMAC-MD5 of the server's challenge.
208pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void {
209 try c.send("AUTH CRAM-MD5", .{});
210 const reply = try c.expect(334);
211
212 var challenge_buf: [512]u8 = undefined;
213 const challenge_len = std.base64.standard.Decoder.calcSizeForSlice(reply.text) catch
214 return error.InvalidChallenge;
215 if (challenge_len > challenge_buf.len) return error.InvalidChallenge;
216 std.base64.standard.Decoder.decode(challenge_buf[0..challenge_len], reply.text) catch
217 return error.InvalidChallenge;
218
219 var mac: [std.crypto.auth.hmac.HmacMd5.mac_length]u8 = undefined;
220 std.crypto.auth.hmac.HmacMd5.create(&mac, challenge_buf[0..challenge_len], password);
221 const digest = std.fmt.bytesToHex(mac, .lower);
222
223 var response_buf: [384]u8 = undefined;
224 var response: Io.Writer = .fixed(&response_buf);
225 response.print("{s} {s}", .{ username, digest }) catch return error.CredentialsTooLong;
226 try c.sendBase64(response.buffered());
227 try c.expectAuthSuccess();
228}
229
230/// Sends `bytes` base64-encoded as a bare continuation line.
231fn sendBase64(c: *Client, bytes: []const u8) AuthError!void {
232 var b64_buf: [std.base64.standard.Encoder.calcSize(384)]u8 = undefined;
233 if (std.base64.standard.Encoder.calcSize(bytes.len) > b64_buf.len)
234 return error.CredentialsTooLong;
235 const b64 = std.base64.standard.Encoder.encode(&b64_buf, bytes);
236 try c.send("{s}", .{b64});
237}
238
239fn expectAuthSuccess(c: *Client) AuthError!void {
240 const reply = try c.readReply();
241 if (reply.code != 235) return error.AuthenticationFailed;
242}
243
244/// Starts a mail transaction. An empty `from` sends the null reverse-path
245/// (`MAIL FROM:<>`), used for bounces.
246pub fn mailFrom(c: *Client, from: []const u8) Error!void {
247 try c.send("MAIL FROM:<{s}>", .{from});
248 _ = try c.expectClass(2);
249}
250
251pub fn rcptTo(c: *Client, to: []const u8) Error!void {
252 try c.send("RCPT TO:<{s}>", .{to});
253 _ = try c.expectClass(2);
254}
255
256/// Sends the message content for the current transaction (DATA). Line
257/// endings in `data` are normalized to CRLF and leading dots are stuffed.
258pub fn sendMessage(c: *Client, data: []const u8) Error!void {
259 try c.send("DATA", .{});
260 _ = try c.expect(354);
261 try protocol.writeStuffed(c.writer, data);
262 try c.writer.writeAll("." ++ protocol.crlf);
263 try c.writer.flush();
264 _ = try c.expectClass(2);
265}
266
267/// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient,
268/// then DATA. Call after `greet` and `hello`.
269pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, data: []const u8) Error!void {
270 try c.mailFrom(from);
271 for (recipients) |recipient| try c.rcptTo(recipient);
272 try c.sendMessage(data);
273}
274
275/// Aborts the current mail transaction.
276pub fn rset(c: *Client) Error!void {
277 try c.send("RSET", .{});
278 _ = try c.expectClass(2);
279}
280
281pub fn noop(c: *Client) Error!void {
282 try c.send("NOOP", .{});
283 _ = try c.expectClass(2);
284}
285
286/// Ends the session. The connection should be closed afterwards.
287pub fn quit(c: *Client) Error!void {
288 try c.send("QUIT", .{});
289 _ = try c.expect(221);
290}
291
292fn send(c: *Client, comptime fmt: []const u8, args: anytype) Error!void {
293 try c.writer.print(fmt ++ protocol.crlf, args);
294 try c.writer.flush();
295}
296
297fn readReply(c: *Client) Error!Reply {
298 const reply = try Reply.read(c.reader, c.reply_buffer);
299 c.last_reply = reply;
300 return reply;
301}
302
303fn expect(c: *Client, code: u16) Error!Reply {
304 const reply = try c.readReply();
305 if (reply.code != code) return error.UnexpectedReply;
306 return reply;
307}
308
309fn expectClass(c: *Client, class: u16) Error!Reply {
310 const reply = try c.readReply();
311 if (reply.code / 100 != class) return error.UnexpectedReply;
312 return reply;
313}
314
315test sendMail {
316 const responses = "220 mx.example.com ESMTP\r\n" ++
317 "250-mx.example.com\r\n250-PIPELINING\r\n250-8BITMIME\r\n250 SIZE 1000000\r\n" ++
318 "250 2.1.0 Ok\r\n" ++
319 "250 2.1.5 Ok\r\n" ++
320 "354 End data with <CR><LF>.<CR><LF>\r\n" ++
321 "250 2.0.0 Ok\r\n" ++
322 "221 2.0.0 Bye\r\n";
323 var reader: Io.Reader = .fixed(responses);
324 var out_buf: [1024]u8 = undefined;
325 var writer: Io.Writer = .fixed(&out_buf);
326 var reply_buf: [512]u8 = undefined;
327 var client: Client = .init(&reader, &writer, &reply_buf);
328
329 _ = try client.greet();
330 const ext = try client.hello("client.example.org");
331 try std.testing.expect(ext.pipelining);
332 try std.testing.expect(ext.eight_bit_mime);
333 try std.testing.expect(!ext.starttls);
334 try std.testing.expectEqual(@as(?u64, 1000000), ext.max_size);
335
336 try client.sendMail(
337 "alice@example.com",
338 &.{"bob@example.net"},
339 "Subject: hi\r\n\r\n.leading dot\r\n",
340 );
341 try client.quit();
342
343 try std.testing.expectEqualStrings(
344 "EHLO client.example.org\r\n" ++
345 "MAIL FROM:<alice@example.com>\r\n" ++
346 "RCPT TO:<bob@example.net>\r\n" ++
347 "DATA\r\n" ++
348 "Subject: hi\r\n\r\n..leading dot\r\n.\r\n" ++
349 "QUIT\r\n",
350 writer.buffered(),
351 );
352}
353
354test "HELO fallback for non-ESMTP servers" {
355 const responses = "220 old.example.com\r\n" ++
356 "502 command not implemented\r\n" ++
357 "250 old.example.com\r\n";
358 var reader: Io.Reader = .fixed(responses);
359 var out_buf: [256]u8 = undefined;
360 var writer: Io.Writer = .fixed(&out_buf);
361 var reply_buf: [256]u8 = undefined;
362 var client: Client = .init(&reader, &writer, &reply_buf);
363
364 _ = try client.greet();
365 const ext = try client.hello("client.example.org");
366 try std.testing.expectEqual(Extensions{}, ext);
367 try std.testing.expectEqualStrings(
368 "EHLO client.example.org\r\nHELO client.example.org\r\n",
369 writer.buffered(),
370 );
371}
372
373test "rejected recipient surfaces the reply" {
374 const responses = "550 5.1.1 No such user\r\n";
375 var reader: Io.Reader = .fixed(responses);
376 var out_buf: [256]u8 = undefined;
377 var writer: Io.Writer = .fixed(&out_buf);
378 var reply_buf: [256]u8 = undefined;
379 var client: Client = .init(&reader, &writer, &reply_buf);
380
381 try std.testing.expectError(error.UnexpectedReply, client.rcptTo("nobody@example.com"));
382 try std.testing.expectEqual(@as(u16, 550), client.last_reply.?.code);
383 try std.testing.expectEqualStrings("5.1.1 No such user", client.last_reply.?.text);
384}
385
386test starttls {
387 const plain_responses = "220 mx.example.com ESMTP\r\n" ++
388 "250-mx.example.com\r\n250-STARTTLS\r\n250 8BITMIME\r\n" ++
389 "220 2.0.0 Ready to start TLS\r\n";
390 var reader: Io.Reader = .fixed(plain_responses);
391 var out_buf: [256]u8 = undefined;
392 var writer: Io.Writer = .fixed(&out_buf);
393 var reply_buf: [256]u8 = undefined;
394 var client: Client = .init(&reader, &writer, &reply_buf);
395
396 _ = try client.greet();
397 const ext = try client.hello("client.example.org");
398 try std.testing.expect(ext.starttls);
399 try client.starttls();
400
401 // Simulate the post-handshake encrypted transport with fresh buffers;
402 // the session must re-EHLO on it.
403 const tls_responses = "250-mx.example.com\r\n250 8BITMIME\r\n";
404 var tls_reader: Io.Reader = .fixed(tls_responses);
405 var tls_out_buf: [256]u8 = undefined;
406 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
407 client.setTransport(&tls_reader, &tls_writer);
408
409 const tls_ext = try client.hello("client.example.org");
410 try std.testing.expect(!tls_ext.starttls);
411 try std.testing.expect(tls_ext.eight_bit_mime);
412 try std.testing.expectEqualStrings(
413 "EHLO client.example.org\r\nSTARTTLS\r\n",
414 writer.buffered(),
415 );
416 try std.testing.expectEqualStrings("EHLO client.example.org\r\n", tls_writer.buffered());
417}
418
419test authPlain {
420 const responses = "235 2.7.0 Accepted\r\n";
421 var reader: Io.Reader = .fixed(responses);
422 var out_buf: [256]u8 = undefined;
423 var writer: Io.Writer = .fixed(&out_buf);
424 var reply_buf: [256]u8 = undefined;
425 var client: Client = .init(&reader, &writer, &reply_buf);
426
427 try client.authPlain("", "user", "pass");
428 // base64("\x00user\x00pass")
429 try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered());
430}
431
432test authLogin {
433 const responses = "334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Accepted\r\n";
434 var reader: Io.Reader = .fixed(responses);
435 var out_buf: [256]u8 = undefined;
436 var writer: Io.Writer = .fixed(&out_buf);
437 var reply_buf: [256]u8 = undefined;
438 var client: Client = .init(&reader, &writer, &reply_buf);
439
440 try client.authLogin("user", "pass");
441 try std.testing.expectEqualStrings(
442 "AUTH LOGIN\r\ndXNlcg==\r\ncGFzcw==\r\n",
443 writer.buffered(),
444 );
445}
446
447test authCramMd5 {
448 // Challenge "<1896.697170952@postoffice.reston.mci.net>", user "tim",
449 // password "tanstaaftanstaaf" => digest b913a602c7eda7a495b4e6e7334d3890.
450 const responses = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++
451 "235 2.7.0 Accepted\r\n";
452 var reader: Io.Reader = .fixed(responses);
453 var out_buf: [256]u8 = undefined;
454 var writer: Io.Writer = .fixed(&out_buf);
455 var reply_buf: [256]u8 = undefined;
456 var client: Client = .init(&reader, &writer, &reply_buf);
457
458 try client.authCramMd5("tim", "tanstaaftanstaaf");
459 try std.testing.expectEqualStrings(
460 "AUTH CRAM-MD5\r\ndGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw\r\n",
461 writer.buffered(),
462 );
463}
464
465test authenticate {
466 var out_buf: [256]u8 = undefined;
467 var reply_buf: [256]u8 = undefined;
468 {
469 // Only CRAM-MD5 advertised.
470 const responses = "334 YWJj\r\n235 ok\r\n";
471 var reader: Io.Reader = .fixed(responses);
472 var writer: Io.Writer = .fixed(&out_buf);
473 var client: Client = .init(&reader, &writer, &reply_buf);
474 try client.authenticate(.{ .auth = .{ .cram_md5 = true } }, "u", "p");
475 try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n"));
476 }
477 {
478 // Nothing advertised.
479 var reader: Io.Reader = .fixed("");
480 var writer: Io.Writer = .fixed(&out_buf);
481 var client: Client = .init(&reader, &writer, &reply_buf);
482 try std.testing.expectError(
483 error.NoSupportedMechanism,
484 client.authenticate(.{}, "u", "p"),
485 );
486 }
487}
488
489test "rejected credentials surface AuthenticationFailed" {
490 const responses = "535 5.7.8 Authentication credentials invalid\r\n";
491 var reader: Io.Reader = .fixed(responses);
492 var out_buf: [256]u8 = undefined;
493 var writer: Io.Writer = .fixed(&out_buf);
494 var reply_buf: [256]u8 = undefined;
495 var client: Client = .init(&reader, &writer, &reply_buf);
496
497 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p"));
498 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code);
499}
500
501test hello {
502 const responses = "250-mx.example.com\r\n250-AUTH PLAIN LOGIN CRAM-MD5\r\n250 8BITMIME\r\n";
503 var reader: Io.Reader = .fixed(responses);
504 var out_buf: [256]u8 = undefined;
505 var writer: Io.Writer = .fixed(&out_buf);
506 var reply_buf: [256]u8 = undefined;
507 var client: Client = .init(&reader, &writer, &reply_buf);
508
509 const ext = try client.hello("c.example");
510 try std.testing.expect(ext.auth.plain);
511 try std.testing.expect(ext.auth.login);
512 try std.testing.expect(ext.auth.cram_md5);
513 try std.testing.expect(ext.auth.any());
514}
515
516test init {
517 var reader: Io.Reader = .fixed("");
518 var out_buf: [16]u8 = undefined;
519 var writer: Io.Writer = .fixed(&out_buf);
520 var reply_buf: [128]u8 = undefined;
521 const client: Client = .init(&reader, &writer, &reply_buf);
522 try std.testing.expect(client.last_reply == null);
523}
524
525test greet {
526 var reader: Io.Reader = .fixed("220 mx.example.com ESMTP ready\r\n");
527 var out_buf: [16]u8 = undefined;
528 var writer: Io.Writer = .fixed(&out_buf);
529 var reply_buf: [128]u8 = undefined;
530 var client: Client = .init(&reader, &writer, &reply_buf);
531
532 const reply = try client.greet();
533 try std.testing.expectEqual(@as(u16, 220), reply.code);
534 try std.testing.expectEqualStrings("mx.example.com ESMTP ready", reply.text);
535}
536
537test setTransport {
538 var reader: Io.Reader = .fixed("");
539 var out_buf: [16]u8 = undefined;
540 var writer: Io.Writer = .fixed(&out_buf);
541 var reply_buf: [64]u8 = undefined;
542 var client: Client = .init(&reader, &writer, &reply_buf);
543
544 // After a TLS handshake, point the session at the encrypted streams.
545 var tls_reader: Io.Reader = .fixed("");
546 var tls_out_buf: [16]u8 = undefined;
547 var tls_writer: Io.Writer = .fixed(&tls_out_buf);
548 client.setTransport(&tls_reader, &tls_writer);
549 try std.testing.expectEqual(&tls_reader, client.reader);
550 try std.testing.expectEqual(&tls_writer, client.writer);
551}
552
553test mailFrom {
554 var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n");
555 var out_buf: [64]u8 = undefined;
556 var writer: Io.Writer = .fixed(&out_buf);
557 var reply_buf: [64]u8 = undefined;
558 var client: Client = .init(&reader, &writer, &reply_buf);
559
560 try client.mailFrom("alice@example.com");
561 try std.testing.expectEqualStrings("MAIL FROM:<alice@example.com>\r\n", writer.buffered());
562}
563
564test rcptTo {
565 var reader: Io.Reader = .fixed("250 2.1.5 Ok\r\n");
566 var out_buf: [64]u8 = undefined;
567 var writer: Io.Writer = .fixed(&out_buf);
568 var reply_buf: [64]u8 = undefined;
569 var client: Client = .init(&reader, &writer, &reply_buf);
570
571 try client.rcptTo("bob@example.net");
572 try std.testing.expectEqualStrings("RCPT TO:<bob@example.net>\r\n", writer.buffered());
573}
574
575test sendMessage {
576 var reader: Io.Reader = .fixed("354 End data with <CR><LF>.<CR><LF>\r\n250 2.0.0 Ok\r\n");
577 var out_buf: [128]u8 = undefined;
578 var writer: Io.Writer = .fixed(&out_buf);
579 var reply_buf: [64]u8 = undefined;
580 var client: Client = .init(&reader, &writer, &reply_buf);
581
582 try client.sendMessage("Subject: hi\n\nhello\n");
583 try std.testing.expectEqualStrings(
584 "DATA\r\nSubject: hi\r\n\r\nhello\r\n.\r\n",
585 writer.buffered(),
586 );
587}
588
589test rset {
590 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
591 var out_buf: [16]u8 = undefined;
592 var writer: Io.Writer = .fixed(&out_buf);
593 var reply_buf: [64]u8 = undefined;
594 var client: Client = .init(&reader, &writer, &reply_buf);
595
596 try client.rset();
597 try std.testing.expectEqualStrings("RSET\r\n", writer.buffered());
598}
599
600test noop {
601 var reader: Io.Reader = .fixed("250 2.0.0 Ok\r\n");
602 var out_buf: [16]u8 = undefined;
603 var writer: Io.Writer = .fixed(&out_buf);
604 var reply_buf: [64]u8 = undefined;
605 var client: Client = .init(&reader, &writer, &reply_buf);
606
607 try client.noop();
608 try std.testing.expectEqualStrings("NOOP\r\n", writer.buffered());
609}
610
611test quit {
612 var reader: Io.Reader = .fixed("221 2.0.0 Bye\r\n");
613 var out_buf: [16]u8 = undefined;
614 var writer: Io.Writer = .fixed(&out_buf);
615 var reply_buf: [64]u8 = undefined;
616 var client: Client = .init(&reader, &writer, &reply_buf);
617
618 try client.quit();
619 try std.testing.expectEqualStrings("QUIT\r\n", writer.buffered());
620}