summaryrefslogtreecommitdiff
path: root/corebinutils
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:28:47 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:28:47 +0300
commit3e9a42db4c6c61e0df8e808db51a834525c4fb0c (patch)
tree387cfb80342e7961d32b76955cd490464f946685 /corebinutils
parentf1d193fda398f32fe0b21d6448d3393661e0982b (diff)
parentdfe4433f744e8e68d04dee0eab4eb9ae2a884c3e (diff)
downloadProject-Tick-3e9a42db4c6c61e0df8e808db51a834525c4fb0c.tar.gz
Project-Tick-3e9a42db4c6c61e0df8e808db51a834525c4fb0c.zip
Add 'corebinutils/rmail/' from commit 'dfe4433f744e8e68d04dee0eab4eb9ae2a884c3e'
git-subtree-dir: corebinutils/rmail git-subtree-mainline: f1d193fda398f32fe0b21d6448d3393661e0982b git-subtree-split: dfe4433f744e8e68d04dee0eab4eb9ae2a884c3e
Diffstat (limited to 'corebinutils')
-rw-r--r--corebinutils/rmail/.gitignore25
-rw-r--r--corebinutils/rmail/GNUmakefile43
-rw-r--r--corebinutils/rmail/README.md43
-rw-r--r--corebinutils/rmail/rmail.155
-rw-r--r--corebinutils/rmail/rmail.c516
-rw-r--r--corebinutils/rmail/tests/mock_sendmail.c85
-rw-r--r--corebinutils/rmail/tests/test.sh233
7 files changed, 1000 insertions, 0 deletions
diff --git a/corebinutils/rmail/.gitignore b/corebinutils/rmail/.gitignore
new file mode 100644
index 0000000000..a74d30b48c
--- /dev/null
+++ b/corebinutils/rmail/.gitignore
@@ -0,0 +1,25 @@
+*.a
+*.core
+*.lo
+*.nossppico
+*.o
+*.orig
+*.pico
+*.pieo
+*.po
+*.rej
+*.so
+*.so.[0-9]*
+*.sw[nop]
+*~
+.*DS_Store
+.cache
+.clangd
+.ccls-cache
+.depend*
+compile_commands.json
+compile_commands.events.json
+tags
+build/
+out/
+.linux-obj/
diff --git a/corebinutils/rmail/GNUmakefile b/corebinutils/rmail/GNUmakefile
new file mode 100644
index 0000000000..2d95df56ca
--- /dev/null
+++ b/corebinutils/rmail/GNUmakefile
@@ -0,0 +1,43 @@
+.DEFAULT_GOAL := all
+
+CC ?= cc
+CPPFLAGS ?=
+CPPFLAGS += -D_POSIX_C_SOURCE=200809L
+CFLAGS ?= -O2
+CFLAGS += -std=c17 -g -Wall -Wextra -Werror
+LDFLAGS ?=
+LDLIBS ?=
+
+OBJDIR := $(CURDIR)/build
+OUTDIR := $(CURDIR)/out
+TARGET := $(OUTDIR)/rmail
+OBJS := $(OBJDIR)/rmail.o
+
+HELPER_SRC := $(CURDIR)/tests/mock_sendmail.c
+HELPER_BIN := $(OUTDIR)/mock_sendmail
+
+.PHONY: all clean dirs status test
+
+all: $(TARGET)
+
+dirs:
+ @mkdir -p "$(OBJDIR)" "$(OUTDIR)"
+
+$(TARGET): $(OBJS) | dirs
+ $(CC) $(LDFLAGS) -o "$@" $(OBJS) $(LDLIBS)
+
+$(OBJDIR)/rmail.o: $(CURDIR)/rmail.c | dirs
+ $(CC) $(CPPFLAGS) $(CFLAGS) -c "$(CURDIR)/rmail.c" -o "$@"
+
+$(HELPER_BIN): $(HELPER_SRC) | dirs
+ $(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) "$<" -o "$@" $(LDLIBS)
+
+test: $(TARGET) $(HELPER_BIN)
+ RMAIL_BIN="$(TARGET)" MOCK_SENDMAIL_BIN="$(HELPER_BIN)" \
+ sh "$(CURDIR)/tests/test.sh"
+
+status:
+ @printf '%s\n' "$(TARGET)"
+
+clean:
+ @rm -rf "$(OBJDIR)" "$(OUTDIR)"
diff --git a/corebinutils/rmail/README.md b/corebinutils/rmail/README.md
new file mode 100644
index 0000000000..441588a5f2
--- /dev/null
+++ b/corebinutils/rmail/README.md
@@ -0,0 +1,43 @@
+# rmail
+
+Standalone Linux-native port of FreeBSD `rmail` for Project Tick BSD/Linux Distribution.
+
+## Build
+
+```sh
+gmake -f GNUmakefile
+gmake -f GNUmakefile CC=musl-gcc
+```
+
+## Test
+
+```sh
+gmake -f GNUmakefile test
+gmake -f GNUmakefile test CC=musl-gcc
+```
+
+## Port Strategy
+
+- The FreeBSD/sendmail-lib implementation was rewritten as a standalone C17 utility. BSD build glue, `sm_io`, `sm_snprintf`, `_PATH_SENDMAIL`, and fixed-size header buffers were removed.
+- Input parsing remains manpage-compatible: `rmail` consumes leading `From` / `>From` UUCP envelope lines, compresses relay hops into a single return path, and forwards the remaining message body to a sendmail-compatible MTA.
+- Header parsing uses `getline(3)` instead of static 2048-byte buffers, so long UUCP `From` lines are handled without `PATH_MAX`-style or fixed-buffer limits.
+- Recipient validation stays strict: operands that begin with `-` are rejected instead of being forwarded to sendmail as flags.
+
+## BSD to Linux / POSIX Mapping
+
+| Original mechanism | Linux / POSIX replacement |
+|---|---|
+| `sm_io_fgets`, `sm_io_fprintf`, `sm_io_open`, `sm_io_close` | `getline(3)`, `fprintf(3)`, `fdopen(3)`, `fclose(3)` |
+| `_PATH_SENDMAIL` from sendmail headers | Local default path `/usr/sbin/sendmail` with test override via `RMAIL_SENDMAIL` |
+| `sm_snprintf` / sendmail alloc helpers | Local dynamic allocation helpers with `snprintf(3)` sizing |
+| Fixed 2048-byte parse buffers | Dynamic `getline(3)` + heap strings |
+| `lseek(2)` after `sm_io_tell` on regular stdin | `ftello(3)` to track body start, then `lseek(2)` on `STDIN_FILENO` before `execv(2)` |
+| `fork(2)` + `pipe(2)` + `execv(2)` | Preserved directly on Linux |
+
+## Supported / Unsupported Semantics
+
+- Supported: `-D domain`, `-T`, strict UUCP `From` / `>From` parsing, `remote from` precedence, bang-path compression, recipient wrapping for comma-containing addresses, and sendmail exit-status propagation.
+- Supported: regular-file stdin optimization. If stdin is a seekable regular file, `rmail` seeks to the start of the message body and `exec`s sendmail directly.
+- Unsupported by design: GNU long options and GNU option permutation. Parsing is strict FreeBSD/POSIX style.
+- Unsupported on Linux: there is no bundled sendmail ABI shim. `rmail` requires an external sendmail-compatible binary at `/usr/sbin/sendmail` or, for test harnesses, `RMAIL_SENDMAIL`. Missing or non-executable mailers fail explicitly.
+- Unsupported semantics are not stubbed. If the sendmail-compatible backend exits non-zero or cannot be executed, `rmail` returns an explicit error instead of pretending delivery succeeded.
diff --git a/corebinutils/rmail/rmail.1 b/corebinutils/rmail/rmail.1
new file mode 100644
index 0000000000..ed4c34a1cd
--- /dev/null
+++ b/corebinutils/rmail/rmail.1
@@ -0,0 +1,55 @@
+.\" Copyright (c) 1998-2000 Proofpoint, Inc. and its suppliers.
+.\" All rights reserved.
+.\" Copyright (c) 1983, 1990
+.\" The Regents of the University of California. All rights reserved.
+.\"
+.\" By using this file, you agree to the terms and conditions set
+.\" forth in the LICENSE file which can be found at the top level of
+.\" the sendmail distribution.
+.\"
+.TH RMAIL 1 "March 3, 2026"
+.SH NAME
+rmail
+\- handle remote mail received via uucp
+.SH SYNOPSIS
+.B rmail
+.RB [ \-D
+.IR domain ]
+.RB [ \-T ]
+.I user ...
+.SH DESCRIPTION
+.B Rmail
+interprets incoming mail received via
+uucp(1),
+collapsing ``From'' lines in the form generated by
+mail.local(8)
+into a single line of the form ``return-path!sender'',
+and passing the processed mail on to
+sendmail(8).
+.PP
+.B Rmail
+is explicitly designed for use with
+uucp
+and a sendmail-compatible mail transport agent.
+.SS Flags
+.TP
+.B \-D
+Use the specified
+.I domain
+instead of the default domain of ``UUCP''.
+.TP
+.B \-T
+Turn on debugging.
+.SH SEE ALSO
+uucp(1),
+mail.local(8),
+sendmail(8)
+.SH HISTORY
+The
+.B rmail
+program appeared in
+4.2BSD.
+.SH BUGS
+.B Rmail
+should not reside in
+/bin.
diff --git a/corebinutils/rmail/rmail.c b/corebinutils/rmail/rmail.c
new file mode 100644
index 0000000000..7498bff9cc
--- /dev/null
+++ b/corebinutils/rmail/rmail.c
@@ -0,0 +1,516 @@
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if __has_include(<sysexits.h>)
+#include <sysexits.h>
+#endif
+
+#ifndef EX_OK
+#define EX_OK 0
+#endif
+#ifndef EX_USAGE
+#define EX_USAGE 64
+#endif
+#ifndef EX_DATAERR
+#define EX_DATAERR 65
+#endif
+#ifndef EX_UNAVAILABLE
+#define EX_UNAVAILABLE 69
+#endif
+#ifndef EX_SOFTWARE
+#define EX_SOFTWARE 70
+#endif
+#ifndef EX_OSERR
+#define EX_OSERR 71
+#endif
+#ifndef EX_TEMPFAIL
+#define EX_TEMPFAIL 75
+#endif
+
+#ifndef RMAIL_SENDMAIL_PATH
+#define RMAIL_SENDMAIL_PATH "/usr/sbin/sendmail"
+#endif
+
+struct parsed_from_line {
+ char *system;
+ char *user;
+};
+
+static const char usage_message[] = "usage: rmail [-D domain] [-T] user ...";
+
+static void
+die_with_errno(int code, const char *prefix)
+{
+ int saved_errno;
+
+ saved_errno = errno;
+ if (saved_errno == 0)
+ saved_errno = EIO;
+
+ fprintf(stderr, "rmail: %s: %s\n", prefix, strerror(saved_errno));
+ exit(code);
+}
+
+static void
+die(int code, const char *fmt, ...)
+{
+ va_list ap;
+
+ fputs("rmail: ", stderr);
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fputc('\n', stderr);
+ exit(code);
+}
+
+static void
+usage(void)
+{
+ fprintf(stderr, "%s\n", usage_message);
+ exit(EX_USAGE);
+}
+
+static void *
+xmalloc(size_t size)
+{
+ void *ptr;
+
+ if (size == 0)
+ size = 1;
+ ptr = malloc(size);
+ if (ptr == NULL)
+ die(EX_TEMPFAIL, "out of memory");
+ return ptr;
+}
+
+static void *
+xrealloc(void *ptr, size_t size)
+{
+ void *next;
+
+ if (size == 0)
+ size = 1;
+ next = realloc(ptr, size);
+ if (next == NULL)
+ die(EX_TEMPFAIL, "out of memory");
+ return next;
+}
+
+static char *
+xstrdup(const char *text)
+{
+ size_t len;
+ char *copy;
+
+ len = strlen(text) + 1;
+ copy = xmalloc(len);
+ memcpy(copy, text, len);
+ return copy;
+}
+
+static char *
+xstrndup(const char *text, size_t len)
+{
+ char *copy;
+
+ copy = xmalloc(len + 1);
+ memcpy(copy, text, len);
+ copy[len] = '\0';
+ return copy;
+}
+
+static char *
+xasprintf(const char *fmt, ...)
+{
+ va_list ap;
+ va_list ap_copy;
+ int needed;
+ char *buffer;
+
+ va_start(ap, fmt);
+ va_copy(ap_copy, ap);
+ needed = vsnprintf(NULL, 0, fmt, ap_copy);
+ va_end(ap_copy);
+ if (needed < 0) {
+ va_end(ap);
+ die(EX_SOFTWARE, "snprintf sizing failed");
+ }
+
+ buffer = xmalloc((size_t)needed + 1);
+ if (vsnprintf(buffer, (size_t)needed + 1, fmt, ap) != needed) {
+ va_end(ap);
+ die(EX_SOFTWARE, "snprintf formatting failed");
+ }
+ va_end(ap);
+
+ return buffer;
+}
+
+static void
+set_owned_string(char **slot, char *value)
+{
+ free(*slot);
+ *slot = value;
+}
+
+static void
+append_path_component(char **path, size_t *path_len, const char *component)
+{
+ size_t component_len;
+
+ component_len = strlen(component);
+ *path = xrealloc(*path, *path_len + component_len + 2);
+ memcpy(*path + *path_len, component, component_len);
+ *path_len += component_len;
+ (*path)[(*path_len)++] = '!';
+ (*path)[*path_len] = '\0';
+}
+
+static const char *
+skip_token_end(const char *text)
+{
+ while (*text != '\0' && !isspace((unsigned char)*text))
+ text++;
+ return text;
+}
+
+static bool
+parse_from_line(const char *line, struct parsed_from_line *parsed)
+{
+ static const char remote_marker[] = " remote from ";
+ const char *cursor;
+ const char *token_end;
+ const char *remote;
+ char *token;
+ char *last_bang;
+
+ memset(parsed, 0, sizeof(*parsed));
+
+ if (strncmp(line, "From ", 5) == 0)
+ cursor = line + 5;
+ else if (strncmp(line, ">From ", 6) == 0)
+ cursor = line + 6;
+ else
+ return false;
+
+ if (*cursor == '\0')
+ die(EX_DATAERR, "corrupted From line: %s", line);
+
+ token_end = skip_token_end(cursor);
+ if (token_end == cursor)
+ die(EX_DATAERR, "corrupted From line: %s", line);
+
+ remote = strstr(cursor, remote_marker);
+ if (remote != NULL) {
+ const char *site_start;
+ const char *site_end;
+
+ parsed->user = xstrndup(cursor, (size_t)(token_end - cursor));
+
+ site_start = remote + strlen(remote_marker);
+ site_end = skip_token_end(site_start);
+ if (site_end == site_start)
+ die(EX_DATAERR, "corrupted From line: %s", line);
+ parsed->system = xstrndup(site_start, (size_t)(site_end - site_start));
+ return true;
+ }
+
+ token = xstrndup(cursor, (size_t)(token_end - cursor));
+ if (token[0] == '!')
+ die(EX_DATAERR, "bang starts address: %s", token);
+
+ last_bang = strrchr(token, '!');
+ if (last_bang == NULL) {
+ parsed->user = token;
+ return true;
+ }
+
+ *last_bang++ = '\0';
+ if (*last_bang == '\0')
+ die(EX_DATAERR, "corrupted From line: %s", line);
+
+ parsed->system = xstrdup(token);
+ parsed->user = xstrdup(last_bang);
+ free(token);
+ return true;
+}
+
+static const char *
+resolve_sendmail_path(void)
+{
+ const char *override;
+
+ override = getenv("RMAIL_SENDMAIL");
+ if (override != NULL && *override != '\0')
+ return override;
+ return RMAIL_SENDMAIL_PATH;
+}
+
+static void
+debug_print_value(bool debug, const char *label, const char *value)
+{
+ if (!debug)
+ return;
+ fprintf(stderr, "%s: %s\n", label, value);
+}
+
+static void
+write_stream_or_die(FILE *stream, const char *buffer, size_t length)
+{
+ if (length == 0)
+ return;
+ if (fwrite(buffer, 1, length, stream) != length)
+ die_with_errno(EX_TEMPFAIL, "write");
+}
+
+int
+main(int argc, char *argv[])
+{
+ bool debug;
+ bool saw_header;
+ bool stdin_is_regular;
+ int ch;
+ int pipe_fds[2];
+ int status;
+ pid_t child_pid;
+ size_t arg_count;
+ size_t path_len;
+ char *domain;
+ char *line;
+ char *from_path;
+ char *from_sys;
+ char *from_user;
+ char *protocol_arg;
+ char *from_arg;
+ char **sendmail_argv;
+ const char *sendmail_path;
+ FILE *child_stdin;
+ struct stat stdin_stat;
+ ssize_t line_size;
+ size_t line_capacity;
+ off_t body_offset;
+ char *first_body_line;
+ size_t first_body_length;
+
+ debug = false;
+ domain = "UUCP";
+ while ((ch = getopt(argc, argv, "D:T")) != -1) {
+ switch (ch) {
+ case 'D':
+ if (optarg == NULL || optarg[0] == '\0')
+ die(EX_USAGE, "-D requires a non-empty domain");
+ domain = optarg;
+ break;
+ case 'T':
+ debug = true;
+ break;
+ case '?':
+ default:
+ usage();
+ }
+ }
+
+ argc -= optind;
+ argv += optind;
+ if (argc < 1)
+ usage();
+
+ for (int i = 0; i < argc; i++) {
+ if (argv[i][0] == '-')
+ die(EX_USAGE, "dash precedes argument: %s", argv[i]);
+ }
+
+ if (fstat(STDIN_FILENO, &stdin_stat) != 0)
+ die_with_errno(EX_OSERR, "stdin");
+ stdin_is_regular = S_ISREG(stdin_stat.st_mode);
+
+ line = NULL;
+ line_capacity = 0;
+ body_offset = 0;
+ first_body_line = NULL;
+ first_body_length = 0;
+ from_path = NULL;
+ from_sys = NULL;
+ from_user = NULL;
+ path_len = 0;
+ saw_header = false;
+
+ for (;;) {
+ char *logical_line;
+ struct parsed_from_line parsed;
+
+ errno = 0;
+ line_size = getline(&line, &line_capacity, stdin);
+ if (line_size < 0) {
+ if (ferror(stdin))
+ die_with_errno(EX_TEMPFAIL, "stdin");
+ if (!saw_header)
+ die(EX_DATAERR, "no data");
+ die(EX_DATAERR, "no data");
+ }
+ if (line_size == 0 || line[(size_t)line_size - 1] != '\n')
+ die(EX_DATAERR, "unterminated input line");
+
+ logical_line = xstrndup(line, (size_t)line_size - 1);
+ if (!parse_from_line(logical_line, &parsed)) {
+ if (!saw_header)
+ die(EX_DATAERR, "missing or empty From line: %s",
+ logical_line);
+ first_body_line = xstrdup(line);
+ first_body_length = (size_t)line_size;
+ free(logical_line);
+ break;
+ }
+ free(logical_line);
+ saw_header = true;
+
+ if (parsed.system != NULL) {
+ debug_print_value(debug, strstr(line, " remote from ") != NULL ?
+ "remote from" : "bang", parsed.system);
+ if (from_sys == NULL)
+ from_sys = xstrdup(parsed.system);
+ append_path_component(&from_path, &path_len, parsed.system);
+ }
+
+ if (parsed.user[0] == '\0')
+ set_owned_string(&from_user, xstrdup("<>"));
+ else
+ set_owned_string(&from_user, parsed.user);
+ parsed.user = NULL;
+
+ debug_print_value(debug, "from_sys",
+ from_sys != NULL ? from_sys : "(none)");
+ if (from_path != NULL)
+ debug_print_value(debug, "from_path", from_path);
+ debug_print_value(debug, "from_user", from_user);
+
+ free(parsed.system);
+ free(parsed.user);
+
+ if (stdin_is_regular) {
+ body_offset = ftello(stdin);
+ if (body_offset < 0)
+ die_with_errno(EX_TEMPFAIL, "stdin");
+ } else if (body_offset == 0) {
+ body_offset = 1;
+ }
+ }
+
+ if (from_user == NULL)
+ set_owned_string(&from_user, xstrdup("<>"));
+
+#ifdef QUEUE_ONLY
+ {
+ const char *delivery_mode = "-odq";
+#else
+ {
+ const char *delivery_mode = "-odi";
+#endif
+ protocol_arg = NULL;
+ from_arg = NULL;
+ sendmail_argv = NULL;
+
+ if (from_sys == NULL) {
+ protocol_arg = xasprintf("-p%s", domain);
+ } else if (strchr(from_sys, '.') == NULL) {
+ protocol_arg = xasprintf("-p%s:%s.%s",
+ domain, from_sys, domain);
+ } else {
+ protocol_arg = xasprintf("-p%s:%s", domain, from_sys);
+ }
+
+ from_arg = xasprintf("-f%s%s",
+ from_path != NULL ? from_path : "", from_user);
+
+ arg_count = (size_t)argc + 8;
+ sendmail_argv = xmalloc(sizeof(*sendmail_argv) * arg_count);
+ sendmail_path = resolve_sendmail_path();
+
+ sendmail_argv[0] = (char *)sendmail_path;
+ sendmail_argv[1] = "-G";
+ sendmail_argv[2] = "-oee";
+ sendmail_argv[3] = (char *)delivery_mode;
+ sendmail_argv[4] = "-oi";
+ sendmail_argv[5] = protocol_arg;
+ sendmail_argv[6] = from_arg;
+ for (int i = 0; i < argc; i++) {
+ if (strchr(argv[i], ',') != NULL && strchr(argv[i], '<') == NULL)
+ sendmail_argv[7 + i] = xasprintf("<%s>", argv[i]);
+ else
+ sendmail_argv[7 + i] = argv[i];
+ }
+ sendmail_argv[7 + argc] = NULL;
+ }
+
+ if (debug) {
+ fprintf(stderr, "Sendmail arguments:\n");
+ for (size_t i = 0; sendmail_argv[i] != NULL; i++)
+ fprintf(stderr, "\t%s\n", sendmail_argv[i]);
+ }
+
+ if (stdin_is_regular) {
+ clearerr(stdin);
+ if (lseek(STDIN_FILENO, body_offset, SEEK_SET) == (off_t)-1)
+ die_with_errno(EX_TEMPFAIL, "stdin seek");
+ execv(sendmail_path, sendmail_argv);
+ die(EX_UNAVAILABLE, "%s: %s", sendmail_path, strerror(errno));
+ }
+
+ if (pipe(pipe_fds) != 0)
+ die_with_errno(EX_OSERR, "pipe");
+
+ child_pid = fork();
+ if (child_pid < 0)
+ die_with_errno(EX_OSERR, "fork");
+ if (child_pid == 0) {
+ if (dup2(pipe_fds[0], STDIN_FILENO) < 0) {
+ fprintf(stderr, "rmail: dup2: %s\n", strerror(errno));
+ _exit(EX_OSERR);
+ }
+ close(pipe_fds[0]);
+ close(pipe_fds[1]);
+ execv(sendmail_path, sendmail_argv);
+ fprintf(stderr, "rmail: %s: %s\n", sendmail_path, strerror(errno));
+ _exit(EX_UNAVAILABLE);
+ }
+
+ close(pipe_fds[0]);
+ child_stdin = fdopen(pipe_fds[1], "w");
+ if (child_stdin == NULL)
+ die_with_errno(EX_OSERR, "fdopen");
+
+ write_stream_or_die(child_stdin, first_body_line, first_body_length);
+ while ((line_size = getline(&line, &line_capacity, stdin)) >= 0)
+ write_stream_or_die(child_stdin, line, (size_t)line_size);
+ if (ferror(stdin))
+ die_with_errno(EX_TEMPFAIL, "stdin");
+ if (fclose(child_stdin) != 0)
+ die_with_errno(EX_OSERR, "pipe close");
+
+ if (waitpid(child_pid, &status, 0) < 0)
+ die_with_errno(EX_OSERR, sendmail_path);
+ if (!WIFEXITED(status))
+ die(EX_OSERR, "%s: did not terminate normally", sendmail_path);
+ if (WEXITSTATUS(status) != 0)
+ die(WEXITSTATUS(status), "%s: terminated with %d (non-zero) status",
+ sendmail_path, WEXITSTATUS(status));
+
+ free(line);
+ free(first_body_line);
+ free(from_path);
+ free(from_sys);
+ free(from_user);
+
+ return EX_OK;
+}
diff --git a/corebinutils/rmail/tests/mock_sendmail.c b/corebinutils/rmail/tests/mock_sendmail.c
new file mode 100644
index 0000000000..0e1403c7ee
--- /dev/null
+++ b/corebinutils/rmail/tests/mock_sendmail.c
@@ -0,0 +1,85 @@
+#define _POSIX_C_SOURCE 200809L
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static const char *
+require_env(const char *name)
+{
+ const char *value;
+
+ value = getenv(name);
+ if (value == NULL || value[0] == '\0') {
+ fprintf(stderr, "mock_sendmail: missing %s\n", name);
+ exit(111);
+ }
+ return value;
+}
+
+int
+main(int argc, char *argv[])
+{
+ const char *argv_path;
+ const char *stdin_path;
+ const char *exit_env;
+ FILE *argv_file;
+ FILE *stdin_file;
+ char buffer[4096];
+ size_t nread;
+
+ argv_path = require_env("RMAIL_SENDMAIL_LOG");
+ stdin_path = require_env("RMAIL_SENDMAIL_INPUT");
+
+ argv_file = fopen(argv_path, "w");
+ if (argv_file == NULL) {
+ fprintf(stderr, "mock_sendmail: fopen(%s): %s\n",
+ argv_path, strerror(errno));
+ return 111;
+ }
+ for (int i = 0; i < argc; i++) {
+ if (fprintf(argv_file, "%s\n", argv[i]) < 0) {
+ fprintf(stderr, "mock_sendmail: fprintf(%s): %s\n",
+ argv_path, strerror(errno));
+ fclose(argv_file);
+ return 111;
+ }
+ }
+ if (fclose(argv_file) != 0) {
+ fprintf(stderr, "mock_sendmail: fclose(%s): %s\n",
+ argv_path, strerror(errno));
+ return 111;
+ }
+
+ stdin_file = fopen(stdin_path, "w");
+ if (stdin_file == NULL) {
+ fprintf(stderr, "mock_sendmail: fopen(%s): %s\n",
+ stdin_path, strerror(errno));
+ return 111;
+ }
+ while ((nread = fread(buffer, 1, sizeof(buffer), stdin)) > 0) {
+ if (fwrite(buffer, 1, nread, stdin_file) != nread) {
+ fprintf(stderr, "mock_sendmail: fwrite(%s): %s\n",
+ stdin_path, strerror(errno));
+ fclose(stdin_file);
+ return 111;
+ }
+ }
+ if (ferror(stdin)) {
+ fprintf(stderr, "mock_sendmail: fread(stdin): %s\n",
+ strerror(errno));
+ fclose(stdin_file);
+ return 111;
+ }
+ if (fclose(stdin_file) != 0) {
+ fprintf(stderr, "mock_sendmail: fclose(%s): %s\n",
+ stdin_path, strerror(errno));
+ return 111;
+ }
+
+ exit_env = getenv("RMAIL_SENDMAIL_EXIT");
+ if (exit_env != NULL && exit_env[0] != '\0')
+ return (int)strtol(exit_env, NULL, 10);
+ return 0;
+}
diff --git a/corebinutils/rmail/tests/test.sh b/corebinutils/rmail/tests/test.sh
new file mode 100644
index 0000000000..04605175a3
--- /dev/null
+++ b/corebinutils/rmail/tests/test.sh
@@ -0,0 +1,233 @@
+#!/bin/sh
+set -eu
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+RMAIL_BIN=${RMAIL_BIN:-"$ROOT/out/rmail"}
+MOCK_SENDMAIL_BIN=${MOCK_SENDMAIL_BIN:-"$ROOT/out/mock_sendmail"}
+TMPDIR=${TMPDIR:-/tmp}
+WORKDIR=$(mktemp -d "$TMPDIR/rmail-test.XXXXXX")
+STDOUT_FILE="$WORKDIR/stdout"
+STDERR_FILE="$WORKDIR/stderr"
+SENDMAIL_LOG="$WORKDIR/sendmail-argv"
+SENDMAIL_INPUT="$WORKDIR/sendmail-input"
+LAST_STATUS=0
+LAST_STDOUT=
+LAST_STDERR=
+
+trap 'rm -rf "$WORKDIR"' EXIT INT TERM
+
+export LC_ALL=C
+
+EX_USAGE=64
+EX_DATAERR=65
+EX_UNAVAILABLE=69
+
+fail() {
+ printf '%s\n' "FAIL: $1" >&2
+ exit 1
+}
+
+assert_eq() {
+ name=$1
+ expected=$2
+ actual=$3
+ if [ "$expected" != "$actual" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "--- expected ---" >&2
+ printf '%s' "$expected" >&2
+ printf '\n%s\n' "--- actual ---" >&2
+ printf '%s' "$actual" >&2
+ printf '\n' >&2
+ exit 1
+ fi
+}
+
+assert_contains() {
+ name=$1
+ text=$2
+ pattern=$3
+ case $text in
+ *"$pattern"*) ;;
+ *) fail "$name" ;;
+ esac
+}
+
+assert_empty() {
+ name=$1
+ text=$2
+ if [ -n "$text" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "--- expected empty ---" >&2
+ printf '%s\n' "--- actual ---" >&2
+ printf '%s' "$text" >&2
+ printf '\n' >&2
+ exit 1
+ fi
+}
+
+assert_status() {
+ name=$1
+ expected=$2
+ actual=$3
+ if [ "$expected" -ne "$actual" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "expected status: $expected" >&2
+ printf '%s\n' "actual status: $actual" >&2
+ exit 1
+ fi
+}
+
+run_capture() {
+ rm -f "$STDOUT_FILE" "$STDERR_FILE"
+ if "$@" >"$STDOUT_FILE" 2>"$STDERR_FILE"; then
+ LAST_STATUS=0
+ else
+ LAST_STATUS=$?
+ fi
+ LAST_STDOUT=$(cat "$STDOUT_FILE")
+ LAST_STDERR=$(cat "$STDERR_FILE")
+}
+
+run_with_input() {
+ input=$1
+ shift
+ rm -f "$STDOUT_FILE" "$STDERR_FILE"
+ if printf '%b' "$input" | "$@" >"$STDOUT_FILE" 2>"$STDERR_FILE"; then
+ LAST_STATUS=0
+ else
+ LAST_STATUS=$?
+ fi
+ LAST_STDOUT=$(cat "$STDOUT_FILE")
+ LAST_STDERR=$(cat "$STDERR_FILE")
+}
+
+run_with_regular_file() {
+ input_file=$1
+ shift
+ rm -f "$STDOUT_FILE" "$STDERR_FILE"
+ if "$@" <"$input_file" >"$STDOUT_FILE" 2>"$STDERR_FILE"; then
+ LAST_STATUS=0
+ else
+ LAST_STATUS=$?
+ fi
+ LAST_STDOUT=$(cat "$STDOUT_FILE")
+ LAST_STDERR=$(cat "$STDERR_FILE")
+}
+
+read_file() {
+ cat "$1"
+}
+
+[ -x "$RMAIL_BIN" ] || fail "missing binary: $RMAIL_BIN"
+[ -x "$MOCK_SENDMAIL_BIN" ] || fail "missing mock sendmail: $MOCK_SENDMAIL_BIN"
+
+export RMAIL_SENDMAIL="$MOCK_SENDMAIL_BIN"
+export RMAIL_SENDMAIL_LOG="$SENDMAIL_LOG"
+export RMAIL_SENDMAIL_INPUT="$SENDMAIL_INPUT"
+unset RMAIL_SENDMAIL_EXIT
+
+run_capture "$RMAIL_BIN"
+assert_status "usage status" "$EX_USAGE" "$LAST_STATUS"
+assert_empty "usage stdout" "$LAST_STDOUT"
+assert_eq "usage stderr" "usage: rmail [-D domain] [-T] user ..." "$LAST_STDERR"
+
+run_capture "$RMAIL_BIN" -D ''
+assert_status "empty domain status" "$EX_USAGE" "$LAST_STATUS"
+assert_empty "empty domain stdout" "$LAST_STDOUT"
+assert_eq "empty domain stderr" "rmail: -D requires a non-empty domain" "$LAST_STDERR"
+
+run_capture "$RMAIL_BIN" -- -bad@example.test
+assert_status "dash recipient status" "$EX_USAGE" "$LAST_STATUS"
+assert_empty "dash recipient stdout" "$LAST_STDOUT"
+assert_eq "dash recipient stderr" "rmail: dash precedes argument: -bad@example.test" "$LAST_STDERR"
+
+run_with_input '' "$RMAIL_BIN" user@example.test
+assert_status "no data status" "$EX_DATAERR" "$LAST_STATUS"
+assert_empty "no data stdout" "$LAST_STDOUT"
+assert_eq "no data stderr" "rmail: no data" "$LAST_STDERR"
+
+run_with_input 'Subject: hi\n\nbody\n' "$RMAIL_BIN" user@example.test
+assert_status "missing from status" "$EX_DATAERR" "$LAST_STATUS"
+assert_empty "missing from stdout" "$LAST_STDOUT"
+assert_eq "missing from stderr" "rmail: missing or empty From line: Subject: hi" "$LAST_STDERR"
+
+run_with_input 'From !bad Tue Jan 1 00:00:00 2025\n\nbody\n' "$RMAIL_BIN" user@example.test
+assert_status "bang starts status" "$EX_DATAERR" "$LAST_STATUS"
+assert_empty "bang starts stdout" "$LAST_STDOUT"
+assert_eq "bang starts stderr" "rmail: bang starts address: !bad" "$LAST_STDERR"
+
+run_with_input 'From user Tue Jan 1 00:00:00 2025' "$RMAIL_BIN" user@example.test
+assert_status "unterminated header status" "$EX_DATAERR" "$LAST_STATUS"
+assert_empty "unterminated header stdout" "$LAST_STDOUT"
+assert_eq "unterminated header stderr" "rmail: unterminated input line" "$LAST_STDERR"
+
+PIPE_INPUT='From host1!host2!alice Tue Jan 1 00:00:00 2025
+>From bob Tue Jan 1 00:01:00 2025 remote from relay.example
+
+hello from pipe
+'
+run_with_input "$PIPE_INPUT" "$RMAIL_BIN" first@example.test second,third@example.test '<already@wrapped>'
+assert_status "pipe happy status" 0 "$LAST_STATUS"
+assert_empty "pipe happy stdout" "$LAST_STDOUT"
+assert_empty "pipe happy stderr" "$LAST_STDERR"
+assert_eq "pipe sendmail argv" "$MOCK_SENDMAIL_BIN
+-G
+-oee
+-odi
+-oi
+-pUUCP:host1!host2.UUCP
+-fhost1!host2!relay.example!bob
+first@example.test
+<second,third@example.test>
+<already@wrapped>" "$(read_file "$SENDMAIL_LOG")"
+assert_eq "pipe sendmail input" "
+hello from pipe" "$(read_file "$SENDMAIL_INPUT")"
+
+REGULAR_INPUT="$WORKDIR/regular-input"
+cat >"$REGULAR_INPUT" <<'EOF'
+From charlie Tue Jan 1 00:00:00 2025 remote from mailhub
+
+hello from regular file
+EOF
+run_with_regular_file "$REGULAR_INPUT" "$RMAIL_BIN" -D LINUX target@example.test
+assert_status "regular happy status" 0 "$LAST_STATUS"
+assert_empty "regular happy stdout" "$LAST_STDOUT"
+assert_empty "regular happy stderr" "$LAST_STDERR"
+assert_eq "regular sendmail argv" "$MOCK_SENDMAIL_BIN
+-G
+-oee
+-odi
+-oi
+-pLINUX:mailhub.LINUX
+-fmailhub!charlie
+target@example.test" "$(read_file "$SENDMAIL_LOG")"
+assert_eq "regular sendmail input" "
+hello from regular file" "$(read_file "$SENDMAIL_INPUT")"
+
+DEBUG_INPUT='From user Tue Jan 1 00:00:00 2025 remote from relay
+
+debug body
+'
+run_with_input "$DEBUG_INPUT" "$RMAIL_BIN" -T user@example.test
+assert_status "debug status" 0 "$LAST_STATUS"
+assert_empty "debug stdout" "$LAST_STDOUT"
+assert_contains "debug stderr remote" "$LAST_STDERR" "remote from: relay"
+assert_contains "debug stderr from_sys" "$LAST_STDERR" "from_sys: relay"
+assert_contains "debug stderr from_path" "$LAST_STDERR" "from_path: relay!"
+assert_contains "debug stderr from_user" "$LAST_STDERR" "from_user: user"
+assert_contains "debug stderr args" "$LAST_STDERR" "Sendmail arguments:"
+
+export RMAIL_SENDMAIL_EXIT=23
+run_with_input 'From user Tue Jan 1 00:00:00 2025\n\nbody\n' "$RMAIL_BIN" user@example.test
+assert_status "sendmail exit status propagation" 23 "$LAST_STATUS"
+assert_empty "sendmail exit stdout" "$LAST_STDOUT"
+assert_contains "sendmail exit stderr" "$LAST_STDERR" "terminated with 23 (non-zero) status"
+unset RMAIL_SENDMAIL_EXIT
+
+export RMAIL_SENDMAIL="$WORKDIR/missing-sendmail"
+run_with_input 'From user Tue Jan 1 00:00:00 2025\n\nbody\n' "$RMAIL_BIN" user@example.test
+assert_status "missing sendmail status" "$EX_UNAVAILABLE" "$LAST_STATUS"
+assert_empty "missing sendmail stdout" "$LAST_STDOUT"
+assert_contains "missing sendmail stderr" "$LAST_STDERR" "$WORKDIR/missing-sendmail"
+
+printf '%s\n' "PASS"