Skip to content

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 →