stamail.c (19904B)
1 /* 2 * ============================================================================ 3 * ███████╗████████╗ █████╗ ███╗ ███╗ █████╗ ██╗██╗ 4 * ██╔════╝╚══██╔══╝██╔══██╗████╗ ████║██╔══██╗██║██║ 5 * ███████╗ ██║ ███████║██╔████╔██║███████║██║██║ 6 * ╚════██║ ██║ ██╔══██║██║╚██╔╝██║██╔══██║██║██║ 7 * ███████║ ██║ ██║ ██║██║ ╚═╝ ██║██║ ██║██║███████╗ 8 * ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝ 9 * ============================================================================ 10 * 11 * Copyright (C) 2026 Binkd. 12 * 13 * This file is part of stamail. 14 * 15 * stamail is free software: you can redistribute it and/or modify it under the 16 * terms of the GNU General Public License as published by the Free Software 17 * Foundation, either version 3 of the License, or (at your option) any later 18 * version. 19 * 20 * stamail is distributed in the hope that it will be useful, but WITHOUT ANY 21 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 22 * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 23 * details. 24 * 25 * You should have received a copy of the GNU General Public License 26 * along with stamail. If not, see <https://www.gnu.org/licenses/>. 27 * 28 * ============================================================================= 29 */ 30 31 //// Includes 32 #include <stdio.h> 33 #include <stdlib.h> 34 #include <string.h> 35 #include <time.h> 36 #include <sys/stat.h> 37 #include <sys/types.h> 38 #include <errno.h> 39 40 #include "stamail.h" 41 #include "sexp.h" 42 #include "thread.h" 43 #include "headers.h" 44 45 /* Callback handler */ 46 /* 47 static void print_message(struct message *m, void *ud) { 48 (void)ud; 49 50 printf("Message-ID: %.*s\n", m->id_len, m->id); 51 printf("From: %.*s\n", m->from_len, m->from); 52 printf("Subject: %.*s\n", m->subject_len, m->subject); 53 printf("Date: %.*s\n", m->date_len, m->date); 54 if (m->body) { 55 printf("Body:\n%.*s\n", m->body_len, m->body); 56 } 57 printf("--------------------------------------------------\n"); 58 } 59 */ 60 61 static struct message_node *head = NULL; 62 static struct message_node *tail = NULL; 63 static size_t message_count = 0; 64 65 66 /* Current callback handler */ 67 static void collect_message(struct message *m, void *ud) { 68 (void)ud; 69 70 struct message_node *n = malloc(sizeof(struct message_node)); 71 if (!n) { 72 perror("malloc"); 73 exit(1); 74 } 75 76 /* shallow copy: pointers still reference JSON buffer */ 77 n->m = *m; 78 n->next = NULL; 79 80 if (!head) { 81 head = tail = n; 82 } else { 83 tail->next = n; 84 tail = n; 85 } 86 message_count++; 87 } 88 89 static struct sexp *plist_get(struct sexp *plist, const char *key) { 90 for (struct sexp *e = plist; e && e->next; e = e->next->next) { 91 if (e->type == SEXP_SYMBOL && 92 e->len == (int)strlen(key) && 93 memcmp(e->start, key, e->len) == 0) 94 return e->next; 95 } 96 return NULL; 97 } 98 99 static void parse_message_plist(struct sexp *plist, 100 struct message *m) { 101 memset(m, 0, sizeof(*m)); 102 103 struct sexp *v; 104 105 if ((v = plist_get(plist, ":id"))) { 106 m->id = v->start; 107 m->id_len = v->len; 108 } 109 if ((v = plist_get(plist, ":filename"))) { 110 if (v->type == SEXP_LIST && v->child) { 111 m->filename = v->child->start; 112 m->filename_len = v->child->len; 113 } 114 } 115 if ((v = plist_get(plist, ":timestamp"))) { 116 m->timestamp = strtoul(v->start, NULL, 10); 117 } 118 119 if ((v = plist_get(plist, ":headers")) && v->type == SEXP_LIST) { 120 struct sexp *h = v->child; 121 struct sexp *x; 122 123 if ((x = plist_get(h, ":From"))) { 124 m->from = x->start; 125 m->from_len = x->len; 126 } 127 if ((x = plist_get(h, ":Subject"))) { 128 m->subject = x->start; 129 m->subject_len = x->len; 130 } 131 if ((x = plist_get(h, ":Date"))) { 132 m->date = x->start; 133 m->date_len = x->len; 134 } 135 } 136 137 if ((v = plist_get(plist, ":body")) && v->type == SEXP_LIST) { 138 struct sexp *b = v->child; 139 if (b && (b = plist_get(b->child, ":content"))) { 140 m->body = b->start; 141 m->body_len = b->len; 142 } 143 } 144 } 145 146 void parse_mail_sexp(const char *sexp, size_t len, 147 message_cb cb, void *ud) { 148 struct sexp *root = sexp_parse(sexp, len); 149 if (!root) return; 150 151 struct message m; 152 153 /* DFS */ 154 void walk(struct sexp *n) { 155 if (!n) return; 156 157 if (n->type == SEXP_LIST && n->child && 158 n->child->type == SEXP_LIST) { 159 160 struct sexp *plist = n->child->child; 161 struct sexp *id = plist_get(plist, ":id"); 162 struct sexp *headers = plist_get(plist, ":headers"); 163 164 if (id && headers && headers->type == SEXP_LIST) { 165 parse_message_plist(plist, &m); 166 167 /* sanity: must have a non-empty message-id */ 168 if (m.id && m.id_len > 1) { 169 cb(&m, ud); 170 } 171 } 172 } 173 174 walk(n->child); 175 walk(n->next); 176 } 177 178 walk(root); 179 sexp_free(root); 180 } 181 182 183 184 /* HTML Escape Function */ 185 static void fprinthtml(FILE *fp, const char *s, int len) { 186 if (!s || len <= 0) 187 return; 188 189 for (int i = 0; i < len; i++) { 190 /* Handle escaped newlines: \n */ 191 if (s[i] == '\\' && i + 1 < len && s[i + 1] == 'n') { 192 fputs("<br/>\n", fp); 193 i++; /* skip 'n' */ 194 continue; 195 } 196 197 /* Handle escaped tabs */ 198 if (s[i] == '\\' && i + 1 < len && s[i + 1] == 't') { 199 fputs(" ", fp); 200 i++; 201 continue; 202 } 203 switch (s[i]) { 204 case '<': fputs("<", fp); break; 205 case '>': fputs(">", fp); break; 206 case '&': fputs("&", fp); break; 207 case '"': fputs(""", fp); break; 208 case '\'': fputs("'", fp); break; 209 case '\n': fputs("<br/>\n", fp); break; 210 case '\r': /* ignore */ break; 211 default: fputc(s[i], fp); break; 212 } 213 } 214 } 215 216 /* Simple hash of message-id for filename */ 217 static unsigned long hash_msgid(const char *s, int len) { 218 unsigned long hash = 5381; 219 for (int i = 0; i < len; i++) 220 hash = ((hash << 5) + hash) + (unsigned char)s[i]; 221 return hash; 222 } 223 224 /* Write HTML header */ 225 static void writeheader(FILE *fp, const char *title) { 226 fprintf(fp, "<!DOCTYPE html>\n"); 227 fprintf(fp, "<html>\n"); 228 fprintf(fp, "<head>\n"); 229 fprintf(fp, "<meta charset=\"UTF-8\">\n"); 230 fprintf(fp, "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"); 231 fprintf(fp, "<title>%s</title>\n", title); 232 fprintf(fp, "<link rel=\"stylesheet\" href=\"style.css\">\n"); 233 fprintf(fp, "</head>\n"); 234 fprintf(fp, "<body>\n"); 235 } 236 237 /* Write HTML footer */ 238 static void writefooter(FILE *fp) { 239 fprintf(fp, "</body>\n"); 240 fprintf(fp, "</html>\n"); 241 } 242 243 /* Create output directory if it doesn't exist */ 244 static int ensure_dir(const char *path) { 245 struct stat st; 246 if (stat(path, &st) == 0) 247 return 0; /* Already exists */ 248 249 if (mkdir(path, 0755) != 0 && errno != EEXIST) { 250 perror(path); 251 return -1; 252 } 253 return 0; 254 } 255 256 /* Copy style.css from source to output directory */ 257 static int copy_stylesheet(const char *source, const char *output_dir) { 258 FILE *src, *dst; 259 char dst_path[1024]; 260 char buf[4096]; 261 size_t n; 262 263 /* Open source file */ 264 src = fopen(source, "r"); 265 if (!src) { 266 perror(source); 267 return -1; 268 } 269 270 /* Open destination file */ 271 snprintf(dst_path, sizeof(dst_path), "%s/style.css", output_dir); 272 dst = fopen(dst_path, "w"); 273 if (!dst) { 274 perror(dst_path); 275 fclose(src); 276 return -1; 277 } 278 279 /* Copy contents */ 280 while ((n = fread(buf, 1, sizeof(buf), src)) > 0) { 281 if (fwrite(buf, 1, n, dst) != n) { 282 perror("fwrite"); 283 fclose(src); 284 fclose(dst); 285 return -1; 286 } 287 } 288 289 fclose(src); 290 fclose(dst); 291 return 0; 292 } 293 294 /* Write index.html */ 295 static int write_index(const char *output_dir, const char *archive_name) { 296 FILE *fp; 297 char path[1024]; 298 299 snprintf(path, sizeof(path), "%s/index.html", output_dir); 300 301 fp = fopen(path, "w"); 302 if (!fp) { 303 perror(path); 304 return -1; 305 } 306 307 writeheader(fp, archive_name); 308 309 fprintf(fp, "<h1>%s</h1>\n", archive_name); 310 fprintf(fp, "<nav>\n"); 311 fprintf(fp, "<a href=\"index.html\">Messages</a> | "); 312 fprintf(fp, "<a href=\"threads.html\">Threads</a>\n"); 313 fprintf(fp, "</nav>\n"); 314 315 fprintf(fp, "<p>%zu messages</p>\n", message_count); 316 317 fprintf(fp, "<table>\n"); 318 fprintf(fp, "<thead>\n"); 319 fprintf(fp, "<tr><th>Date</th><th>From</th><th>Subject</th></tr>\n"); 320 fprintf(fp, "</thead>\n"); 321 fprintf(fp, "<tbody>\n"); 322 323 for (struct message_node *n = head; n; n = n->next) { 324 struct message *m = &n->m; 325 unsigned long hash = hash_msgid(m->id, m->id_len); 326 327 fprintf(fp, "<tr>"); 328 329 /* Date */ 330 fprintf(fp, "<td class=\"date\">"); 331 fprinthtml(fp, m->date, m->date_len); 332 fprintf(fp, "</td>"); 333 334 /* From */ 335 fprintf(fp, "<td class=\"from\">"); 336 fprinthtml(fp, m->from, m->from_len); 337 fprintf(fp, "</td>"); 338 339 /* Subject (with link to message page) */ 340 fprintf(fp, "<td class=\"subject\">"); 341 fprintf(fp, "<a href=\"msg-%08lx.html\">", hash); 342 fprinthtml(fp, m->subject, m->subject_len); 343 fprintf(fp, "</a>"); 344 fprintf(fp, "</td>"); 345 346 fprintf(fp, "</tr>\n"); 347 } 348 349 fprintf(fp, "</tbody>\n"); 350 fprintf(fp, "</table>\n"); 351 352 writefooter(fp); 353 fclose(fp); 354 return 0; 355 } 356 357 /* Write individual message page */ 358 static int write_message(const char *output_dir, struct message *m) { 359 FILE *fp; 360 char path[1024]; 361 unsigned long hash = hash_msgid(m->id, m->id_len); 362 363 snprintf(path, sizeof(path), "%s/msg-%08lx.html", output_dir, hash); 364 365 fp = fopen(path, "w"); 366 if (!fp) { 367 perror(path); 368 return -1; 369 } 370 371 /* Title from subject */ 372 writeheader(fp, "Message"); 373 374 fprintf(fp, "<nav>\n"); 375 fprintf(fp, "<a href=\"index.html\">← Back to index</a>\n"); 376 fprintf(fp, "</nav>\n"); 377 378 fprintf(fp, "<div style=\"margin: 20px 0; padding: 15px; background: #f5f5f5; border-left: 3px solid #666;\">\n"); 379 380 /* From */ 381 fprintf(fp, "<div><strong>From:</strong> "); 382 fprinthtml(fp, m->from, m->from_len); 383 fprintf(fp, "</div>\n"); 384 385 /* Date */ 386 fprintf(fp, "<div><strong>Date:</strong> "); 387 fprinthtml(fp, m->date, m->date_len); 388 fprintf(fp, "</div>\n"); 389 390 /* Subject */ 391 fprintf(fp, "<div><strong>Subject:</strong> "); 392 fprinthtml(fp, m->subject, m->subject_len); 393 fprintf(fp, "</div>\n"); 394 395 /* Message-ID */ 396 fprintf(fp, "<div style=\"font-size: 0.9em; color: #666;\"><strong>Message-ID:</strong> "); 397 fprinthtml(fp, m->id, m->id_len); 398 fprintf(fp, "</div>\n"); 399 400 fprintf(fp, "</div>\n"); 401 402 /* Body */ 403 if (m->body && m->body_len > 0) { 404 fprintf(fp, "<pre style=\"white-space: pre-wrap; font-family: monospace; padding: 15px; background: #fafafa; border: 1px solid #ddd;\">"); 405 fprinthtml(fp, m->body, m->body_len); 406 fprintf(fp, "</pre>\n"); 407 } 408 409 writefooter(fp); 410 fclose(fp); 411 return 0; 412 } 413 414 static void render_message_line(FILE *fp, 415 struct message *m) { 416 unsigned long hash = hash_msgid(m->id, m->id_len); 417 418 /* subject link */ 419 fprintf(fp, "<a href=\"msg-%08lx.html\">", hash); 420 fprinthtml(fp, m->subject, m->subject_len); 421 fprinthtml(fp, " <", 2); 422 fprinthtml(fp, m->from, m->from_len); 423 fprinthtml(fp, ">", 1); 424 fprintf(fp, "</a>"); 425 426 /* inline details for body */ 427 if (m->body && m->body_len > 0) { 428 fprintf(fp, " "); 429 fprintf(fp, "<details>"); 430 fprintf(fp, "<summary>+</summary>"); 431 fprintf(fp, "<div class=\"body\">"); 432 fprinthtml(fp, m->body, m->body_len); 433 fprintf(fp, "</div>"); 434 fprintf(fp, "</details>"); 435 } 436 } 437 438 static void render_thread_ascii(FILE *fp, 439 struct thread_node *n, 440 const char *prefix, 441 int is_last) { 442 char next_prefix[1024]; 443 444 fprintf(fp, "%s%s ", 445 prefix, 446 is_last ? "└─" : "├─"); 447 448 render_message_line(fp, n->msg); 449 fputc('\n', fp); 450 451 snprintf(next_prefix, sizeof(next_prefix), 452 "%s%s", 453 prefix, 454 is_last ? " " : "│ "); 455 456 for (struct thread_node *c = n->child; c; c = c->sibling) { 457 render_thread_ascii(fp, 458 c, 459 next_prefix, 460 c->sibling == NULL); 461 } 462 } 463 464 465 static int write_threads(const char *output_dir, 466 const char *archive_name, 467 struct thread_node **threads, 468 int num_threads) 469 { 470 FILE *fp; 471 char path[1024]; 472 473 snprintf(path, sizeof(path), "%s/threads.html", output_dir); 474 fp = fopen(path, "w"); 475 if (!fp) { 476 perror(path); 477 return -1; 478 } 479 480 writeheader(fp, archive_name); 481 482 fprintf(fp, "<h1>%s — Threads</h1>\n", archive_name); 483 fprintf(fp, "<nav>\n"); 484 fprintf(fp, "<a href=\"index.html\">Messages</a> | "); 485 fprintf(fp, "<a href=\"threads.html\">Threads</a>\n"); 486 fprintf(fp, "</nav>\n"); 487 488 fprintf(fp, 489 "<pre class=\"threads\">\n"); 490 491 for (int i = 0; i < num_threads; i++) { 492 struct thread_node *r = *threads++; 493 494 /* root line (no prefix) */ 495 unsigned long hash = 496 hash_msgid(r->msg->id, r->msg->id_len); 497 498 fprintf(fp, "<details open class=\"threads\">\n"); 499 fprintf(fp, "<summary>"); 500 501 /* root rendered as tree head */ 502 fprintf(fp, "└─ "); 503 render_message_line(fp, r->msg); 504 505 fprintf(fp, "</summary>\n"); 506 507 /* children share the same tree */ 508 for (struct thread_node *c = r->child; c; c = c->sibling) { 509 render_thread_ascii(fp, 510 c, 511 " ", 512 c->sibling == NULL); 513 } 514 515 fprintf(fp, "</details>\n"); 516 517 518 fprintf(fp, "\n"); 519 } 520 521 fprintf(fp, "</pre>\n"); 522 writefooter(fp); 523 fclose(fp); 524 return 0; 525 } 526 527 int main(int argc, char *argv[]) { 528 /* CONFIG */ 529 const char *output_dir = "./output"; 530 const char *archive_name = "Mail Archive"; 531 const char *stylesheet = "./style.css"; 532 533 /* Simple argument parsing */ 534 for (int i = 1; i < argc; i++) { 535 if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) { 536 output_dir = argv[++i]; 537 } else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) { 538 archive_name = argv[++i]; 539 } else if (strcmp(argv[i], "-s") == 0 && i + 1 < argc) { 540 stylesheet = argv[++i]; 541 } else if (strcmp(argv[i], "-h") == 0) { 542 printf("usage: stamail [-o output_dir] [-n archive_name] [-s style.css]\n"); 543 printf(" -o output directory (default: .)\n"); 544 printf(" -n archive name\n"); 545 printf(" -s path to style.css to copy\n"); 546 return 0; 547 } 548 } 549 550 /* Slurp stdin */ 551 char *buf = NULL; 552 size_t cap = 0, len = 0; 553 int c; 554 555 while ((c = getchar()) != EOF) { 556 if (len + 1 >= cap) { 557 cap = cap ? cap * 2 : 4096; 558 buf = realloc(buf, cap); 559 if (!buf) { 560 perror("realloc"); 561 return 1; 562 } 563 } 564 buf[len++] = c; 565 } 566 567 if (!buf) { 568 fprintf(stderr, "No input\n"); 569 return 1; 570 } 571 572 parse_mail_sexp(buf, len, collect_message, NULL); 573 574 /* Debug: print what we got from JSON */ 575 for (struct message_node *n = head; n; n = n->next) { 576 struct message *m = &n->m; 577 printf("MSG: %.*s\n", m->subject_len, m->subject); 578 printf(" ID: %.*s\n", m->id_len, m->id); 579 if (m->in_reply_to_len > 0) { 580 printf(" IRT: %.*s\n", m->in_reply_to_len, m->in_reply_to); 581 } else { 582 printf(" IRT: (none)\n"); 583 } 584 printf("\n"); 585 } 586 587 for (struct message_node *n = head; n; n = n->next) { 588 struct message *m = &n->m; 589 590 printf("Message-ID: %.*s\n", m->id_len, m->id); 591 printf("From: %.*s\n", m->from_len, m->from); 592 printf("Subject: %.*s\n", m->subject_len, m->subject); 593 printf("Date: %.*s\n", m->date_len, m->date); 594 if (m->body) { 595 printf("Body:\n%.*s\n", m->body_len, m->body); 596 } 597 printf("--------------------------------------------------\n"); 598 } 599 600 if (message_count == 0) { 601 fprintf(stderr, "No messages found\n"); 602 free(buf); 603 return 1; 604 } 605 606 /* Extract threading headers from maildir files */ 607 fprintf(stderr, "Reading threading headers from %zu messages...\n", message_count); 608 for (struct message_node *n = head; n; n = n->next) { 609 extract_threading_headers(n); 610 } 611 612 /* Debug: print what we got */ 613 for (struct message_node *n = head; n; n = n->next) { 614 struct message *m = &n->m; 615 printf("MSG: %.*s\n", m->subject_len, m->subject); 616 printf(" ID: %.*s\n", m->id_len, m->id); 617 if (m->in_reply_to_len > 0) { 618 printf(" IRT: %.*s\n", m->in_reply_to_len, m->in_reply_to); 619 } else { 620 printf(" IRT: (none)\n"); 621 } 622 printf("\n"); 623 } 624 625 /* Create output directory */ 626 if (ensure_dir(output_dir) != 0) { 627 free(buf); 628 return 1; 629 } 630 631 /* Generate HTML files */ 632 fprintf(stderr, "Generating HTML for %zu messages...\n", message_count); 633 634 /* Copy stylesheet if provided */ 635 if (stylesheet) { 636 if (copy_stylesheet(stylesheet, output_dir) != 0) { 637 fprintf(stderr, "Warning: failed to copy stylesheet\n"); 638 } 639 } 640 641 if (write_index(output_dir, archive_name) != 0) { 642 free(buf); 643 return 1; 644 } 645 646 /* Write individual message pages */ 647 for (struct message_node *n = head; n; n = n->next) { 648 if (write_message(output_dir, &n->m) != 0) { 649 free(buf); 650 return 1; 651 } 652 } 653 654 fprintf(stderr, "Done! Output written to %s/\n", output_dir); 655 656 /* Build thread tree */ 657 int num_threads = 0; 658 struct thread_node **threads = build_threads(head, &num_threads); 659 660 if (threads) { 661 printf("Debug print of threading"); 662 print_threads(threads, num_threads); 663 } 664 665 if (threads) { 666 if (write_threads(output_dir, 667 archive_name, 668 threads, 669 num_threads) != 0) { 670 fprintf(stderr, "Failed to write threads.html\n"); 671 } 672 } 673 674 free(buf); 675 struct message_node *n = head; 676 while (n) { 677 struct message_node *next = n->next; 678 free(n); 679 n = next; 680 } 681 682 return 0; 683 } 684