/* * 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 ."; * * (c) Alexander Vdolainen 2007, 2026 * subrelay: a minimalistic maillist server based on external IMAPs/SMTPs * * mail_processor.c: RFC 822/RFC 5322 header parsing/rewriting and routing * */ #include #include #include #include #include #include #include #include #include /* Extract raw address from formats like "Alice " */ 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; } /* * 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: \r\nList-Post: \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+@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); }