Email Header Analyzer — C source
Paste raw email headers to trace the message path, detect spoofing, and check SPF/DKIM/DMARC authentication results. Runs entirely in your browser.
This is the C implementation — the same logic the interactive tool runs, in a shareable, citable form.
/*
* email-header-analyzer — RFC 5322 email header parser + spoofing analysis.
*
* Language: C (C11, POSIX) + the C stdlib only (pure string parsing — no
* OpenSSL, no network, deterministic; the TS reference has no
* dependencies either).
* Source: CosmoDev polyglot showcase port of the email-header-analyzer
* tool, ported from src/lib/email-header-analyzer.ts (the canonical
* TypeScript implementation).
* License: display source — part of CosmoDev's polyglot tool pages.
*
* Parses raw email headers into unfolded name/value pairs, a chronological
* Received-hop chain with per-leg delays, SPF/DKIM/DMARC results from
* Authentication-Results, and classic spoofing anomalies (From vs Return-Path
* mismatch, Reply-To redirection, suspicious mailers, hop gaps, negative
* delays, missing authentication).
*
* Memory model: every string a container owns is individually malloc'd and
* released by free_parsed_headers(); the TS garbage collector does the same
* job implicitly.
*
* Build: cc -std=c11 email-header-analyzer.c
*/
#define _POSIX_C_SOURCE 200809L
#include <ctype.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* --------------------------------------------------------------- types --- */
typedef struct {
char *name;
char *value;
} header;
typedef struct {
header *items;
size_t count, cap;
} header_list;
typedef struct {
char *from;
char *by;
time_t timestamp; /* valid only when has_timestamp */
bool has_timestamp;
double delay; /* seconds since the previous (earlier) hop; only when has_delay */
bool has_delay;
char *protocol;
} hop;
typedef struct {
hop *items;
size_t count, cap;
} hop_list;
/** 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' |
* 'permerror' | 'unknown' — kept as a string, exactly like the TS, because
* Authentication-Results is an open set. */
typedef struct {
char *result;
char *domain; /* NULL when absent */
char *selector; /* NULL when absent (dkim only) */
char *policy; /* DMARC policy from the `p=` token (dmarc only); NULL when absent */
char *detail; /* the full authentication clause, when present */
} auth_result;
typedef struct {
auth_result spf, dkim, dmarc;
} auth_results;
typedef enum { SEV_CRITICAL, SEV_WARNING, SEV_INFO } anomaly_severity;
typedef struct {
char *type;
anomaly_severity severity;
char *message;
} anomaly;
typedef struct {
anomaly *items;
size_t count, cap;
} anomaly_list;
typedef struct {
header_list headers;
hop_list hops; /* chronological order: hops[0] is the OLDEST hop (parsed bottom-up) */
auth_results auth;
anomaly_list anomalies;
} parsed_headers;
/* ------------------------------------------------------------- helpers --- */
static void *xrealloc(void *buf, size_t n) {
void *p = realloc(buf, n);
if (!p) {
fprintf(stderr, "out of memory\n");
exit(1);
}
return p;
}
static char *xstrdup(const char *s) {
size_t n = strlen(s) + 1;
char *p = xrealloc(NULL, n);
memcpy(p, s, n);
return p;
}
static char *xstrndup(const char *s, size_t n) {
char *p = xrealloc(NULL, n + 1);
memcpy(p, s, n);
p[n] = '\0';
return p;
}
/** printf-style string builder (returns a fresh malloc'd string). */
static char *strf(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
if (n < 0) return xstrdup("");
char *out = xrealloc(NULL, (size_t)n + 1);
va_start(ap, fmt);
vsnprintf(out, (size_t)n + 1, fmt, ap);
va_end(ap);
return out;
}
/** Case-insensitive equality (the TS `a.toLowerCase() === b.toLowerCase()`). */
static bool ci_equals(const char *a, const char *b) {
while (*a && *b) {
if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false;
a++;
b++;
}
return *a == '\0' && *b == '\0';
}
/** Case-insensitive equality of the first n bytes. */
static bool ci_equals_n(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; i++) {
if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i])) return false;
}
return true;
}
/** True when s starts with prefix, ignoring case. */
static bool ci_starts_with(const char *s, const char *prefix) {
size_t n = strlen(prefix);
return strlen(s) >= n && ci_equals_n(s, prefix, n);
}
/** Case-insensitive substring search (haystack/needle both non-NULL). */
static const char *ci_strstr(const char *hay, const char *needle) {
size_t n = strlen(needle);
if (n == 0) return hay;
for (const char *p = hay; *p; p++) {
if (ci_equals_n(p, needle, n)) return p;
}
return NULL;
}
/** True when the char before s[i] (if any) starts a new regex word. */
static bool word_boundary_before(const char *s, size_t i) {
if (i == 0) return true;
unsigned char c = (unsigned char)s[i - 1];
return !(isalnum(c) || c == '_');
}
/** Strip one trailing '.' or ',' (the TS `.replace(/[.,]$/, '')`). */
static void strip_trailing_dot_comma(char *s) {
size_t n = strlen(s);
if (n > 0 && (s[n - 1] == '.' || s[n - 1] == ',')) s[n - 1] = '\0';
}
/* ------------------------------------------------------ header parsing --- */
/**
* Split raw header text into unfolded name/value pairs. Stops at the first
* empty line (the body separator). Continuation lines (leading space/tab,
* RFC 5322 folding) are appended to the previous value; lines without a
* colon are skipped as garbage.
*/
static void parse_header_lines(const char *raw, header_list *out) {
memset(out, 0, sizeof *out);
char *copy = xstrdup(raw);
/* CRLF -> LF, in place. */
for (char *c = strstr(copy, "\r\n"); c; c = strstr(c + 1, "\r\n")) {
memmove(c, c + 1, strlen(c + 1) + 1); /* drop the '\r' */
}
/* Manual line split — empty lines must be visible (strtok would collapse
* them and read the body as headers). */
char *p = copy;
while (p) {
char *nl = strchr(p, '\n');
char *line = p;
if (nl) *nl = '\0';
p = nl ? nl + 1 : NULL;
/* blank (all-whitespace) line — end of headers */
const char *c = line;
while (*c && isspace((unsigned char)*c)) c++;
if (*c == '\0') break;
if ((line[0] == ' ' || line[0] == '\t') && out->count > 0) {
/* Continuation line — append to the previous value with one space. */
size_t skip = (size_t)(c - line);
while (line[skip] && isspace((unsigned char)line[skip])) skip++;
header *prev = &out->items[out->count - 1];
size_t old_len = strlen(prev->value);
size_t add = strlen(line + skip);
prev->value = xrealloc(prev->value, old_len + 1 + add + 1);
prev->value[old_len] = ' ';
memcpy(prev->value + old_len + 1, line + skip, add + 1);
continue;
}
char *colon = strchr(line, ':');
if (!colon || colon == line) continue; /* not a header line — skip garbage */
size_t name_len = (size_t)(colon - line);
size_t ns = 0, ne = name_len;
while (ns < ne && isspace((unsigned char)line[ns])) ns++;
while (ne > ns && isspace((unsigned char)line[ne - 1])) ne--;
size_t vs = name_len + 1, ve = strlen(line);
while (vs < ve && isspace((unsigned char)line[vs])) vs++;
while (ve > vs && isspace((unsigned char)line[ve - 1])) ve--;
header h = { xstrndup(line + ns, ne - ns), xstrndup(line + vs, ve - vs) };
out->items = xrealloc(out->items, (out->count + 1) * sizeof(header));
out->items[out->count++] = h;
}
free(copy);
}
/** All values for a header name, case-insensitive, in file order. */
static void get_headers(const header_list *headers, const char *name, char ***out_values,
size_t *out_count) {
char **values = NULL;
size_t count = 0;
for (size_t i = 0; i < headers->count; i++) {
if (ci_equals(headers->items[i].name, name)) {
values = xrealloc(values, (count + 1) * sizeof(char *));
values[count++] = xstrdup(headers->items[i].value);
}
}
*out_values = values;
*out_count = count;
}
/** First value for a header name (case-insensitive) or NULL when absent. */
static char *get_header_first(const header_list *headers, const char *name) {
char **values;
size_t count;
get_headers(headers, name, &values, &count);
char *first = count > 0 ? values[0] : NULL;
for (size_t i = 1; i < count; i++) free(values[i]); /* keep only the first */
free(values);
return first;
}
/** Extract an email address from a header value: prefers <addr>, falls back
* to the first bare address (runs of chars outside the TS stop class). */
static char *extract_address(const char *value) {
for (const char *lt = value; (lt = strchr(lt, '<')) != NULL; lt++) {
const char *gt = strchr(lt + 1, '>');
if (!gt) break;
const char *a = lt + 1;
if (a == gt) continue; /* "<>" — the regex needs at least one char */
bool ok = true;
for (const char *c = a; c < gt; c++) {
if (isspace((unsigned char)*c) || *c == '<' || *c == '>') { ok = false; break; }
}
if (ok) return xstrndup(a, (size_t)(gt - a));
}
/* Fallback: the first run of non-stop characters containing '@'. */
for (const char *p = value; *p;) {
if (*p == '\0') break;
unsigned char c0 = (unsigned char)*p;
if (isspace(c0) || strchr("<>,;\"'", c0)) { p++; continue; }
const char *start = p;
while (*p && !isspace((unsigned char)*p) && !strchr("<>,;\"'", (unsigned char)*p)) p++;
for (const char *c = start; c < p; c++) {
if (*c == '@') return xstrndup(start, (size_t)(p - start));
}
}
return NULL;
}
/* ---------------------------------------------------------- date parsing --- */
/** Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm). */
static long long days_from_civil(long long y, unsigned m, unsigned d) {
y -= m <= 2;
long long era = (y >= 0 ? y : y - 399) / 400;
unsigned yoe = (unsigned)(y - era * 400);
unsigned doy = (153u * (m + (m > 2 ? (unsigned)-3 : 9u)) + 2u) / 5u + d - 1u;
unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
return era * 146097LL + (long long)doe - 719468LL;
}
/** Offset in seconds east of UTC for a zone token, or false when unknown. */
static bool zone_offset(const char *z, long *out) {
struct { const char *name; long off; } TABLE[] = {
{"UT", 0}, {"GMT", 0}, {"UTC", 0}, {"Z", 0},
{"EST", -5 * 3600}, {"EDT", -4 * 3600},
{"CST", -6 * 3600}, {"CDT", -5 * 3600},
{"MST", -7 * 3600}, {"MDT", -6 * 3600},
{"PST", -8 * 3600}, {"PDT", -7 * 3600},
};
if ((z[0] == '+' || z[0] == '-') && z[1] != '\0') {
int sign = z[0] == '-' ? -1 : 1;
int hh = 0, mm = 0;
int n = sscanf(z + 1, "%2d%2d", &hh, &mm);
if (n >= 1) {
*out = sign * (hh * 3600 + (n == 2 ? mm * 60 : 0));
return true;
}
return false;
}
for (size_t i = 0; i < sizeof TABLE / sizeof TABLE[0]; i++) {
if (strcmp(z, TABLE[i].name) == 0) { *out = TABLE[i].off; return true; }
}
return false;
}
/**
* Parse an RFC 2822 date ("Mon, 17 Aug 2026 12:34:56 +0000 (UTC)"),
* ignoring "(ZONE)" comments. Returns false when unparseable. A numeric zone
* is honoured; the common named zones map to their offsets; anything else is
* treated as UTC (JS Date.parse knows more names — the approximation is
* documented and enough for Received-chain analysis).
*/
static bool parse_rfc2822_date(const char *value, time_t *out) {
char *cleaned = xstrdup(value);
/* Replace "(...)" comments with spaces. */
for (char *c = cleaned; (c = strchr(c, '(')) != NULL;) {
char *end = strchr(c, ')');
if (!end) break;
memset(c, ' ', (size_t)(end - c) + 1);
c = end + 1;
}
const char *MONTHS[] = {
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
};
const char *p = cleaned;
/* Optional day-of-week: letters followed by ','. */
const char *q = p;
while (isalpha((unsigned char)*q)) q++;
if (q > p && *q == ',') p = q + 1;
while (isspace((unsigned char)*p)) p++;
int day = 0, year = 0, hh = 0, mm = 0, ss = 0;
char mon[4] = {0};
char zone[16] = {0};
int n = sscanf(p, "%d %3[A-Za-z] %d %d:%d:%d %15s",
&day, mon, &year, &hh, &mm, &ss, zone);
if (n < 6) { /* seconds missing — retry without them */
zone[0] = '\0';
n = sscanf(p, "%d %3[A-Za-z] %d %d:%d %15s", &day, mon, &year, &hh, &mm, zone);
if (n < 5) { free(cleaned); return false; }
ss = 0;
if (n == 5) zone[0] = '\0';
}
unsigned month = 0;
for (size_t i = 0; i < 12; i++) {
if (ci_equals(mon, MONTHS[i])) { month = (unsigned)i + 1; break; }
}
if (month == 0 || day < 1 || day > 31 || year < 1000 || year > 9999) {
free(cleaned);
return false;
}
long off = 0;
if (zone[0] != '\0' && !zone_offset(zone, &off)) off = 0; /* unknown name -> UTC */
long long days = days_from_civil(year, month, (unsigned)day);
*out = (time_t)(days * 86400LL + hh * 3600 + mm * 60 + ss - off);
free(cleaned);
return true;
}
/* -------------------------------------------------------- Received hops --- */
/**
* Find `kw` at a word boundary, skip the whitespace after it, and copy the
* token that follows (until whitespace or any of stop_chars). Mirrors the TS
* regexes `/\bkw\s+([^\s...]+)/i`. Returns a malloc'd token (one trailing
* '.' or ',' stripped) or NULL.
*/
static char *scan_kw_token(const char *value, const char *kw, const char *stop_chars) {
size_t kw_len = strlen(kw);
for (const char *p = value; (p = ci_strstr(p, kw)) != NULL; p++) {
if (!word_boundary_before(value, (size_t)(p - value))) continue;
const char *q = p + kw_len;
/* \s+ — at least one whitespace must follow the keyword. */
if (!isspace((unsigned char)*q)) continue;
while (isspace((unsigned char)*q)) q++;
const char *start = q;
while (*q && !isspace((unsigned char)*q) && !strchr(stop_chars, (unsigned char)*q)) q++;
if (q == start) continue;
char *token = xstrndup(start, (size_t)(q - start));
strip_trailing_dot_comma(token);
return token;
}
return NULL;
}
/** Parse one Received header value into a hop (delay is filled in later). */
static void parse_received(const char *value, hop *out) {
memset(out, 0, sizeof *out);
out->from = scan_kw_token(value, "from", "(;");
if (!out->from) out->from = xstrdup("");
out->by = scan_kw_token(value, "by", "(;");
if (!out->by) out->by = xstrdup("");
out->protocol = scan_kw_token(value, "with", ";()");
if (!out->protocol) out->protocol = xstrdup("");
/* The timestamp follows the last ';' in the value. */
const char *last_semi = strrchr(value, ';');
if (last_semi) {
out->has_timestamp = parse_rfc2822_date(last_semi + 1, &out->timestamp);
}
if (!out->has_timestamp) {
/* Some clients omit the ';'. Fall back to the first date-looking token. */
static const char *DAYS[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
for (size_t d = 0; d < 7 && !out->has_timestamp; d++) {
for (const char *p = value; (p = ci_strstr(p, DAYS[d])) != NULL; p++) {
if (!word_boundary_before(value, (size_t)(p - value))) continue;
const char *q = p + 3;
if (*q != ',') continue; /* regex wants the comma right after */
while (isspace((unsigned char)*q)) q++;
if (*q == '\0') continue; /* \s+ then more input */
time_t ts;
if (parse_rfc2822_date(p, &ts)) {
out->timestamp = ts;
out->has_timestamp = true;
}
break;
}
}
}
}
/* -------------------------------------------------- Authentication-Results --- */
static void auth_result_init(auth_result *r) {
r->result = xstrdup("none");
r->domain = r->selector = r->policy = r->detail = NULL;
}
static void auth_result_free(auth_result *r) {
free(r->result);
free(r->domain);
free(r->selector);
free(r->policy);
free(r->detail);
}
/** The result word of a clause: the letters right after its first '='. */
static char *result_word(const char *clause) {
const char *eq = strchr(clause, '=');
if (!eq) return xstrdup("unknown");
eq++;
while (isspace((unsigned char)*eq)) eq++;
if (!isalpha((unsigned char)*eq)) return xstrdup("unknown");
char *word = xstrdup(eq); /* then truncate at the first non-letter */
for (char *c = word; *c; c++) {
if (!isalpha((unsigned char)*c)) { *c = '\0'; break; }
*c = (char)tolower((unsigned char)*c);
}
if (word[0] == '\0') { free(word); return xstrdup("unknown"); }
return word;
}
/** Token after `key=` in a clause (TS clauseToken): `\bkey=([^\s;)]+)`. */
static char *clause_token(const char *clause, const char *key) {
size_t key_len = strlen(key);
for (const char *p = clause; (p = ci_strstr(p, key)) != NULL; p++) {
if (!word_boundary_before(clause, (size_t)(p - clause))) continue;
if (p[key_len] != '=') continue;
const char *q = p + key_len + 1;
const char *start = q;
while (*q && !isspace((unsigned char)*q) && *q != ';' && *q != ')') q++;
if (q == start) continue;
char *token = xstrndup(start, (size_t)(q - start));
strip_trailing_dot_comma(token);
return token;
}
return NULL;
}
/** The DMARC `p=` policy token: `\bp=([a-z]+)` case-insensitive. */
static char *dmarc_policy_token(const char *clause) {
for (size_t i = 0; clause[i]; i++) {
if (tolower((unsigned char)clause[i]) != 'p') continue;
if (!word_boundary_before(clause, i)) continue;
if (clause[i + 1] != '=') continue;
if (!isalpha((unsigned char)clause[i + 2])) continue;
char *word = xstrdup(clause + i + 2);
for (char *c = word; *c; c++) {
if (!isalpha((unsigned char)*c)) { *c = '\0'; break; }
*c = (char)tolower((unsigned char)*c);
}
return word;
}
return NULL;
}
/**
* Parse Authentication-Results clauses. Example:
* mx.google.com; spf=pass smtp.mailfrom=a@b.com; dkim=pass header.s=sel
* header.d=b.com; dmarc=pass (p=REJECT) header.from=b.com
* The first spf=/dkim=/dmarc= clause per kind wins.
*/
static void parse_auth_results(char **values, size_t value_count, auth_results *out) {
auth_result_init(&out->spf);
auth_result_init(&out->dkim);
auth_result_init(&out->dmarc);
for (size_t v = 0; v < value_count; v++) {
char *copy = xstrdup(values[v]);
char *p = copy;
while (p) {
char *semi = strchr(p, ';');
if (semi) *semi = '\0';
char *clause = p;
p = semi ? semi + 1 : NULL;
char *c = clause;
while (*c && isspace((unsigned char)*c)) c++;
char *end = c + strlen(c);
while (end > c && isspace((unsigned char)end[-1])) end--;
*end = '\0';
auth_result *target = NULL;
if (ci_starts_with(c, "spf=")) target = &out->spf;
else if (ci_starts_with(c, "dkim=")) target = &out->dkim;
else if (ci_starts_with(c, "dmarc=")) target = &out->dmarc;
if (!target) continue;
if (strcmp(target->result, "none") != 0) continue; /* first result wins */
free(target->result);
target->result = result_word(c);
target->detail = xstrdup(c);
if (target == &out->spf) {
target->domain = clause_token(c, "smtp.mailfrom");
if (!target->domain) target->domain = clause_token(c, "mailfrom");
} else if (target == &out->dkim) {
target->domain = clause_token(c, "header.d");
if (!target->domain) target->domain = clause_token(c, "header.i");
target->selector = clause_token(c, "header.s");
} else {
target->domain = clause_token(c, "header.from");
target->policy = dmarc_policy_token(c);
}
}
free(copy);
}
}
/* ----------------------------------------------------------- anomalies --- */
static void push_anomaly(anomaly_list *list, const char *type, anomaly_severity sev, char *message) {
list->items = xrealloc(list->items, (list->count + 1) * sizeof(anomaly));
anomaly *a = &list->items[list->count++];
a->type = xstrdup(type);
a->severity = sev;
a->message = message;
}
static const char *SEVERITY_NAMES[] = {"critical", "warning", "info"};
static const char *SUSPICIOUS_MAILERS[] = {
"bulk", "mass mail", "massmail", "storm", "flood", "bomber",
"grabber", "harvest", "spambot", "stealth", "anonymous", "dark", "crack",
};
static void detect_anomalies(const header_list *headers, const hop_list *hops,
anomaly_list *anomalies) {
/* 1. From vs Return-Path mismatch — classic spoofing signal. */
char *from_raw = get_header_first(headers, "From");
char *return_path_raw = get_header_first(headers, "Return-Path");
char *from_addr = from_raw ? extract_address(from_raw) : NULL;
char *return_path = return_path_raw ? extract_address(return_path_raw) : NULL;
if (from_addr && return_path && !ci_equals(from_addr, return_path)) {
push_anomaly(anomalies, "from-return-path-mismatch", SEV_CRITICAL,
strf("Return-Path (%s) does not match From (%s) — the envelope sender "
"differs from the displayed sender. Common in spoofing and "
"mailing-list relay.", return_path, from_addr));
}
/* 2. Reply-To pointing somewhere other than From. */
char *reply_to_raw = get_header_first(headers, "Reply-To");
char *reply_to = reply_to_raw ? extract_address(reply_to_raw) : NULL;
if (from_addr && reply_to && !ci_equals(reply_to, from_addr)) {
push_anomaly(anomalies, "reply-to-mismatch", SEV_WARNING,
strf("Reply-To (%s) differs from From (%s) — replies would go to a "
"different address than the visible sender.", reply_to, from_addr));
}
/* 3. Suspicious X-Mailer / User-Agent strings. */
char *mailer = get_header_first(headers, "X-Mailer");
if (!mailer) mailer = get_header_first(headers, "User-Agent");
if (mailer && mailer[0] != '\0') {
char *lower = xstrdup(mailer);
for (char *c = lower; *c; c++) *c = (char)tolower((unsigned char)*c);
for (size_t i = 0; i < sizeof SUSPICIOUS_MAILERS / sizeof SUSPICIOUS_MAILERS[0]; i++) {
if (strstr(lower, SUSPICIOUS_MAILERS[i])) {
push_anomaly(anomalies, "suspicious-mailer", SEV_WARNING,
strf("Mailer string \"%s\" contains a suspicious token (\"%s\") "
"often seen in bulk sending tools.", mailer, SUSPICIOUS_MAILERS[i]));
break;
}
}
free(lower);
}
/* 4. Received chain gaps: unparseable/missing timestamps and time going backwards. */
for (size_t i = 0; i < hops->count; i++) {
hop *h = &hops->items[i];
if (!h->has_timestamp) {
const char *who = h->from[0] ? h->from : (h->by[0] ? h->by : "unknown");
push_anomaly(anomalies, "hop-missing-timestamp", SEV_INFO,
strf("Hop %zu (%s) has no parseable timestamp — delay for this leg "
"cannot be computed.", i + 1, who));
continue;
}
if (i > 0 && hops->items[i - 1].has_timestamp) {
double delta = difftime(h->timestamp, hops->items[i - 1].timestamp);
if (delta < 0) {
push_anomaly(anomalies, "negative-delay", SEV_WARNING,
strf("Hop %zu is timestamped %.1fs BEFORE hop %zu — clock skew "
"between servers or a forged Received header.",
i + 1, -delta, i));
}
}
}
/* 5. No authentication results at all. */
char **auth_values;
size_t auth_count;
get_headers(headers, "Authentication-Results", &auth_values, &auth_count);
for (size_t i = 0; i < auth_count; i++) free(auth_values[i]);
free(auth_values);
if (auth_count == 0) {
push_anomaly(anomalies, "no-auth-results", SEV_INFO,
xstrdup("No Authentication-Results header found — SPF/DKIM/DMARC status "
"cannot be verified from this message."));
}
free(from_raw);
free(return_path_raw);
free(reply_to_raw);
free(from_addr);
free(return_path);
free(reply_to);
free(mailer);
}
/* ------------------------------------------------------------ pipeline --- */
/**
* Parse raw email headers (RFC 5322) into structured data: unfolded headers,
* chronological Received hops with delays, SPF/DKIM/DMARC results, and
* spoofing anomalies.
*/
static void parse_headers(const char *raw, parsed_headers *out) {
memset(out, 0, sizeof *out);
parse_header_lines(raw, &out->headers);
/* Received headers are listed newest-first; parse bottom-up so hops[0] is
* the oldest. */
char **received;
size_t received_count;
get_headers(&out->headers, "Received", &received, &received_count);
for (size_t i = 0; i < received_count; i++) {
hop h;
parse_received(received[i], &h);
out->hops.items = xrealloc(out->hops.items, (out->hops.count + 1) * sizeof(hop));
out->hops.items[out->hops.count++] = h;
}
for (size_t i = 0; i < received_count; i++) free(received[i]);
free(received);
/* reverse -> chronological order */
for (size_t i = 0, j = out->hops.count; i + 1 < j; i++, j--) {
hop tmp = out->hops.items[i];
out->hops.items[i] = out->hops.items[j - 1];
out->hops.items[j - 1] = tmp;
}
/* per-leg delays */
for (size_t i = 0; i < out->hops.count; i++) {
hop *h = &out->hops.items[i];
if (i == 0 || !h->has_timestamp || !out->hops.items[i - 1].has_timestamp) continue;
h->delay = difftime(h->timestamp, out->hops.items[i - 1].timestamp);
h->has_delay = true;
}
char **auth_values;
size_t auth_count;
get_headers(&out->headers, "Authentication-Results", &auth_values, &auth_count);
parse_auth_results(auth_values, auth_count, &out->auth);
for (size_t i = 0; i < auth_count; i++) free(auth_values[i]);
free(auth_values);
detect_anomalies(&out->headers, &out->hops, &out->anomalies);
}
static void free_parsed_headers(parsed_headers *p) {
for (size_t i = 0; i < p->headers.count; i++) {
free(p->headers.items[i].name);
free(p->headers.items[i].value);
}
free(p->headers.items);
for (size_t i = 0; i < p->hops.count; i++) {
free(p->hops.items[i].from);
free(p->hops.items[i].by);
free(p->hops.items[i].protocol);
}
free(p->hops.items);
auth_result_free(&p->auth.spf);
auth_result_free(&p->auth.dkim);
auth_result_free(&p->auth.dmarc);
for (size_t i = 0; i < p->anomalies.count; i++) {
free(p->anomalies.items[i].type);
free(p->anomalies.items[i].message);
}
free(p->anomalies.items);
memset(p, 0, sizeof *p);
}
/* ------------------------------------------------------------- demo main --- */
static void print_auth(const char *label, const auth_result *r) {
printf("%-6s %-9s domain=%s", label, r->result, r->domain ? r->domain : "-");
if (r->selector) printf(" selector=%s", r->selector);
if (r->policy) printf(" policy=%s", r->policy);
printf("\n");
}
int main(void) {
const char *RAW =
"Return-Path: <bounce@spam-server.example>\r\n"
"Received: from mail receiver.example (receiver.example [198.51.100.2])\r\n"
"\tby mx.example.com with ESMTPS id abc123;\r\n"
"\tMon, 17 Aug 2026 12:04:30 +0000 (UTC)\r\n"
"Received: from bulk-sender.example (unknown [203.0.113.9])\r\n"
"\tby mail receiver.example with SMTP id def456;\r\n"
"\tMon, 17 Aug 2026 12:03:00 +0000 (UTC)\r\n"
"Authentication-Results: mx.example.com;\r\n"
"\tspf=fail smtp.mailfrom=spam-server.example;\r\n"
"\tdkim=pass header.s=sel1 header.d=example.com;\r\n"
"\tdmarc=pass (p=REJECT) header.from=example.com\r\n"
"From: \"Support\" <support@example.com>\r\n"
"Reply-To: support-reply@other-domain.example\r\n"
"X-Mailer: FloodMail 3000 MassMailer\r\n"
"Subject: Your account\r\n"
"\r\n"
"Body starts here — the parser stops at the blank line.";
parsed_headers parsed;
parse_headers(RAW, &parsed);
printf("headers: %zu\n", parsed.headers.count);
for (size_t i = 0; i < parsed.hops.count; i++) {
hop *h = &parsed.hops.items[i];
char when[32] = "-";
if (h->has_timestamp) {
time_t t = h->timestamp;
struct tm tmv;
gmtime_r(&t, &tmv);
strftime(when, sizeof when, "%Y-%m-%d %H:%M:%SZ", &tmv);
}
printf("hop %zu: %s -> %s (%s) at %s", i + 1, h->from, h->by, h->protocol, when);
if (h->has_delay) printf(" delay=%.1fs", h->delay);
printf("\n");
}
print_auth("spf", &parsed.auth.spf);
print_auth("dkim", &parsed.auth.dkim);
print_auth("dmarc", &parsed.auth.dmarc);
for (size_t i = 0; i < parsed.anomalies.count; i++) {
anomaly *a = &parsed.anomalies.items[i];
printf("[%s] %s: %s\n", SEVERITY_NAMES[a->severity], a->type, a->message);
}
free_parsed_headers(&parsed);
return 0;
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →