An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-smtp / src / main.zig
24 kB 563 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! Demo CLI for the zig-smtp library. 5//! 6//! zig-smtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] 7//! [--chunking] [--smtputf8] [--user <u> --password <p>] 8//! [--auth-method plain|login|cram-md5] 9//! [--ret full|hdrs] [--envid <id>] 10//! [--notify never|success,failure,delay] [--orcpt <address>] 11//! [--lmtp] [--binarymime] <host> <port> <from> <to>... 12//! send a message read from stdin; --tls speaks TLS from the first 13//! byte (port 465 style), --starttls upgrades after EHLO (port 587 14//! style), --insecure skips certificate verification, --user/--password 15//! authenticate with the best advertised mechanism (or the one forced 16//! by --auth-method), and --allow-cleartext-auth permits a mechanism 17//! that sends the password over an unencrypted connection; the DSN 18//! options (RFC 3461) are --ret and --envid on the message and 19//! --notify and --orcpt on every recipient; --binarymime sends the 20//! input as BODY=BINARYMIME over BDAT, byte for byte 21//! zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 22//! [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]] 23//! <port> 24//! run a debug server on 127.0.0.1 that prints received messages; 25//! --auth requires authentication with the given credentials; with a 26//! certificate and key it advertises and accepts STARTTLS, or speaks 27//! TLS from the first byte with --implicit-tls; --lmtp speaks LMTP 28//! instead of SMTP, where --fail-delivery names one recipient to 29//! report as undeliverable at the end of the message 30 31const std = @import("std"); 32const Io = std.Io; 33const smtp = @import("smtp"); 34 35pub fn main(init: std.process.Init) !void { 36 const arena = init.arena.allocator(); 37 const io = init.io; 38 const args = try init.minimal.args.toSlice(arena); 39 40 if (args.len >= 2 and std.mem.eql(u8, args[1], "send")) { 41 var config: SendConfig = .{}; 42 var rest = args[2..]; 43 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { 44 if (std.mem.eql(u8, rest[0], "--tls")) { 45 config.mode = .tls; 46 } else if (std.mem.eql(u8, rest[0], "--starttls")) { 47 config.mode = .starttls; 48 } else if (std.mem.eql(u8, rest[0], "--insecure")) { 49 config.insecure = true; 50 } else if (std.mem.eql(u8, rest[0], "--allow-cleartext-auth")) { 51 config.allow_cleartext_auth = true; 52 } else if (std.mem.eql(u8, rest[0], "--chunking")) { 53 config.chunking = true; 54 } else if (std.mem.eql(u8, rest[0], "--smtputf8")) { 55 config.smtputf8 = true; 56 } else if (std.mem.eql(u8, rest[0], "--lmtp")) { 57 config.protocol = .lmtp; 58 } else if (std.mem.eql(u8, rest[0], "--binarymime")) { 59 // Binary content can only be framed by BDAT, so this is 60 // chunking plus a declaration of what the chunks hold. 61 config.body = .binary_mime; 62 config.chunking = true; 63 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--ret")) { 64 config.ret = smtp.protocol.Ret.parse(rest[1]) catch return usage(); 65 rest = rest[1..]; 66 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--envid")) { 67 config.envid = rest[1]; 68 rest = rest[1..]; 69 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--notify")) { 70 config.notify = smtp.protocol.Notify.parse(rest[1]) catch return usage(); 71 rest = rest[1..]; 72 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--orcpt")) { 73 // Applied to every recipient, which is all a one-shot 74 // sender can sensibly do with it. 75 config.orcpt = rest[1]; 76 rest = rest[1..]; 77 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--user")) { 78 config.username = rest[1]; 79 rest = rest[1..]; 80 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--password")) { 81 config.password = rest[1]; 82 rest = rest[1..]; 83 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth-method")) { 84 config.auth_method = std.meta.stringToEnum( 85 @TypeOf(config.auth_method), 86 rest[1], 87 ) orelse if (std.mem.eql(u8, rest[1], "cram-md5")) .cram_md5 else return usage(); 88 rest = rest[1..]; 89 } else { 90 return usage(); 91 } 92 rest = rest[1..]; 93 } 94 if ((config.username == null) != (config.password == null)) return usage(); 95 if (rest.len < 4) return usage(); 96 return send(io, arena, config, rest[0], rest[1], rest[2], rest[3..]); 97 } 98 if (args.len >= 2 and std.mem.eql(u8, args[1], "serve")) { 99 var config: ServeConfig = .{}; 100 var rest = args[2..]; 101 while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) { 102 if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-cert")) { 103 config.cert_path = rest[1]; 104 rest = rest[1..]; 105 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--tls-key")) { 106 config.key_path = rest[1]; 107 rest = rest[1..]; 108 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--auth")) { 109 const sep = std.mem.indexOfScalar(u8, rest[1], ':') orelse return usage(); 110 config.username = rest[1][0..sep]; 111 config.password = rest[1][sep + 1 ..]; 112 rest = rest[1..]; 113 } else if (std.mem.eql(u8, rest[0], "--implicit-tls")) { 114 config.implicit_tls = true; 115 } else if (std.mem.eql(u8, rest[0], "--lmtp")) { 116 config.protocol = .lmtp; 117 } else if (rest.len >= 2 and std.mem.eql(u8, rest[0], "--fail-delivery")) { 118 config.fail_delivery = rest[1]; 119 rest = rest[1..]; 120 } else { 121 return usage(); 122 } 123 rest = rest[1..]; 124 } 125 if (rest.len != 1) return usage(); 126 if ((config.cert_path == null) != (config.key_path == null)) return usage(); 127 if (config.implicit_tls and config.cert_path == null) return usage(); 128 if (config.fail_delivery != null and config.protocol != .lmtp) return usage(); 129 return serve(io, arena, config, rest[0]); 130 } 131 return usage(); 132} 133 134const ServeConfig = struct { 135 cert_path: ?[]const u8 = null, 136 key_path: ?[]const u8 = null, 137 implicit_tls: bool = false, 138 protocol: smtp.Server.Protocol = .smtp, 139 /// Accepted at RCPT time and then failed at the end of the message, 140 /// which only LMTP can say. 141 fail_delivery: ?[]const u8 = null, 142 username: ?[]const u8 = null, 143 password: ?[]const u8 = null, 144}; 145 146const SendConfig = struct { 147 mode: enum { plain, tls, starttls } = .plain, 148 insecure: bool = false, 149 allow_cleartext_auth: bool = false, 150 chunking: bool = false, 151 smtputf8: bool = false, 152 protocol: smtp.Client.Protocol = .smtp, 153 body: ?smtp.protocol.Body = null, 154 ret: ?smtp.protocol.Ret = null, 155 envid: ?[]const u8 = null, 156 notify: ?smtp.protocol.Notify = null, 157 orcpt: ?[]const u8 = null, 158 username: ?[]const u8 = null, 159 password: ?[]const u8 = null, 160 auth_method: enum { auto, plain, login, cram_md5 } = .auto, 161}; 162 163fn usage() noreturn { 164 std.log.err( 165 \\usage: 166 \\ zig-smtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] 167 \\ [--user <u> --password <p>] 168 \\ [--auth-method plain|login|cram-md5] 169 \\ [--ret full|hdrs] [--envid <id>] 170 \\ [--notify never|success,failure,delay] [--orcpt <address>] 171 \\ [--lmtp] [--binarymime] <host> <port> <from> <to>... 172 \\ (message is read from stdin) 173 \\ zig-smtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 174 \\ [--auth <user>:<pass>] [--lmtp [--fail-delivery <address>]] 175 \\ <port> 176 , .{}); 177 std.process.exit(1); 178} 179 180fn send( 181 io: Io, 182 arena: std.mem.Allocator, 183 config: SendConfig, 184 host_arg: []const u8, 185 port_arg: []const u8, 186 from: []const u8, 187 recipients: []const []const u8, 188) !void { 189 const host = try Io.net.HostName.init(host_arg); 190 const port = try std.fmt.parseInt(u16, port_arg, 10); 191 192 var stdin_buf: [4096]u8 = undefined; 193 var stdin: Io.File.Reader = .init(.stdin(), io, &stdin_buf); 194 195 const stream = try host.connect(io, port, .{ .mode = .stream }); 196 defer stream.close(io); 197 // The TLS layer requires stream buffers of at least min_buffer_len. 198 const read_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len); 199 const write_buf = try arena.alloc(u8, smtp.Tls.min_buffer_len); 200 var stream_reader = stream.reader(io, read_buf); 201 var stream_writer = stream.writer(io, write_buf); 202 203 const tls_options: smtp.Tls.Options = .{ 204 .host = host_arg, 205 .ca = if (config.insecure) .insecure else .system, 206 }; 207 var tls: smtp.Tls = undefined; 208 var tls_active = false; 209 defer if (tls_active) { 210 tls.end() catch {}; 211 tls.deinit(arena); 212 }; 213 214 var reply_buf: [1024]u8 = undefined; 215 var client: smtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 216 client.allow_cleartext_auth = config.allow_cleartext_auth; 217 // The SASL scratch is the caller's; a mechanism never allocates one for 218 // itself and nothing puts one on the stack behind your back. 219 var sasl_scratch: [smtp.Client.sasl_buffer_suggested]u8 = undefined; 220 client.sasl_buffer = &sasl_scratch; 221 client.mode = config.protocol; 222 223 if (config.mode == .tls) { 224 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options); 225 tls_active = true; 226 client.setTransport(tls.reader(), tls.writer(), .encrypted); 227 } 228 229 _ = try client.greet(); 230 var extensions = try client.hello("localhost"); 231 232 if (config.mode == .starttls) { 233 try client.starttls(); 234 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options); 235 tls_active = true; 236 client.setTransport(tls.reader(), tls.writer(), .encrypted); 237 extensions = try client.hello("localhost"); 238 } 239 240 if (config.username) |username| { 241 const password = config.password.?; 242 // The mechanisms come from zig-sasl; what is chosen from them is the 243 // caller's business, and this one lets --auth-method force it. 244 var plain: smtp.sasl.Plain = .init(username, password); 245 var login: smtp.sasl.Login = .init(username, password); 246 var cram_md5: smtp.sasl.CramMd5 = .init(username, password); 247 const offered: []const smtp.sasl.Client = switch (config.auth_method) { 248 // In order of preference, which `selectFromList` reads as such: 249 // PLAIN because every server implements it correctly, CRAM-MD5 250 // last because it is the oldest. On a carrier with no encryption 251 // the first two are skipped and it is the only one left. 252 .auto => &.{ plain.client(), login.client(), cram_md5.client() }, 253 .plain => &.{plain.client()}, 254 .login => &.{login.client()}, 255 .cram_md5 => &.{cram_md5.client()}, 256 }; 257 const mechanism = smtp.sasl.Client.selectFromList( 258 offered, 259 extensions.auth, 260 client.security == .encrypted or client.allow_cleartext_auth, 261 ) orelse { 262 std.log.err( 263 "no usable mechanism; the server offers: {s}{s}", 264 .{ 265 if (extensions.auth.len == 0) "(none)" else extensions.auth, 266 // The common case by far: everything on offer sends the 267 // password, and this connection is not encrypted. 268 if (client.security == .plaintext and !client.allow_cleartext_auth) 269 ", and this connection is not encrypted " ++ 270 "(use --starttls or --tls, or --allow-cleartext-auth)" 271 else 272 "", 273 }, 274 ); 275 return error.NoSupportedMechanism; 276 }; 277 client.authenticate(mechanism) catch |err| { 278 switch (err) { 279 error.AuthenticationFailed => { 280 const reply = client.last_reply.?; 281 std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 282 }, 283 error.ServerNotAuthenticated => std.log.err( 284 "the server accepted the login without proving itself; " ++ 285 "this is not the server it claims to be", 286 .{}, 287 ), 288 error.InsecureTransport => std.log.err( 289 "refusing to send credentials over an unencrypted connection; " ++ 290 "use --starttls or --tls, or pass --allow-cleartext-auth", 291 .{}, 292 ), 293 else => {}, 294 } 295 return err; 296 }; 297 } 298 299 if (config.chunking and !extensions.chunking) { 300 std.log.err("server does not advertise CHUNKING", .{}); 301 return error.ChunkingNotAdvertised; 302 } 303 if (config.smtputf8 and !extensions.smtputf8) { 304 std.log.err("server does not advertise SMTPUTF8", .{}); 305 return error.SmtpUtf8NotAdvertised; 306 } 307 if (config.body == .binary_mime and !extensions.binary_mime) { 308 // RFC 3030 is absolute about this one: without the advertisement, 309 // binary must not be sent under any circumstances. 310 std.log.err("server does not advertise BINARYMIME", .{}); 311 return error.BinaryMimeNotAdvertised; 312 } 313 const wants_dsn = config.ret != null or config.envid != null or 314 config.notify != null or config.orcpt != null; 315 if (wants_dsn and !extensions.dsn) { 316 // A conforming server answers an unrecognized parameter with 555, 317 // so this is only a clearer way to say the same thing. 318 std.log.err("server does not advertise DSN", .{}); 319 return error.DsnNotAdvertised; 320 } 321 transact(&client, config, from, recipients, &stdin.interface) catch |err| { 322 if (err == error.UnexpectedReply) { 323 const reply = client.last_reply.?; 324 std.log.err("server rejected: {d} {s}", .{ reply.code, reply.text }); 325 } 326 return err; 327 }; 328 try client.quit(); 329 std.log.info("message sent to {d} recipient(s)", .{recipients.len}); 330} 331 332/// Runs the mail transaction, streaming the message from `message` so 333/// arbitrarily large input never has to fit in memory. 334fn transact( 335 client: *smtp.Client, 336 config: SendConfig, 337 from: []const u8, 338 recipients: []const []const u8, 339 message: *Io.Reader, 340) (smtp.Client.Error || smtp.Client.ArgumentError)!void { 341 try client.mail(from, .{ 342 .smtputf8 = config.smtputf8, 343 .body = config.body, 344 .ret = config.ret, 345 .envid = config.envid, 346 }); 347 for (recipients) |recipient| try client.rcpt(recipient, .{ 348 .notify = config.notify, 349 .orcpt = if (config.orcpt) |address| 350 .{ .addr_type = "rfc822", .address = address } 351 else 352 null, 353 }); 354 if (config.chunking) { 355 // BDAT sends the input verbatim (no line-ending normalization). 356 while (true) { 357 const chunk = message.peekGreedy(1) catch |err| switch (err) { 358 error.EndOfStream => break, 359 error.ReadFailed => return error.ReadFailed, 360 }; 361 try client.bdat(chunk, false); 362 message.toss(chunk.len); 363 } 364 try client.bdat("", true); 365 } else { 366 var data_writer = try client.data(); 367 while (true) { 368 const chunk = message.peekGreedy(1) catch |err| switch (err) { 369 error.EndOfStream => break, 370 error.ReadFailed => return error.ReadFailed, 371 }; 372 try data_writer.interface.writeAll(chunk); 373 message.toss(chunk.len); 374 } 375 // In LMTP there is one verdict per recipient rather than one for 376 // the message, and reporting them individually is the only reason 377 // to be speaking it. 378 var verdicts = try data_writer.endResults(); 379 var failed = false; 380 while (try verdicts.next()) |reply| { 381 if (config.protocol == .lmtp) { 382 std.log.info("{s}: {d} {s}", .{ 383 recipients[verdicts.index - 1], 384 reply.code, 385 reply.text, 386 }); 387 } 388 if (!reply.isPositiveCompletion()) failed = true; 389 } 390 // Each verdict was reported above, so the error only has to say 391 // that one of them was a refusal. 392 if (failed) return if (config.protocol == .lmtp) 393 error.RecipientRejected 394 else 395 error.UnexpectedReply; 396 } 397} 398 399fn serve(io: Io, gpa: std.mem.Allocator, config: ServeConfig, port_arg: []const u8) !void { 400 const port = try std.fmt.parseInt(u16, port_arg, 10); 401 const address: Io.net.IpAddress = .{ .ip4 = .loopback(port) }; 402 var listener = try address.listen(io, .{}); 403 defer listener.deinit(io); 404 405 var auth: ?smtp.tls.config.CertKeyPair = if (config.cert_path) |cert_path| 406 try .fromFilePath(gpa, io, .cwd(), cert_path, config.key_path.?) 407 else 408 null; 409 const tls_options: ?smtp.Server.TlsOptions = if (auth) |*a| .{ 410 .io = io, 411 .auth = a, 412 .mode = if (config.implicit_tls) .implicit else .starttls, 413 } else null; 414 std.log.info("listening on 127.0.0.1:{d}{s}", .{ 415 port, 416 if (tls_options) |t| switch (t.mode) { 417 .starttls => " with STARTTLS", 418 .implicit => " with implicit TLS", 419 } else "", 420 }); 421 422 var stdout_buf: [4096]u8 = undefined; 423 var stdout: Io.File.Writer = .init(.stdout(), io, &stdout_buf); 424 425 var printer: MessagePrinter = .{ 426 .out = &stdout.interface, 427 .username = config.username, 428 .password = config.password, 429 .fail_delivery = config.fail_delivery, 430 }; 431 // The credential check, which PLAIN and LOGIN share, and the password 432 // lookup CRAM-MD5 needs instead. Both close over the same one account. 433 const check: smtp.sasl.Server.PasswordCheck = .{ 434 .context = &printer, 435 .verify = MessagePrinter.verify, 436 }; 437 const passwords: smtp.sasl.Server.PasswordLookup = .{ 438 .context = &printer, 439 .lookup = MessagePrinter.lookup, 440 }; 441 442 var connections: usize = 0; 443 while (true) { 444 const stream = try listener.accept(io); 445 defer stream.close(io); 446 connections += 1; 447 448 // A fresh set per connection: the mechanisms hold per-exchange state, 449 // and CRAM-MD5's challenge must not repeat between them. 450 // RFC 2195 wants a challenge that never repeats. A counter and the 451 // clock is what a real server would use, plus its hostname. 452 var challenge_buf: [128]u8 = undefined; 453 const challenge = std.fmt.bufPrint( 454 &challenge_buf, 455 "<{d}.{d}@localhost>", 456 .{ connections, Io.Clock.real.now(io).nanoseconds }, 457 ) catch unreachable; 458 var sasl_scratch: [smtp.Server.sasl_buffer_suggested]u8 = undefined; 459 var plain: smtp.sasl.PlainServer = .init(check); 460 var login: smtp.sasl.LoginServer = .init(check); 461 var cram_md5: smtp.sasl.CramMd5Server = .init(challenge, passwords); 462 const mechanisms: []const smtp.sasl.Server = if (config.username == null) 463 &.{} 464 else 465 &.{ plain.server(), login.server(), cram_md5.server() }; 466 467 // Sized for the TLS handshake, which runs over the raw stream. 468 const read_buf = try gpa.alloc(u8, smtp.tls.input_buffer_len); 469 defer gpa.free(read_buf); 470 const write_buf = try gpa.alloc(u8, smtp.tls.output_buffer_len); 471 defer gpa.free(write_buf); 472 var stream_reader = stream.reader(io, read_buf); 473 var stream_writer = stream.writer(io, write_buf); 474 var session: smtp.Server = .init( 475 &stream_reader.interface, 476 &stream_writer.interface, 477 .{ .context = &printer, .vtable = &.{ 478 .message = MessagePrinter.onMessage, 479 .recipientResult = MessagePrinter.onRecipientResult, 480 } }, 481 .{ 482 .protocol = config.protocol, 483 .hostname = "localhost", 484 .tls = tls_options, 485 .auth_mechanisms = mechanisms, 486 .sasl_buffer = &sasl_scratch, 487 .require_auth = config.username != null, 488 }, 489 ); 490 session.run(gpa) catch |err| { 491 std.log.warn("session ended with error: {t}", .{err}); 492 }; 493 } 494} 495 496const MessagePrinter = struct { 497 out: *Io.Writer, 498 username: ?[]const u8 = null, 499 password: ?[]const u8 = null, 500 fail_delivery: ?[]const u8 = null, 501 502 /// What PLAIN and LOGIN ask: is this password right? The answer is the 503 /// identity to report, which for this one-account server is the username. 504 fn verify( 505 context: ?*anyopaque, 506 authzid: []const u8, 507 authcid: []const u8, 508 password: []const u8, 509 ) ?[]const u8 { 510 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 511 // Acting as somebody else is not a thing this server does. 512 if (authzid.len != 0) return null; 513 if (!std.mem.eql(u8, authcid, printer.username.?)) return null; 514 if (!std.mem.eql(u8, password, printer.password.?)) return null; 515 return printer.username.?; 516 } 517 518 /// What CRAM-MD5 asks instead: the password itself, because it has to 519 /// compute the same HMAC the client did. 520 fn lookup(context: ?*anyopaque, username: []const u8) ?[]const u8 { 521 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 522 if (!std.mem.eql(u8, username, printer.username.?)) return null; 523 return printer.password.?; 524 } 525 526 fn onMessage(context: ?*anyopaque, envelope: smtp.Server.Envelope, data: []const u8) smtp.Server.Decision { 527 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 528 printer.print(envelope, data) catch 529 return .{ .reject = .{ .code = 451, .text = "4.3.0 Local error" } }; 530 return .accept; 531 } 532 533 /// LMTP's per-recipient verdict. Everything was already printed by 534 /// `onMessage`; this only reports the one address `--fail-delivery` 535 /// names as undeliverable, which is the outcome SMTP has no way to 536 /// express for one recipient out of several. 537 fn onRecipientResult( 538 context: ?*anyopaque, 539 envelope: smtp.Server.Envelope, 540 index: usize, 541 ) smtp.Server.Decision { 542 const printer: *MessagePrinter = @ptrCast(@alignCast(context.?)); 543 const failing = printer.fail_delivery orelse return .accept; 544 if (std.mem.eql(u8, envelope.recipients[index].address, failing)) 545 return .{ .reject = .{ .code = 550, .text = "5.2.1 Mailbox disabled" } }; 546 return .accept; 547 } 548 549 fn print(printer: *MessagePrinter, envelope: smtp.Server.Envelope, data: []const u8) !void { 550 try printer.out.print("--- message from <{s}> to", .{envelope.from}); 551 for (envelope.recipients) |recipient| { 552 try printer.out.print(" <{s}>", .{recipient.address}); 553 // DSN parameters, printed so that a session can be checked from 554 // the outside (which is what the interop test does). 555 if (recipient.notify) |notify| try printer.out.print(" NOTIFY={f}", .{notify}); 556 if (recipient.orcpt) |orcpt| try printer.out.print(" ORCPT={f}", .{orcpt}); 557 } 558 if (envelope.ret) |ret| try printer.out.print(" RET={f}", .{ret}); 559 if (envelope.envid) |envid| try printer.out.print(" ENVID={s}", .{envid}); 560 try printer.out.print(" ({d} bytes)\n{s}---\n", .{ data.len, data }); 561 try printer.out.flush(); 562 } 563};