subrelay/src/mail_processor.c

726 lines
22 KiB
C

/*
* this is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* this is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.";
*
* (c) Alexander Vdolainen 2007, 2026 <alex@vapaa.xyz>
* subrelay: a minimalistic maillist server based on external IMAPs/SMTPs
*
* mail_processor.c: RFC 822/RFC 5322 header parsing/rewriting and routing
*
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <ctype.h>
#include <errno.h>
#include <ilib/misc.h>
#include <ilib/mail_processor.h>
#include <ilib/template.h>
/* Extract raw address from formats like "Alice <alice@domain.tld>" */
static int extract_email_address(const char *raw, char *out, size_t out_len)
{
if (!raw || !out || out_len == 0) return -1;
const char *start = strchr(raw, '<');
const char *end = start ? strchr(start + 1, '>') : NULL;
if (start && end) {
raw = start + 1;
size_t len = (size_t)(end - raw);
if (len == 0 || len >= out_len) return -1;
memcpy(out, raw, len);
out[len] = '\0';
} else {
size_t len = strlen(raw);
if (len >= out_len) return -1;
memcpy(out, raw, len + 1);
}
strtrim_whitespace(out);
if (out[0] == '\0') return -1;
return 0;
}
/* Case-insensitive header value extractor supporting multi-line folding */
static char *get_header_value(const char *raw_msg, const char *header_name)
{
if (!raw_msg || !header_name || !*header_name) return NULL;
const size_t name_len = strlen(header_name);
const char *p = raw_msg;
char *value = NULL;
size_t value_len = 0;
while (*p) {
const char *line = p;
const char *lf = strchr(p, '\n');
const char *end = lf ? lf : p + strlen(p);
if (end > line && end[-1] == '\r') end--;
/* Empty line marks the end of the header block. */
if (end == line) break;
if (*line == ' ' || *line == '\t') {
/* Continuation belongs to the preceding header. */
if (value) {
const char *q = line;
while (q < end && (*q == ' ' || *q == '\t')) q++;
size_t add = (size_t)(end - q);
char *tmp = realloc(value, value_len + 1 + add + 1);
if (!tmp) {
free(value);
return NULL;
}
value = tmp;
value[value_len++] = ' ';
memcpy(value + value_len, q, add);
value_len += add;
value[value_len] = '\0';
}
} else {
/* A new header ends the value we were collecting. */
if (value) break;
const char *colon = memchr(line, ':', (size_t)(end - line));
if (colon && (size_t)(colon - line) == name_len &&
strncasecmp(line, header_name, name_len) == 0) {
const char *v = colon + 1;
while (v < end && (*v == ' ' || *v == '\t')) v++;
value_len = (size_t)(end - v);
value = malloc(value_len + 1);
if (!value) return NULL;
memcpy(value, v, value_len);
value[value_len] = '\0';
}
}
p = lf ? lf + 1 : end;
if (!lf) break;
}
if (value) strtrim_whitespace(value);
return value;
}
/* Reject values that could inject additional message headers. */
static int header_value_is_safe(const char *s)
{
if (!s) return 0;
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
if (c == '\r' || c == '\n' || c == 0x7f ||
(c < 0x20 && c != '\t'))
return 0;
}
return 1;
}
static int field_name_eq(const char *name, size_t name_len, const char *wanted)
{
return strlen(wanted) == name_len &&
strncasecmp(name, wanted, name_len) == 0;
}
/* Checks if a header key should be stripped */
static int should_strip_header(const char *line, size_t line_len)
{
static const char *const exact[] = {
"From", "Resent-From", "X-Original-From", "Envelope-From",
"Reply-To", "Sender", "Precedence", "Auto-Submitted",
"List-Id", "List-Unsubscribe", "List-Post", "List-Help",
"List-Subscribe", "List-Archive", "DKIM-Signature",
"DomainKey-Signature", "Authentication-Results", "Received",
"Received-SPF", "Delivered-To", "Return-Path", "X-Received",
NULL};
for (size_t i = 0; exact[i]; i++) {
if (field_name_eq(line, line_len, exact[i]))
return 1;
}
static const char *const prefix[] = {
"X-Spam-", "X-Google-", "X-Zoho-", "X-Gm-", "ARC-",
NULL};
for (size_t i = 0; prefix[i]; i++) {
size_t n = strlen(prefix[i]);
if(line_len >= n && strncasecmp(line, prefix[i], n) == 0)
return 1;
}
return 0;
}
static int is_header_continuation(const char *line)
{
return line && (line[0] == ' ' || line[0] == '\t');
}
/*
* Locate the earliest CRLF-CRLF or LF-LF separator. (RFC 822)
* Returns the start of the body and reports the separator length.
* If there is no separator, the body is considered empty.
*/
static const char *find_body_start(const char *msg, size_t *separator_len)
{
const char *crlf = strstr(msg, "\r\n\r\n");
const char *lf = strstr(msg, "\n\n");
if (crlf && (!lf || crlf <= lf)) {
*separator_len = 4;
return crlf + 4;
}
if (lf) {
*separator_len = 2;
return lf + 2;
}
*separator_len = 0;
return msg + strlen(msg);
}
/*
* Build a safe quoted display name from the original 'From' value.
* This deliberately uses a conservative fallback for malformed or
* control-character-containing values.
*/
static void get_display_name(const char *raw_msg, char *out, size_t out_len)
{
if (!out || out_len == 0) return;
snprintf(out, out_len, "%s", "Subscriber");
char *from = get_header_value(raw_msg, "From");
if (!from) return;
char *lt = strchr(from, '<');
if (lt) {
while (lt > from && isspace((unsigned char)lt[-1])) lt--;
size_t n = (size_t)(lt - from);
while (n && isspace((unsigned char)from[n - 1])) n--;
/* remove surrounding quotes, if any */
const char *start = from;
if (n >= 2 && ((from[0] == '"' && from[n - 1] == '"') ||
(from[0] == '\'' && from[n - 1] == '\''))) {
start++;
n -= 2;
}
if (n > 0 && n < out_len) {
int safe = 1;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)start[i];
if (c < 0x20 || c == 0x7f) {
safe = 0;
break;
}
}
if (safe) {
memcpy(out, start, n);
out[n] = '\0';
strtrim_whitespace(out);
}
}
} else {
/* no display name -- use the parsed address as the display text. */
char addr[EMAILLEN_MAX];
if (extract_email_address(from, addr, sizeof(addr)) == 0 &&
header_value_is_safe(addr))
snprintf(out, out_len, "%s", addr);
}
free(from);
if (out[0] == '\0') snprintf(out, out_len, "%s", "Subscriber");
return;
}
/* append a quoted phrase, escaping quote and backslash */
static int append_quoted_phrase(message_builder_t *b, const char *s)
{
if (mb_append_cstr(b, "\"") != 0) return -1;
for (; *s; s++) {
if (*s == '"' || *s == '\\') {
if (mb_append(b, "\\", 1) != 0) return -1;
}
if (mb_append(b, s, 1) != 0) return -1;
}
return mb_append_cstr(b, "\"");
}
/*
* Rebuild a message for broadcast.
*
* Rebuilds raw email buffer: strips stale/auth headers, injects headers
* Allocates a buffer in a heap of *out_len length (it sets it too), otherwise nil
*/
static char *rebuild_message_for_broadcast(mail_processor_t *mp, const char *raw_msg,
size_t *out_len)
{
if (out_len) *out_len = 0;
if (!mp || !mp->cfg || !raw_msg || !out_len) return NULL;
const char *list_addr = mp->cfg->maillist_addr;
const char *list_name = mp->cfg->maillist_name;
if (!header_value_is_safe(list_addr) ||
!header_value_is_safe(list_name)) return NULL;
/* Split the configured list address into local part and domain. */
const char *at = strchr(list_addr, '@');
if (!at || at == list_addr || at[1] == '\0') return NULL;
size_t local_len = (size_t)(at - list_addr);
char *local_part = malloc(local_len + 1);
if (!local_part) return NULL;
memcpy(local_part, list_addr, local_len);
local_part[local_len] = '\0';
const char *domain_part = at + 1;
if (!header_value_is_safe(local_part) || !header_value_is_safe(domain_part)) {
free(local_part);
return NULL;
}
char display[EMAILLEN_MAX];
get_display_name(raw_msg, display, sizeof(display));
message_builder_t out = {0};
int failed = 0;
/* Add list-controlled headers first. */
if (mb_append_cstr(&out, "From: ") != 0 ||
append_quoted_phrase(&out, display) != 0 ||
mb_append_cstr(&out, " via ") != 0 ||
append_quoted_phrase(&out, list_name) != 0 ||
mb_append_cstr(&out, " <") != 0 ||
mb_append_cstr(&out, list_addr) != 0 ||
mb_append_cstr(&out, ">\r\nReply-To: <") != 0 ||
mb_append_cstr(&out, list_addr) != 0 ||
mb_append_cstr(&out, ">\r\nSender: <") != 0 ||
mb_append_cstr(&out, list_addr) != 0 ||
mb_append_cstr(&out, ">\r\n"
"Precedence: list\r\n"
"Auto-Submitted: auto-generated\r\n"
"List-Id: <") != 0 ||
mb_append_cstr(&out, list_addr) != 0 ||
mb_append_cstr(&out, ">\r\n"
"List-Unsubscribe: <mailto:") != 0 ||
mb_append_cstr(&out, local_part) != 0 ||
mb_append_cstr(&out, "+unsubscribe@") != 0 ||
mb_append_cstr(&out, domain_part) != 0 ||
mb_append_cstr(&out, ">\r\nList-Post: <mailto:") != 0 ||
mb_append_cstr(&out, list_addr) != 0 ||
mb_append_cstr(&out, ">\r\n"))
failed = 1;
free(local_part);
if (failed) goto error;
size_t separator_len = 0;
const char *body_start = find_body_start(raw_msg, &separator_len);
const char *headers_end = body_start - separator_len;
int has_subject = 0;
int subject_tagged = 0;
/*
* Read and copy complete header fields. A field consists of its initial
* line plus all following continuation lines. Malformed header lines
* cause failure rather than being copied into the rebuilt message.
*/
const char *p = raw_msg;
while (p < headers_end) {
const char *line_start = p;
const char *lf = memchr(p, '\n', (size_t)(headers_end - p));
const char *line_end = lf ? lf + 1 : headers_end;
/* Empty line before the detected separator: stop header parsing. */
const char *content_end = lf ? lf : line_end;
if (content_end > line_start && content_end[-1] == '\r') content_end--;
if (content_end == line_start) break;
/* Header field names cannot begin with whitespace. */
if (*line_start == ' ' || *line_start == '\t') {
failed = 1;
goto error;
}
const char *colon = memchr(line_start, ':', (size_t)(content_end - line_start));
if (!colon || colon == line_start) {
failed = 1;
goto error;
}
size_t name_len = (size_t)(colon - line_start);
for (size_t i = 0; i < name_len; i++) {
unsigned char c = (unsigned char)line_start[i];
if (!(isalnum(c) || c == '-')) {
failed = 1;
goto error;
}
}
const char *field_end = line_end;
/* Consume continuation lines belonging to this field. */
while (field_end < headers_end) {
const char *next_lf = memchr(field_end, '\n', (size_t)(headers_end - field_end));
const char *next_end = next_lf ? next_lf + 1 : headers_end;
const char *next_content_end = next_lf ? next_lf : next_end;
if (next_content_end > field_end && next_content_end[-1] == '\r')
next_content_end--;
if (next_content_end == field_end || (*field_end != ' ' && *field_end != '\t'))
break;
field_end = next_end;
}
int is_subject = field_name_eq(line_start, name_len, "Subject");
int strip = should_strip_header(line_start, name_len);
if (is_subject) {
has_subject = 1;
/* unfold the value only for duplicate tag detection. */
message_builder_t unfolded = {0};
const char *v = colon + 1;
while (v < content_end && (*v == ' ' || *v == '\t')) v++;
if (mb_append(&unfolded, v, (size_t)(content_end - v)) != 0) {
free(unfolded.data);
failed = 1;
goto error;
}
const char *q = line_end;
while (q < field_end) {
const char *next_lf = memchr(q, '\n', (size_t)(field_end - q));
const char *next_end = next_lf ? next_lf + 1 : field_end;
const char *next_content_end = next_lf ? next_lf : next_end;
if (next_content_end > q && next_content_end[-1] == '\r')
next_content_end--;
while (q < next_content_end && (*q == ' ' || *q == '\t')) q++;
if (mb_append(&unfolded, " ", 1) != 0 ||
mb_append(&unfolded, q, (size_t)(next_content_end - q)) != 0) {
free(unfolded.data);
failed = 1;
goto error;
}
q = next_end;
}
/* IMPROVE: make subject tagging configurable */
char tag[TAGLEN_MAX];
int n = snprintf(tag, sizeof(tag), "[%s]", list_name);
if (n < 0 || (size_t)n >= sizeof(tag)) {
free(unfolded.data);
failed = 1;
goto error;
}
if (unfolded.data && strcasestr(unfolded.data, tag) != NULL)
subject_tagged = 1;
free(unfolded.data);
}
if (!strip) {
if (is_subject && !subject_tagged) {
if (mb_append_cstr(&out, "Subject: [") != 0 ||
mb_append_cstr(&out, list_name) != 0 ||
mb_append_cstr(&out, "] ") != 0) {
failed = 1;
goto error;
}
/* Copy the original subject value and its folded lines. */
if (mb_append(&out, colon + 1, (size_t)(field_end - (colon + 1))) != 0) {
failed = 1;
goto error;
}
/*
* The copied field includes its original newline(s). If the
* input used LF, keep that style for this field.
*/
if (field_end == headers_end || (field_end > raw_msg && field_end[-1] != '\n')) {
if (mb_append_cstr(&out, "\r\n") != 0) {
failed = 1;
goto error;
}
}
} else {
if (mb_append(&out, line_start, (size_t)(field_end - line_start)) != 0) {
failed = 1;
goto error;
}
if (field_end == headers_end || (field_end > raw_msg && field_end[-1] != '\n')) {
if (mb_append_cstr(&out, "\r\n") != 0) {
failed = 1;
goto error;
}
}
}
}
p = field_end;
}
if (!has_subject) {
if (mb_append_cstr(&out, "Subject: [") != 0 ||
mb_append_cstr(&out, list_name) != 0 ||
mb_append_cstr(&out, "] (No Subject)\r\n") != 0) {
failed = 1;
goto error;
}
}
/* End headers, then append the original body unchanged. */
if (mb_append_cstr(&out, "\r\n") != 0 || mb_append(&out, body_start, strlen(body_start)) != 0) {
failed = 1;
goto error;
}
*out_len = out.len;
return out.data;
error:
free(out.data);
*out_len = 0;
return NULL;
}
int mail_processor_init(mail_processor_t *mp, subrelay_config_t *cfg,
subscriber_pool_t *subscribers, pending_pool_t *pending)
{
if (!mp || !cfg || !subscribers || !pending) return -EINVAL;
mp->cfg = cfg;
mp->subscribers = subscribers;
mp->pending = pending;
return 0;
}
int mail_processor_execute(mail_processor_t *mp, mail_message_t *msg)
{
if (!mp || !msg || !msg->raw_data) return -EINVAL;
char *raw_from = get_header_value(msg->raw_data, "From");
char *raw_to = get_header_value(msg->raw_data, "To");
if (!raw_from || !raw_to) {
if (raw_from) free(raw_from);
if (raw_to) free(raw_to);
/* Corrupt or unparseable headers -> drop and expunge */
return imap_expunge_message(mp->cfg, msg->uid);
}
/* IMPROVE: more checking up for email addresses */
char sender[EMAILLEN_MAX], recipient[EMAILLEN_MAX];
extract_email_address(raw_from, sender, sizeof(sender));
extract_email_address(raw_to, recipient, sizeof(recipient));
free(raw_from);
free(raw_to);
/* ---------------------------------------------------------------------- */
/* UC 4.3: Subscription case (to is maillist+subscribe@domaintld) */
/* ---------------------------------------------------------------------- */
/* IMPROVE: extract before at sign and compare */
if (strcasecmp(recipient, "subscribe") == 0 || strstr(recipient, "+subscribe@")) {
if (!subscriber_exists(mp->subscribers, sender)) {
char token[32];
if (pending_add(mp->pending, sender, token, sizeof(token)) == 0) {
pending_save_file(mp->pending);
char confirm_addr[512];
char local_part[128] = {0};
char domain_part[128] = {0};
const char *at_sign = strchr(mp->cfg->maillist_addr, '@');
if (at_sign) {
size_t local_len = at_sign - mp->cfg->maillist_addr;
strncpy(local_part, mp->cfg->maillist_addr, local_len);
strcpy(domain_part, at_sign + 1);
} else {
strcpy(local_part, "subrelay");
strcpy(domain_part, "localhost");
}
snprintf(confirm_addr, sizeof(confirm_addr), "%s+%s@%s",
local_part, token, domain_part);
template_var_t vars[] = {
{ "MAILLIST_NAME", mp->cfg->maillist_name },
{ "MAILLIST_ADDR", mp->cfg->maillist_addr },
{ "CONFIRM_ADDR", confirm_addr },
{ "SUBSCRIBER_ADDR", sender }
};
char tpath[PATHLEN_MAX];
snprintf(tpath, sizeof(tpath), "%s/subscribe.txt", mp->cfg->template_path);
char *body = template_render_file(tpath, vars, 4);
if (body) {
const char *recips[] = { sender };
time_t now = time(NULL);
char date_buf[128];
struct tm *gmt = gmtime(&now);
strftime(date_buf, sizeof(date_buf), "%a, %d %b %Y %H:%M:%S +0000", gmt);
size_t full_msg_len = strlen(body) + 1024;
char *full_msg = malloc(full_msg_len);
snprintf(full_msg, full_msg_len,
"From: \"%s\" <%s>\r\n"
"To: <%s>\r\n"
"Reply-To: <%s>\r\n"
"Subject: Confirm your subscription to %s\r\n"
"Date: %s\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"\r\n"
"%s",
mp->cfg->maillist_name, mp->cfg->maillist_addr,
sender,
confirm_addr,
mp->cfg->maillist_name,
date_buf,
body);
smtp_send_message(mp->cfg, recips, 1, full_msg, strlen(full_msg));
free(body);
free(full_msg);
}
}
}
return imap_expunge_message(mp->cfg, msg->uid);
}
/* ---------------------------------------------------------------------- */
/* UC 4.5: Unsubscribe (to is maillist+unsubscribe@domain.tld) */
/* ---------------------------------------------------------------------- */
if (strcasecmp(recipient, "unsubscribe") == 0 || strstr(recipient, "+unsubscribe@")) {
if (subscriber_exists(mp->subscribers, sender)) {
subscriber_remove(mp->subscribers, sender);
subscriber_save_file(mp->subscribers);
}
return imap_expunge_message(mp->cfg, msg->uid);
}
/* ------------------------------------------------------------------------- */
/* UC 4.4: Subscription confirmation (to is maillist+<unique_id>@domain.tld) */
/* ------------------------------------------------------------------------- */
char *plus = strchr(recipient, '+');
char *at = strchr(recipient, '@');
if (plus && at && at > plus) {
size_t token_len = at - plus - 1;
char token[64];
if (token_len < sizeof(token)) {
strncpy(token, plus + 1, token_len);
token[token_len] = '\0';
char *pending_email = pending_lookup_email(mp->pending, token);
if (pending_email && strcasecmp(pending_email, sender) == 0) {
/* Add member to list */
subscriber_add(mp->subscribers, sender);
subscriber_save_file(mp->subscribers);
pending_remove(mp->pending, token);
pending_save_file(mp->pending);
/* Send Welcome Message */
template_var_t vars[] = {
{ "MAILLIST_NAME", mp->cfg->maillist_name },
{ "MAILLIST_ADDR", mp->cfg->maillist_addr },
{ "SUBSCRIBER_ADDR", sender }
};
char tpath[PATHLEN_MAX];
snprintf(tpath, sizeof(tpath), "%s/welcome.txt", mp->cfg->template_path);
char *body = template_render_file(tpath, vars, 3);
if (body) {
const char *recips[] = { sender };
smtp_send_message(mp->cfg, recips, 1, body, strlen(body));
free(body);
}
}
}
return imap_expunge_message(mp->cfg, msg->uid);
}
/* ---------------------------------------------------------------------- */
/* UC 4.2: Broadcast (maillist@domain.tld) */
/* ---------------------------------------------------------------------- */
if (!subscriber_exists(mp->subscribers, sender)) {
/* Drop unauthorized message from non-subscriber */
return imap_expunge_message(mp->cfg, msg->uid);
}
/* Build recipient list for fan-out (all subscribers) */
size_t sub_count = mp->subscribers->tree.nodecount;
if (sub_count == 0) {
return imap_expunge_message(mp->cfg, msg->uid);
}
const char **recipients = malloc(sizeof(char *) * sub_count);
size_t r_idx = 0;
splay_node_t *curr = splay_first(&mp->subscribers->tree);
while (curr != NULL) {
subscriber_entry_t *entry = (subscriber_entry_t *)curr;
recipients[r_idx++] = entry->email;
curr = splay_next(&mp->subscribers->tree, curr);
}
/* Rewrite RFC 822 headers according to E001 - E004 */
size_t outbound_len = 0;
char *outbound_msg = rebuild_message_for_broadcast(mp, msg->raw_data, &outbound_len);
if (outbound_msg) {
/* Fan-out message to all list members via SMTP */
smtp_send_message(mp->cfg, recipients, sub_count, outbound_msg, outbound_len);
free(outbound_msg);
}
free(recipients);
/* Expunge processed mail from remote IMAP inbox */
return imap_expunge_message(mp->cfg, msg->uid);
}