Line data Source code
1 : #include "mime_util.h"
2 : #include "html_render.h"
3 : #include "raii.h"
4 : #include <stdio.h>
5 : #include <stdlib.h>
6 : #include <string.h>
7 : #include <ctype.h>
8 : #include <time.h>
9 : #include <iconv.h>
10 : #include <errno.h>
11 :
12 : /* ── Header extraction ──────────────────────────────────────────────── */
13 :
14 10727 : char *mime_get_header(const char *msg, const char *name) {
15 10727 : if (!msg || !name) return NULL;
16 10725 : size_t nlen = strlen(name);
17 10725 : const char *p = msg;
18 :
19 48506 : while (p && *p) {
20 : /* Stop at the blank line separating headers from body. */
21 37777 : if (*p == '\r' || *p == '\n')
22 : break;
23 :
24 34396 : if (strncasecmp(p, name, nlen) == 0 && p[nlen] == ':') {
25 7340 : const char *val = p + nlen + 1;
26 14680 : while (*val == ' ' || *val == '\t') val++;
27 :
28 7340 : size_t cap = 512, n = 0;
29 7340 : char *result = malloc(cap);
30 7340 : if (!result) return NULL;
31 :
32 : /* Collect value, unfolding continuation lines */
33 208937 : while (*val) {
34 208937 : if (*val == '\r' || *val == '\n') {
35 7630 : const char *next = val;
36 7630 : if (*next == '\r') next++;
37 7630 : if (*next == '\n') next++;
38 7630 : if (*next == ' ' || *next == '\t') {
39 : /* Continuation line: skip CRLF and the leading whitespace */
40 290 : val = next;
41 580 : while (*val == ' ' || *val == '\t') val++;
42 : /* Add a single space to separate folded content if needed */
43 290 : if (n > 0 && result[n-1] != ' ') {
44 290 : if (n + 1 >= cap) {
45 1 : cap *= 2;
46 1 : char *tmp = realloc(result, cap);
47 1 : if (!tmp) { free(result); return NULL; }
48 1 : result = tmp;
49 : }
50 290 : result[n++] = ' ';
51 : }
52 290 : continue;
53 : } else {
54 : /* Not a continuation line: we are done with this header */
55 : break;
56 : }
57 : }
58 :
59 201307 : if (n + 1 >= cap) {
60 1 : cap *= 2;
61 1 : char *tmp = realloc(result, cap);
62 1 : if (!tmp) { free(result); return NULL; }
63 1 : result = tmp;
64 : }
65 201307 : result[n++] = *val++;
66 : }
67 7340 : result[n] = '\0';
68 7340 : return result;
69 : }
70 :
71 : /* Advance to next line */
72 27056 : p = strchr(p, '\n');
73 27056 : if (p) p++;
74 : }
75 3385 : return NULL;
76 : }
77 :
78 : /* ── Base64 decoder ─────────────────────────────────────────────────── */
79 :
80 4718 : static int b64val(unsigned char c) {
81 4718 : if (c >= 'A' && c <= 'Z') return c - 'A';
82 1907 : if (c >= 'a' && c <= 'z') return c - 'a' + 26;
83 498 : if (c >= '0' && c <= '9') return c - '0' + 52;
84 281 : if (c == '+') return 62;
85 281 : if (c == '/') return 63;
86 281 : return -1;
87 : }
88 :
89 114 : static char *decode_base64(const char *in, size_t inlen, size_t *out_len) {
90 114 : size_t max = (inlen / 4 + 1) * 3 + 4;
91 114 : char *out = malloc(max);
92 114 : if (!out) return NULL;
93 114 : size_t n = 0;
94 114 : int buf = 0, bits = 0;
95 4832 : for (size_t i = 0; i < inlen; i++) {
96 4718 : int v = b64val((unsigned char)in[i]);
97 4718 : if (v < 0) continue;
98 4437 : buf = (buf << 6) | v;
99 4437 : bits += 6;
100 4437 : if (bits >= 8) {
101 3311 : bits -= 8;
102 3311 : out[n++] = (char)((buf >> bits) & 0xFF);
103 : }
104 : }
105 114 : out[n] = '\0';
106 114 : if (out_len) *out_len = n;
107 114 : return out;
108 : }
109 :
110 : /* ── Quoted-Printable decoder ───────────────────────────────────────── */
111 :
112 2 : static char *decode_qp(const char *in, size_t inlen) {
113 2 : char *out = malloc(inlen + 1);
114 2 : if (!out) return NULL;
115 2 : size_t n = 0, i = 0;
116 26 : while (i < inlen) {
117 24 : if (in[i] == '=' && i + 1 < inlen &&
118 4 : (in[i + 1] == '\r' || in[i + 1] == '\n')) {
119 : /* Soft line break — skip */
120 1 : i++;
121 1 : if (i < inlen && in[i] == '\r') i++;
122 1 : if (i < inlen && in[i] == '\n') i++;
123 23 : } else if (in[i] == '=' && i + 2 < inlen &&
124 3 : isxdigit((unsigned char)in[i + 1]) &&
125 3 : isxdigit((unsigned char)in[i + 2])) {
126 3 : char hex[3] = { in[i + 1], in[i + 2], '\0' };
127 3 : out[n++] = (char)strtol(hex, NULL, 16);
128 3 : i += 3;
129 : } else {
130 20 : out[n++] = in[i++];
131 : }
132 : }
133 2 : out[n] = '\0';
134 2 : return out;
135 : }
136 :
137 : /* ── Body helpers ───────────────────────────────────────────────────── */
138 :
139 427 : static const char *body_start(const char *msg) {
140 427 : const char *p = strstr(msg, "\r\n\r\n");
141 427 : if (p) return p + 4;
142 4 : p = strstr(msg, "\n\n");
143 4 : if (p) return p + 2;
144 3 : return NULL;
145 : }
146 :
147 140 : static char *decode_transfer(const char *body, size_t len, const char *enc) {
148 140 : if (enc && strcasecmp(enc, "base64") == 0)
149 12 : return decode_base64(body, len, NULL);
150 128 : if (enc && strcasecmp(enc, "quoted-printable") == 0)
151 2 : return decode_qp(body, len);
152 126 : return strndup(body, len);
153 : }
154 :
155 : /* Extract the charset parameter value from a Content-Type header value.
156 : * E.g. "text/plain; charset=iso-8859-2" → "iso-8859-2".
157 : * Returns a malloc'd string or NULL if not found. */
158 325 : static char *extract_charset(const char *ctype) {
159 325 : if (!ctype) return NULL;
160 :
161 : /* RFC 2045 §5.1: parameters are attribute [LWSP] "=" [LWSP] value,
162 : * and the value may be a token or a quoted-string. All of these are
163 : * legal and must yield "iso-8859-2":
164 : *
165 : * charset=iso-8859-2
166 : * charset="iso-8859-2"
167 : * charset = "iso-8859-2"
168 : *
169 : * Matching the literal "charset=" misses the spaced forms, and the
170 : * conversion is then skipped silently, emitting the raw bytes. */
171 301 : for (const char *p = ctype; (p = strcasestr(p, "charset")) != NULL; p += 7) {
172 : /* Must start a parameter, so that e.g. "x-charset" does not match. */
173 192 : if (p != ctype) {
174 192 : char prev = p[-1];
175 192 : if (prev != ';' && prev != ' ' && prev != '\t' &&
176 1 : prev != '\r' && prev != '\n')
177 1 : continue;
178 : }
179 :
180 191 : const char *q = p + 7; /* past "charset" */
181 193 : while (*q == ' ' || *q == '\t') q++;
182 191 : if (*q != '=') continue;
183 191 : q++;
184 193 : while (*q == ' ' || *q == '\t') q++;
185 :
186 191 : if (*q == '"') { /* quoted-string value */
187 4 : q++;
188 4 : const char *end = strchr(q, '"');
189 4 : if (!end || end == q) return NULL;
190 3 : return strndup(q, (size_t)(end - q));
191 : }
192 :
193 187 : const char *start = q; /* bare token value */
194 1196 : while (*q && *q != ';' && *q != ' ' && *q != '\t' &&
195 2205 : *q != '"' && *q != '\r' && *q != '\n')
196 1009 : q++;
197 187 : if (q == start) return NULL;
198 187 : return strndup(start, (size_t)(q - start));
199 : }
200 109 : return NULL;
201 : }
202 :
203 : /* Convert s from from_charset to UTF-8 via iconv.
204 : * Returns a malloc'd UTF-8 string; on failure returns strdup(s).
205 : *
206 : * @param info Optional; records what happened so that callers can report a
207 : * pass-through instead of letting undecoded bytes reach the user
208 : * unannounced. The core layer only records — deciding whether to
209 : * tell the user is the caller's job. */
210 134 : static char *charset_to_utf8_info(const char *s, const char *from_charset,
211 : MimeTextInfo *info) {
212 134 : if (!s) return NULL;
213 134 : if (info && from_charset && !info->declared_charset[0])
214 23 : snprintf(info->declared_charset, sizeof(info->declared_charset),
215 : "%s", from_charset);
216 :
217 134 : if (!from_charset ||
218 108 : strcasecmp(from_charset, "utf-8") == 0 ||
219 13 : strcasecmp(from_charset, "utf8") == 0 ||
220 13 : strcasecmp(from_charset, "us-ascii") == 0)
221 121 : return strdup(s);
222 :
223 13 : iconv_t cd = iconv_open("UTF-8", from_charset);
224 13 : if (cd == (iconv_t)-1) {
225 1 : if (info) info->unknown_charset = 1;
226 1 : return strdup(s);
227 : }
228 :
229 12 : size_t in_len = strlen(s);
230 12 : size_t out_size = in_len * 4 + 1;
231 12 : char *out = malloc(out_size);
232 12 : if (!out) { iconv_close(cd); return strdup(s); }
233 :
234 12 : char *inp = (char *)s;
235 12 : char *outp = out;
236 12 : size_t inbytes = in_len;
237 12 : size_t outbytes = out_size - 1;
238 12 : size_t r = iconv(cd, &inp, &inbytes, &outp, &outbytes);
239 12 : iconv_close(cd);
240 :
241 12 : if (r == (size_t)-1) {
242 0 : if (info) info->invalid_sequence = 1;
243 0 : free(out);
244 0 : return strdup(s);
245 : }
246 12 : *outp = '\0';
247 12 : if (info) info->converted = 1;
248 12 : return out;
249 : }
250 :
251 : /* Backwards-compatible wrapper for call sites that do not want diagnostics. */
252 65 : static char *charset_to_utf8(const char *s, const char *from_charset) {
253 65 : return charset_to_utf8_info(s, from_charset, NULL);
254 : }
255 :
256 : static char *text_from_part_info(const char *part, MimeTextInfo *info);
257 :
258 16 : static char *text_from_multipart_info(const char *msg, const char *ctype,
259 : MimeTextInfo *info) {
260 16 : const char *b = strcasestr(ctype, "boundary=");
261 16 : if (!b) return NULL;
262 16 : b += strlen("boundary=");
263 :
264 16 : char boundary[512] = {0};
265 16 : if (*b == '"') {
266 14 : b++;
267 14 : const char *end = strchr(b, '"');
268 14 : if (!end) return NULL;
269 14 : snprintf(boundary, sizeof(boundary), "%.*s", (int)(end - b), b);
270 : } else {
271 2 : size_t i = 0;
272 11 : while (*b && *b != ';' && *b != ' ' && *b != '\r' && *b != '\n' &&
273 : i < sizeof(boundary) - 1)
274 9 : boundary[i++] = *b++;
275 2 : boundary[i] = '\0';
276 : }
277 16 : if (!boundary[0]) return NULL;
278 :
279 : char delim[520];
280 16 : snprintf(delim, sizeof(delim), "--%s", boundary);
281 16 : size_t dlen = strlen(delim);
282 :
283 16 : const char *p = strstr(msg, delim);
284 17 : while (p) {
285 17 : p = strchr(p + dlen, '\n');
286 17 : if (!p) break;
287 17 : p++;
288 :
289 17 : const char *next = strstr(p, delim);
290 17 : if (!next) break;
291 :
292 17 : size_t partlen = (size_t)(next - p);
293 17 : char *part = strndup(p, partlen);
294 17 : if (!part) break;
295 17 : char *result = text_from_part_info(part, info);
296 17 : free(part);
297 17 : if (result) return result;
298 :
299 2 : p = next + dlen;
300 2 : if (p[0] == '-' && p[1] == '-') break;
301 1 : p = strchr(p, '\n');
302 1 : if (p) p++;
303 : }
304 1 : return NULL;
305 : }
306 :
307 88 : static char *text_from_part_info(const char *part, MimeTextInfo *info) {
308 88 : char *ctype = mime_get_header(part, "Content-Type");
309 88 : char *enc = mime_get_header(part, "Content-Transfer-Encoding");
310 88 : char *charset = extract_charset(ctype);
311 88 : const char *body = body_start(part);
312 88 : char *result = NULL;
313 :
314 88 : if (!ctype || strncasecmp(ctype, "text/plain", 10) == 0) {
315 70 : if (body) {
316 68 : char *raw = decode_transfer(body, strlen(body), enc);
317 68 : if (raw) {
318 68 : result = charset_to_utf8_info(raw, charset, info);
319 68 : free(raw);
320 : }
321 : }
322 18 : } else if (strncasecmp(ctype, "multipart/", 10) == 0) {
323 16 : result = text_from_multipart_info(part, ctype, info);
324 2 : } else if (strncasecmp(ctype, "text/html", 9) == 0) {
325 1 : if (body) {
326 1 : char *raw = decode_transfer(body, strlen(body), enc);
327 1 : if (raw) {
328 1 : char *utf8 = charset_to_utf8_info(raw, charset, info);
329 1 : free(raw);
330 1 : if (utf8) {
331 1 : result = html_render(utf8, 0, 0);
332 1 : free(utf8);
333 : }
334 : }
335 : }
336 : }
337 :
338 88 : free(ctype);
339 88 : free(enc);
340 88 : free(charset);
341 88 : return result;
342 : }
343 :
344 : /* ── RFC 2047 encoded-word decoder ──────────────────────────────────── */
345 :
346 : /**
347 : * Decode the text portion of one encoded word and convert to UTF-8.
348 : *
349 : * enc == 'Q'/'q': quoted-printable variant (underscore = space).
350 : * enc == 'B'/'b': base64.
351 : * charset: the declared charset of the encoded bytes.
352 : *
353 : * Returns a malloc'd NUL-terminated UTF-8 string, or NULL on failure.
354 : */
355 319 : static char *decode_encoded_word(const char *charset, char enc,
356 : const char *text, size_t text_len) {
357 319 : char *raw = NULL;
358 :
359 319 : if (enc == 'Q' || enc == 'q') {
360 312 : raw = malloc(text_len + 1);
361 312 : if (!raw) return NULL;
362 312 : size_t i = 0, j = 0;
363 3126 : while (i < text_len) {
364 2814 : if (text[i] == '_') {
365 310 : raw[j++] = ' ';
366 310 : i++;
367 2504 : } else if (text[i] == '=' && i + 2 < text_len &&
368 11 : isxdigit((unsigned char)text[i + 1]) &&
369 11 : isxdigit((unsigned char)text[i + 2])) {
370 11 : char hex[3] = { text[i + 1], text[i + 2], '\0' };
371 11 : raw[j++] = (char)strtol(hex, NULL, 16);
372 11 : i += 3;
373 : } else {
374 2493 : raw[j++] = text[i++];
375 : }
376 : }
377 312 : raw[j] = '\0';
378 : } else {
379 : /* B encoding */
380 7 : raw = decode_base64(text, text_len, NULL);
381 7 : if (!raw) return NULL;
382 : }
383 :
384 : /* If the declared charset is already UTF-8, return as-is. */
385 319 : if (strcasecmp(charset, "utf-8") == 0 || strcasecmp(charset, "utf8") == 0)
386 317 : return raw;
387 :
388 : /* Otherwise convert via iconv. */
389 2 : iconv_t cd = iconv_open("UTF-8", charset);
390 2 : if (cd == (iconv_t)-1)
391 1 : return raw; /* unknown charset — return raw bytes */
392 :
393 1 : size_t raw_len = strlen(raw);
394 1 : size_t out_size = raw_len * 4 + 1;
395 1 : char *utf8 = malloc(out_size);
396 1 : if (!utf8) { iconv_close(cd); return raw; }
397 :
398 1 : char *inp = raw;
399 1 : char *outp = utf8;
400 1 : size_t inbytes = raw_len;
401 1 : size_t outbytes = out_size - 1;
402 1 : size_t r = iconv(cd, &inp, &inbytes, &outp, &outbytes);
403 1 : iconv_close(cd);
404 :
405 1 : if (r == (size_t)-1) { free(utf8); return raw; }
406 :
407 1 : *outp = '\0';
408 1 : free(raw);
409 1 : return utf8;
410 : }
411 :
412 : /**
413 : * Try to parse and decode one encoded word starting exactly at *pp.
414 : * Format: =?charset?Q|B?encoded_text?=
415 : *
416 : * On success, *pp is advanced past the closing "?=" and the decoded
417 : * UTF-8 string (malloc'd) is returned.
418 : * On failure, *pp is unchanged and NULL is returned.
419 : */
420 319 : static char *try_decode_encoded_word(const char **pp) {
421 319 : const char *p = *pp;
422 319 : if (p[0] != '=' || p[1] != '?') return NULL;
423 319 : p += 2;
424 :
425 : /* charset */
426 319 : const char *cs = p;
427 1931 : while (*p && *p != '?') p++;
428 319 : if (!*p) return NULL;
429 319 : size_t cs_len = (size_t)(p - cs);
430 319 : if (cs_len == 0 || cs_len >= 64) return NULL;
431 : char charset[64];
432 319 : memcpy(charset, cs, cs_len);
433 319 : charset[cs_len] = '\0';
434 319 : p++; /* skip ? */
435 :
436 : /* encoding indicator */
437 319 : char enc = *p;
438 319 : if (enc != 'Q' && enc != 'q' && enc != 'B' && enc != 'b') return NULL;
439 319 : p++;
440 319 : if (*p != '?') return NULL;
441 319 : p++; /* skip ? */
442 :
443 : /* encoded text — ends at next ?= */
444 319 : const char *txt = p;
445 5563 : while (*p && !(*p == '?' && p[1] == '=')) p++;
446 319 : if (!*p) return NULL;
447 319 : size_t txt_len = (size_t)(p - txt);
448 319 : p += 2; /* skip ?= */
449 :
450 319 : char *decoded = decode_encoded_word(charset, enc, txt, txt_len);
451 319 : if (!decoded) return NULL;
452 319 : *pp = p;
453 319 : return decoded;
454 : }
455 :
456 3550 : char *mime_decode_words(const char *value) {
457 3550 : if (!value) return NULL;
458 :
459 3549 : size_t vlen = strlen(value);
460 : /* Upper bound: each raw byte can expand to at most 4 UTF-8 bytes. */
461 3549 : size_t cap = vlen * 4 + 1;
462 3549 : char *out = malloc(cap);
463 3549 : if (!out) return NULL;
464 :
465 3549 : size_t n = 0;
466 3549 : const char *p = value;
467 3549 : int prev_encoded = 0;
468 :
469 87690 : while (*p) {
470 : /* RFC 2047 §6.2: linear whitespace between adjacent encoded words
471 : * must be ignored. */
472 84141 : if (prev_encoded && (*p == ' ' || *p == '\t')) {
473 308 : const char *ws = p;
474 616 : while (*ws == ' ' || *ws == '\t') ws++;
475 308 : if (ws[0] == '=' && ws[1] == '?') {
476 1 : p = ws;
477 1 : continue;
478 : }
479 : }
480 :
481 84140 : if (p[0] == '=' && p[1] == '?') {
482 319 : char *decoded = try_decode_encoded_word(&p);
483 319 : if (decoded) {
484 319 : size_t dlen = strlen(decoded);
485 319 : if (n + dlen >= cap) {
486 0 : cap = n + dlen + vlen + 1;
487 0 : char *tmp = realloc(out, cap);
488 0 : if (!tmp) { free(decoded); break; }
489 0 : out = tmp;
490 : }
491 319 : memcpy(out + n, decoded, dlen);
492 319 : n += dlen;
493 319 : free(decoded);
494 319 : prev_encoded = 1;
495 319 : continue;
496 : }
497 : }
498 :
499 83821 : prev_encoded = 0;
500 83821 : out[n++] = *p++;
501 : }
502 :
503 3549 : out[n] = '\0';
504 3549 : return out;
505 : }
506 :
507 : /* ── Date formatting ────────────────────────────────────────────────── */
508 :
509 1855 : char *mime_format_date(const char *date) {
510 1855 : if (!date || !*date) return NULL;
511 :
512 : static const char * const fmts[] = {
513 : "%a, %d %b %Y %T %z", /* "Tue, 10 Mar 2026 15:07:40 +0000" */
514 : "%d %b %Y %T %z", /* "10 Mar 2026 15:07:40 +0000" */
515 : "%a, %d %b %Y %T %Z", /* "Tue, 24 Mar 2026 16:38:21 GMT" */
516 : "%d %b %Y %T %Z", /* "24 Mar 2026 16:38:21 UTC" */
517 : NULL
518 : };
519 :
520 : struct tm tm;
521 1854 : int parsed = 0;
522 1861 : for (int i = 0; fmts[i]; i++) {
523 1860 : memset(&tm, 0, sizeof(tm));
524 1860 : if (strptime(date, fmts[i], &tm)) { parsed = 1; break; }
525 : }
526 1854 : if (!parsed) return strdup(date);
527 :
528 : /* Save tm_gmtoff before calling timegm(): timegm() normalises the struct
529 : * and resets tm_gmtoff to 0. timegm() treats the fields as UTC, so
530 : * subtracting the original offset converts to true UTC. */
531 1853 : long gmtoff = tm.tm_gmtoff;
532 1853 : time_t utc = timegm(&tm) - gmtoff;
533 1853 : if (utc == (time_t)-1) return strdup(date);
534 :
535 : struct tm local;
536 1853 : localtime_r(&utc, &local);
537 :
538 1853 : char *buf = malloc(17); /* "YYYY-MM-DD HH:MM\0" */
539 1853 : if (!buf) return NULL;
540 1853 : if (strftime(buf, 17, "%Y-%m-%d %H:%M", &local) == 0) {
541 0 : free(buf);
542 0 : return strdup(date);
543 : }
544 1853 : return buf;
545 : }
546 :
547 : /* ── HTML part extractor ────────────────────────────────────────────── */
548 :
549 : static char *html_from_part(const char *part);
550 :
551 70 : static char *html_from_multipart(const char *msg, const char *ctype) {
552 70 : const char *b = strcasestr(ctype, "boundary=");
553 70 : if (!b) return NULL;
554 70 : b += strlen("boundary=");
555 :
556 70 : char boundary[512] = {0};
557 70 : if (*b == '"') {
558 69 : b++;
559 69 : const char *end = strchr(b, '"');
560 69 : if (!end) return NULL;
561 69 : snprintf(boundary, sizeof(boundary), "%.*s", (int)(end - b), b);
562 : } else {
563 1 : size_t i = 0;
564 9 : while (*b && *b != ';' && *b != ' ' && *b != '\r' && *b != '\n' &&
565 : i < sizeof(boundary) - 1)
566 8 : boundary[i++] = *b++;
567 1 : boundary[i] = '\0';
568 : }
569 70 : if (!boundary[0]) return NULL;
570 :
571 : char delim[520];
572 70 : snprintf(delim, sizeof(delim), "--%s", boundary);
573 70 : size_t dlen = strlen(delim);
574 :
575 70 : const char *p = strstr(msg, delim);
576 145 : while (p) {
577 145 : if (p[dlen] == '-' && p[dlen+1] == '-') break; /* end boundary */
578 138 : p = strchr(p + dlen, '\n');
579 138 : if (!p) break;
580 138 : p++;
581 138 : const char *next = strstr(p, delim);
582 138 : if (!next) break;
583 138 : size_t partlen = (size_t)(next - p);
584 138 : char *part = strndup(p, partlen);
585 138 : if (!part) break;
586 138 : char *result = html_from_part(part);
587 138 : free(part);
588 138 : if (result) return result;
589 75 : p = next; /* keep p pointing at delimiter for next iteration */
590 : }
591 7 : return NULL;
592 : }
593 :
594 237 : static char *html_from_part(const char *part) {
595 237 : char *ctype = mime_get_header(part, "Content-Type");
596 237 : char *enc = mime_get_header(part, "Content-Transfer-Encoding");
597 237 : char *charset = extract_charset(ctype);
598 237 : const char *body = body_start(part);
599 237 : char *result = NULL;
600 :
601 237 : if (ctype && strncasecmp(ctype, "text/html", 9) == 0) {
602 65 : if (body) {
603 65 : char *raw = decode_transfer(body, strlen(body), enc);
604 65 : if (raw) {
605 65 : result = charset_to_utf8(raw, charset);
606 65 : free(raw);
607 : }
608 : }
609 172 : } else if (ctype && strncasecmp(ctype, "multipart/", 10) == 0) {
610 70 : result = html_from_multipart(part, ctype);
611 : }
612 :
613 237 : free(ctype); free(enc); free(charset);
614 237 : return result;
615 : }
616 :
617 : /* ── Public API ─────────────────────────────────────────────────────── */
618 :
619 37 : char *mime_get_text_body(const char *msg) {
620 37 : return mime_get_text_body_ex(msg, NULL);
621 : }
622 :
623 72 : char *mime_get_text_body_ex(const char *msg, MimeTextInfo *info) {
624 72 : if (info) memset(info, 0, sizeof(*info));
625 72 : if (!msg) return NULL;
626 71 : return text_from_part_info(msg, info);
627 : }
628 :
629 100 : char *mime_get_html_part(const char *msg) {
630 100 : if (!msg) return NULL;
631 99 : return html_from_part(msg);
632 : }
633 :
634 : /* ── Attachment extraction ──────────────────────────────────────────── */
635 :
636 : /* Extract a MIME header parameter value, e.g. filename="foo.pdf" or name=bar.
637 : * Handles quoted and unquoted values. Returns malloc'd string or NULL. */
638 187 : static char *extract_param(const char *header, const char *param) {
639 187 : if (!header || !param) return NULL;
640 : char search[64];
641 187 : snprintf(search, sizeof(search), "%s=", param);
642 187 : const char *p = strcasestr(header, search);
643 187 : if (!p) return NULL;
644 103 : p += strlen(search);
645 103 : if (*p == '"') {
646 99 : p++;
647 99 : const char *end = strchr(p, '"');
648 99 : if (!end) return NULL;
649 99 : return strndup(p, (size_t)(end - p));
650 : }
651 : /* unquoted value: ends at ';', whitespace, or end-of-string */
652 4 : const char *end = p;
653 44 : while (*end && *end != ';' && *end != ' ' && *end != '\t' &&
654 84 : *end != '\r' && *end != '\n')
655 40 : end++;
656 4 : if (end == p) return NULL;
657 4 : return strndup(p, (size_t)(end - p));
658 : }
659 :
660 : /* Sanitise a filename: strip directory separators and leading dots. */
661 100 : static char *sanitise_filename(const char *name) {
662 100 : if (!name || !*name) return NULL;
663 : /* take only the basename portion */
664 100 : const char *base = name;
665 1037 : for (const char *p = name; *p; p++)
666 937 : if (*p == '/' || *p == '\\') base = p + 1;
667 100 : if (!*base) return NULL;
668 100 : char *s = strdup(base);
669 100 : if (!s) return NULL;
670 : /* strip leading dots (hidden files / directory traversal) */
671 100 : char *p = s;
672 100 : while (*p == '.') p++;
673 100 : if (!*p) { free(s); return strdup("attachment"); }
674 100 : if (p != s) memmove(s, p, strlen(p) + 1);
675 100 : return s;
676 : }
677 :
678 : /* Dynamic array for building the attachment list */
679 : typedef struct { MimeAttachment *data; int count; int cap; } AttachList;
680 :
681 101 : static int alist_push(AttachList *al, MimeAttachment att) {
682 101 : if (al->count >= al->cap) {
683 62 : int newcap = al->cap ? al->cap * 2 : 4;
684 62 : MimeAttachment *tmp = realloc(al->data,
685 62 : (size_t)newcap * sizeof(MimeAttachment));
686 62 : if (!tmp) return -1;
687 62 : al->data = tmp;
688 62 : al->cap = newcap;
689 : }
690 101 : al->data[al->count++] = att;
691 101 : return 0;
692 : }
693 :
694 : /* Forward declaration */
695 : static void collect_parts(const char *msg, AttachList *al, int *unnamed_idx);
696 :
697 : /* Walk a multipart body and collect attachments from each sub-part. */
698 65 : static void collect_multipart_attachments(const char *msg, const char *ctype,
699 : AttachList *al, int *idx) {
700 65 : const char *b = strcasestr(ctype, "boundary=");
701 65 : if (!b) return;
702 65 : b += strlen("boundary=");
703 :
704 65 : char boundary[512] = {0};
705 65 : if (*b == '"') {
706 64 : b++;
707 64 : const char *end = strchr(b, '"');
708 64 : if (!end) return;
709 64 : snprintf(boundary, sizeof(boundary), "%.*s", (int)(end - b), b);
710 : } else {
711 1 : size_t i = 0;
712 5 : while (*b && *b != ';' && *b != ' ' && *b != '\r' && *b != '\n' &&
713 : i < sizeof(boundary) - 1)
714 4 : boundary[i++] = *b++;
715 1 : boundary[i] = '\0';
716 : }
717 65 : if (!boundary[0]) return;
718 :
719 : char delim[520];
720 65 : snprintf(delim, sizeof(delim), "--%s", boundary);
721 65 : size_t dlen = strlen(delim);
722 :
723 65 : const char *p = strstr(msg, delim);
724 198 : while (p) {
725 198 : p = strchr(p + dlen, '\n');
726 198 : if (!p) break;
727 198 : p++;
728 :
729 198 : const char *next = strstr(p, delim);
730 198 : if (!next) break;
731 :
732 198 : size_t partlen = (size_t)(next - p);
733 198 : char *part = strndup(p, partlen);
734 198 : if (!part) break;
735 198 : collect_parts(part, al, idx);
736 198 : free(part);
737 :
738 198 : p = next + dlen;
739 198 : if (p[0] == '-' && p[1] == '-') break;
740 133 : p = strchr(p, '\n');
741 133 : if (p) p++;
742 : }
743 : }
744 :
745 : /* Examine one MIME part (headers + body) and add to al if it is an attachment. */
746 295 : static void collect_parts(const char *msg, AttachList *al, int *unnamed_idx) {
747 295 : char *ctype = mime_get_header(msg, "Content-Type");
748 295 : char *disp = mime_get_header(msg, "Content-Disposition");
749 295 : char *enc = mime_get_header(msg, "Content-Transfer-Encoding");
750 :
751 : /* Recurse into multipart containers */
752 295 : if (ctype && strncasecmp(ctype, "multipart/", 10) == 0) {
753 65 : collect_multipart_attachments(msg, ctype, al, unnamed_idx);
754 65 : free(ctype); free(disp); free(enc);
755 194 : return;
756 : }
757 :
758 : /* Determine filename from Content-Disposition or Content-Type name= */
759 230 : char *filename = NULL;
760 230 : int explicit_attach = 0;
761 230 : if (disp) {
762 104 : if (strncasecmp(disp, "attachment", 10) == 0) explicit_attach = 1;
763 104 : filename = extract_param(disp, "filename");
764 : /* RFC 5987: filename*=charset''encoded — simplified: strip trailing * */
765 104 : if (!filename) filename = extract_param(disp, "filename*");
766 : }
767 230 : if (!filename && ctype)
768 80 : filename = extract_param(ctype, "name");
769 :
770 : /* Skip non-attachment text and multipart parts unless explicitly marked */
771 230 : if (!explicit_attach) {
772 128 : if (!filename) {
773 126 : free(ctype); free(disp); free(enc);
774 126 : return; /* no filename → body part, skip */
775 : }
776 : /* text/plain and text/html without attachment disposition are body parts */
777 2 : if (ctype && (strncasecmp(ctype, "text/plain", 10) == 0 ||
778 1 : strncasecmp(ctype, "text/html", 9) == 0)) {
779 2 : free(ctype); free(disp); free(enc); free(filename);
780 2 : return;
781 : }
782 : }
783 :
784 102 : const char *body = body_start(msg);
785 102 : if (!body) {
786 1 : free(ctype); free(disp); free(enc); free(filename);
787 1 : return;
788 : }
789 :
790 : /* Decode body content; for base64 capture exact decoded byte count. */
791 101 : size_t data_size = 0;
792 : unsigned char *data;
793 101 : if (enc && strcasecmp(enc, "base64") == 0)
794 95 : data = (unsigned char *)decode_base64(body, strlen(body), &data_size);
795 : else {
796 6 : data = (unsigned char *)decode_transfer(body, strlen(body), enc);
797 6 : data_size = data ? strlen((char *)data) : 0;
798 : }
799 :
800 : /* Sanitise / generate filename */
801 101 : char *safe_name = NULL;
802 101 : if (filename) {
803 100 : char *decoded = mime_decode_words(filename);
804 100 : free(filename);
805 100 : safe_name = sanitise_filename(decoded ? decoded : "");
806 100 : free(decoded);
807 : }
808 101 : if (!safe_name) {
809 : char gen[32];
810 1 : snprintf(gen, sizeof(gen), "attachment-%d.bin", ++(*unnamed_idx));
811 1 : safe_name = strdup(gen);
812 : }
813 :
814 101 : MimeAttachment att = {0};
815 101 : att.filename = safe_name;
816 101 : att.content_type = ctype ? strdup(ctype) : strdup("application/octet-stream");
817 101 : att.data = data;
818 101 : att.size = data_size;
819 :
820 101 : if (alist_push(al, att) < 0) {
821 0 : free(att.filename); free(att.content_type); free(att.data);
822 : }
823 :
824 101 : free(ctype); free(disp); free(enc);
825 : }
826 :
827 98 : MimeAttachment *mime_list_attachments(const char *msg, int *count_out) {
828 98 : if (!msg || !count_out) { if (count_out) *count_out = 0; return NULL; }
829 97 : AttachList al = {NULL, 0, 0};
830 97 : int idx = 0;
831 97 : collect_parts(msg, &al, &idx);
832 97 : *count_out = al.count;
833 97 : if (al.count == 0) { free(al.data); return NULL; }
834 62 : return al.data;
835 : }
836 :
837 85 : void mime_free_attachments(MimeAttachment *list, int count) {
838 85 : if (!list) return;
839 127 : for (int i = 0; i < count; i++) {
840 77 : free(list[i].filename);
841 77 : free(list[i].content_type);
842 77 : free(list[i].data);
843 : }
844 50 : free(list);
845 : }
846 :
847 12 : int mime_save_attachment(const MimeAttachment *att, const char *dest_path) {
848 12 : if (!att || !dest_path || !att->data) return -1;
849 22 : RAII_FILE FILE *f = fopen(dest_path, "wb");
850 11 : if (!f) return -1;
851 : /* Write the full decoded buffer; for base64 the NUL terminator is not
852 : * part of the content — use att->size if accurate, else strlen fallback. */
853 11 : size_t n = att->size > 0 ? att->size : strlen((char *)att->data);
854 11 : size_t written = fwrite(att->data, 1, n, f);
855 11 : return (written != n) ? -1 : 0;
856 : }
857 :
858 4 : char *mime_extract_imap_literal(const char *response) {
859 4 : if (!response) return NULL;
860 3 : const char *brace = strchr(response, '{');
861 3 : if (!brace) return NULL;
862 :
863 2 : char *end = NULL;
864 2 : long size = strtol(brace + 1, &end, 10);
865 2 : if (!end || *end != '}' || size <= 0) return NULL;
866 :
867 2 : const char *content = end + 1;
868 2 : if (*content == '\r') content++;
869 2 : if (*content == '\n') content++;
870 :
871 : // Safety check
872 2 : size_t avail = strlen(content);
873 2 : if (avail < (size_t)size) {
874 1 : return strndup(content, avail);
875 : }
876 :
877 1 : return strndup(content, (size_t)size);
878 : }
879 :
880 1441 : int mime_get_dmarc_status(const char *auth_results) {
881 1441 : if (!auth_results) return 0;
882 20 : const char *p = auth_results;
883 22 : while ((p = strcasestr(p, "dmarc=")) != NULL) {
884 18 : p += 6;
885 18 : if (strncasecmp(p, "pass", 4) == 0) return 1;
886 11 : if (strncasecmp(p, "fail", 4) == 0) return -1;
887 5 : if (strncasecmp(p, "temperror", 9) == 0) return -2;
888 : }
889 4 : return 0;
890 : }
891 :
892 78 : char *mime_describe_dmarc(const char *auth_results) {
893 78 : if (!auth_results)
894 66 : return strdup("Not evaluated (no Authentication-Results header)");
895 :
896 12 : const char *dpos = strcasestr(auth_results, "dmarc=");
897 12 : if (!dpos)
898 1 : return strdup("Not evaluated (no dmarc entry in Authentication-Results)");
899 :
900 11 : const char *r = dpos + 6;
901 :
902 : /* Extract result token */
903 : char result[32];
904 11 : size_t ri = 0;
905 83 : while (*r && *r != ' ' && *r != ';' && *r != '\t' && *r != '\r' && *r != '\n'
906 147 : && ri < sizeof(result) - 1)
907 68 : result[ri++] = *r++;
908 11 : result[ri] = '\0';
909 :
910 11 : if (strcasecmp(result, "pass") == 0) {
911 : /* Optionally include header.from= for clarity */
912 2 : const char *hf = strcasestr(dpos, "header.from=");
913 2 : if (hf) {
914 1 : hf += 12;
915 : char hfrom[64];
916 1 : size_t hfi = 0;
917 9 : while (*hf && *hf != ' ' && *hf != ';' && *hf != '\t' &&
918 17 : *hf != '\r' && *hf != '\n' && hfi < sizeof(hfrom) - 1)
919 8 : hfrom[hfi++] = *hf++;
920 1 : hfrom[hfi] = '\0';
921 1 : if (hfi > 0) {
922 1 : char *out = NULL;
923 1 : if (asprintf(&out, "Pass (header.from=%s)", hfrom) >= 0)
924 1 : return out;
925 : }
926 : }
927 1 : return strdup("Pass");
928 : }
929 :
930 9 : if (strcasecmp(result, "fail") == 0) {
931 : /* Policy is in "(p=REJECT sp=... dis=...)" after the dmarc= token */
932 4 : char policy[32] = "";
933 4 : const char *pp = strcasestr(dpos, "(p=");
934 4 : if (pp) {
935 3 : pp += 3;
936 3 : size_t pi = 0;
937 23 : while (*pp && *pp != ')' && *pp != ' ' && *pp != ';' &&
938 : pi < sizeof(policy) - 1)
939 20 : policy[pi++] = *pp++;
940 3 : policy[pi] = '\0';
941 : }
942 4 : char *out = NULL;
943 4 : if (strcasecmp(policy, "reject") == 0)
944 1 : out = strdup("Fail — policy: reject (message rejected, likely spoofed)");
945 3 : else if (strcasecmp(policy, "quarantine") == 0)
946 1 : out = strdup("Fail — policy: quarantine (message may be spoofed or spam)");
947 2 : else if (strcasecmp(policy, "none") == 0)
948 1 : out = strdup("Fail — policy: none (monitoring only, no enforcement)");
949 1 : else if (policy[0]) {
950 0 : if (asprintf(&out, "Fail — policy: %s", policy) < 0) out = NULL;
951 : } else
952 1 : out = strdup("Fail (no policy information)");
953 4 : return out ? out : strdup("Fail");
954 : }
955 :
956 5 : if (strcasecmp(result, "none") == 0)
957 1 : return strdup("No record (domain has no DMARC policy)");
958 4 : if (strcasecmp(result, "bestguesspass") == 0)
959 1 : return strdup("Best-guess pass (no formal DMARC record, implicit pass)");
960 3 : if (strcasecmp(result, "temperror") == 0)
961 1 : return strdup("Temporary error (DNS lookup may have failed during evaluation)");
962 2 : if (strcasecmp(result, "permerror") == 0)
963 1 : return strdup("Permanent error (malformed or invalid DMARC record)");
964 :
965 1 : char *out = NULL;
966 1 : if (asprintf(&out, "Unknown result: %s", result) >= 0)
967 1 : return out;
968 0 : return strdup("Unknown");
969 : }
|