aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 15:50:24 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 15:50:42 +0200
commit5e624647d2f2750cf1fe7f97d8c89718d187879d (patch)
tree6909b8ddc7ee6a1843ecab064fbf8b96e662eaa0
parentfb2addedce94bea29c9be5eab2786e621a5d0e65 (diff)
smtp: send messages with a PDF attachment
-rw-r--r--src/smtp.c951
-rw-r--r--src/smtp.h24
-rw-r--r--tests/smtp_check.c663
3 files changed, 1638 insertions, 0 deletions
diff --git a/src/smtp.c b/src/smtp.c
new file mode 100644
index 0000000..ed70519
--- /dev/null
+++ b/src/smtp.c
@@ -0,0 +1,951 @@
+#include "smtp.h"
+
+#include <ctype.h>
+#include <errno.h>
+#include <limits.h>
+#include <netdb.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/socket.h>
+#include <sys/time.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <openssl/err.h>
+#include <openssl/ssl.h>
+#include <openssl/x509.h>
+
+#include "util.h"
+
+#define SMTP_TIMEOUT_SEC 30
+#define SMTP_LINE_MAX 1024
+#define SMTP_REPLY_TEXT_MAX 2048
+#define SMTP_READ_BUF 4096
+
+struct smtp_conn {
+ int fd;
+ SSL_CTX *ctx;
+ SSL *ssl;
+ unsigned char in[SMTP_READ_BUF];
+ size_t in_len;
+ size_t in_pos;
+};
+
+struct smtp_reply {
+ int code;
+ char text[SMTP_REPLY_TEXT_MAX];
+};
+
+static void set_err(char *err, size_t errlen, const char *fmt, ...)
+{
+ if (!err || errlen == 0)
+ return;
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(err, errlen, fmt, ap);
+ va_end(ap);
+}
+
+static void buf_append_str(struct buf *b, const char *s)
+{
+ buf_append(b, s, strlen(s));
+}
+
+static void buf_appendf(struct buf *b, const char *fmt, ...)
+{
+ va_list ap;
+ va_start(ap, fmt);
+ char tmp[512];
+ int n = vsnprintf(tmp, sizeof tmp, fmt, ap);
+ va_end(ap);
+ if (n < 0)
+ return;
+ if ((size_t)n < sizeof tmp) {
+ buf_append(b, tmp, (size_t)n);
+ return;
+ }
+ char *big = xmalloc((size_t)n + 1);
+ va_start(ap, fmt);
+ vsnprintf(big, (size_t)n + 1, fmt, ap);
+ va_end(ap);
+ buf_append(b, big, (size_t)n);
+ free(big);
+}
+
+static void conn_close(struct smtp_conn *c)
+{
+ if (c->ssl) {
+ SSL_shutdown(c->ssl);
+ SSL_free(c->ssl);
+ c->ssl = NULL;
+ }
+ if (c->ctx) {
+ SSL_CTX_free(c->ctx);
+ c->ctx = NULL;
+ }
+ if (c->fd >= 0) {
+ close(c->fd);
+ c->fd = -1;
+ }
+}
+
+static int conn_write(struct smtp_conn *c, const void *data, size_t n,
+ char *err, size_t errlen)
+{
+ const unsigned char *p = data;
+ while (n > 0) {
+ ssize_t w;
+ if (c->ssl) {
+ int chunk = n > (size_t)INT_MAX ? INT_MAX : (int)n;
+ w = SSL_write(c->ssl, p, chunk);
+ if (w <= 0) {
+ int e = SSL_get_error(c->ssl, (int)w);
+ if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE)
+ continue;
+ set_err(err, errlen, "smtp: TLS write failed");
+ return -1;
+ }
+ } else {
+ w = send(c->fd, p, n, MSG_NOSIGNAL);
+ if (w < 0) {
+ if (errno == EINTR)
+ continue;
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+ set_err(err, errlen, "smtp: send timed out");
+ return -1;
+ }
+ set_err(err, errlen, "smtp: send failed: %s",
+ strerror(errno));
+ return -1;
+ }
+ }
+ p += (size_t)w;
+ n -= (size_t)w;
+ }
+ return 0;
+}
+
+static int conn_line(struct smtp_conn *c, const char *line, char *err,
+ size_t errlen)
+{
+ if (conn_write(c, line, strlen(line), err, errlen) != 0)
+ return -1;
+ return conn_write(c, "\r\n", 2, err, errlen);
+}
+
+static int conn_buf_line(struct smtp_conn *c, const struct buf *b, char *err,
+ size_t errlen)
+{
+ if (conn_write(c, b->p, b->len, err, errlen) != 0)
+ return -1;
+ return conn_write(c, "\r\n", 2, err, errlen);
+}
+
+static int conn_fill(struct smtp_conn *c, char *err, size_t errlen)
+{
+ for (;;) {
+ if (c->ssl) {
+ int n = SSL_read(c->ssl, c->in, (int)sizeof c->in);
+ if (n > 0) {
+ c->in_len = (size_t)n;
+ c->in_pos = 0;
+ return 0;
+ }
+ int e = SSL_get_error(c->ssl, n);
+ if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE)
+ continue;
+ if (e == SSL_ERROR_ZERO_RETURN) {
+ set_err(err, errlen, "smtp: connection closed by server");
+ return -1;
+ }
+ if (e == SSL_ERROR_SYSCALL && n == 0) {
+ set_err(err, errlen, "smtp: connection closed by server");
+ return -1;
+ }
+ if (e == SSL_ERROR_SYSCALL &&
+ (errno == EAGAIN || errno == EWOULDBLOCK)) {
+ set_err(err, errlen, "smtp: receive timed out");
+ return -1;
+ }
+ unsigned long ec = ERR_get_error();
+ if (ec)
+ set_err(err, errlen, "smtp: TLS read failed: %s",
+ ERR_error_string(ec, NULL));
+ else
+ set_err(err, errlen, "smtp: TLS read failed");
+ return -1;
+ }
+ ssize_t n = recv(c->fd, c->in, sizeof c->in, 0);
+ if (n > 0) {
+ c->in_len = (size_t)n;
+ c->in_pos = 0;
+ return 0;
+ }
+ if (n == 0) {
+ set_err(err, errlen, "smtp: connection closed by server");
+ return -1;
+ }
+ if (errno == EINTR)
+ continue;
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+ set_err(err, errlen, "smtp: receive timed out");
+ return -1;
+ }
+ set_err(err, errlen, "smtp: receive failed: %s", strerror(errno));
+ return -1;
+ }
+}
+
+static int conn_read_line(struct smtp_conn *c, char *out, size_t outsz,
+ char *err, size_t errlen)
+{
+ size_t o = 0;
+ for (;;) {
+ if (c->in_pos >= c->in_len) {
+ if (conn_fill(c, err, errlen) != 0)
+ return -1;
+ }
+ unsigned char ch = c->in[c->in_pos++];
+ if (ch == '\n') {
+ while (o > 0 && out[o - 1] == '\r')
+ o--;
+ out[o] = '\0';
+ return 0;
+ }
+ if (o + 1 < outsz)
+ out[o++] = (char)ch;
+ }
+}
+
+static int parse_code(const char *line, int *code)
+{
+ if (strlen(line) < 3)
+ return -1;
+ for (int i = 0; i < 3; i++)
+ if (!isdigit((unsigned char)line[i]))
+ return -1;
+ *code = (line[0] - '0') * 100 + (line[1] - '0') * 10 + (line[2] - '0');
+ return 0;
+}
+
+static int conn_read_reply(struct smtp_conn *c, struct smtp_reply *r,
+ char *err, size_t errlen)
+{
+ char line[SMTP_LINE_MAX];
+ size_t tlen = 0;
+ r->code = 0;
+ r->text[0] = '\0';
+ for (;;) {
+ if (conn_read_line(c, line, sizeof line, err, errlen) != 0)
+ return -1;
+ int code;
+ if (parse_code(line, &code) != 0) {
+ set_err(err, errlen, "smtp: malformed server reply");
+ return -1;
+ }
+ r->code = code;
+ const char *text = line + 3;
+ while (*text == ' ' || *text == '-')
+ text++;
+ if (*text) {
+ size_t n = strlen(text);
+ if (tlen) {
+ if (tlen + 1 < sizeof r->text)
+ r->text[tlen++] = '\n';
+ else
+ n = 0;
+ }
+ if (tlen + n >= sizeof r->text)
+ n = sizeof r->text - 1 - tlen;
+ memcpy(r->text + tlen, text, n);
+ tlen += n;
+ r->text[tlen] = '\0';
+ }
+ if (line[3] != '-')
+ break;
+ }
+ return 0;
+}
+
+static int conn_expect(struct smtp_conn *c, int want, const char *what,
+ char *err, size_t errlen)
+{
+ struct smtp_reply r;
+ if (conn_read_reply(c, &r, err, errlen) != 0)
+ return -1;
+ if (r.code != want) {
+ if (r.text[0])
+ set_err(err, errlen, "%s: server said %d %s", what, r.code,
+ r.text);
+ else
+ set_err(err, errlen, "%s: server said %d", what, r.code);
+ return -1;
+ }
+ return 0;
+}
+
+static int tcp_connect(struct smtp_conn *c, const char *host, int port,
+ char *err, size_t errlen)
+{
+ char portstr[16];
+ snprintf(portstr, sizeof portstr, "%d", port);
+ struct addrinfo hints;
+ memset(&hints, 0, sizeof hints);
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = SOCK_STREAM;
+ struct addrinfo *res = NULL;
+ int gai = getaddrinfo(host, portstr, &hints, &res);
+ if (gai != 0) {
+ set_err(err, errlen, "smtp: cannot resolve %s: %s", host,
+ gai_strerror(gai));
+ return -1;
+ }
+ int last = 0;
+ for (struct addrinfo *ai = res; ai; ai = ai->ai_next) {
+ int fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC,
+ ai->ai_protocol);
+ if (fd < 0) {
+ last = errno;
+ continue;
+ }
+ struct timeval tv;
+ tv.tv_sec = SMTP_TIMEOUT_SEC;
+ tv.tv_usec = 0;
+ (void)setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
+ (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
+ if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
+ c->fd = fd;
+ freeaddrinfo(res);
+ return 0;
+ }
+ last = errno;
+ close(fd);
+ }
+ freeaddrinfo(res);
+ if (last)
+ set_err(err, errlen, "smtp: cannot connect to %s:%d: %s", host, port,
+ strerror(last));
+ else
+ set_err(err, errlen, "smtp: cannot connect to %s:%d", host, port);
+ return -1;
+}
+
+static int tls_start(struct smtp_conn *c, const char *host, char *err,
+ size_t errlen)
+{
+ c->ctx = SSL_CTX_new(TLS_client_method());
+ if (!c->ctx) {
+ set_err(err, errlen, "smtp: cannot create TLS context");
+ return -1;
+ }
+ SSL_CTX_set_min_proto_version(c->ctx, TLS1_2_VERSION);
+ SSL_CTX_set_options(c->ctx, SSL_OP_NO_COMPRESSION |
+ SSL_OP_NO_RENEGOTIATION);
+ SSL_CTX_set_verify(c->ctx, SSL_VERIFY_PEER, NULL);
+ if (SSL_CTX_set_default_verify_paths(c->ctx) != 1) {
+ set_err(err, errlen, "smtp: cannot load system CA certificates");
+ return -1;
+ }
+ c->ssl = SSL_new(c->ctx);
+ if (!c->ssl) {
+ set_err(err, errlen, "smtp: cannot create TLS connection");
+ return -1;
+ }
+ if (SSL_set_fd(c->ssl, c->fd) != 1 ||
+ SSL_set_tlsext_host_name(c->ssl, host) != 1 ||
+ SSL_set1_host(c->ssl, host) != 1) {
+ set_err(err, errlen, "smtp: cannot set up TLS connection");
+ return -1;
+ }
+ if (SSL_connect(c->ssl) != 1) {
+ long vr = SSL_get_verify_result(c->ssl);
+ unsigned long ec = ERR_get_error();
+ if (vr != X509_V_OK)
+ set_err(err, errlen,
+ "smtp: TLS certificate verification failed: %s",
+ X509_verify_cert_error_string(vr));
+ else if (ec)
+ set_err(err, errlen, "smtp: TLS handshake failed: %s",
+ ERR_error_string(ec, NULL));
+ else
+ set_err(err, errlen, "smtp: TLS handshake failed");
+ return -1;
+ }
+ if (SSL_get_verify_result(c->ssl) != X509_V_OK) {
+ set_err(err, errlen, "smtp: TLS certificate verification failed: %s",
+ X509_verify_cert_error_string(SSL_get_verify_result(c->ssl)));
+ return -1;
+ }
+ return 0;
+}
+
+static int mech_offered(const char *caps, const char *mech)
+{
+ if (!caps)
+ return 0;
+ size_t mlen = strlen(mech);
+ for (const char *p = caps; *p;) {
+ const char *line_end = strchr(p, '\n');
+ size_t llen = line_end ? (size_t)(line_end - p) : strlen(p);
+ if (llen >= 4 && strncasecmp(p, "AUTH", 4) == 0) {
+ const char *q = p + 4;
+ const char *end = p + llen;
+ while (q < end) {
+ while (q < end && (*q == ' ' || *q == '=' || *q == '\t'))
+ q++;
+ const char *w = q;
+ while (q < end && *q != ' ' && *q != '=' && *q != '\t')
+ q++;
+ size_t wl = (size_t)(q - w);
+ if (wl == mlen && strncasecmp(w, mech, mlen) == 0)
+ return 1;
+ }
+ }
+ if (!line_end)
+ break;
+ p = line_end + 1;
+ }
+ return 0;
+}
+
+static void ehlo_name(char *out, size_t n)
+{
+ if (gethostname(out, n) != 0 || out[0] == '\0' || !strchr(out, '.'))
+ snprintf(out, n, "localhost");
+ out[n - 1] = '\0';
+}
+
+static int smtp_ehlo(struct smtp_conn *c, const char *name, struct buf *caps,
+ char *err, size_t errlen)
+{
+ struct buf cmd;
+ buf_init(&cmd);
+ buf_appendf(&cmd, "EHLO %s", name);
+ int rc = conn_buf_line(c, &cmd, err, errlen);
+ buf_free(&cmd);
+ if (rc != 0)
+ return -1;
+ struct smtp_reply r;
+ if (conn_read_reply(c, &r, err, errlen) != 0)
+ return -1;
+ if (r.code != 250) {
+ if (r.text[0])
+ set_err(err, errlen, "EHLO: server said %d %s", r.code, r.text);
+ else
+ set_err(err, errlen, "EHLO: server said %d", r.code);
+ return -1;
+ }
+ caps->len = 0;
+ buf_append(caps, r.text, strlen(r.text) + 1);
+ return 0;
+}
+
+static int auth_plain(struct smtp_conn *c, const char *user, const char *pass,
+ char *err, size_t errlen)
+{
+ size_t ulen = strlen(user);
+ size_t plen = strlen(pass);
+ unsigned char *raw = xmalloc(ulen + plen + 2);
+ raw[0] = 0;
+ memcpy(raw + 1, user, ulen);
+ raw[1 + ulen] = 0;
+ memcpy(raw + 2 + ulen, pass, plen);
+ char *b64 = util_b64(raw, ulen + plen + 2);
+ free(raw);
+
+ struct buf cmd;
+ buf_init(&cmd);
+ buf_appendf(&cmd, "AUTH PLAIN %s", b64);
+ int rc = conn_buf_line(c, &cmd, err, errlen);
+ buf_free(&cmd);
+ if (rc != 0) {
+ free(b64);
+ return -1;
+ }
+ struct smtp_reply r;
+ if (conn_read_reply(c, &r, err, errlen) != 0) {
+ free(b64);
+ return -1;
+ }
+ if (r.code == 334) {
+ rc = conn_line(c, b64, err, errlen);
+ if (rc == 0 && conn_read_reply(c, &r, err, errlen) != 0)
+ rc = -1;
+ }
+ free(b64);
+ if (rc != 0)
+ return -1;
+ if (r.code != 235) {
+ if (r.text[0])
+ set_err(err, errlen, "AUTH PLAIN rejected: %d %s", r.code,
+ r.text);
+ else
+ set_err(err, errlen, "AUTH PLAIN rejected: %d", r.code);
+ return -1;
+ }
+ return 0;
+}
+
+static int auth_login(struct smtp_conn *c, const char *user, const char *pass,
+ char *err, size_t errlen)
+{
+ struct smtp_reply r;
+ if (conn_line(c, "AUTH LOGIN", err, errlen) != 0)
+ return -1;
+ if (conn_read_reply(c, &r, err, errlen) != 0)
+ return -1;
+ if (r.code != 334) {
+ set_err(err, errlen, "AUTH LOGIN rejected: %d", r.code);
+ return -1;
+ }
+ char *ub = util_b64((const unsigned char *)user, strlen(user));
+ int rc = conn_line(c, ub, err, errlen);
+ free(ub);
+ if (rc != 0)
+ return -1;
+ if (conn_read_reply(c, &r, err, errlen) != 0)
+ return -1;
+ if (r.code != 334) {
+ set_err(err, errlen, "AUTH LOGIN rejected: %d", r.code);
+ return -1;
+ }
+ char *pb = util_b64((const unsigned char *)pass, strlen(pass));
+ rc = conn_line(c, pb, err, errlen);
+ free(pb);
+ if (rc != 0)
+ return -1;
+ if (conn_read_reply(c, &r, err, errlen) != 0)
+ return -1;
+ if (r.code != 235) {
+ if (r.text[0])
+ set_err(err, errlen, "AUTH LOGIN rejected: %d %s", r.code,
+ r.text);
+ else
+ set_err(err, errlen, "AUTH LOGIN rejected: %d", r.code);
+ return -1;
+ }
+ return 0;
+}
+
+static int smtp_auth(struct smtp_conn *c, const struct smtp_message *m,
+ const char *caps, char *err, size_t errlen)
+{
+ if (!m->user || !m->user[0])
+ return 0;
+ const char *pass = m->password ? m->password : "";
+ if (mech_offered(caps, "PLAIN"))
+ return auth_plain(c, m->user, pass, err, errlen);
+ if (mech_offered(caps, "LOGIN"))
+ return auth_login(c, m->user, pass, err, errlen);
+ set_err(err, errlen, "smtp: server offers no supported AUTH mechanism");
+ return -1;
+}
+
+static int normalize_addr(const char *in, char *out, size_t outsz)
+{
+ while (*in == ' ' || *in == '\t')
+ in++;
+ size_t n = strlen(in);
+ while (n > 0 && (in[n - 1] == ' ' || in[n - 1] == '\t'))
+ n--;
+ if (n >= 2 && in[0] == '<' && in[n - 1] == '>') {
+ in++;
+ n -= 2;
+ }
+ if (n == 0 || n >= outsz)
+ return -1;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char ch = (unsigned char)in[i];
+ if (ch <= 0x20 || ch == 0x7f || ch == '<' || ch == '>')
+ return -1;
+ }
+ memcpy(out, in, n);
+ out[n] = '\0';
+ return 0;
+}
+
+static int ascii_printable(const char *s)
+{
+ for (; *s; s++) {
+ unsigned char ch = (unsigned char)*s;
+ if (ch < 0x20 || ch >= 0x7f)
+ return 0;
+ }
+ return 1;
+}
+
+static int needs_quoting(const char *s)
+{
+ if (!*s)
+ return 1;
+ size_t n = strlen(s);
+ if (*s == ' ' || *s == '\t' || s[n - 1] == ' ' || s[n - 1] == '\t')
+ return 1;
+ for (const char *p = s; *p; p++)
+ if (strchr("()<>[]:;@\\,.\"", *p))
+ return 1;
+ return 0;
+}
+
+static size_t last_line_len(const struct buf *b)
+{
+ size_t i = b->len;
+ while (i > 0 && b->p[i - 1] != '\n')
+ i--;
+ return b->len - i;
+}
+
+static void append_encoded_words(struct buf *b, const char *s)
+{
+ size_t i = 0;
+ while (s[i]) {
+ size_t start = i;
+ size_t chunk = 0;
+ while (s[i] && chunk < 45) {
+ unsigned char ch = (unsigned char)s[i];
+ size_t clen;
+ if (ch < 0x80)
+ clen = 1;
+ else if ((ch & 0xE0) == 0xC0)
+ clen = 2;
+ else if ((ch & 0xF0) == 0xE0)
+ clen = 3;
+ else if ((ch & 0xF8) == 0xF0)
+ clen = 4;
+ else
+ clen = 1;
+ if (chunk + clen > 45)
+ break;
+ chunk += clen;
+ i += clen;
+ }
+ if (chunk == 0) {
+ chunk = 1;
+ i++;
+ }
+ char *b64 = util_b64((const unsigned char *)(s + start), chunk);
+ if (start > 0) {
+ if (last_line_len(b) > 60)
+ buf_append_str(b, "\r\n ");
+ else
+ buf_append_str(b, " ");
+ }
+ buf_append(b, "=?UTF-8?B?", 10);
+ buf_append(b, b64, strlen(b64));
+ buf_append(b, "?=", 2);
+ free(b64);
+ }
+}
+
+static char *sanitize_dup(const char *s)
+{
+ size_t n = strlen(s);
+ char *out = xmalloc(n + 1);
+ for (size_t i = 0; i < n; i++) {
+ unsigned char ch = (unsigned char)s[i];
+ out[i] = (ch == '\r' || ch == '\n') ? ' ' : (char)ch;
+ }
+ out[n] = '\0';
+ return out;
+}
+
+static void append_phrase(struct buf *b, const char *s)
+{
+ if (ascii_printable(s) && !needs_quoting(s)) {
+ buf_append(b, s, strlen(s));
+ return;
+ }
+ if (ascii_printable(s)) {
+ buf_append(b, "\"", 1);
+ for (const char *p = s; *p; p++) {
+ if (*p == '"' || *p == '\\')
+ buf_append(b, "\\", 1);
+ buf_append(b, p, 1);
+ }
+ buf_append(b, "\"", 1);
+ return;
+ }
+ append_encoded_words(b, s);
+}
+
+static void append_b64_wrapped(struct buf *out, const unsigned char *data,
+ size_t n)
+{
+ if (n == 0)
+ return;
+ char *b64 = util_b64(data, n);
+ size_t len = strlen(b64);
+ for (size_t i = 0; i < len; i += 76) {
+ size_t chunk = len - i < 76 ? len - i : 76;
+ buf_append(out, b64 + i, chunk);
+ buf_append(out, "\r\n", 2);
+ }
+ free(b64);
+}
+
+static void append_quoted_param(struct buf *b, const char *s)
+{
+ for (; *s; s++) {
+ if (*s == '"' || *s == '\\')
+ buf_append(b, "\\", 1);
+ buf_append(b, s, 1);
+ }
+}
+
+static void copy_str(char *dst, size_t n, const char *src)
+{
+ size_t len = strlen(src);
+ if (len >= n)
+ len = n - 1;
+ memcpy(dst, src, len);
+ dst[len] = '\0';
+}
+
+static void message_domain(const char *from, char *out, size_t outsz)
+{
+ const char *at = strrchr(from, '@');
+ if (at && at[1])
+ copy_str(out, outsz, at + 1);
+ else
+ copy_str(out, outsz, "localhost");
+ for (char *p = out; *p; p++) {
+ unsigned char ch = (unsigned char)*p;
+ if (ch < 0x21 || ch > 0x7e || ch == '<' || ch == '>' || ch == '[' ||
+ ch == ']' || ch == '\\')
+ *p = '_';
+ }
+}
+
+static int build_message(const struct smtp_message *m, const char *from,
+ const char *to, struct buf *out, char *err,
+ size_t errlen)
+{
+ unsigned char rnd[12];
+ if (util_random(rnd, sizeof rnd) != 0) {
+ set_err(err, errlen, "smtp: no entropy source");
+ return -1;
+ }
+ char rhex[25];
+ util_hex(rnd, sizeof rnd, rhex);
+
+ char domain[256];
+ message_domain(from, domain, sizeof domain);
+
+ char date[64];
+ time_t now = time(NULL);
+ struct tm tm;
+ if (gmtime_r(&now, &tm))
+ strftime(date, sizeof date, "%a, %d %b %Y %H:%M:%S +0000", &tm);
+ else
+ snprintf(date, sizeof date, "Thu, 01 Jan 1970 00:00:00 +0000");
+
+ buf_append_str(out, "From: ");
+ if (m->from_name && m->from_name[0]) {
+ char *name = sanitize_dup(m->from_name);
+ append_phrase(out, name);
+ free(name);
+ buf_append_str(out, " ");
+ }
+ buf_appendf(out, "<%s>\r\n", from);
+ buf_appendf(out, "To: <%s>\r\n", to);
+
+ char *subject = sanitize_dup(m->subject ? m->subject : "");
+ buf_append_str(out, "Subject: ");
+ if (ascii_printable(subject))
+ buf_append(out, subject, strlen(subject));
+ else
+ append_encoded_words(out, subject);
+ free(subject);
+ buf_append_str(out, "\r\n");
+
+ buf_appendf(out, "Date: %s\r\n", date);
+ buf_appendf(out, "Message-ID: <%s.%ld@%s>\r\n", rhex, (long)getpid(),
+ domain);
+ buf_append_str(out, "MIME-Version: 1.0\r\n");
+
+ int has_attach = m->attach_name && m->attach_name[0] && m->attach &&
+ m->attach_len > 0;
+ if (has_attach) {
+ char boundary[64];
+ snprintf(boundary, sizeof boundary, "=_bokf_%s", rhex);
+ char *fname = sanitize_dup(m->attach_name);
+ const char *body = m->body ? m->body : "";
+ size_t body_len = strlen(body);
+
+ buf_appendf(out, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n",
+ boundary);
+ buf_appendf(out, "\r\n--%s\r\n", boundary);
+ buf_append_str(out, "Content-Type: text/plain; charset=utf-8\r\n");
+ buf_append_str(out, "Content-Transfer-Encoding: base64\r\n");
+ buf_append(out, "\r\n", 2);
+ append_b64_wrapped(out, (const unsigned char *)body, body_len);
+ buf_appendf(out, "--%s\r\n", boundary);
+ buf_append_str(out, "Content-Type: application/pdf; name=\"");
+ append_quoted_param(out, fname);
+ buf_append_str(out, "\"\r\n");
+ buf_append_str(out, "Content-Transfer-Encoding: base64\r\n");
+ buf_append_str(out, "Content-Disposition: attachment; filename=\"");
+ append_quoted_param(out, fname);
+ buf_append_str(out, "\"\r\n\r\n");
+ append_b64_wrapped(out, m->attach, m->attach_len);
+ buf_appendf(out, "--%s--\r\n", boundary);
+ free(fname);
+ } else {
+ const char *body = m->body ? m->body : "";
+ size_t body_len = strlen(body);
+ buf_append_str(out, "Content-Type: text/plain; charset=utf-8\r\n");
+ buf_append_str(out, "Content-Transfer-Encoding: base64\r\n");
+ buf_append(out, "\r\n", 2);
+ append_b64_wrapped(out, (const unsigned char *)body, body_len);
+ }
+ return 0;
+}
+
+static int conn_write_dotstuffed(struct smtp_conn *c, const unsigned char *data,
+ size_t n, char *err, size_t errlen)
+{
+ size_t start = 0;
+ while (start < n) {
+ const unsigned char *nl = memchr(data + start, '\n', n - start);
+ size_t end = nl ? (size_t)(nl - data) + 1 : n;
+ if (data[start] == '.') {
+ if (conn_write(c, ".", 1, err, errlen) != 0)
+ return -1;
+ }
+ if (conn_write(c, data + start, end - start, err, errlen) != 0)
+ return -1;
+ start = end;
+ }
+ return 0;
+}
+
+int smtp_send(const struct smtp_message *m, char *err, size_t errlen)
+{
+ if (err && errlen)
+ err[0] = '\0';
+ if (!m) {
+ set_err(err, errlen, "smtp: no message");
+ return -1;
+ }
+ if (!m->host || !m->host[0]) {
+ set_err(err, errlen, "smtp: no host");
+ return -1;
+ }
+ if (!m->from || !m->from[0]) {
+ set_err(err, errlen, "smtp: no from address");
+ return -1;
+ }
+ if (!m->to || !m->to[0]) {
+ set_err(err, errlen, "smtp: no to address");
+ return -1;
+ }
+ if (m->port <= 0 || m->port > 65535) {
+ set_err(err, errlen, "smtp: invalid port");
+ return -1;
+ }
+ const char *security = m->security ? m->security : "plain";
+ int use_tls = strcmp(security, "tls") == 0;
+ int use_starttls = strcmp(security, "starttls") == 0;
+ int use_plain = strcmp(security, "plain") == 0;
+ if (!use_tls && !use_starttls && !use_plain) {
+ set_err(err, errlen, "smtp: unknown security mode \"%s\"", security);
+ return -1;
+ }
+
+ char from[512];
+ char to[512];
+ if (normalize_addr(m->from, from, sizeof from) != 0 ||
+ normalize_addr(m->to, to, sizeof to) != 0) {
+ set_err(err, errlen, "smtp: invalid address");
+ return -1;
+ }
+
+ int ret = -1;
+ struct smtp_conn c;
+ memset(&c, 0, sizeof c);
+ c.fd = -1;
+ struct buf message;
+ buf_init(&message);
+ struct buf caps;
+ buf_init(&caps);
+
+ if (tcp_connect(&c, m->host, m->port, err, errlen) != 0)
+ goto out;
+ if (use_tls && tls_start(&c, m->host, err, errlen) != 0)
+ goto out;
+ if (conn_expect(&c, 220, "greeting", err, errlen) != 0)
+ goto out;
+
+ char hostname[256];
+ ehlo_name(hostname, sizeof hostname);
+ if (smtp_ehlo(&c, hostname, &caps, err, errlen) != 0)
+ goto out;
+
+ if (use_starttls) {
+ if (conn_line(&c, "STARTTLS", err, errlen) != 0 ||
+ conn_expect(&c, 220, "STARTTLS", err, errlen) != 0)
+ goto out;
+ if (tls_start(&c, m->host, err, errlen) != 0)
+ goto out;
+ if (smtp_ehlo(&c, hostname, &caps, err, errlen) != 0)
+ goto out;
+ }
+
+ if (smtp_auth(&c, m, (const char *)caps.p, err, errlen) != 0)
+ goto out;
+
+ char cmd[1200];
+ int n = snprintf(cmd, sizeof cmd, "MAIL FROM:<%s>", from);
+ if (n < 0 || (size_t)n >= sizeof cmd) {
+ set_err(err, errlen, "smtp: from address too long");
+ goto out;
+ }
+ if (conn_line(&c, cmd, err, errlen) != 0 ||
+ conn_expect(&c, 250, "MAIL FROM", err, errlen) != 0)
+ goto out;
+
+ n = snprintf(cmd, sizeof cmd, "RCPT TO:<%s>", to);
+ if (n < 0 || (size_t)n >= sizeof cmd) {
+ set_err(err, errlen, "smtp: to address too long");
+ goto out;
+ }
+ if (conn_line(&c, cmd, err, errlen) != 0)
+ goto out;
+ struct smtp_reply r;
+ if (conn_read_reply(&c, &r, err, errlen) != 0)
+ goto out;
+ if (r.code != 250 && r.code != 251) {
+ if (r.text[0])
+ set_err(err, errlen, "RCPT TO: server said %d %s", r.code,
+ r.text);
+ else
+ set_err(err, errlen, "RCPT TO: server said %d", r.code);
+ goto out;
+ }
+
+ if (conn_line(&c, "DATA", err, errlen) != 0 ||
+ conn_expect(&c, 354, "DATA", err, errlen) != 0)
+ goto out;
+
+ if (build_message(m, from, to, &message, err, errlen) != 0)
+ goto out;
+ if (conn_write_dotstuffed(&c, message.p, message.len, err, errlen) != 0)
+ goto out;
+ if (conn_write(&c, ".\r\n", 3, err, errlen) != 0 ||
+ conn_expect(&c, 250, "message", err, errlen) != 0)
+ goto out;
+
+ if (conn_line(&c, "QUIT", NULL, 0) == 0)
+ (void)conn_read_reply(&c, &r, NULL, 0);
+
+ ret = 0;
+
+out:
+ buf_free(&caps);
+ buf_free(&message);
+ conn_close(&c);
+ return ret;
+}
diff --git a/src/smtp.h b/src/smtp.h
new file mode 100644
index 0000000..a6c4a34
--- /dev/null
+++ b/src/smtp.h
@@ -0,0 +1,24 @@
+#ifndef BOKF_SMTP_H
+#define BOKF_SMTP_H
+
+#include <stddef.h>
+
+struct smtp_message {
+ const char *host;
+ int port;
+ const char *security; /* "tls" (implicit), "starttls", "plain" */
+ const char *user; /* NULL/empty = no AUTH */
+ const char *password;
+ const char *from; /* envelope + From address */
+ const char *from_name; /* display name, may be NULL */
+ const char *to;
+ const char *subject;
+ const char *body; /* UTF-8 text body */
+ const char *attach_name; /* NULL -> text-only */
+ const unsigned char *attach;
+ size_t attach_len;
+};
+
+int smtp_send(const struct smtp_message *m, char *err, size_t errlen);
+
+#endif
diff --git a/tests/smtp_check.c b/tests/smtp_check.c
new file mode 100644
index 0000000..331f4a9
--- /dev/null
+++ b/tests/smtp_check.c
@@ -0,0 +1,663 @@
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include <openssl/ssl.h>
+
+#include "smtp.h"
+#include "util.h"
+
+enum {
+ SRV_NOAUTH = 0,
+ SRV_AUTH_PLAIN,
+ SRV_AUTH_LOGIN,
+ SRV_RCPT_REJECT,
+ SRV_TLS,
+ SRV_STARTTLS
+};
+
+struct srv_result {
+ int saw_ehlo;
+ int saw_auth;
+ int saw_auth_plain;
+ int saw_auth_login;
+ int saw_starttls;
+ size_t data_len;
+};
+
+static int failures;
+
+static void check(int cond, const char *what)
+{
+ if (!cond) {
+ printf("FAIL %s\n", what);
+ failures++;
+ } else {
+ printf("ok %s\n", what);
+ }
+}
+
+static int contains(const unsigned char *data, size_t len, const char *needle)
+{
+ size_t n = strlen(needle);
+ if (n > len)
+ return 0;
+ for (size_t i = 0; i + n <= len; i++)
+ if (memcmp(data + i, needle, n) == 0)
+ return 1;
+ return 0;
+}
+
+static int count_occ(const unsigned char *data, size_t len, const char *needle)
+{
+ size_t n = strlen(needle);
+ int c = 0;
+ if (n == 0 || n > len)
+ return 0;
+ for (size_t i = 0; i + n <= len; i++)
+ if (memcmp(data + i, needle, n) == 0)
+ c++;
+ return c;
+}
+
+static ssize_t read_full(int fd, void *buf, size_t n)
+{
+ unsigned char *p = buf;
+ size_t got = 0;
+ while (got < n) {
+ ssize_t r = read(fd, p + got, n - got);
+ if (r < 0) {
+ if (errno == EINTR)
+ continue;
+ return -1;
+ }
+ if (r == 0)
+ break;
+ got += (size_t)r;
+ }
+ return (ssize_t)got;
+}
+
+static int write_full(int fd, const void *buf, size_t n)
+{
+ const unsigned char *p = buf;
+ size_t done = 0;
+ while (done < n) {
+ ssize_t w = write(fd, p + done, n - done);
+ if (w < 0) {
+ if (errno == EINTR)
+ continue;
+ return -1;
+ }
+ done += (size_t)w;
+ }
+ return 0;
+}
+
+struct srv_io {
+ int fd;
+ SSL *ssl;
+ unsigned char buf[4096];
+ size_t len;
+ size_t pos;
+};
+
+static int srv_write_all(struct srv_io *io, const char *s)
+{
+ size_t n = strlen(s);
+ size_t o = 0;
+ while (o < n) {
+ ssize_t w = io->ssl ? SSL_write(io->ssl, s + o, (int)(n - o))
+ : write(io->fd, s + o, n - o);
+ if (w <= 0)
+ return -1;
+ o += (size_t)w;
+ }
+ return 0;
+}
+
+static ssize_t srv_line(struct srv_io *io, char *out, size_t outsz)
+{
+ size_t o = 0;
+ for (;;) {
+ if (io->pos >= io->len) {
+ ssize_t n = io->ssl ? SSL_read(io->ssl, io->buf, (int)sizeof io->buf)
+ : read(io->fd, io->buf, sizeof io->buf);
+ if (n <= 0)
+ return -1;
+ io->len = (size_t)n;
+ io->pos = 0;
+ }
+ unsigned char ch = io->buf[io->pos++];
+ if (ch == '\n') {
+ while (o > 0 && out[o - 1] == '\r')
+ o--;
+ out[o] = '\0';
+ return (ssize_t)o;
+ }
+ if (o + 1 < outsz)
+ out[o++] = (char)ch;
+ }
+}
+
+static void srv_tls_accept(struct srv_io *io)
+{
+ SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
+ if (!ctx ||
+ SSL_CTX_use_certificate_chain_file(ctx,
+ "tests/tls_test_cert.pem") != 1 ||
+ SSL_CTX_use_PrivateKey_file(ctx, "tests/tls_test_key.pem",
+ SSL_FILETYPE_PEM) != 1)
+ _exit(1);
+ SSL *ssl = SSL_new(ctx);
+ if (!ssl || SSL_set_fd(ssl, io->fd) != 1 || SSL_accept(ssl) != 1)
+ _exit(1);
+ io->ssl = ssl;
+}
+
+static void srv_ehlo(struct srv_io *io, int mode, int after_tls)
+{
+ srv_write_all(io, "250-test.local hello\r\n");
+ if (mode == SRV_AUTH_PLAIN)
+ srv_write_all(io, "250-AUTH PLAIN LOGIN\r\n");
+ else if (mode == SRV_AUTH_LOGIN)
+ srv_write_all(io, "250-AUTH LOGIN\r\n");
+ if (mode == SRV_STARTTLS && !after_tls)
+ srv_write_all(io, "250-STARTTLS\r\n");
+ srv_write_all(io, "250 SIZE 10485760\r\n");
+}
+
+static int plain_ok(const char *b64)
+{
+ static const unsigned char want[] = { 0, 'm', 'a', 'i', 'l',
+ 'e', 'r', 0, 's', '3',
+ 'c', 'r', 'e', 't' };
+ unsigned char *raw = NULL;
+ size_t len = 0;
+ if (util_b64_decode(b64, strlen(b64), &raw, &len) != 0) {
+ if (getenv("SMTP_CHECK_DUMP"))
+ fprintf(stderr, "server: AUTH PLAIN decode failed [%s]\n", b64);
+ return 0;
+ }
+ int ok = len == sizeof want && memcmp(raw, want, len) == 0;
+ if (!ok && getenv("SMTP_CHECK_DUMP"))
+ fprintf(stderr, "server: AUTH PLAIN mismatch len=%zu [%s]\n", len,
+ b64);
+ free(raw);
+ return ok;
+}
+
+static void server_main(int wfd, int mode)
+{
+ int sfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ if (sfd < 0)
+ _exit(1);
+ struct sockaddr_in sa;
+ memset(&sa, 0, sizeof sa);
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ sa.sin_port = 0;
+ if (bind(sfd, (struct sockaddr *)&sa, sizeof sa) != 0 ||
+ listen(sfd, 1) != 0)
+ _exit(1);
+ socklen_t slen = sizeof sa;
+ if (getsockname(sfd, (struct sockaddr *)&sa, &slen) != 0)
+ _exit(1);
+ int port = ntohs(sa.sin_port);
+ if (write_full(wfd, &port, sizeof port) != 0)
+ _exit(1);
+ int cfd = accept(sfd, NULL, NULL);
+ if (cfd < 0)
+ _exit(1);
+ close(sfd);
+
+ struct srv_io io;
+ memset(&io, 0, sizeof io);
+ io.fd = cfd;
+ struct srv_result res;
+ memset(&res, 0, sizeof res);
+ struct buf data;
+ buf_init(&data);
+
+ char *b64_user = util_b64((const unsigned char *)"mailer", 6);
+ char *b64_pass = util_b64((const unsigned char *)"s3cret", 6);
+
+ if (mode == SRV_TLS)
+ srv_tls_accept(&io);
+ srv_write_all(&io, "220 test.local ESMTP\r\n");
+
+ int in_data = 0;
+ char line[2048];
+ for (;;) {
+ ssize_t n = srv_line(&io, line, sizeof line);
+ if (n < 0)
+ break;
+ if (in_data) {
+ if (strcmp(line, ".") == 0) {
+ res.data_len = data.len;
+ in_data = 0;
+ if (srv_write_all(&io, "250 2.0.0 queued\r\n") != 0)
+ break;
+ continue;
+ }
+ const char *payload = line[0] == '.' ? line + 1 : line;
+ buf_append(&data, payload, strlen(payload));
+ buf_append(&data, "\r\n", 2);
+ continue;
+ }
+ if (strncmp(line, "EHLO", 4) == 0) {
+ res.saw_ehlo++;
+ srv_ehlo(&io, mode, mode == SRV_TLS || io.ssl != NULL);
+ } else if (strcmp(line, "STARTTLS") == 0) {
+ res.saw_starttls = 1;
+ srv_write_all(&io, "220 2.0.0 ready\r\n");
+ srv_tls_accept(&io);
+ } else if (strncmp(line, "AUTH PLAIN", 10) == 0) {
+ res.saw_auth = 1;
+ res.saw_auth_plain = 1;
+ const char *arg = line + 10;
+ while (*arg == ' ')
+ arg++;
+ srv_write_all(&io, plain_ok(arg)
+ ? "235 2.7.0 authenticated\r\n"
+ : "535 5.7.8 bad credentials\r\n");
+ } else if (strcmp(line, "AUTH LOGIN") == 0) {
+ res.saw_auth = 1;
+ res.saw_auth_login = 1;
+ srv_write_all(&io, "334 VXNlcm5hbWU6\r\n");
+ if (srv_line(&io, line, sizeof line) < 0)
+ _exit(1);
+ if (strcmp(line, b64_user) != 0) {
+ srv_write_all(&io, "535 5.7.8 bad credentials\r\n");
+ continue;
+ }
+ srv_write_all(&io, "334 UGFzc3dvcmQ6\r\n");
+ if (srv_line(&io, line, sizeof line) < 0)
+ _exit(1);
+ srv_write_all(&io, strcmp(line, b64_pass) == 0
+ ? "235 2.7.0 authenticated\r\n"
+ : "535 5.7.8 bad credentials\r\n");
+ } else if (strncmp(line, "MAIL FROM:", 10) == 0) {
+ srv_write_all(&io, "250 2.1.0 ok\r\n");
+ } else if (strncmp(line, "RCPT TO:", 8) == 0) {
+ srv_write_all(&io, mode == SRV_RCPT_REJECT
+ ? "550 5.1.1 no such user\r\n"
+ : "250 2.1.5 ok\r\n");
+ } else if (strcmp(line, "DATA") == 0) {
+ if (mode == SRV_RCPT_REJECT) {
+ srv_write_all(&io, "503 5.5.1 need RCPT\r\n");
+ } else {
+ srv_write_all(&io, "354 end with .\r\n");
+ in_data = 1;
+ }
+ } else if (strcmp(line, "QUIT") == 0) {
+ srv_write_all(&io, "221 2.0.0 bye\r\n");
+ break;
+ } else {
+ srv_write_all(&io, "500 5.5.1 unknown command\r\n");
+ }
+ }
+
+ (void)write_full(wfd, &res, sizeof res);
+ if (data.len)
+ (void)write_full(wfd, data.p, data.len);
+ close(wfd);
+ close(cfd);
+ buf_free(&data);
+ free(b64_user);
+ free(b64_pass);
+ _exit(0);
+}
+
+struct case_result {
+ int rc;
+ char err[512];
+ struct srv_result res;
+ unsigned char data[131072];
+ size_t data_len;
+ int server_ok;
+};
+
+static void run_case(int mode, struct smtp_message m, struct case_result *out)
+{
+ signal(SIGPIPE, SIG_IGN);
+ int pfd[2];
+ memset(out, 0, sizeof *out);
+ if (pipe(pfd) != 0) {
+ printf("FAIL pipe\n");
+ failures++;
+ return;
+ }
+ pid_t pid = fork();
+ if (pid == 0) {
+ close(pfd[0]);
+ server_main(pfd[1], mode);
+ }
+ close(pfd[1]);
+ int port = 0;
+ if (read_full(pfd[0], &port, sizeof port) != (ssize_t)sizeof port)
+ port = 0;
+ m.port = port;
+ out->rc = smtp_send(&m, out->err, sizeof out->err);
+
+ struct srv_result res;
+ memset(&res, 0, sizeof res);
+ if (read_full(pfd[0], &res, sizeof res) == (ssize_t)sizeof res) {
+ out->res = res;
+ size_t cap = res.data_len < sizeof out->data ? res.data_len
+ : sizeof out->data;
+ if (cap && read_full(pfd[0], out->data, cap) == (ssize_t)cap)
+ out->data_len = cap;
+ }
+ close(pfd[0]);
+ int status = 0;
+ waitpid(pid, &status, 0);
+ out->server_ok = WIFEXITED(status) && WEXITSTATUS(status) == 0;
+}
+
+static void check_delivered(const char *what, const struct case_result *c)
+{
+ check(c->server_ok && c->rc == 0, what);
+ if (c->server_ok && c->rc == 0)
+ return;
+ if (!c->server_ok)
+ printf(" server aborted\n");
+ if (c->rc != 0)
+ printf(" err: %s\n", c->err);
+}
+
+static struct smtp_message base_message(void)
+{
+ struct smtp_message m;
+ memset(&m, 0, sizeof m);
+ m.host = "localhost";
+ m.security = "plain";
+ m.from = "invoice@example.test";
+ m.from_name = "Bokf AB";
+ m.to = "kund@example.test";
+ m.subject = "Faktura 1001";
+ m.body = "Hej!\nBifogad faktura.\n";
+ return m;
+}
+
+static const unsigned char pdf[] =
+ "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n"
+ "<< /Root 1 0 R >>\n%%EOF\n";
+
+static void flatten(const unsigned char *data, size_t len,
+ unsigned char **out, size_t *out_len)
+{
+ unsigned char *flat = malloc(len + 1);
+ size_t o = 0;
+ for (size_t i = 0; i < len; i++)
+ if (data[i] != '\r' && data[i] != '\n')
+ flat[o++] = data[i];
+ flat[o] = '\0';
+ *out = flat;
+ *out_len = o;
+}
+
+static void check_crlf(const unsigned char *data, size_t len)
+{
+ int crlf = 1;
+ int max = 0;
+ size_t start = 0;
+ for (size_t i = 0; i < len; i++) {
+ if (data[i] != '\n')
+ continue;
+ size_t l = i - start;
+ if (l > 0 && data[i - 1] == '\r')
+ l--;
+ else
+ crlf = 0;
+ if (l > (size_t)max)
+ max = (int)l;
+ start = i + 1;
+ }
+ check(crlf, "message uses CRLF only");
+ check(max > 0 && max < 998, "line lengths under 998");
+}
+
+static void test_multipart(void)
+{
+ struct smtp_message m = base_message();
+ m.from_name = "Bokf \xc3\x84rende";
+ m.subject = "Faktura p\xc3\xa5 svenska";
+ m.attach_name = "faktura.pdf";
+ m.attach = pdf;
+ m.attach_len = sizeof pdf - 1;
+
+ struct case_result c;
+ run_case(SRV_NOAUTH, m, &c);
+ check_delivered("multipart: delivered", &c);
+ check(c.res.saw_ehlo == 1, "multipart: one EHLO");
+ check(!c.res.saw_auth, "multipart: no AUTH when user empty");
+
+ const unsigned char *d = c.data;
+ size_t n = c.data_len;
+ if (getenv("SMTP_CHECK_DUMP"))
+ printf("--- message ---\n%.*s\n---\n", (int)n, d);
+ char *name_word = NULL;
+ char *subj_word = NULL;
+ {
+ char *b = util_b64((const unsigned char *)m.from_name,
+ strlen(m.from_name));
+ size_t l = strlen(b) + 13;
+ name_word = malloc(l);
+ snprintf(name_word, l, "=?UTF-8?B?%s?=", b);
+ free(b);
+ b = util_b64((const unsigned char *)m.subject, strlen(m.subject));
+ l = strlen(b) + 13;
+ subj_word = malloc(l);
+ snprintf(subj_word, l, "=?UTF-8?B?%s?=", b);
+ free(b);
+ }
+ char from_hdr[256];
+ snprintf(from_hdr, sizeof from_hdr, "From: %s <invoice@example.test>",
+ name_word);
+ char subj_hdr[256];
+ snprintf(subj_hdr, sizeof subj_hdr, "Subject: %s", subj_word);
+
+ check(contains(d, n, "MIME-Version: 1.0\r\n"), "multipart: MIME-Version");
+ check(contains(d, n, from_hdr), "multipart: From with B-encoded name");
+ check(contains(d, n, "To: <kund@example.test>\r\n"), "multipart: To");
+ check(contains(d, n, subj_hdr), "multipart: B-encoded subject");
+ check(contains(d, n, "Date: ") && contains(d, n, "+0000\r\n"),
+ "multipart: UTC Date");
+ check(contains(d, n, "Message-ID: <"), "multipart: Message-ID");
+ check(contains(d, n, "Content-Type: multipart/mixed; boundary=\""),
+ "multipart: multipart content type");
+ check(count_occ(d, n, "Content-Type: text/plain; charset=utf-8") == 1,
+ "multipart: text part");
+ check(contains(d, n, "Content-Type: application/pdf; name=\"faktura.pdf\""),
+ "multipart: attachment type and name");
+ check(contains(d, n,
+ "Content-Disposition: attachment; filename=\"faktura.pdf\""),
+ "multipart: attachment disposition");
+ check(count_occ(d, n, "Content-Transfer-Encoding: base64") == 2,
+ "multipart: two base64 parts");
+
+ unsigned char *flat = NULL;
+ size_t flat_len = 0;
+ flatten(d, n, &flat, &flat_len);
+ char *b64 = util_b64(pdf, sizeof pdf - 1);
+ check(contains(flat, flat_len, b64), "multipart: attachment base64");
+ free(b64);
+ b64 = util_b64((const unsigned char *)m.body, strlen(m.body));
+ check(contains(flat, flat_len, b64), "multipart: body base64");
+ free(b64);
+ free(flat);
+
+ check(count_occ(d, n, "--=_bokf_") == 3, "multipart: boundary delimiters");
+ check_crlf(d, n);
+ free(name_word);
+ free(subj_word);
+}
+
+static void test_text_only(void)
+{
+ struct smtp_message m = base_message();
+ struct case_result c;
+ run_case(SRV_NOAUTH, m, &c);
+ check_delivered("text-only: delivered", &c);
+ const unsigned char *d = c.data;
+ size_t n = c.data_len;
+ check(contains(d, n, "From: Bokf AB <invoice@example.test>\r\n"),
+ "text-only: plain display name");
+ check(contains(d, n, "Subject: Faktura 1001\r\n"),
+ "text-only: plain subject");
+ check(contains(d, n, "Content-Type: text/plain; charset=utf-8\r\n"),
+ "text-only: text content type");
+ check(!contains(d, n, "multipart/mixed"), "text-only: not multipart");
+ check_crlf(d, n);
+}
+
+static void test_rcpt_reject(void)
+{
+ struct smtp_message m = base_message();
+ m.attach_name = "faktura.pdf";
+ m.attach = pdf;
+ m.attach_len = sizeof pdf - 1;
+ struct case_result c;
+ run_case(SRV_RCPT_REJECT, m, &c);
+ check(c.server_ok, "reject: server completed session");
+ check(c.rc == -1, "reject: smtp_send failed");
+ check(strstr(c.err, "550") != NULL, "reject: err has 550");
+ check(strstr(c.err, "no such user") != NULL, "reject: err has server text");
+ check(!contains(c.data, c.data_len, "faktura"),
+ "reject: no DATA delivered");
+}
+
+static void test_auth_plain(void)
+{
+ struct smtp_message m = base_message();
+ m.user = "mailer";
+ m.password = "s3cret";
+ struct case_result c;
+ run_case(SRV_AUTH_PLAIN, m, &c);
+ check_delivered("auth plain: delivered", &c);
+ check(c.res.saw_auth_plain == 1, "auth plain: PLAIN used");
+ check(c.res.saw_auth_login == 0, "auth plain: LOGIN not used");
+ check(!contains(c.data, c.data_len, "s3cret"),
+ "auth plain: no secret in message");
+}
+
+static void test_auth_login(void)
+{
+ struct smtp_message m = base_message();
+ m.user = "mailer";
+ m.password = "s3cret";
+ struct case_result c;
+ run_case(SRV_AUTH_LOGIN, m, &c);
+ check_delivered("auth login: delivered", &c);
+ check(c.res.saw_auth_login == 1, "auth login: LOGIN used");
+ check(c.res.saw_auth_plain == 0, "auth login: PLAIN not used");
+}
+
+static void test_auth_reject(void)
+{
+ struct smtp_message m = base_message();
+ m.user = "mailer";
+ m.password = "wrong";
+ struct case_result c;
+ run_case(SRV_AUTH_PLAIN, m, &c);
+ check(c.rc == -1, "auth reject: smtp_send failed");
+ check(strstr(c.err, "535") != NULL, "auth reject: err has 535");
+ check(strstr(c.err, "s3cret") == NULL, "auth reject: no secret in err");
+}
+
+static void test_validation(void)
+{
+ char err[256];
+ check(smtp_send(NULL, err, sizeof err) == -1, "validation: NULL message");
+
+ struct smtp_message m = base_message();
+ m.port = 25;
+ m.security = "bogus";
+ check(smtp_send(&m, err, sizeof err) == -1, "validation: bad security");
+ check(strstr(err, "security") != NULL, "validation: bad security text");
+
+ m = base_message();
+ m.from = "";
+ check(smtp_send(&m, err, sizeof err) == -1, "validation: empty from");
+
+ m = base_message();
+ m.host = NULL;
+ check(smtp_send(&m, err, sizeof err) == -1, "validation: NULL host");
+}
+
+static int tls_available(void)
+{
+ return access("tests/tls_test_cert.pem", R_OK) == 0 &&
+ access("tests/tls_test_key.pem", R_OK) == 0;
+}
+
+static void with_test_ca(void (*fn)(struct smtp_message *, struct case_result *))
+{
+ char *saved = getenv("SSL_CERT_FILE");
+ char *copy = saved ? strdup(saved) : NULL;
+ setenv("SSL_CERT_FILE", "tests/tls_test_cert.pem", 1);
+ struct smtp_message m = base_message();
+ struct case_result c;
+ fn(&m, &c);
+ check_delivered("TLS case delivered", &c);
+ if (saved)
+ setenv("SSL_CERT_FILE", copy, 1);
+ else
+ unsetenv("SSL_CERT_FILE");
+ free(copy);
+}
+
+static void tls_case(struct smtp_message *m, struct case_result *c)
+{
+ m->security = "tls";
+ m->attach_name = "faktura.pdf";
+ m->attach = pdf;
+ m->attach_len = sizeof pdf - 1;
+ run_case(SRV_TLS, *m, c);
+}
+
+static void starttls_case(struct smtp_message *m, struct case_result *c)
+{
+ m->security = "starttls";
+ m->attach_name = "faktura.pdf";
+ m->attach = pdf;
+ m->attach_len = sizeof pdf - 1;
+ run_case(SRV_STARTTLS, *m, c);
+ check(c->res.saw_starttls == 1, "starttls: STARTTLS sent");
+ check(c->res.saw_ehlo == 2, "starttls: EHLO before and after");
+}
+
+int main(void)
+{
+ signal(SIGPIPE, SIG_IGN);
+ alarm(120);
+
+ test_multipart();
+ test_text_only();
+ test_rcpt_reject();
+ test_auth_plain();
+ test_auth_login();
+ test_auth_reject();
+ test_validation();
+ if (tls_available()) {
+ with_test_ca(tls_case);
+ with_test_ca(starttls_case);
+ } else {
+ printf("skip TLS cases: tests/tls_test_cert.pem missing\n");
+ }
+
+ if (failures) {
+ printf("%d failures\n", failures);
+ return 1;
+ }
+ puts("all ok");
+ return 0;
+}