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