LCOV - code coverage report
Current view: top level - libemail/src/domain - email_service.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 38.0 % 1855 704
Test Date: 2026-04-15 21:12:52 Functions: 55.6 % 63 35

            Line data    Source code
       1              : #include "email_service.h"
       2              : #include "config_store.h"
       3              : #include "input_line.h"
       4              : #include "path_complete.h"
       5              : #include "imap_client.h"
       6              : #include "local_store.h"
       7              : #include "mime_util.h"
       8              : #include "html_render.h"
       9              : #include "imap_util.h"
      10              : #include "raii.h"
      11              : #include "logger.h"
      12              : #include "platform/terminal.h"
      13              : #include "platform/path.h"
      14              : #include "platform/process.h"
      15              : #include <stdio.h>
      16              : #include <stdlib.h>
      17              : #include <string.h>
      18              : #include <unistd.h>
      19              : #include <stdint.h>
      20              : #include <poll.h>
      21              : #include <sys/stat.h>
      22              : #include <sys/wait.h>
      23              : #include <fcntl.h>
      24              : #include <signal.h>
      25              : #include <time.h>
      26              : 
      27              : /* ── Column-aware printing ───────────────────────────────────────────── */
      28              : 
      29              : /**
      30              :  * Print a UTF-8 string left-aligned in exactly `width` terminal columns.
      31              :  * Truncates at character boundaries so that the output never exceeds `width`
      32              :  * columns, then pads with spaces to reach exactly `width` columns.
      33              :  * Uses wcwidth(3) for per-character column measurement (handles multi-byte
      34              :  * UTF-8, wide/emoji characters, and combining marks correctly).
      35              :  * Requires setlocale(LC_ALL, "") to have been called in main().
      36              :  */
      37           88 : static void print_padded_col(const char *s, int width) {
      38           88 :     if (!s) s = "";
      39           88 :     const unsigned char *p = (const unsigned char *)s;
      40           88 :     int used = 0;
      41              : 
      42         1463 :     while (*p) {
      43              :         /* Decode one UTF-8 code point. */
      44              :         uint32_t cp;
      45              :         int seqlen;
      46         1416 :         if      (*p < 0x80) { cp = *p;        seqlen = 1; }
      47            6 :         else if (*p < 0xC2) { cp = 0xFFFD;    seqlen = 1; } /* invalid lead byte */
      48            5 :         else if (*p < 0xE0) { cp = *p & 0x1F; seqlen = 2; }
      49            3 :         else if (*p < 0xF0) { cp = *p & 0x0F; seqlen = 3; }
      50            2 :         else if (*p < 0xF8) { cp = *p & 0x07; seqlen = 4; }
      51            1 :         else                { cp = 0xFFFD;    seqlen = 1; } /* invalid lead byte */
      52              : 
      53         1422 :         for (int i = 1; i < seqlen; i++) {
      54            7 :             if ((p[i] & 0xC0) != 0x80) { seqlen = i; cp = 0xFFFD; break; }
      55            6 :             cp = (cp << 6) | (p[i] & 0x3F);
      56              :         }
      57              : 
      58         1416 :         int w = terminal_wcwidth(cp);
      59         1416 :         if (w == 0) { p += seqlen; continue; }  /* skip control/non-printable */
      60         1416 :         if (used + w > width) break;             /* doesn't fit — stop here */
      61              : 
      62         1375 :         fwrite(p, 1, (size_t)seqlen, stdout);
      63         1375 :         used += w;
      64         1375 :         p    += seqlen;
      65              :     }
      66              : 
      67          677 :     for (int i = used; i < width; i++) putchar(' ');
      68           88 : }
      69              : 
      70              : /** Print n copies of the double-horizontal-bar character ═ (U+2550). */
      71          125 : static void print_dbar(int n) {
      72         2706 :     for (int i = 0; i < n; i++) fputs("\xe2\x95\x90", stdout);
      73          125 : }
      74              : 
      75              : /**
      76              :  * Count extra bytes introduced by multi-byte UTF-8 sequences in s.
      77              :  * printf("%-*s", w, s) pads by byte count; adding this value corrects
      78              :  * the width for strings containing accented/non-ASCII characters.
      79              :  */
      80            8 : static int utf8_extra_bytes(const char *s) {
      81            8 :     int extra = 0;
      82          116 :     for (const unsigned char *p = (const unsigned char *)s; *p; p++)
      83          108 :         if ((*p & 0xC0) == 0x80) extra++;   /* continuation byte */
      84            8 :     return extra;
      85              : }
      86              : 
      87              : /**
      88              :  * Format an integer with space as thousands separator into buf (size >= 16).
      89              :  * Returns buf.  Zero → empty string (blank cell).
      90              :  */
      91           24 : static char *fmt_thou(char *buf, size_t sz, int n) {
      92           24 :     if (n <= 0) { buf[0] = '\0'; return buf; }
      93            6 :     char tmp[32];
      94            6 :     snprintf(tmp, sizeof(tmp), "%d", n);
      95            6 :     int len = (int)strlen(tmp);
      96            6 :     int out = 0;
      97           12 :     for (int i = 0; i < len; i++) {
      98            6 :         int rem = len - i;          /* digits remaining including this one */
      99            6 :         if (i > 0 && rem % 3 == 0)
     100            0 :             buf[out++] = ' ';
     101            6 :         buf[out++] = tmp[i];
     102              :     }
     103            6 :     buf[out] = '\0';
     104              :     (void)sz;
     105            6 :     return buf;
     106              : }
     107              : 
     108              : /**
     109              :  * Soft-wrap text at word boundaries so no output line exceeds `width`
     110              :  * terminal columns (measured by wcwidth).  Long words that exceed `width`
     111              :  * are emitted on a line of their own.  Returns a heap-allocated string;
     112              :  * caller must free.  Returns strdup(text) on allocation failure.
     113              :  */
     114           12 : static char *word_wrap(const char *text, int width) {
     115           12 :     if (!text) return NULL;
     116           11 :     if (width < 20) width = 20;
     117              : 
     118           11 :     size_t in_len = strlen(text);
     119              :     /* Hard breaks add one '\n' per `width` chars; space-breaks are net-zero. */
     120           11 :     char *out = malloc(in_len + in_len / (size_t)width + 4);
     121           11 :     if (!out) return strdup(text);
     122           11 :     char *wp = out;
     123              : 
     124           11 :     const char *src = text;
     125           23 :     while (*src) {
     126              :         /* Isolate one source line. */
     127           12 :         const char *eol      = strchr(src, '\n');
     128           12 :         const char *line_end = eol ? eol : src + strlen(src);
     129              : 
     130              :         /* Emit the source line as one or more width-limited output lines. */
     131           12 :         const char *seg = src;
     132           26 :         while (seg < line_end) {
     133           14 :             const unsigned char *p = (const unsigned char *)seg;
     134           14 :             int col = 0;
     135           14 :             const char *brk = NULL;   /* last candidate break (space) */
     136              : 
     137          179 :             while ((const char *)p < line_end) {
     138              :                 uint32_t cp; int seqlen;
     139          167 :                 if      (*p < 0x80) { cp = *p;        seqlen = 1; }
     140            8 :                 else if (*p < 0xC2) { cp = 0xFFFD;    seqlen = 1; }
     141            7 :                 else if (*p < 0xE0) { cp = *p & 0x1F; seqlen = 2; }
     142            3 :                 else if (*p < 0xF0) { cp = *p & 0x0F; seqlen = 3; }
     143            2 :                 else if (*p < 0xF8) { cp = *p & 0x07; seqlen = 4; }
     144            1 :                 else                { cp = 0xFFFD;    seqlen = 1; }
     145          175 :                 for (int i = 1; i < seqlen; i++) {
     146            9 :                     if ((p[i] & 0xC0) != 0x80) { seqlen = i; cp = 0xFFFD; break; }
     147            8 :                     cp = (cp << 6) | (p[i] & 0x3F);
     148              :                 }
     149          167 :                 if ((const char *)p + seqlen > line_end) break;
     150              : 
     151          167 :                 int cw = terminal_wcwidth(cp);
     152              :                 /* cw is already 0 for non-printable characters */
     153          167 :                 if (col + cw > width) break;
     154              : 
     155          165 :                 if (*p == ' ') brk = (const char *)p;
     156          165 :                 col += cw;
     157          165 :                 p   += seqlen;
     158              :             }
     159              : 
     160           14 :             const char *chunk_end = (const char *)p;
     161              : 
     162           14 :             if (chunk_end >= line_end) {
     163              :                 /* Rest of line fits. */
     164           12 :                 size_t n = (size_t)(line_end - seg);
     165           12 :                 memcpy(wp, seg, n); wp += n;
     166           12 :                 seg = line_end;
     167            2 :             } else if (brk) {
     168              :                 /* Break at last space (replace space with newline). */
     169            1 :                 size_t n = (size_t)(brk - seg);
     170            1 :                 memcpy(wp, seg, n); wp += n;
     171            1 :                 *wp++ = '\n';
     172            1 :                 seg = brk + 1;
     173              :             } else {
     174              :                 /* No space found: hard break. */
     175            1 :                 size_t n = (size_t)(chunk_end - seg);
     176            1 :                 if (n == 0) {
     177              :                     /* Single wide char exceeds width: emit it anyway. */
     178            0 :                     const unsigned char *u = (const unsigned char *)seg;
     179            0 :                     int sl = (*u < 0x80) ? 1
     180            0 :                            : (*u < 0xE0) ? 2
     181            0 :                            : (*u < 0xF0) ? 3 : 4;
     182            0 :                     memcpy(wp, seg, (size_t)sl); wp += sl; seg += sl;
     183              :                 } else {
     184            1 :                     memcpy(wp, seg, n); wp += n;
     185            1 :                     *wp++ = '\n';
     186            1 :                     seg = chunk_end;
     187              :                 }
     188              :             }
     189              :         }
     190              : 
     191           12 :         *wp++ = '\n';
     192           12 :         src = eol ? eol + 1 : line_end;
     193              :     }
     194           11 :     *wp = '\0';
     195           11 :     return out;
     196              : }
     197              : 
     198              : /* Forward declaration — defined after visible_line_cols (below). */
     199              : static void print_statusbar(int trows, int width, const char *text);
     200              : 
     201              : /**
     202              :  * Pager prompt for the standalone `show` command.
     203              :  * Returns scroll delta: 0 = quit, positive = forward N lines, negative = back N.
     204              :  */
     205            0 : static int pager_prompt(int cur_page, int total_pages, int page_size,
     206              :                         int term_rows, int sb_width) {
     207            0 :     for (;;) {
     208            0 :         char sb[256];
     209            0 :         snprintf(sb, sizeof(sb),
     210              :                  "-- [%d/%d] PgDn/\u2193=scroll  PgUp/\u2191=back  ESC=quit --",
     211              :                  cur_page, total_pages);
     212            0 :         print_statusbar(term_rows, sb_width, sb);
     213            0 :         TermKey key = terminal_read_key();
     214            0 :         fprintf(stderr, "\r\033[K");
     215            0 :         fflush(stderr);
     216              : 
     217            0 :         switch (key) {
     218            0 :         case TERM_KEY_QUIT:
     219              :         case TERM_KEY_ESC:
     220            0 :         case TERM_KEY_BACK:      return 0;
     221            0 :         case TERM_KEY_NEXT_PAGE: return  page_size;
     222            0 :         case TERM_KEY_PREV_PAGE: return -page_size;
     223            0 :         case TERM_KEY_NEXT_LINE: return  1;
     224            0 :         case TERM_KEY_PREV_LINE: return -1;
     225            0 :         case TERM_KEY_ENTER:
     226              :         case TERM_KEY_TAB:
     227              :         case TERM_KEY_SHIFT_TAB:
     228              :         case TERM_KEY_LEFT:
     229              :         case TERM_KEY_RIGHT:
     230              :         case TERM_KEY_HOME:
     231              :         case TERM_KEY_END:
     232              :         case TERM_KEY_DELETE:
     233            0 :         case TERM_KEY_IGNORE:    continue;
     234              :         }
     235              :     }
     236              : }
     237              : 
     238              : /** Count newlines in s (= number of lines). */
     239              : /**
     240              :  * Count visible terminal columns in bytes [p, end), skipping ANSI SGR
     241              :  * and OSC escape sequences.  Uses terminal_wcwidth for multi-byte chars.
     242              :  */
     243           81 : static int visible_line_cols(const char *p, const char *end) {
     244           81 :     int cols = 0;
     245         3469 :     while (p < end) {
     246         3388 :         unsigned char c = (unsigned char)*p;
     247              :         /* Skip ANSI CSI sequence: ESC [ ... final_byte (0x40–0x7E) */
     248         3388 :         if (c == 0x1b && p + 1 < end && (unsigned char)*(p + 1) == '[') {
     249            6 :             p += 2;
     250           41 :             while (p < end && ((unsigned char)*p < 0x40 || (unsigned char)*p > 0x7e))
     251           35 :                 p++;
     252            6 :             if (p < end) p++;
     253            6 :             continue;
     254              :         }
     255              :         /* Skip OSC sequence: ESC ] ... BEL  or  ESC ] ... ESC \ */
     256         3382 :         if (c == 0x1b && p + 1 < end && (unsigned char)*(p + 1) == ']') {
     257            0 :             p += 2;
     258            0 :             while (p < end) {
     259            0 :                 if ((unsigned char)*p == 0x07) { p++; break; }
     260            0 :                 if ((unsigned char)*p == 0x1b && p + 1 < end &&
     261            0 :                     (unsigned char)*(p + 1) == '\\') { p += 2; break; }
     262            0 :                 p++;
     263              :             }
     264            0 :             continue;
     265              :         }
     266              :         /* Decode one UTF-8 codepoint */
     267              :         uint32_t cp; int sl;
     268         3382 :         if      (c < 0x80) { cp = c;        sl = 1; }
     269            2 :         else if (c < 0xC2) { cp = 0xFFFD;   sl = 1; }
     270            2 :         else if (c < 0xE0) { cp = c & 0x1F; sl = 2; }
     271            2 :         else if (c < 0xF0) { cp = c & 0x0F; sl = 3; }
     272            0 :         else if (c < 0xF8) { cp = c & 0x07; sl = 4; }
     273            0 :         else               { cp = 0xFFFD;   sl = 1; }
     274         3386 :         for (int i = 1; i < sl && p + i < end; i++) {
     275            4 :             if (((unsigned char)p[i] & 0xC0) != 0x80) { sl = i; cp = 0xFFFD; break; }
     276            4 :             cp = (cp << 6) | ((unsigned char)p[i] & 0x3F);
     277              :         }
     278         3382 :         int w = terminal_wcwidth((wchar_t)cp);
     279         3382 :         if (w > 0) cols += w;
     280         3382 :         p += sl;
     281              :     }
     282           81 :     return cols;
     283              : }
     284              : 
     285              : /**
     286              :  * Count total visual (physical terminal) rows that 'body' occupies when
     287              :  * rendered in a terminal of 'term_cols' columns.  A logical line whose
     288              :  * visible width exceeds term_cols wraps onto ceil(width/term_cols) rows.
     289              :  * Semantics mirror count_lines: each newline-terminated segment plus the
     290              :  * final segment (even if empty) each contribute at least 1 visual row.
     291              :  */
     292           10 : static int count_visual_rows(const char *body, int term_cols) {
     293           10 :     if (!body || !*body || term_cols <= 0) return 0;
     294            8 :     int total = 0;
     295            8 :     const char *p = body;
     296            6 :     for (;;) {
     297           14 :         const char *eol = strchr(p, '\n');
     298           14 :         const char *seg_end = eol ? eol : (p + strlen(p));
     299           14 :         int cols = visible_line_cols(p, seg_end);
     300           11 :         int rows = (cols == 0 || cols <= term_cols) ? 1
     301           25 :                    : (cols + term_cols - 1) / term_cols;
     302           14 :         total += rows;
     303           14 :         if (!eol) break;
     304            6 :         p = eol + 1;
     305              :     }
     306            8 :     return total;
     307              : }
     308              : 
     309              : /* ── Interactive pager helpers ───────────────────────────────────────── */
     310              : 
     311              : /**
     312              :  * Print a reverse-video status bar at terminal row trows, exactly width columns wide.
     313              :  * text must not contain ANSI escapes that move the cursor off the line.
     314              :  */
     315              : /**
     316              :  * Return a pointer one past the last byte of @p text that still fits in
     317              :  * @p max_cols visible columns, skipping ANSI escape sequences.
     318              :  * The returned slice can be fputs'd directly; its visible width is <= max_cols.
     319              :  */
     320            1 : static const char *text_end_at_cols(const char *text, int max_cols) {
     321            1 :     const char *p = text;
     322            1 :     int cols = 0;
     323           70 :     while (*p) {
     324           69 :         unsigned char c = (unsigned char)*p;
     325              :         /* Skip ANSI CSI escape */
     326           69 :         if (c == 0x1b && (unsigned char)*(p + 1) == '[') {
     327            0 :             const char *q = p + 2;
     328            0 :             while (*q && ((unsigned char)*q < 0x40 || (unsigned char)*q > 0x7e))
     329            0 :                 q++;
     330            0 :             if (*q) q++;
     331            0 :             p = q;
     332            0 :             continue;
     333              :         }
     334              :         /* Decode UTF-8 codepoint width */
     335              :         uint32_t cp; int sl;
     336           69 :         if      (c < 0x80) { cp = c;        sl = 1; }
     337            2 :         else if (c < 0xC2) { cp = 0xFFFD;   sl = 1; }
     338            2 :         else if (c < 0xE0) { cp = c & 0x1F; sl = 2; }
     339            2 :         else if (c < 0xF0) { cp = c & 0x0F; sl = 3; }
     340            0 :         else if (c < 0xF8) { cp = c & 0x07; sl = 4; }
     341            0 :         else               { cp = 0xFFFD;   sl = 1; }
     342           73 :         for (int i = 1; i < sl && p[i]; i++) {
     343            4 :             if (((unsigned char)p[i] & 0xC0) != 0x80) { sl = i; cp = 0xFFFD; break; }
     344            4 :             cp = (cp << 6) | ((unsigned char)p[i] & 0x3F);
     345              :         }
     346           69 :         int w = terminal_wcwidth((wchar_t)cp);
     347           69 :         if (w > 0 && cols + w > max_cols) break;
     348           69 :         if (w > 0) cols += w;
     349           69 :         p += sl;
     350              :     }
     351            1 :     return p;
     352              : }
     353              : 
     354            1 : static void print_statusbar(int trows, int width, const char *text) {
     355            1 :     fprintf(stderr, "\033[%d;1H\033[7m", trows);
     356            1 :     const char *end = text_end_at_cols(text, width);
     357            1 :     fwrite(text, 1, (size_t)(end - text), stderr);
     358            1 :     int used = visible_line_cols(text, end);
     359            1 :     int pad  = width - used;
     360           12 :     for (int i = 0; i < pad; i++) fputc(' ', stderr);
     361            1 :     fprintf(stderr, "\033[0m");
     362            1 :     fflush(stderr);
     363            1 : }
     364              : 
     365              : /**
     366              :  * Print a plain (non-reverse) info line at terminal row trows-1.
     367              :  * Used as the second-from-bottom status row for persistent informational messages.
     368              :  * If text is empty, the line is cleared to blank.
     369              :  */
     370            1 : static void print_infoline(int trows, int width, const char *text) {
     371            1 :     fprintf(stderr, "\033[%d;1H\033[0m", trows - 1);
     372            1 :     if (text && *text) {
     373            0 :         fputs(text, stderr);
     374            0 :         int used = visible_line_cols(text, text + strlen(text));
     375            0 :         int pad  = width - used;
     376            0 :         for (int i = 0; i < pad; i++) fputc(' ', stderr);
     377              :     } else {
     378           81 :         for (int i = 0; i < width; i++) fputc(' ', stderr);
     379              :     }
     380            1 :     fprintf(stderr, "\033[0m");
     381            1 :     fflush(stderr);
     382            1 : }
     383              : 
     384              : /**
     385              :  * Show a two-column help popup overlay and wait for any key to dismiss.
     386              :  *
     387              :  * @param title  Title displayed in the popup header.
     388              :  * @param rows   Array of {key_label, description} string pairs.
     389              :  * @param n      Number of rows.
     390              :  */
     391            0 : static void show_help_popup(const char *title,
     392              :                             const char *rows[][2], int n) {
     393            0 :     int tcols = terminal_cols();
     394            0 :     int trows = terminal_rows();
     395            0 :     if (tcols <= 0) tcols = 80;
     396            0 :     if (trows <= 0) trows = 24;
     397              : 
     398              :     /* Compute popup dimensions */
     399            0 :     int key_col_w = 12;   /* width of the key column */
     400            0 :     int desc_col_w = 44;  /* width of the description column */
     401            0 :     int inner_w = key_col_w + 2 + desc_col_w; /* key + "  " + desc */
     402            0 :     int box_w   = inner_w + 4;  /* "| " + inner + " |" */
     403            0 :     int box_h   = n + 4;        /* title + separator + n rows + bottom border */
     404              : 
     405              :     /* Center the popup */
     406            0 :     int col0 = (tcols - box_w) / 2;
     407            0 :     int row0 = (trows - box_h) / 2;
     408            0 :     if (col0 < 1) col0 = 1;
     409            0 :     if (row0 < 1) row0 = 1;
     410              : 
     411              :     /* Draw popup using stderr so it overlays stdout content */
     412              :     /* Top border */
     413            0 :     fprintf(stderr, "\033[%d;%dH\033[7m", row0, col0);
     414            0 :     fprintf(stderr, "\u250c");
     415            0 :     for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
     416            0 :     fprintf(stderr, "\u2510\033[0m");
     417              : 
     418              :     /* Title row */
     419            0 :     fprintf(stderr, "\033[%d;%dH\033[7m\u2502 ", row0 + 1, col0);
     420            0 :     int tlen = (int)strlen(title);
     421            0 :     int pad_left  = (box_w - 4 - tlen) / 2;
     422            0 :     int pad_right = (box_w - 4 - tlen) - pad_left;
     423            0 :     for (int i = 0; i < pad_left;  i++) fputc(' ', stderr);
     424            0 :     fprintf(stderr, "%s", title);
     425            0 :     for (int i = 0; i < pad_right; i++) fputc(' ', stderr);
     426            0 :     fprintf(stderr, " \u2502\033[0m");
     427              : 
     428              :     /* Separator */
     429            0 :     fprintf(stderr, "\033[%d;%dH\033[7m\u251c", row0 + 2, col0);
     430            0 :     for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
     431            0 :     fprintf(stderr, "\u2524\033[0m");
     432              : 
     433              :     /* Data rows */
     434            0 :     for (int i = 0; i < n; i++) {
     435            0 :         fprintf(stderr, "\033[%d;%dH\033[7m\u2502 ", row0 + 3 + i, col0);
     436              :         /* key label — bold, left-padded to key_col_w */
     437            0 :         fprintf(stderr, "\033[1m%-*.*s\033[22m", key_col_w, key_col_w, rows[i][0]);
     438            0 :         fprintf(stderr, "  ");
     439              :         /* description — truncated to desc_col_w */
     440            0 :         fprintf(stderr, "%-*.*s", desc_col_w, desc_col_w, rows[i][1]);
     441            0 :         fprintf(stderr, " \u2502\033[0m");
     442              :     }
     443              : 
     444              :     /* Bottom border */
     445            0 :     fprintf(stderr, "\033[%d;%dH\033[7m\u2514", row0 + 3 + n, col0);
     446            0 :     for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
     447            0 :     fprintf(stderr, "\u2518\033[0m");
     448              : 
     449              :     /* Footer: "Press any key to close" */
     450            0 :     const char *footer = " Press any key to close ";
     451            0 :     int flen = (int)strlen(footer);
     452            0 :     if (flen < box_w - 2) {
     453            0 :         int fc = col0 + (box_w - flen) / 2;
     454            0 :         fprintf(stderr, "\033[%d;%dH\033[2m%s\033[0m", row0 + 4 + n, fc, footer);
     455              :     }
     456            0 :     fflush(stderr);
     457              : 
     458              :     /* Wait for any key */
     459            0 :     terminal_read_key();
     460              : 
     461              :     /* Clear the popup area */
     462            0 :     for (int r = row0; r <= row0 + 4 + n; r++) {
     463            0 :         fprintf(stderr, "\033[%d;%dH\033[K", r, col0);
     464            0 :         for (int c = 0; c < box_w; c++) fputc(' ', stderr);
     465              :     }
     466            0 :     fflush(stderr);
     467            0 : }
     468              : 
     469              : /**
     470              :  * ANSI SGR state tracked while scanning skipped body lines.
     471              :  * Only the subset emitted by html_render() is handled.
     472              :  */
     473              : typedef struct {
     474              :     int bold, italic, uline, strike;
     475              :     int fg_on; int fg_r, fg_g, fg_b;
     476              :     int bg_on; int bg_r, bg_g, bg_b;
     477              : } AnsiState;
     478              : 
     479              : /** Scan bytes [begin, end) for SGR sequences and update *st. */
     480           19 : static void ansi_scan(const char *begin, const char *end, AnsiState *st)
     481              : {
     482           19 :     const char *p = begin;
     483           82 :     while (p < end) {
     484           63 :         if (*p != '\033' || p + 1 >= end || *(p+1) != '[') { p++; continue; }
     485           21 :         p += 2;
     486           21 :         char seq[64]; int si = 0;
     487          121 :         while (p < end && *p != 'm' && si < 62) seq[si++] = *p++;
     488           21 :         seq[si] = '\0';
     489           21 :         if (p < end && *p == 'm') p++;
     490           21 :         if      (!strcmp(seq,"0"))   { st->bold=0; st->italic=0; st->uline=0;
     491            1 :                                        st->strike=0; st->fg_on=0; st->bg_on=0; }
     492           20 :         else if (!strcmp(seq,"1"))   { st->bold   = 1; }
     493           16 :         else if (!strcmp(seq,"22"))  { st->bold   = 0; }
     494           15 :         else if (!strcmp(seq,"3"))   { st->italic = 1; }
     495           13 :         else if (!strcmp(seq,"23"))  { st->italic = 0; }
     496           12 :         else if (!strcmp(seq,"4"))   { st->uline  = 1; }
     497           11 :         else if (!strcmp(seq,"24"))  { st->uline  = 0; }
     498           10 :         else if (!strcmp(seq,"9"))   { st->strike = 1; }
     499            9 :         else if (!strcmp(seq,"29"))  { st->strike = 0; }
     500            8 :         else if (!strcmp(seq,"39"))  { st->fg_on  = 0; }
     501            7 :         else if (!strcmp(seq,"49"))  { st->bg_on  = 0; }
     502            6 :         else if (!strncmp(seq,"38;2;",5)) {
     503            4 :             st->fg_on = 1;
     504            4 :             sscanf(seq+5, "%d;%d;%d", &st->fg_r, &st->fg_g, &st->fg_b);
     505              :         }
     506            2 :         else if (!strncmp(seq,"48;2;",5)) {
     507            2 :             st->bg_on = 1;
     508            2 :             sscanf(seq+5, "%d;%d;%d", &st->bg_r, &st->bg_g, &st->bg_b);
     509              :         }
     510              :     }
     511           19 : }
     512              : 
     513              : /** Re-emit escapes needed to restore *st on a freshly-reset terminal. */
     514            4 : static void ansi_replay(const AnsiState *st)
     515              : {
     516            4 :     if (st->bold)   printf("\033[1m");
     517            4 :     if (st->italic) printf("\033[3m");
     518            4 :     if (st->uline)  printf("\033[4m");
     519            4 :     if (st->strike) printf("\033[9m");
     520            4 :     if (st->fg_on)  printf("\033[38;2;%d;%d;%dm", st->fg_r, st->fg_g, st->fg_b);
     521            4 :     if (st->bg_on)  printf("\033[48;2;%d;%d;%dm", st->bg_r, st->bg_g, st->bg_b);
     522            4 : }
     523              : 
     524              : /**
     525              :  * Print up to 'vrow_budget' visual rows from 'body', starting at visual
     526              :  * row 'from_vrow'.  A logical line whose visible width exceeds 'term_cols'
     527              :  * counts as ceil(width/term_cols) visual rows.
     528              :  *
     529              :  * Replays any ANSI SGR state accumulated in skipped content so that
     530              :  * multi-line styled spans remain correct across page boundaries.
     531              :  *
     532              :  * At least one logical line is always shown even if it alone exceeds the
     533              :  * budget (ensures very long URLs are never silently skipped).
     534              :  */
     535            7 : static void print_body_page(const char *body, int from_vrow, int vrow_budget,
     536              :                              int term_cols) {
     537            7 :     if (!body) return;
     538              : 
     539              :     /* ── Skip to from_vrow ──────────────────────────────────────────── */
     540            7 :     const char *p = body;
     541            7 :     int vrow = 0;
     542           11 :     while (*p) {
     543           11 :         const char *eol = strchr(p, '\n');
     544           11 :         const char *seg = eol ? eol : (p + strlen(p));
     545           11 :         int cols = visible_line_cols(p, seg);
     546           11 :         int rows = (cols == 0 || (term_cols > 0 && cols <= term_cols)) ? 1
     547           22 :                    : (cols + term_cols - 1) / term_cols;
     548           11 :         if (vrow + rows > from_vrow) break;   /* this line spans from_vrow */
     549            4 :         vrow += rows;
     550            4 :         p = eol ? eol + 1 : seg;
     551            4 :         if (!eol) break;
     552              :     }
     553              : 
     554              :     /* Restore ANSI state that was active at the start of the visible region */
     555            7 :     if (p > body) {
     556            4 :         AnsiState st = {0};
     557            4 :         ansi_scan(body, p, &st);
     558            4 :         ansi_replay(&st);
     559              :     }
     560              : 
     561              :     /* ── Display up to vrow_budget visual rows ───────────────────────── */
     562            7 :     int displayed = 0;
     563            7 :     int any_shown = 0;
     564           17 :     while (*p) {
     565           14 :         const char *eol = strchr(p, '\n');
     566           14 :         const char *seg = eol ? eol : (p + strlen(p));
     567           14 :         int cols = visible_line_cols(p, seg);
     568           12 :         int rows = (cols == 0 || (term_cols > 0 && cols <= term_cols)) ? 1
     569           26 :                    : (cols + term_cols - 1) / term_cols;
     570              : 
     571              :         /* Stop when budget exhausted, but always show at least one line */
     572           14 :         if (any_shown && displayed + rows > vrow_budget) break;
     573              : 
     574           10 :         if (eol) {
     575            8 :             printf("%.*s\n", (int)(eol - p), p);
     576            8 :             p = eol + 1;
     577              :         } else {
     578            2 :             printf("%s\n", p);
     579            2 :             p += strlen(p);
     580              :         }
     581           10 :         displayed += rows;
     582           10 :         any_shown = 1;
     583              :     }
     584              : }
     585              : 
     586              : /* ── IMAP helpers ────────────────────────────────────────────────────── */
     587              : 
     588           51 : static ImapClient *make_imap(const Config *cfg) {
     589           51 :     return imap_connect(cfg->host, cfg->user, cfg->pass, !cfg->ssl_no_verify);
     590              : }
     591              : 
     592              : /* ── Folder status ───────────────────────────────────────────────────── */
     593              : 
     594              : typedef struct { int messages; int unseen; int flagged; } FolderStatus;
     595              : 
     596              : /** Read total, unseen and flagged counts for each folder from their local manifests.
     597              :  *  Instant — no server connection needed.
     598              :  *  Returns heap-allocated array; caller must free(). */
     599            2 : static FolderStatus *fetch_all_folder_statuses(const Config *cfg __attribute__((unused)),
     600              :                                                 char **folders, int count) {
     601            2 :     FolderStatus *st = calloc((size_t)count, sizeof(FolderStatus));
     602            2 :     if (!st || count == 0) return st;
     603           10 :     for (int i = 0; i < count; i++)
     604            8 :         manifest_count_folder(folders[i], &st[i].messages,
     605            8 :                               &st[i].unseen, &st[i].flagged);
     606            2 :     return st;
     607              : }
     608              : 
     609              : /** Fetches headers or full message for a UID in <folder>.  Caller must free.
     610              :  *  Opens a new IMAP connection each call.  For bulk fetching (sync), use
     611              :  *  the imap_client API directly with a shared connection. */
     612            4 : static char *fetch_uid_content_in(const Config *cfg, const char *folder,
     613              :                                   int uid, int headers_only) {
     614            8 :     RAII_IMAP ImapClient *imap = make_imap(cfg);
     615            4 :     if (!imap) return NULL;
     616            4 :     if (imap_select(imap, folder) != 0) return NULL;
     617            0 :     return headers_only ? imap_uid_fetch_headers(imap, uid)
     618            4 :                         : imap_uid_fetch_body(imap, uid);
     619              : }
     620              : 
     621              : /* ── Cached header fetch ─────────────────────────────────────────────── */
     622              : 
     623              : /** Fetches headers for uid/folder, using the header cache. Caller must free. */
     624            0 : static char *fetch_uid_headers_cached(const Config *cfg, const char *folder,
     625              :                                        int uid) {
     626            0 :     if (local_hdr_exists(folder, uid))
     627            0 :         return local_hdr_load(folder, uid);
     628            0 :     char *hdrs = fetch_uid_content_in(cfg, folder, uid, 1);
     629            0 :     if (hdrs)
     630            0 :         local_hdr_save(folder, uid, hdrs, strlen(hdrs));
     631            0 :     return hdrs;
     632              : }
     633              : 
     634              : /**
     635              :  * Like fetch_uid_headers_cached but uses an already-connected and folder-selected
     636              :  * ImapClient instead of opening a new connection.  Falls back to the cache first.
     637              :  * Caller must free the returned string.
     638              :  */
     639           24 : static char *fetch_uid_headers_via(ImapClient *imap, const char *folder, int uid) {
     640           24 :     if (local_hdr_exists(folder, uid))
     641            0 :         return local_hdr_load(folder, uid);
     642           24 :     char *hdrs = imap_uid_fetch_headers(imap, uid);
     643           24 :     if (hdrs)
     644           24 :         local_hdr_save(folder, uid, hdrs, strlen(hdrs));
     645           24 :     return hdrs;
     646              : }
     647              : 
     648              : /* ── Show helpers ────────────────────────────────────────────────────── */
     649              : 
     650              : #define SHOW_WIDTH 80
     651              : #define SHOW_SEPARATOR \
     652              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     653              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     654              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     655              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     656              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     657              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     658              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     659              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"
     660              : 
     661              : /*
     662              :  * Print s (cleaning control chars), truncating at max_cols display columns.
     663              :  * Falls back to `fallback` if s is NULL.  Uses terminal_wcwidth for accurate
     664              :  * multi-byte / wide-character measurement.
     665              :  */
     666           17 : static void print_clean(const char *s, const char *fallback, int max_cols) {
     667           17 :     const unsigned char *p = (const unsigned char *)(s ? s : fallback);
     668           17 :     int col = 0;
     669          304 :     while (*p) {
     670              :         uint32_t cp; int sl;
     671          288 :         if      (*p < 0x80) { cp = *p;        sl = 1; }
     672            0 :         else if (*p < 0xC2) { cp = 0xFFFD;    sl = 1; }
     673            0 :         else if (*p < 0xE0) { cp = *p & 0x1F; sl = 2; }
     674            0 :         else if (*p < 0xF0) { cp = *p & 0x0F; sl = 3; }
     675            0 :         else if (*p < 0xF8) { cp = *p & 0x07; sl = 4; }
     676            0 :         else                { cp = 0xFFFD;    sl = 1; }
     677          288 :         for (int i = 1; i < sl; i++) {
     678            0 :             if ((p[i] & 0xC0) != 0x80) { sl = i; cp = 0xFFFD; break; }
     679            0 :             cp = (cp << 6) | (p[i] & 0x3F);
     680              :         }
     681          288 :         int w = terminal_wcwidth(cp);
     682          288 :         if (w < 0) w = 0;
     683          288 :         if (col + w > max_cols) break;
     684          287 :         if (cp < 0x20 && cp != '\t') putchar(' ');
     685          287 :         else fwrite(p, 1, (size_t)sl, stdout);
     686          287 :         col += w;
     687          287 :         p   += sl;
     688              :     }
     689           17 : }
     690              : 
     691            5 : static void print_show_headers(const char *from, const char *subject,
     692              :                                 const char *date) {
     693              :     /* label = 9 chars ("From:    "), remaining = SHOW_WIDTH - 9 = 71 */
     694            5 :     printf("From:    "); print_clean(from,    "(none)", SHOW_WIDTH - 9); putchar('\n');
     695            5 :     printf("Subject: "); print_clean(subject, "(none)", SHOW_WIDTH - 9); putchar('\n');
     696            5 :     printf("Date:    "); print_clean(date,    "(none)", SHOW_WIDTH - 9); putchar('\n');
     697            5 :     printf(SHOW_SEPARATOR);
     698            5 : }
     699              : 
     700              : /* ── Attachment picker ───────────────────────────────────────────────── */
     701              : 
     702              : 
     703              : /* Determine the best directory to save attachments into.
     704              :  * Prefers ~/Downloads if it exists, else falls back to ~.
     705              :  * Returns a heap-allocated string the caller must free(). */
     706            0 : static char *attachment_save_dir(void) {
     707            0 :     const char *home = platform_home_dir();
     708            0 :     if (!home) return strdup(".");
     709            0 :     char dl[1024];
     710            0 :     snprintf(dl, sizeof(dl), "%s/Downloads", home);
     711            0 :     struct stat st;
     712            0 :     if (stat(dl, &st) == 0 && S_ISDIR(st.st_mode))
     713            0 :         return strdup(dl);
     714            0 :     return strdup(home);
     715              : }
     716              : 
     717              : /* Sanitise a filename component for use in a path (strip path separators). */
     718            0 : static char *safe_filename_for_path(const char *name) {
     719            0 :     if (!name || !*name) return strdup("attachment");
     720            0 :     char *s = strdup(name);
     721            0 :     if (!s) return NULL;
     722            0 :     for (char *p = s; *p; p++)
     723            0 :         if (*p == '/' || *p == '\\') *p = '_';
     724            0 :     return s;
     725              : }
     726              : 
     727              : /* Attachment picker: full-screen list, navigate with arrows, Enter to select.
     728              :  * Returns selected index (0-based), or -1 if Backspace (back), -2 if ESC/Quit. */
     729            0 : static int show_attachment_picker(const MimeAttachment *atts, int count,
     730              :                                   int tcols, int trows) {
     731            0 :     int cursor = 0;
     732            0 :     for (;;) {
     733            0 :         printf("\033[0m\033[H\033[2J");
     734            0 :         printf("  Attachments (%d):\n\n", count);
     735            0 :         for (int i = 0; i < count; i++) {
     736            0 :             const char *name  = atts[i].filename     ? atts[i].filename     : "(no name)";
     737            0 :             const char *ctype = atts[i].content_type ? atts[i].content_type : "";
     738            0 :             char sz[32];
     739            0 :             if (atts[i].size >= 1024 * 1024)
     740            0 :                 snprintf(sz, sizeof(sz), "%.1f MB",
     741            0 :                          (double)atts[i].size / (1024.0 * 1024.0));
     742            0 :             else if (atts[i].size >= 1024)
     743            0 :                 snprintf(sz, sizeof(sz), "%.0f KB",
     744            0 :                          (double)atts[i].size / 1024.0);
     745              :             else
     746            0 :                 snprintf(sz, sizeof(sz), "%zu B", atts[i].size);
     747              : 
     748            0 :             if (i == cursor)
     749            0 :                 printf("  \033[7m> %-36s  %-28s  %8s\033[0m\n", name, ctype, sz);
     750              :             else
     751            0 :                 printf("    %-36s  %-28s  %8s\n", name, ctype, sz);
     752              :         }
     753            0 :         fflush(stdout);
     754            0 :         char sb[160];
     755            0 :         snprintf(sb, sizeof(sb),
     756              :                  "  \u2191\u2193=select  Enter=choose  Backspace=back  ESC=quit");
     757            0 :         print_statusbar(trows, tcols, sb);
     758              : 
     759            0 :         TermKey key = terminal_read_key();
     760            0 :         switch (key) {
     761            0 :         case TERM_KEY_BACK:    return -1;
     762            0 :         case TERM_KEY_ESC:
     763            0 :         case TERM_KEY_QUIT:    return -2;
     764            0 :         case TERM_KEY_ENTER:   return cursor;
     765            0 :         case TERM_KEY_NEXT_LINE:
     766              :         case TERM_KEY_NEXT_PAGE:
     767            0 :             if (cursor < count - 1) cursor++;
     768            0 :             break;
     769            0 :         case TERM_KEY_PREV_LINE:
     770              :         case TERM_KEY_PREV_PAGE:
     771            0 :             if (cursor > 0) cursor--;
     772            0 :             break;
     773            0 :         default: break;
     774              :         }
     775              :     }
     776              : }
     777              : 
     778              : /**
     779              :  * Show a message in interactive pager mode.
     780              :  * Returns 0 = back to list (Backspace/ESC/q), -1 = error.
     781              :  */
     782            1 : static int show_uid_interactive(const Config *cfg, const char *folder,
     783              :                                 int uid, int page_size) {
     784            1 :     char *raw = NULL;
     785            1 :     if (local_msg_exists(folder, uid)) {
     786            1 :         raw = local_msg_load(folder, uid);
     787              :     } else {
     788            0 :         raw = fetch_uid_content_in(cfg, folder, uid, 0);
     789            0 :         if (raw) {
     790            0 :             local_msg_save(folder, uid, raw, strlen(raw));
     791            0 :             local_index_update(folder, uid, raw);
     792              :         }
     793              :     }
     794            1 :     if (!raw) {
     795            0 :         fprintf(stderr, "Could not load UID %d.\n", uid);
     796            0 :         return -1;
     797              :     }
     798              : 
     799            1 :     char *from_raw = mime_get_header(raw, "From");
     800            1 :     char *from     = from_raw ? mime_decode_words(from_raw) : NULL;
     801            1 :     free(from_raw);
     802            1 :     char *subj_raw = mime_get_header(raw, "Subject");
     803            1 :     char *subject  = subj_raw ? mime_decode_words(subj_raw) : NULL;
     804            1 :     free(subj_raw);
     805            1 :     char *date_raw = mime_get_header(raw, "Date");
     806            1 :     char *date     = date_raw ? mime_format_date(date_raw) : NULL;
     807            1 :     free(date_raw);
     808            1 :     int term_cols = terminal_cols();
     809            1 :     int term_rows = terminal_rows();
     810            1 :     if (term_rows <= 0) term_rows = page_size;
     811            1 :     int wrap_cols = term_cols > SHOW_WIDTH ? SHOW_WIDTH : term_cols;
     812            1 :     char *body = NULL;
     813            1 :     char *html_raw = mime_get_html_part(raw);
     814            1 :     if (html_raw) {
     815            0 :         body = html_render(html_raw, wrap_cols, 1);
     816            0 :         free(html_raw);
     817              :     } else {
     818            1 :         char *plain = mime_get_text_body(raw);
     819            1 :         if (plain) {
     820            1 :             char *wrapped = word_wrap(plain, wrap_cols);
     821            1 :             if (wrapped) { free(plain); body = wrapped; }
     822            0 :             else body = plain;
     823              :         }
     824              :     }
     825            1 :     const char *body_text = body ? body : "(no readable text body)";
     826            1 :     char *body_wrapped = NULL; /* kept for free() at cleanup */
     827              : 
     828              :     /* Detect attachments once */
     829            1 :     int att_count = 0;
     830            1 :     MimeAttachment *atts = mime_list_attachments(raw, &att_count);
     831              : 
     832              : /* Bottom two rows: row (trows-1) = info line, row trows = shortcut hints.
     833              :  * Reserve one extra row compared to the previous single-line footer. */
     834              : #define SHOW_HDR_LINES_INT 6
     835            1 :     int rows_avail  = (page_size > SHOW_HDR_LINES_INT)
     836            1 :                       ? page_size - SHOW_HDR_LINES_INT : 1;
     837            1 :     int body_vrows  = count_visual_rows(body_text, term_cols);
     838            1 :     int total_pages = (body_vrows + rows_avail - 1) / rows_avail;
     839            1 :     if (total_pages < 1) total_pages = 1;
     840              : 
     841              :     /* Persistent info message — stays until replaced by a newer one */
     842            1 :     char info_msg[2048] = "";
     843              : 
     844            1 :     int result = 0;
     845            1 :     for (int cur_line = 0;;) {
     846            1 :         printf("\033[0m\033[H\033[2J");     /* reset attrs + clear screen */
     847            1 :         print_show_headers(from, subject, date);
     848            1 :         print_body_page(body_text, cur_line, rows_avail, term_cols);
     849            1 :         printf("\033[0m");                  /* close any open ANSI from body */
     850            1 :         fflush(stdout);
     851              : 
     852            1 :         int cur_page = cur_line / rows_avail + 1;
     853              : 
     854              :         /* Info line (second from bottom) — persistent until overwritten */
     855            1 :         print_infoline(term_rows, wrap_cols, info_msg);
     856              : 
     857              :         /* Shortcut hints (bottom row) */
     858              :         {
     859            1 :             char sb[256];
     860            1 :             if (att_count > 0) {
     861            0 :                 snprintf(sb, sizeof(sb),
     862              :                          "-- [%d/%d] PgDn/\u2193=scroll  PgUp/\u2191=back"
     863              :                          "  r=reply  a=save  A=save-all(%d)  Backspace/ESC/q=list --",
     864              :                          cur_page, total_pages, att_count);
     865              :             } else {
     866            1 :                 snprintf(sb, sizeof(sb),
     867              :                          "-- [%d/%d] PgDn/\u2193=scroll  PgUp/\u2191=back"
     868              :                          "  r=reply  Backspace/ESC/q=list --",
     869              :                          cur_page, total_pages);
     870              :             }
     871            1 :             print_statusbar(term_rows, wrap_cols, sb);
     872              :         }
     873              : 
     874            1 :         TermKey key = terminal_read_key();
     875            1 :         fprintf(stderr, "\r\033[K");
     876            1 :         fflush(stderr);
     877              : 
     878            1 :         switch (key) {
     879            1 :         case TERM_KEY_BACK:
     880              :         case TERM_KEY_ESC:
     881              :         case TERM_KEY_QUIT:
     882            1 :             result = 0;          /* back to list */
     883            1 :             goto show_int_done;
     884            0 :         case TERM_KEY_NEXT_PAGE:
     885              :         {
     886            0 :             int next = cur_line + rows_avail;
     887            0 :             if (next < body_vrows) cur_line = next;
     888              :         }
     889            0 :             break;
     890            0 :         case TERM_KEY_ENTER:
     891            0 :             break;
     892            0 :         case TERM_KEY_PREV_PAGE:
     893            0 :             cur_line -= rows_avail;
     894            0 :             if (cur_line < 0) cur_line = 0;
     895            0 :             break;
     896            0 :         case TERM_KEY_NEXT_LINE:
     897            0 :             if (cur_line < body_vrows - 1) cur_line++;
     898            0 :             break;
     899            0 :         case TERM_KEY_PREV_LINE:
     900            0 :             if (cur_line > 0) cur_line--;
     901            0 :             break;
     902            0 :         case TERM_KEY_LEFT:
     903              :         case TERM_KEY_RIGHT:
     904              :         case TERM_KEY_HOME:
     905              :         case TERM_KEY_END:
     906              :         case TERM_KEY_DELETE:
     907              :         case TERM_KEY_TAB:
     908              :         case TERM_KEY_SHIFT_TAB:
     909              :         case TERM_KEY_IGNORE: {
     910            0 :             int ch = terminal_last_printable();
     911            0 :             if (ch == 'r') {
     912            0 :                 result = 2;      /* reply to this message */
     913            0 :                 goto show_int_done;
     914            0 :             } else if (ch == 'q') {
     915            0 :                 result = 0;      /* back to list */
     916            0 :                 goto show_int_done;
     917            0 :             } else if (ch == 'h' || ch == '?') {
     918              :                 static const char *help[][2] = {
     919              :                     { "PgDn / \u2193",   "Scroll down one page / one line"  },
     920              :                     { "PgUp / \u2191",   "Scroll up one page / one line"    },
     921              :                     { "r",              "Reply to this message"             },
     922              :                     { "a",              "Save an attachment"                },
     923              :                     { "A",              "Save all attachments"              },
     924              :                     { "Backspace",      "Back to message list"              },
     925              :                     { "ESC / q",        "Back to message list"              },
     926              :                     { "h / ?",          "Show this help"                    },
     927              :                 };
     928            0 :                 show_help_popup("Message reader shortcuts",
     929              :                                 help, (int)(sizeof(help)/sizeof(help[0])));
     930            0 :                 break;
     931            0 :             } else if (ch == 'a' && att_count > 0) {
     932            0 :                 int sel = 0;
     933            0 :                 if (att_count > 1) {
     934            0 :                     sel = show_attachment_picker(atts, att_count,
     935              :                                                  term_cols, term_rows);
     936            0 :                     if (sel == -2) {
     937            0 :                         break;       /* ESC/q → back to show view */
     938              :                     }
     939            0 :                     if (sel < 0) break;  /* Backspace → back to show */
     940              :                 }
     941              :                 /* Build suggested path and let user edit it */
     942              :                 {
     943            0 :                     char *dir  = attachment_save_dir();
     944            0 :                     char *fname = safe_filename_for_path(atts[sel].filename);
     945            0 :                     char dest[2048];
     946            0 :                     snprintf(dest, sizeof(dest), "%s/%s",
     947              :                              dir ? dir : ".", fname ? fname : "attachment");
     948            0 :                     free(dir);
     949            0 :                     free(fname);
     950            0 :                     InputLine il;
     951            0 :                     input_line_init(&il, dest, sizeof(dest), dest);
     952            0 :                     path_complete_attach(&il);
     953            0 :                     int ok = input_line_run(&il, term_rows - 1, "Save as: ");
     954            0 :                     path_complete_reset();
     955              :                     /* Clear the edited line and the completion row */
     956            0 :                     printf("\033[%d;1H\033[2K\033[%d;1H\033[2K\033[?25l",
     957              :                            term_rows - 1, term_rows);
     958            0 :                     if (ok == 1) {
     959            0 :                         int r = mime_save_attachment(&atts[sel], dest);
     960            0 :                         snprintf(info_msg, sizeof(info_msg),
     961              :                                  r == 0 ? "  Saved: %.1900s"
     962              :                                         : "  Save FAILED: %.1900s", dest);
     963              :                     }
     964              :                 }
     965            0 :             } else if (ch == 'A' && att_count > 0) {
     966              :                 /* Save ALL attachments to a chosen directory */
     967            0 :                 char *def_dir = attachment_save_dir();
     968            0 :                 char dest_dir[2048];
     969            0 :                 snprintf(dest_dir, sizeof(dest_dir), "%s",
     970              :                          def_dir ? def_dir : ".");
     971            0 :                 free(def_dir);
     972            0 :                 InputLine il;
     973            0 :                 input_line_init(&il, dest_dir, sizeof(dest_dir), dest_dir);
     974            0 :                 path_complete_attach(&il);
     975            0 :                 int ok = input_line_run(&il, term_rows - 1, "Save all to: ");
     976            0 :                 path_complete_reset();
     977              :                 /* Clear the edited line and the completion row */
     978            0 :                 printf("\033[%d;1H\033[2K\033[%d;1H\033[2K\033[?25l",
     979              :                        term_rows - 1, term_rows);
     980            0 :                 if (ok == 1) {
     981            0 :                     int saved = 0;
     982            0 :                     for (int i = 0; i < att_count; i++) {
     983            0 :                         char *fname = safe_filename_for_path(atts[i].filename);
     984            0 :                         char fpath[4096];
     985            0 :                         snprintf(fpath, sizeof(fpath), "%s/%s",
     986              :                                  dest_dir, fname ? fname : "attachment");
     987            0 :                         free(fname);
     988            0 :                         if (mime_save_attachment(&atts[i], fpath) == 0)
     989            0 :                             saved++;
     990              :                     }
     991            0 :                     snprintf(info_msg, sizeof(info_msg),
     992            0 :                              saved == att_count
     993              :                              ? "  Saved %d/%d files to: %.1900s"
     994              :                              : "  Saved %d/%d (errors) to: %.1900s",
     995              :                              saved, att_count, dest_dir);
     996              :                 }
     997              :             }
     998            0 :             break;
     999              :         }
    1000              :         }
    1001              :     }
    1002            1 : show_int_done:
    1003              : #undef SHOW_HDR_LINES_INT
    1004            1 :     mime_free_attachments(atts, att_count);
    1005            1 :     free(body); free(body_wrapped); free(from); free(subject); free(date); free(raw);
    1006            1 :     return result;
    1007              : }
    1008              : 
    1009              : /* ── List helpers ────────────────────────────────────────────────────── */
    1010              : 
    1011              : typedef struct { int uid; int flags; time_t epoch; } MsgEntry;
    1012              : 
    1013              : /* Parse "YYYY-MM-DD HH:MM" (manifest date format) to time_t in local time.
    1014              :  * Returns 0 on failure. */
    1015           17 : static time_t parse_manifest_date(const char *d) {
    1016           17 :     if (!d || !*d) return 0;
    1017           17 :     struct tm tm = {0};
    1018           17 :     if (sscanf(d, "%d-%d-%d %d:%d",
    1019              :                &tm.tm_year, &tm.tm_mon, &tm.tm_mday,
    1020            0 :                &tm.tm_hour, &tm.tm_min) != 5) return 0;
    1021           17 :     tm.tm_year -= 1900;
    1022           17 :     tm.tm_mon  -= 1;
    1023           17 :     tm.tm_isdst = -1;
    1024           17 :     return mktime(&tm);
    1025              : }
    1026              : 
    1027              : /* Return 1 if a background sync process is currently running. */
    1028           41 : static int sync_is_running(void) {
    1029           41 :     const char *cache_base = platform_cache_dir();
    1030           41 :     if (!cache_base) return 0;
    1031           41 :     char pid_path[2048];
    1032           41 :     snprintf(pid_path, sizeof(pid_path), "%s/email-cli/sync.pid", cache_base);
    1033           41 :     FILE *pf = fopen(pid_path, "r");
    1034           41 :     if (!pf) return 0;
    1035            1 :     int pid = 0;
    1036            1 :     if (fscanf(pf, "%d", &pid) != 1) pid = 0;
    1037            1 :     fclose(pf);
    1038            1 :     if (pid <= 0) return 0;
    1039              :     /* Accept any of the binary names that may be running sync */
    1040            2 :     return platform_pid_is_program((pid_t)pid, "email-cli") ||
    1041            2 :            platform_pid_is_program((pid_t)pid, "email-sync") ||
    1042            1 :            platform_pid_is_program((pid_t)pid, "email-tui");
    1043              : }
    1044              : 
    1045              : /* Build path to email-sync binary (same directory as the running binary). */
    1046            0 : static void get_sync_bin_path(char *buf, size_t size) {
    1047            0 :     snprintf(buf, size, "email-sync"); /* fallback: PATH lookup */
    1048              : #ifdef __linux__
    1049            0 :     char self[1024] = {0};
    1050            0 :     ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1);
    1051            0 :     if (n > 0) {
    1052            0 :         self[n] = '\0';
    1053            0 :         char *slash = strrchr(self, '/');
    1054            0 :         if (slash)
    1055            0 :             snprintf(buf, size, "%.*s/email-sync", (int)(slash - self), self);
    1056              :     }
    1057              : #endif
    1058            0 : }
    1059              : 
    1060              : /* Set by the SIGCHLD handler when the background sync child exits. */
    1061              : static volatile sig_atomic_t bg_sync_done = 0;
    1062              : static pid_t bg_sync_pid = -1;
    1063              : 
    1064            0 : static void bg_sync_sigchld(int sig) {
    1065              :     (void)sig;
    1066            0 :     if (bg_sync_pid > 0) {
    1067            0 :         int status;
    1068            0 :         if (waitpid(bg_sync_pid, &status, WNOHANG) == bg_sync_pid) {
    1069            0 :             bg_sync_pid = -1;
    1070            0 :             bg_sync_done = 1;
    1071              :         }
    1072              :     }
    1073            0 : }
    1074              : 
    1075              : /**
    1076              :  * Fork and exec email-sync in the background.
    1077              :  * Installs a SIGCHLD handler (without SA_RESTART) so the blocked read() in
    1078              :  * terminal_read_key() is interrupted when the child exits — this lets the TUI
    1079              :  * react immediately without any polling.
    1080              :  * Returns 1 if the child was spawned, 0 if already running, -1 on error.
    1081              :  */
    1082            0 : static int sync_start_background(void) {
    1083            0 :     if (sync_is_running()) return 0;
    1084              : 
    1085            0 :     struct sigaction sa = {0};
    1086            0 :     sa.sa_handler = bg_sync_sigchld;
    1087            0 :     sigemptyset(&sa.sa_mask);
    1088            0 :     sa.sa_flags = 0; /* no SA_RESTART: read() must be interrupted on SIGCHLD */
    1089            0 :     sigaction(SIGCHLD, &sa, NULL);
    1090              : 
    1091            0 :     char sync_bin[1024];
    1092            0 :     get_sync_bin_path(sync_bin, sizeof(sync_bin));
    1093              : 
    1094            0 :     pid_t pid = fork();
    1095            0 :     if (pid < 0) return -1;
    1096            0 :     if (pid == 0) {
    1097              :         /* Child: detach from the TUI session */
    1098            0 :         setsid();
    1099            0 :         int devnull = open("/dev/null", O_RDWR);
    1100            0 :         if (devnull >= 0) {
    1101            0 :             dup2(devnull, STDIN_FILENO);
    1102            0 :             dup2(devnull, STDOUT_FILENO);
    1103            0 :             dup2(devnull, STDERR_FILENO);
    1104            0 :             if (devnull > STDERR_FILENO) close(devnull);
    1105              :         }
    1106            0 :         char *args[] = {sync_bin, NULL};
    1107            0 :         execvp(sync_bin, args);
    1108            0 :         _exit(1); /* exec failed */
    1109              :     }
    1110            0 :     bg_sync_pid = pid;
    1111            0 :     return 1;
    1112              : }
    1113              : 
    1114              : /* Sort group: 0=unseen, 1=flagged (read), 2=rest */
    1115           12 : static int msg_group(int flags) {
    1116           12 :     if (flags & MSG_FLAG_UNSEEN)  return 0;
    1117            6 :     if (flags & MSG_FLAG_FLAGGED) return 1;
    1118            5 :     return 2;
    1119              : }
    1120              : 
    1121            6 : static int cmp_uid_entry(const void *a, const void *b) {
    1122            6 :     const MsgEntry *ea = a, *eb = b;
    1123            6 :     int ga = msg_group(ea->flags);
    1124            6 :     int gb = msg_group(eb->flags);
    1125            6 :     if (ga != gb) return ga - gb;         /* group order: unseen, flagged, rest */
    1126              :     /* Within group: newer date first; fall back to UID if date unavailable */
    1127            3 :     if (eb->epoch != ea->epoch) return (eb->epoch > ea->epoch) ? 1 : -1;
    1128            1 :     return eb->uid - ea->uid;
    1129              : }
    1130              : 
    1131              : /* ── Folder list helpers ─────────────────────────────────────────────── */
    1132              : 
    1133           16 : static int cmp_str(const void *a, const void *b) {
    1134           16 :     return strcmp(*(const char **)a, *(const char **)b);
    1135              : }
    1136              : 
    1137              : /* ── Folder tree renderer ────────────────────────────────────────────── */
    1138              : 
    1139              : /**
    1140              :  * Returns 1 if names[i] is the last child of its parent in the sorted list.
    1141              :  * Skips descendants of names[i] before checking for siblings.
    1142              :  */
    1143           10 : static int is_last_sibling(char **names, int count, int i, char sep) {
    1144           10 :     const char *name = names[i];
    1145           10 :     size_t name_len  = strlen(name);
    1146           10 :     const char *lsep = strrchr(name, sep);
    1147           10 :     size_t parent_len = lsep ? (size_t)(lsep - name) : 0;
    1148              : 
    1149              :     /* Find the last position that belongs to names[i]'s subtree */
    1150           10 :     int last = i;
    1151           13 :     for (int j = i + 1; j < count; j++) {
    1152            9 :         if (strncmp(names[j], name, name_len) == 0 &&
    1153            3 :             (names[j][name_len] == sep || names[j][name_len] == '\0'))
    1154            3 :             last = j;
    1155              :         else
    1156              :             break;
    1157              :     }
    1158              : 
    1159              :     /* After the subtree, look for a sibling */
    1160           10 :     for (int j = last + 1; j < count; j++) {
    1161            6 :         if (parent_len == 0)
    1162            2 :             return 0; /* any following item is a root-level sibling */
    1163            4 :         if (strlen(names[j]) > parent_len &&
    1164            3 :             strncmp(names[j], name, parent_len) == 0 &&
    1165            3 :             names[j][parent_len] == sep)
    1166            3 :             return 0;
    1167            1 :         return 1; /* jumped to a different parent subtree */
    1168              :     }
    1169            4 :     return 1;
    1170              : }
    1171              : 
    1172              : /**
    1173              :  * Returns 1 if the ancestor of names[i] at indent-level 'level'
    1174              :  * (0 = root component) is the last child of its own parent.
    1175              :  */
    1176            8 : static int ancestor_is_last(char **names, int count, int i,
    1177              :                              int level, char sep) {
    1178            8 :     const char *name = names[i];
    1179              : 
    1180              :     /* ancestor prefix length: (level+1) components */
    1181            8 :     size_t anc_len = 0;
    1182            8 :     int sep_cnt = 0;
    1183           41 :     while (name[anc_len]) {
    1184           40 :         if (name[anc_len] == sep && sep_cnt++ == level) break;
    1185           33 :         anc_len++;
    1186              :     }
    1187              : 
    1188              :     /* parent prefix length: level components */
    1189            8 :     size_t parent_len = 0;
    1190            8 :     sep_cnt = 0;
    1191           39 :     for (size_t k = 0; k < anc_len; k++) {
    1192           32 :         if (name[k] == sep) {
    1193            1 :             if (sep_cnt++ == level - 1) { parent_len = k; break; }
    1194              :         }
    1195              :     }
    1196            8 :     if (level == 0) parent_len = 0;
    1197              : 
    1198              :     /* Last item in ancestor's subtree */
    1199            8 :     int last = i;
    1200           13 :     for (int j = i + 1; j < count; j++) {
    1201            8 :         if (strncmp(names[j], name, anc_len) == 0 &&
    1202            5 :             (names[j][anc_len] == sep || names[j][anc_len] == '\0'))
    1203            5 :             last = j;
    1204              :         else
    1205              :             break;
    1206              :     }
    1207              : 
    1208              :     /* After subtree, look for sibling of ancestor */
    1209            8 :     for (int j = last + 1; j < count; j++) {
    1210            3 :         if (parent_len == 0)
    1211            2 :             return 0; /* another root-level item */
    1212            1 :         if (strlen(names[j]) > parent_len &&
    1213            1 :             strncmp(names[j], name, parent_len) == 0 &&
    1214            0 :             names[j][parent_len] == sep)
    1215            0 :             return 0;
    1216            1 :         return 1;
    1217              :     }
    1218            5 :     return 1;
    1219              : }
    1220              : 
    1221              : /** Returns 1 if folder `name` has any direct or indirect children. */
    1222            0 : static int folder_has_children(char **names, int count, const char *name, char sep) {
    1223            0 :     size_t len = strlen(name);
    1224            0 :     for (int i = 0; i < count; i++)
    1225            0 :         if (strncmp(names[i], name, len) == 0 && names[i][len] == sep)
    1226            0 :             return 1;
    1227            0 :     return 0;
    1228              : }
    1229              : 
    1230              : /** Sum unseen/flagged/messages for a folder and all its descendants. */
    1231            0 : static void sum_subtree(char **names, int count, char sep,
    1232              :                         const char *prefix, const FolderStatus *statuses,
    1233              :                         int *msgs_out, int *unseen_out, int *flagged_out) {
    1234            0 :     size_t plen = strlen(prefix);
    1235            0 :     int msgs = 0, unseen = 0, flagged = 0;
    1236            0 :     for (int i = 0; i < count; i++) {
    1237            0 :         const char *n = names[i];
    1238            0 :         if (strcmp(n, prefix) == 0 ||
    1239            0 :             (strncmp(n, prefix, plen) == 0 && n[plen] == sep)) {
    1240            0 :             msgs   += statuses ? statuses[i].messages : 0;
    1241            0 :             unseen += statuses ? statuses[i].unseen   : 0;
    1242            0 :             flagged+= statuses ? statuses[i].flagged  : 0;
    1243              :         }
    1244              :     }
    1245            0 :     *msgs_out   = msgs;
    1246            0 :     *unseen_out = unseen;
    1247            0 :     *flagged_out= flagged;
    1248            0 : }
    1249              : 
    1250              : /**
    1251              :  * Build filtered index (into names[]) of direct children of `prefix`.
    1252              :  * prefix="" means root level (folders with no sep in their name).
    1253              :  * Returns number of visible entries written into vis_out[].
    1254              :  */
    1255            0 : static int build_flat_view(char **names, int count, char sep,
    1256              :                            const char *prefix, int *vis_out) {
    1257            0 :     int vcount = 0;
    1258            0 :     size_t plen = strlen(prefix);
    1259            0 :     for (int i = 0; i < count; i++) {
    1260            0 :         const char *name = names[i];
    1261            0 :         if (plen == 0) {
    1262            0 :             if (strchr(name, sep) == NULL)
    1263            0 :                 vis_out[vcount++] = i;
    1264              :         } else {
    1265            0 :             if (strncmp(name, prefix, plen) == 0 && name[plen] == sep &&
    1266            0 :                 strchr(name + plen + 1, sep) == NULL)
    1267            0 :                 vis_out[vcount++] = i;
    1268              :         }
    1269              :     }
    1270            0 :     return vcount;
    1271              : }
    1272              : 
    1273              : /** Print one folder item with its tree/flat prefix and optional selection highlight. */
    1274              : /* Flat mode column layout: Unread | Flagged | Folder | Total
    1275              :  * name_w: width of the folder name column (ignored in tree mode).
    1276              :  * flagged: number of flagged messages (0 = blank cell). */
    1277            0 : static void print_folder_item(char **names, int count, int i, char sep,
    1278              :                                int tree_mode, int selected, int has_kids,
    1279              :                                int messages, int unseen, int flagged, int name_w) {
    1280            0 :     if (selected)
    1281            0 :         printf("\033[7m");
    1282            0 :     else if (messages == 0)
    1283            0 :         printf("\033[2m");          /* dim: empty folder */
    1284              : 
    1285            0 :     if (tree_mode) {
    1286              :         /* Build "tree-prefix + component-name" into name_buf for column layout */
    1287            0 :         char name_buf[512];
    1288            0 :         int pos = 0;
    1289            0 :         int depth = 0;
    1290            0 :         for (const char *p = names[i]; *p; p++)
    1291            0 :             if (*p == sep) depth++;
    1292            0 :         for (int lv = 0; lv < depth; lv++) {
    1293            0 :             int anc_last = ancestor_is_last(names, count, i, lv, sep);
    1294            0 :             const char *branch = anc_last ? "    " : "\u2502   ";
    1295            0 :             int blen = (int)strlen(branch);
    1296            0 :             if (pos + blen < (int)sizeof(name_buf) - 1) {
    1297            0 :                 memcpy(name_buf + pos, branch, blen);
    1298            0 :                 pos += blen;
    1299              :             }
    1300              :         }
    1301            0 :         int last = is_last_sibling(names, count, i, sep);
    1302            0 :         const char *conn = last ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ";
    1303            0 :         int clen = (int)strlen(conn);
    1304            0 :         if (pos + clen < (int)sizeof(name_buf) - 1) {
    1305            0 :             memcpy(name_buf + pos, conn, clen);
    1306            0 :             pos += clen;
    1307              :         }
    1308            0 :         const char *comp = strrchr(names[i], sep);
    1309            0 :         snprintf(name_buf + pos, sizeof(name_buf) - pos, "%s",
    1310            0 :                  comp ? comp + 1 : names[i]);
    1311            0 :         char u[16], f[16], t[16];
    1312            0 :         fmt_thou(u, sizeof(u), unseen);
    1313            0 :         fmt_thou(f, sizeof(f), flagged);
    1314            0 :         fmt_thou(t, sizeof(t), messages);
    1315            0 :         printf("  %6s  %7s  %-*s  %7s", u, f,
    1316            0 :                name_w + utf8_extra_bytes(name_buf), name_buf, t);
    1317              :     } else {
    1318              :         /* Flat mode: Unread | Flagged | Folder | Total */
    1319            0 :         const char *comp    = strrchr(names[i], sep);
    1320            0 :         const char *display = comp ? comp + 1 : names[i];
    1321            0 :         char name_buf[256];
    1322            0 :         snprintf(name_buf, sizeof(name_buf), "%s%s", display, has_kids ? "/" : "");
    1323            0 :         char u[16], f[16], t[16];
    1324            0 :         fmt_thou(u, sizeof(u), unseen);
    1325            0 :         fmt_thou(f, sizeof(f), flagged);
    1326            0 :         fmt_thou(t, sizeof(t), messages);
    1327            0 :         printf("  %6s  %7s  %-*s  %7s", u, f,
    1328            0 :                name_w + utf8_extra_bytes(name_buf), name_buf, t);
    1329              :     }
    1330              : 
    1331            0 :     if (selected) printf("\033[K\033[0m");
    1332            0 :     else if (messages == 0) printf("\033[0m");
    1333            0 :     printf("\n");
    1334            0 : }
    1335              : 
    1336            1 : static void render_folder_tree(char **names, int count, char sep,
    1337              :                                 const FolderStatus *statuses) {
    1338            1 :     int name_w = 40;
    1339            1 :     printf("  %6s  %7s  %-*s  %7s\n", "Unread", "Flagged", name_w, "Folder", "Total");
    1340            1 :     printf("  \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    1341            1 :     print_dbar(name_w);
    1342            1 :     printf("  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
    1343              : 
    1344            5 :     for (int i = 0; i < count; i++) {
    1345            4 :         int unseen   = statuses ? statuses[i].unseen   : 0;
    1346            4 :         int flagged  = statuses ? statuses[i].flagged  : 0;
    1347            4 :         int messages = statuses ? statuses[i].messages : 0;
    1348              : 
    1349              :         /* Build "tree-prefix + component-name" */
    1350            4 :         char name_buf[512];
    1351            4 :         int pos = 0;
    1352            4 :         int depth = 0;
    1353           41 :         for (const char *p = names[i]; *p; p++)
    1354           37 :             if (*p == sep) depth++;
    1355            7 :         for (int lv = 0; lv < depth; lv++) {
    1356            3 :             int anc_last = ancestor_is_last(names, count, i, lv, sep);
    1357            3 :             const char *branch = anc_last ? "    " : "\u2502   ";
    1358            3 :             int blen = (int)strlen(branch);
    1359            3 :             if (pos + blen < (int)sizeof(name_buf) - 1) {
    1360            3 :                 memcpy(name_buf + pos, branch, blen);
    1361            3 :                 pos += blen;
    1362              :             }
    1363              :         }
    1364            4 :         int last = is_last_sibling(names, count, i, sep);
    1365            4 :         const char *conn = last ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ";
    1366            4 :         int clen = (int)strlen(conn);
    1367            4 :         if (pos + clen < (int)sizeof(name_buf) - 1) {
    1368            4 :             memcpy(name_buf + pos, conn, clen);
    1369            4 :             pos += clen;
    1370              :         }
    1371            4 :         const char *comp = strrchr(names[i], sep);
    1372            4 :         snprintf(name_buf + pos, sizeof(name_buf) - pos, "%s",
    1373            1 :                  comp ? comp + 1 : names[i]);
    1374              : 
    1375            4 :         char u[16], f[16], t[16];
    1376            4 :         fmt_thou(u, sizeof(u), unseen);
    1377            4 :         fmt_thou(f, sizeof(f), flagged);
    1378            4 :         fmt_thou(t, sizeof(t), messages);
    1379            4 :         int nw = name_w + utf8_extra_bytes(name_buf);
    1380            4 :         if (messages == 0)
    1381            3 :             printf("\033[2m  %6s  %7s  %-*s  %7s\033[0m\n", u, f, nw, name_buf, t);
    1382              :         else
    1383            1 :             printf("  %6s  %7s  %-*s  %7s\n", u, f, nw, name_buf, t);
    1384              :     }
    1385            1 : }
    1386              : 
    1387              : /* ── Public API ──────────────────────────────────────────────────────── */
    1388              : 
    1389              : /**
    1390              :  * Case-insensitive match of `name` against the cached server folder list.
    1391              :  * Returns a heap-allocated canonical name if the case differs, or NULL if
    1392              :  * the name is already canonical (or the cache is unavailable).
    1393              :  * Caller must free() the returned string.
    1394              :  */
    1395           45 : static char *resolve_folder_name_dup(const char *name) {
    1396           45 :     int fcount = 0;
    1397           45 :     char **fl = local_folder_list_load(&fcount, NULL);
    1398           45 :     if (!fl) return NULL;
    1399            5 :     char *result = NULL;
    1400           25 :     for (int i = 0; i < fcount; i++) {
    1401           20 :         if (strcasecmp(fl[i], name) == 0 && strcmp(fl[i], name) != 0) {
    1402            0 :             result = strdup(fl[i]);   /* canonical differs from input */
    1403            0 :             break;
    1404              :         }
    1405              :     }
    1406           25 :     for (int i = 0; i < fcount; i++) free(fl[i]);
    1407            5 :     free(fl);
    1408            5 :     return result;
    1409              : }
    1410              : 
    1411           45 : int email_service_list(const Config *cfg, EmailListOpts *opts) {
    1412              :     /* Always re-initialise the local store so the correct account's manifests
    1413              :      * and header cache are used, regardless of which account was active before. */
    1414           45 :     local_store_init(cfg->host, cfg->user);
    1415              : 
    1416           45 :     const char *raw_folder = opts->folder ? opts->folder : cfg->folder;
    1417              : 
    1418              :     /* Normalise to the server-canonical name so the manifest key matches
    1419              :      * what sync stored (e.g. config "Inbox" → server "INBOX"). */
    1420           90 :     RAII_STRING char *folder_canonical = resolve_folder_name_dup(raw_folder);
    1421           45 :     const char *folder = folder_canonical ? folder_canonical : raw_folder;
    1422              : 
    1423           45 :     int list_result = 0;
    1424              : 
    1425           45 :     logger_log(LOG_INFO, "Listing %s @ %s/%s", cfg->user, cfg->host, folder);
    1426              : 
    1427              :     /* Load manifest (needed in both online and cron modes) */
    1428           45 :     Manifest *manifest = manifest_load(folder);
    1429           45 :     if (!manifest) {
    1430           28 :         manifest = calloc(1, sizeof(Manifest));
    1431           28 :         if (!manifest) { return -1; }
    1432              :     }
    1433              : 
    1434           45 :     int show_count = 0;
    1435           45 :     int unseen_count = 0;
    1436           45 :     MsgEntry *entries = NULL;
    1437              : 
    1438              :     /* Shared IMAP connection — populated in online mode, NULL in cron mode.
    1439              :      * Kept alive for the full rendering loop so header fetches reuse it. */
    1440           90 :     RAII_IMAP ImapClient *list_imap = NULL;
    1441              : 
    1442           45 :     if (cfg->sync_interval > 0) {
    1443              :         /* ── Cron / cache-only mode: serve entirely from manifest ──────── */
    1444            0 :         if (manifest->count == 0) {
    1445            0 :             manifest_free(manifest);
    1446            0 :             if (!opts->pager) {
    1447            0 :                 printf("No cached data for %s. Run 'email-cli sync' first.\n", folder);
    1448            0 :                 return 0;
    1449              :             }
    1450            0 :             RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    1451            0 :             printf("\033[H\033[2J");
    1452            0 :             printf("No cached data for %s.\n\n", folder);
    1453            0 :             printf("Run 'email-cli sync' to download messages.\n\n");
    1454            0 :             printf("\033[2m  Backspace=folders  ESC=quit\033[0m\n");
    1455            0 :             fflush(stdout);
    1456            0 :             for (;;) {
    1457            0 :                 TermKey key = terminal_read_key();
    1458            0 :                 if (key == TERM_KEY_BACK) return 1;
    1459            0 :                 if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) return 0;
    1460              :             }
    1461              :         }
    1462            0 :         show_count = manifest->count;
    1463            0 :         entries = malloc((size_t)show_count * sizeof(MsgEntry));
    1464            0 :         if (!entries) { manifest_free(manifest); return -1; }
    1465            0 :         for (int i = 0; i < show_count; i++) {
    1466            0 :             entries[i].uid   = manifest->entries[i].uid;
    1467            0 :             entries[i].flags = manifest->entries[i].flags;
    1468            0 :             entries[i].epoch = parse_manifest_date(manifest->entries[i].date);
    1469              :         }
    1470            0 :         for (int i = 0; i < show_count; i++)
    1471            0 :             if (entries[i].flags & MSG_FLAG_UNSEEN) unseen_count++;
    1472              :     } else {
    1473              :         /* ── Online mode: contact the server ───────────────────────────── */
    1474              : 
    1475              :         /* Fetch UNSEEN and ALL UID sets via a shared IMAP connection. */
    1476           45 :         list_imap = make_imap(cfg);
    1477           45 :         if (!list_imap) {
    1478            0 :             manifest_free(manifest);
    1479            0 :             fprintf(stderr, "Failed to connect.\n");
    1480            0 :             return -1;
    1481              :         }
    1482           45 :         if (imap_select(list_imap, folder) != 0) {
    1483            0 :             manifest_free(manifest);
    1484            0 :             fprintf(stderr, "Failed to select folder %s.\n", folder);
    1485            0 :             return -1;
    1486              :         }
    1487              : 
    1488           45 :         int *unseen_uids = NULL;
    1489           45 :         int  unseen_uid_count = 0;
    1490           45 :         if (imap_uid_search(list_imap, "UNSEEN", &unseen_uids, &unseen_uid_count) != 0) {
    1491            0 :             manifest_free(manifest);
    1492            0 :             fprintf(stderr, "Failed to search mailbox.\n");
    1493            0 :             return -1;
    1494              :         }
    1495              : 
    1496           45 :         int *flagged_uids = NULL, flagged_count = 0;
    1497           45 :         imap_uid_search(list_imap, "FLAGGED", &flagged_uids, &flagged_count);
    1498              :         /* ignore errors — treat as 0 flagged */
    1499              : 
    1500           45 :         int *done_uids = NULL, done_count = 0;
    1501           45 :         imap_uid_search(list_imap, "KEYWORD $Done", &done_uids, &done_count);
    1502              :         /* ignore errors — treat as 0 done */
    1503              : 
    1504           45 :         int *all_uids  = NULL;
    1505           45 :         int  all_count = 0;
    1506           45 :         if (imap_uid_search(list_imap, "ALL", &all_uids, &all_count) != 0) {
    1507            0 :             free(unseen_uids);
    1508            0 :             free(flagged_uids);
    1509            0 :             free(done_uids);
    1510            0 :             manifest_free(manifest);
    1511            0 :             fprintf(stderr, "Failed to search mailbox.\n");
    1512            0 :             return -1;
    1513              :         }
    1514              :         /* Evict headers for messages deleted from the server */
    1515           45 :         if (all_count > 0)
    1516           41 :             local_hdr_evict_stale(folder, all_uids, all_count);
    1517              : 
    1518              :         /* Remove entries for UIDs deleted from the server */
    1519           45 :         if (all_count > 0)
    1520           41 :             manifest_retain(manifest, all_uids, all_count);
    1521              : 
    1522           45 :         show_count = all_count;
    1523              : 
    1524           45 :         if (show_count == 0) {
    1525            4 :             manifest_free(manifest);
    1526            4 :             free(unseen_uids);
    1527            4 :             free(flagged_uids);
    1528            4 :             free(done_uids);
    1529            4 :             free(all_uids);
    1530            4 :             if (!opts->pager) {
    1531            4 :                 printf("No messages in %s.\n", folder);
    1532            4 :                 return 0;
    1533              :             }
    1534              :             /* Interactive mode: show empty-folder screen and wait for input.
    1535              :              * Returning immediately would drop the user back to the OS — instead
    1536              :              * let them navigate away with Backspace (→ folder list) or ESC/^C. */
    1537            0 :             RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    1538            0 :             printf("\033[H\033[2J");
    1539            0 :             printf("No messages in %s.\n\n", folder);
    1540            0 :             printf("\033[2m  Backspace=folders  ESC=quit\033[0m\n");
    1541            0 :             fflush(stdout);
    1542            0 :             for (;;) {
    1543            0 :                 TermKey key = terminal_read_key();
    1544            0 :                 if (key == TERM_KEY_BACK)  return 1; /* go to folder list */
    1545            0 :                 if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) return 0;
    1546              :             }
    1547              :         }
    1548              : 
    1549              :         /* Build tagged entry array */
    1550           41 :         entries = malloc((size_t)show_count * sizeof(MsgEntry));
    1551           41 :         if (!entries) { free(unseen_uids); free(flagged_uids); free(done_uids); free(all_uids); manifest_free(manifest); return -1; }
    1552              : 
    1553           82 :         for (int i = 0; i < show_count; i++) {
    1554           41 :             entries[i].uid   = all_uids[i];
    1555           41 :             entries[i].flags = 0;
    1556           41 :             for (int j = 0; j < unseen_uid_count;  j++)
    1557           41 :                 if (unseen_uids[j]  == all_uids[i]) { entries[i].flags |= MSG_FLAG_UNSEEN;  break; }
    1558           41 :             for (int j = 0; j < flagged_count; j++)
    1559           41 :                 if (flagged_uids[j] == all_uids[i]) { entries[i].flags |= MSG_FLAG_FLAGGED; break; }
    1560           41 :             for (int j = 0; j < done_count;    j++)
    1561           41 :                 if (done_uids[j]    == all_uids[i]) { entries[i].flags |= MSG_FLAG_DONE;    break; }
    1562              :             /* Try to get date from cached manifest (may be 0 if not yet fetched) */
    1563           41 :             ManifestEntry *me = manifest_find(manifest, all_uids[i]);
    1564           41 :             entries[i].epoch = me ? parse_manifest_date(me->date) : 0;
    1565              :         }
    1566              :         /* Compute unseen_count for the status line */
    1567           82 :         for (int i = 0; i < show_count; i++)
    1568           41 :             if (entries[i].flags & MSG_FLAG_UNSEEN) unseen_count++;
    1569           41 :         free(unseen_uids);
    1570           41 :         free(flagged_uids);
    1571           41 :         free(done_uids);
    1572           41 :         free(all_uids);
    1573              :     }
    1574              : 
    1575           41 :     if (show_count == 0) {
    1576            0 :         manifest_free(manifest);
    1577            0 :         free(entries);
    1578            0 :         if (!opts->pager) {
    1579            0 :             printf("No messages in %s.\n", folder);
    1580            0 :             return 0;
    1581              :         }
    1582            0 :         RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    1583            0 :         printf("\033[H\033[2J");
    1584            0 :         printf("No messages in %s.\n\n", folder);
    1585            0 :         printf("\033[2m  Backspace=folders  ESC=quit\033[0m\n");
    1586            0 :         fflush(stdout);
    1587            0 :         for (;;) {
    1588            0 :             TermKey key = terminal_read_key();
    1589            0 :             if (key == TERM_KEY_BACK)  return 1;
    1590            0 :             if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) return 0;
    1591              :         }
    1592              :     }
    1593              : 
    1594              :     /* Sort: unseen → flagged → rest, within each group newest (highest UID) first */
    1595           41 :     qsort(entries, (size_t)show_count, sizeof(MsgEntry), cmp_uid_entry);
    1596              : 
    1597           41 :     int limit  = (opts->limit > 0) ? opts->limit : show_count;
    1598           41 :     int cursor = (opts->offset > 1) ? opts->offset - 1 : 0;
    1599           41 :     if (cursor >= show_count) cursor = 0;
    1600           41 :     int wstart = cursor;   /* top of the visible window */
    1601              : 
    1602              :     /* Keep the terminal in raw mode for the entire interactive TUI.
    1603              :      * Without this, each terminal_read_key() call would need to briefly enter
    1604              :      * and exit raw mode per keystroke, which causes escape sequence echo and
    1605              :      * ICANON buffering artefacts.  terminal_read_key() requires raw mode to
    1606              :      * already be active — we enter it once here and exit at list_done. */
    1607           82 :     RAII_TERM_RAW TermRawState *tui_raw = opts->pager
    1608            0 :                                           ? terminal_raw_enter()
    1609           41 :                                           : NULL;
    1610              : 
    1611            0 :     for (;;) {
    1612              :         /* Scroll window to keep cursor visible */
    1613           41 :         if (cursor < wstart)             wstart = cursor;
    1614           41 :         if (cursor >= wstart + limit)    wstart = cursor - limit + 1;
    1615           41 :         if (wstart < 0)                  wstart = 0;
    1616           41 :         int wend = wstart + limit;
    1617           41 :         if (wend > show_count)           wend = show_count;
    1618              : 
    1619              :         /* Compute adaptive column widths.
    1620              :          * Fixed overhead per data row: "  UID  "(7) + "  DATE  "(18) + "  Sts   "(8) = 35
    1621              :          * subj_w gets ~60% of remaining space, from_w ~40%. */
    1622           41 :         int tcols    = terminal_cols();
    1623           41 :         int overhead = 35;
    1624           41 :         int avail    = tcols - overhead;
    1625           41 :         if (avail < 40) avail = 40;
    1626           41 :         int subj_w = avail * 3 / 5;
    1627           41 :         int from_w = avail - subj_w;
    1628              : 
    1629           41 :         if (opts->pager) printf("\033[H\033[2J");
    1630              : 
    1631              :         /* Count / status line — reverse video, padded to full terminal width */
    1632              :         {
    1633           41 :             char cl[512];
    1634           41 :             int sync = sync_is_running();
    1635              :             const char *suffix;
    1636           41 :             if (bg_sync_done)
    1637            0 :                 suffix = "  \u2709 New mail may have arrived!  R=refresh";
    1638           41 :             else if (sync)
    1639            0 :                 suffix = "  \u21bb syncing...";
    1640              :             else
    1641           41 :                 suffix = "";
    1642           41 :             snprintf(cl, sizeof(cl),
    1643              :                      "  %d-%d of %d message(s) in %s (%d unread) [%s].%s",
    1644              :                      wstart + 1, wend, show_count, folder, unseen_count,
    1645           41 :                      cfg->user ? cfg->user : "?", suffix);
    1646           41 :             printf("\033[7m%s", cl);
    1647           41 :             int used = visible_line_cols(cl, cl + strlen(cl));
    1648          800 :             for (int p = used; p < tcols; p++) putchar(' ');
    1649           41 :             printf("\033[0m\n\n");
    1650              :         }
    1651           41 :         printf("  %5s  %-16s  %-4s  %-*s  %s\n",
    1652              :                "UID", "Date", "Sts", subj_w, "Subject", "From");
    1653           41 :         printf("  \u2550\u2550\u2550\u2550\u2550  ");
    1654           41 :         print_dbar(16); printf("  ");
    1655           41 :         printf("\u2550\u2550\u2550\u2550  ");
    1656           41 :         print_dbar(subj_w); printf("  ");
    1657           41 :         print_dbar(from_w); printf("\n");
    1658              : 
    1659              :         /* Data rows: fetch-on-demand + immediate render per row */
    1660           41 :         int manifest_dirty = 0;
    1661           41 :         int load_interrupted = 0;
    1662           82 :         for (int i = wstart; i < wend; i++) {
    1663              :             /* Fetch into manifest if missing; always sync unseen flag */
    1664           41 :             ManifestEntry *cached_me = manifest_find(manifest, entries[i].uid);
    1665           41 :             if (!cached_me) {
    1666              :                 /* Check for user interrupt before slow network fetch */
    1667           24 :                 if (opts->pager) {
    1668            0 :                     struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN};
    1669            0 :                     if (poll(&pfd, 1, 0) > 0) {
    1670            0 :                         TermKey key = terminal_read_key();
    1671            0 :                         if (key == TERM_KEY_BACK) {
    1672            0 :                             list_result = 1; load_interrupted = 1; break;
    1673              :                         }
    1674            0 :                         if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) {
    1675            0 :                             list_result = 0; load_interrupted = 1; break;
    1676              :                         }
    1677              :                     }
    1678              :                 }
    1679           24 :                 char *hdrs     = list_imap
    1680           24 :                                  ? fetch_uid_headers_via(list_imap, folder, entries[i].uid)
    1681           24 :                                  : fetch_uid_headers_cached(cfg, folder, entries[i].uid);
    1682           24 :                 char *fr_raw   = hdrs ? mime_get_header(hdrs, "From")    : NULL;
    1683           24 :                 char *fr       = fr_raw ? mime_decode_words(fr_raw)      : strdup("");
    1684           24 :                 free(fr_raw);
    1685           24 :                 char *su_raw   = hdrs ? mime_get_header(hdrs, "Subject") : NULL;
    1686           24 :                 char *su       = su_raw ? mime_decode_words(su_raw)      : strdup("");
    1687           24 :                 free(su_raw);
    1688           24 :                 char *dt_raw   = hdrs ? mime_get_header(hdrs, "Date")    : NULL;
    1689           24 :                 char *dt       = dt_raw ? mime_format_date(dt_raw)       : strdup("");
    1690           24 :                 free(dt_raw);
    1691              :                 /* Detect attachment: Content-Type: multipart/mixed */
    1692           24 :                 char *ct_raw = hdrs ? mime_get_header(hdrs, "Content-Type") : NULL;
    1693           24 :                 if (ct_raw && strcasestr(ct_raw, "multipart/mixed"))
    1694            0 :                     entries[i].flags |= MSG_FLAG_ATTACH;
    1695           24 :                 free(ct_raw);
    1696           24 :                 free(hdrs);
    1697           24 :                 manifest_upsert(manifest, entries[i].uid, fr, su, dt, entries[i].flags);
    1698           24 :                 manifest_dirty = 1;
    1699           17 :             } else if (cached_me->flags != entries[i].flags) {
    1700              :                 /* Keep manifest flags in sync (relevant in online mode) */
    1701            0 :                 cached_me->flags = entries[i].flags;
    1702            0 :                 manifest_dirty = 1;
    1703              :             }
    1704              : 
    1705              :             /* Render this row immediately */
    1706           41 :             ManifestEntry *me = manifest_find(manifest, entries[i].uid);
    1707           41 :             const char *from    = (me && me->from    && me->from[0])    ? me->from    : "(no from)";
    1708           41 :             const char *subject = (me && me->subject && me->subject[0]) ? me->subject : "(no subject)";
    1709           41 :             const char *date    = (me && me->date)                       ? me->date    : "";
    1710              : 
    1711           41 :             int sel = opts->pager && (i == cursor);
    1712           41 :             if (sel) printf("\033[7m");
    1713              : 
    1714          205 :             char sts[5] = {
    1715           41 :                 (entries[i].flags & MSG_FLAG_UNSEEN)  ? 'N' : '-',
    1716           41 :                 (entries[i].flags & MSG_FLAG_FLAGGED) ? '*' : '-',
    1717           41 :                 (entries[i].flags & MSG_FLAG_DONE)    ? 'D' : '-',
    1718           41 :                 (entries[i].flags & MSG_FLAG_ATTACH)  ? 'A' : '-',
    1719              :                 '\0'
    1720              :             };
    1721           41 :             printf("  %5d  %-16.16s  %s  ", entries[i].uid, date, sts);
    1722           41 :             print_padded_col(subject, subj_w);
    1723           41 :             printf("  ");
    1724           41 :             print_padded_col(from,    from_w);
    1725              : 
    1726           41 :             if (sel) printf("\033[K\033[0m");
    1727           41 :             printf("\n");
    1728           41 :             fflush(stdout); /* show row immediately as it arrives */
    1729              :         }
    1730           41 :         if (manifest_dirty) manifest_save(folder, manifest);
    1731           41 :         if (load_interrupted) goto list_done;
    1732              : 
    1733           41 :         if (!opts->pager) {
    1734           41 :             if (wend < show_count)
    1735            0 :                 printf("\n  -- %d more message(s) --  use --offset %d for next page\n",
    1736              :                        show_count - wend, wend + 1);
    1737           41 :             break;
    1738              :         }
    1739              : 
    1740              :         /* Navigation hint (status bar) — anchored at last terminal row */
    1741            0 :         fflush(stdout);
    1742              :         {
    1743            0 :             int trows = terminal_rows();
    1744            0 :             if (trows <= 0) trows = limit + 6;
    1745            0 :             char sb[256];
    1746            0 :             snprintf(sb, sizeof(sb),
    1747              :                      "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    1748              :                      "  Backspace=folders  ESC=quit"
    1749              :                      "  c=compose  r=reply  n=new  f=flag  d=done"
    1750              :                      "  s=sync  R=refresh  [%d/%d]",
    1751              :                      cursor + 1, show_count);
    1752            0 :             print_statusbar(trows, tcols, sb);
    1753              :         }
    1754              : 
    1755              :         /* terminal_read_key() blocks in read().  When the background sync child
    1756              :          * exits, SIGCHLD fires (SA_RESTART not set) and interrupts read() with
    1757              :          * EINTR — terminal_read_key() returns TERM_KEY_IGNORE (last_printable=0).
    1758              :          * We detect this by checking whether bg_sync_done changed, and if so
    1759              :          * jump back here to wait for the next real keypress without re-rendering.
    1760              :          * The notification will appear the next time the user presses a key. */
    1761            0 : read_key_again: ;
    1762            0 :         int prev_sync_done = bg_sync_done;
    1763            0 :         TermKey key = terminal_read_key();
    1764            0 :         fprintf(stderr, "\r\033[K"); fflush(stderr);
    1765            0 :         if (bg_sync_done && !prev_sync_done) {
    1766            0 :             goto read_key_again; /* SIGCHLD woke us — wait for real keypress */
    1767              :         }
    1768              : 
    1769            0 :         switch (key) {
    1770            0 :         case TERM_KEY_BACK:
    1771            0 :             list_result = 1;
    1772            0 :             goto list_done;
    1773            0 :         case TERM_KEY_QUIT:
    1774              :         case TERM_KEY_ESC:
    1775            0 :             goto list_done;
    1776            0 :         case TERM_KEY_ENTER:
    1777              :             {
    1778            0 :                 int ret = show_uid_interactive(cfg, folder, entries[cursor].uid, opts->limit);
    1779            0 :                 if (ret == 1) goto list_done;  /* user quit from show */
    1780            0 :                 if (ret == 2) {
    1781              :                     /* 'r' pressed in reader → reply to this message */
    1782            0 :                     opts->action_uid = entries[cursor].uid;
    1783            0 :                     list_result = 3;
    1784            0 :                     goto list_done;
    1785              :                 }
    1786              :                 /* ret == 0: Backspace → back to list; ret == -1: error → stay */
    1787              :             }
    1788            0 :             break;
    1789            0 :         case TERM_KEY_LEFT:
    1790              :         case TERM_KEY_RIGHT:
    1791              :         case TERM_KEY_HOME:
    1792              :         case TERM_KEY_END:
    1793              :         case TERM_KEY_DELETE:
    1794              :         case TERM_KEY_TAB:
    1795              :         case TERM_KEY_SHIFT_TAB:
    1796              :         case TERM_KEY_IGNORE: {
    1797            0 :             int ch = terminal_last_printable();
    1798            0 :             if (ch == 'c') {
    1799            0 :                 list_result = 2;
    1800            0 :                 goto list_done;
    1801              :             }
    1802            0 :             if (ch == 'r') {
    1803            0 :                 opts->action_uid = entries[cursor].uid;
    1804            0 :                 list_result = 3;
    1805            0 :                 goto list_done;
    1806              :             }
    1807            0 :             if (ch == 's') {
    1808            0 :                 sync_start_background();
    1809            0 :                 break; /* re-render: shows ⟳ syncing... indicator */
    1810              :             }
    1811            0 :             if (ch == 'R') {
    1812              :                 /* Explicit refresh after sync notification */
    1813            0 :                 bg_sync_done = 0;
    1814            0 :                 list_result = 4;
    1815            0 :                 goto list_done;
    1816              :             }
    1817            0 :             if (ch == 'h' || ch == '?') {
    1818              :                 static const char *help[][2] = {
    1819              :                     { "\u2191 / \u2193",   "Move cursor up / down"               },
    1820              :                     { "PgUp / PgDn",        "Move cursor one page up / down"      },
    1821              :                     { "Enter",             "Open selected message"               },
    1822              :                     { "r",                 "Reply to selected message"           },
    1823              :                     { "c",                 "Compose new message"                 },
    1824              :                     { "n",                 "Toggle New (unread) flag"            },
    1825              :                     { "f",                 "Toggle Flagged (starred) flag"       },
    1826              :                     { "d",                 "Toggle Done flag"                    },
    1827              :                     { "s",                 "Start background sync"               },
    1828              :                     { "R",                 "Refresh after sync"                  },
    1829              :                     { "Backspace",         "Open folder browser"                 },
    1830              :                     { "ESC / q",           "Quit"                                },
    1831              :                     { "h / ?",             "Show this help"                      },
    1832              :                 };
    1833            0 :                 show_help_popup("Message list shortcuts",
    1834              :                                 help, (int)(sizeof(help)/sizeof(help[0])));
    1835            0 :                 break;
    1836              :             }
    1837            0 :             if (ch == 'n' || ch == 'f' || ch == 'd') {
    1838            0 :                 int uid  = entries[cursor].uid;
    1839              :                 int bit;
    1840              :                 const char *flag_name;
    1841            0 :                 if (ch == 'n') {
    1842            0 :                     bit = MSG_FLAG_UNSEEN;  flag_name = "\\Seen";
    1843            0 :                 } else if (ch == 'f') {
    1844            0 :                     bit = MSG_FLAG_FLAGGED; flag_name = "\\Flagged";
    1845              :                 } else {
    1846            0 :                     bit = MSG_FLAG_DONE;    flag_name = "$Done";
    1847              :                 }
    1848            0 :                 int currently = entries[cursor].flags & bit;
    1849              :                 /* Determine the IMAP add/remove direction */
    1850            0 :                 int add_flag = (ch == 'n') ? (currently ? 1 : 0) : (!currently ? 1 : 0);
    1851            0 :                 if (list_imap) {
    1852              :                     /* Online: push immediately */
    1853            0 :                     imap_uid_set_flag(list_imap, uid, flag_name, add_flag);
    1854              :                 }
    1855              :                 /* Always queue for sync (covers offline/cron mode and STORE failures) */
    1856            0 :                 local_pending_flag_add(folder, uid, flag_name, add_flag);
    1857            0 :                 entries[cursor].flags ^= bit;
    1858            0 :                 ManifestEntry *me = manifest_find(manifest, uid);
    1859            0 :                 if (me) me->flags = entries[cursor].flags;
    1860            0 :                 manifest_save(folder, manifest);
    1861              :             }
    1862            0 :             break;
    1863              :         }
    1864            0 :         case TERM_KEY_NEXT_LINE:
    1865            0 :             if (cursor < show_count - 1) cursor++;
    1866            0 :             break;
    1867            0 :         case TERM_KEY_PREV_LINE:
    1868            0 :             if (cursor > 0) cursor--;
    1869            0 :             break;
    1870            0 :         case TERM_KEY_NEXT_PAGE:
    1871            0 :             cursor += limit;
    1872            0 :             if (cursor >= show_count) cursor = show_count - 1;
    1873            0 :             break;
    1874            0 :         case TERM_KEY_PREV_PAGE:
    1875            0 :             cursor -= limit;
    1876            0 :             if (cursor < 0) cursor = 0;
    1877            0 :             break;
    1878              :         }
    1879              :     }
    1880           41 : list_done:
    1881              :     /* tui_raw / folder_canonical cleaned up automatically via RAII macros */
    1882           41 :     manifest_free(manifest);
    1883           41 :     free(entries);
    1884           41 :     return list_result;
    1885              : }
    1886              : 
    1887              : /** Fetch the folder list into a heap-allocated array; caller owns entries and array. */
    1888            1 : static char **fetch_folder_list_from_server(const Config *cfg,
    1889              :                                              int *count_out, char *sep_out) {
    1890            2 :     RAII_IMAP ImapClient *imap = make_imap(cfg);
    1891            1 :     if (!imap) return NULL;
    1892              : 
    1893            1 :     char **folders = NULL;
    1894            1 :     int count = 0;
    1895            1 :     char sep = '.';
    1896            1 :     if (imap_list(imap, &folders, &count, &sep) != 0) return NULL;
    1897              : 
    1898            1 :     *count_out = count;
    1899            1 :     if (sep_out) *sep_out = sep;
    1900            1 :     return folders;
    1901              : }
    1902              : 
    1903            2 : static char **fetch_folder_list(const Config *cfg, int *count_out, char *sep_out) {
    1904              :     /* Try local cache first (populated by sync). */
    1905            2 :     char **cached = local_folder_list_load(count_out, sep_out);
    1906            2 :     if (cached && *count_out > 0) return cached;
    1907            1 :     if (cached) { free(cached); }
    1908              : 
    1909              :     /* Fall back to server. */
    1910            1 :     char sep = '.';
    1911            1 :     char **folders = fetch_folder_list_from_server(cfg, count_out, &sep);
    1912            1 :     if (folders && *count_out > 0) {
    1913            1 :         local_folder_list_save((const char **)folders, *count_out, sep);
    1914            1 :         if (sep_out) *sep_out = sep;
    1915              :     }
    1916            1 :     return folders;
    1917              : }
    1918              : 
    1919            2 : int email_service_list_folders(const Config *cfg, int tree) {
    1920            2 :     int count = 0;
    1921            2 :     char sep = '.';
    1922            2 :     char **folders = fetch_folder_list(cfg, &count, &sep);
    1923              : 
    1924            2 :     if (!folders || count == 0) {
    1925            0 :         printf("No folders found.\n");
    1926            0 :         if (folders) free(folders);
    1927            0 :         return folders ? 0 : -1;
    1928              :     }
    1929              : 
    1930            2 :     qsort(folders, (size_t)count, sizeof(char *), cmp_str);
    1931              : 
    1932            2 :     FolderStatus *statuses = fetch_all_folder_statuses(cfg, folders, count);
    1933              : 
    1934            2 :     if (tree) {
    1935            1 :         render_folder_tree(folders, count, sep, statuses);
    1936              :     } else {
    1937              :         /* Batch flat view: Unread | Flagged | Folder | Total */
    1938            1 :         int name_w = 40;
    1939            1 :         printf("  %6s  %7s  %-*s  %7s\n",
    1940              :                "Unread", "Flagged", name_w, "Folder", "Total");
    1941            1 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    1942            1 :         print_dbar(name_w);
    1943            1 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
    1944            5 :         for (int i = 0; i < count; i++) {
    1945            4 :             int unseen   = statuses ? statuses[i].unseen   : 0;
    1946            4 :             int flagged  = statuses ? statuses[i].flagged  : 0;
    1947            4 :             int messages = statuses ? statuses[i].messages : 0;
    1948            4 :             char u[16], f[16], t[16];
    1949            4 :             fmt_thou(u, sizeof(u), unseen);
    1950            4 :             fmt_thou(f, sizeof(f), flagged);
    1951            4 :             fmt_thou(t, sizeof(t), messages);
    1952            4 :             int nw = name_w + utf8_extra_bytes(folders[i]);
    1953            4 :             if (messages == 0)
    1954            3 :                 printf("\033[2m  %6s  %7s  %-*s  %7s\033[0m\n",
    1955            3 :                        u, f, nw, folders[i], t);
    1956              :             else
    1957            1 :                 printf("  %6s  %7s  %-*s  %7s\n",
    1958            1 :                        u, f, nw, folders[i], t);
    1959              :         }
    1960              :     }
    1961              : 
    1962            2 :     free(statuses);
    1963           10 :     for (int i = 0; i < count; i++) free(folders[i]);
    1964            2 :     free(folders);
    1965            2 :     return 0;
    1966              : }
    1967              : 
    1968            0 : char *email_service_list_folders_interactive(const Config *cfg,
    1969              :                                              const char *current_folder,
    1970              :                                              int *go_up) {
    1971            0 :     local_store_init(cfg->host, cfg->user);
    1972            0 :     if (go_up) *go_up = 0;
    1973            0 :     int count = 0;
    1974            0 :     char sep = '.';
    1975            0 :     char **folders = fetch_folder_list(cfg, &count, &sep);
    1976            0 :     if (!folders || count == 0) {
    1977            0 :         if (folders) free(folders);
    1978            0 :         return NULL;
    1979              :     }
    1980              : 
    1981            0 :     qsort(folders, (size_t)count, sizeof(char *), cmp_str);
    1982              : 
    1983            0 :     FolderStatus *statuses = fetch_all_folder_statuses(cfg, folders, count);
    1984              : 
    1985            0 :     int *vis = malloc((size_t)count * sizeof(int));
    1986            0 :     if (!vis) {
    1987            0 :         free(statuses);
    1988            0 :         for (int i = 0; i < count; i++) free(folders[i]);
    1989            0 :         free(folders);
    1990            0 :         return NULL;
    1991              :     }
    1992              : 
    1993            0 :     int cursor = 0, wstart = 0;
    1994            0 :     int tree_mode = ui_pref_get_int("folder_view_mode", 1);
    1995            0 :     char current_prefix[512] = "";   /* flat mode: current navigation level */
    1996              : 
    1997              :     /* Pre-position cursor on current_folder.
    1998              :      * INBOX is case-insensitive per RFC 3501 — use strcasecmp so that a
    1999              :      * config value of "inbox" still matches the server's "INBOX". */
    2000            0 :     if (current_folder && *current_folder) {
    2001            0 :         if (tree_mode) {
    2002              :             /* In tree mode the flat view is folders[0..count-1] directly */
    2003            0 :             for (int i = 0; i < count; i++) {
    2004            0 :                 if (strcasecmp(folders[i], current_folder) == 0) {
    2005            0 :                     cursor = i; break;
    2006              :                 }
    2007              :             }
    2008              :         } else {
    2009              :             /* In flat mode, navigate to the level that contains current_folder */
    2010            0 :             const char *last = strrchr(current_folder, sep);
    2011            0 :             if (last) {
    2012            0 :                 size_t plen = (size_t)(last - current_folder);
    2013            0 :                 if (plen < sizeof(current_prefix)) {
    2014            0 :                     memcpy(current_prefix, current_folder, plen);
    2015            0 :                     current_prefix[plen] = '\0';
    2016              :                 }
    2017              :             }
    2018            0 :             int tmp_vis[1024];
    2019            0 :             int tv = build_flat_view(folders, count, sep, current_prefix, tmp_vis);
    2020            0 :             for (int i = 0; i < tv; i++) {
    2021            0 :                 if (strcasecmp(folders[tmp_vis[i]], current_folder) == 0) {
    2022            0 :                     cursor = i; break;
    2023              :                 }
    2024              :             }
    2025              :         }
    2026              :     }
    2027            0 :     int vcount = 0;                  /* flat view: number of visible entries */
    2028            0 :     char *selected = NULL;
    2029              : 
    2030            0 :     RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    2031              : 
    2032            0 :     for (;;) {
    2033            0 :         int rows  = terminal_rows();
    2034            0 :         int limit = (rows > 4) ? rows - 3 : 10;
    2035              : 
    2036              :         /* Rebuild flat view on each iteration (alphabetical order) */
    2037              :         int display_count;
    2038            0 :         if (tree_mode) {
    2039            0 :             display_count = count;
    2040              :         } else {
    2041            0 :             vcount = build_flat_view(folders, count, sep, current_prefix, vis);
    2042            0 :             display_count = vcount;
    2043              :         }
    2044            0 :         if (cursor >= display_count && display_count > 0)
    2045            0 :             cursor = display_count - 1;
    2046              : 
    2047            0 :         if (cursor < wstart) wstart = cursor;
    2048            0 :         if (cursor >= wstart + limit) wstart = cursor - limit + 1;
    2049            0 :         int wend = wstart + limit;
    2050            0 :         if (wend > display_count) wend = display_count;
    2051              : 
    2052              :         /* Compute name column width for flat mode */
    2053            0 :         int tcols_f = terminal_cols();
    2054              :         /* Fixed: "  " + 6 (unread) + "  " + 7 (flagged) + "  " + name_w + "  " + 7 (total) = name_w + 28 */
    2055            0 :         int name_w = tcols_f - 28;
    2056            0 :         if (name_w < 20) name_w = 20;
    2057              : 
    2058            0 :         printf("\033[H\033[2J");
    2059            0 :         if (!tree_mode && current_prefix[0])
    2060            0 :             printf("Folders: %s/ (%d)\n\n", current_prefix, display_count);
    2061              :         else
    2062            0 :             printf("Folders (%d)\n\n", display_count);
    2063              : 
    2064              :         /* Column header and separator (both flat and tree mode) */
    2065            0 :         printf("  %6s  %7s  %-*s  %7s\n", "Unread", "Flagged", name_w, "Folder", "Total");
    2066            0 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    2067            0 :         print_dbar(name_w);
    2068            0 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
    2069              : 
    2070            0 :         for (int i = wstart; i < wend; i++) {
    2071            0 :             if (tree_mode) {
    2072            0 :                 int msgs = statuses ? statuses[i].messages : 0;
    2073            0 :                 int unsn = statuses ? statuses[i].unseen   : 0;
    2074            0 :                 int flgd = statuses ? statuses[i].flagged  : 0;
    2075            0 :                 print_folder_item(folders, count, i, sep, 1, i == cursor, 0,
    2076              :                                   msgs, unsn, flgd, name_w);
    2077              :             } else {
    2078            0 :                 int fi = vis[i];
    2079            0 :                 int hk = folder_has_children(folders, count, folders[fi], sep);
    2080            0 :                 int msgs, unsn, flgd;
    2081            0 :                 if (hk) {
    2082              :                     /* Aggregate own + all descendant counts so the user can see
    2083              :                      * total unread/flagged even when children are not expanded. */
    2084            0 :                     sum_subtree(folders, count, sep, folders[fi], statuses,
    2085              :                                 &msgs, &unsn, &flgd);
    2086              :                 } else {
    2087            0 :                     msgs = statuses ? statuses[fi].messages : 0;
    2088            0 :                     unsn = statuses ? statuses[fi].unseen   : 0;
    2089            0 :                     flgd = statuses ? statuses[fi].flagged  : 0;
    2090              :                 }
    2091            0 :                 print_folder_item(folders, count, fi, sep, 0, i == cursor, hk,
    2092              :                                   msgs, unsn, flgd, name_w);
    2093              :             }
    2094              :         }
    2095              : 
    2096            0 :         fflush(stdout);
    2097              :         {
    2098            0 :             int trows_f = terminal_rows();
    2099            0 :             if (trows_f <= 0) trows_f = limit + 4;
    2100            0 :             int tcols_f = terminal_cols();
    2101            0 :             char sb[256];
    2102            0 :             if (!tree_mode && current_prefix[0])
    2103            0 :                 snprintf(sb, sizeof(sb),
    2104              :                          "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open/select"
    2105              :                          "  t=tree  Backspace=up  ESC=quit  [%d/%d]",
    2106              :                          display_count > 0 ? cursor + 1 : 0, display_count);
    2107              :             else
    2108            0 :                 snprintf(sb, sizeof(sb),
    2109              :                          "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open/select"
    2110              :                          "  t=%s  Backspace=back  ESC=quit  [%d/%d]",
    2111              :                          tree_mode ? "flat" : "tree",
    2112              :                          display_count > 0 ? cursor + 1 : 0, display_count);
    2113            0 :             print_statusbar(trows_f, tcols_f, sb);
    2114              :         }
    2115              : 
    2116            0 :         TermKey key = terminal_read_key();
    2117              : 
    2118            0 :         switch (key) {
    2119            0 :         case TERM_KEY_QUIT:
    2120              :         case TERM_KEY_ESC:
    2121            0 :             goto folders_int_done;
    2122            0 :         case TERM_KEY_BACK:
    2123            0 :             if (!tree_mode && current_prefix[0]) {
    2124              :                 /* navigate up one level */
    2125            0 :                 char *last_sep = strrchr(current_prefix, sep);
    2126            0 :                 if (last_sep) *last_sep = '\0';
    2127            0 :                 else          current_prefix[0] = '\0';
    2128            0 :                 cursor = 0; wstart = 0;
    2129              :             } else {
    2130              :                 /* at root: go up to accounts screen (if caller supports it),
    2131              :                  * or return unchanged current folder (legacy behaviour). */
    2132            0 :                 if (go_up) {
    2133            0 :                     *go_up = 1; /* selected stays NULL */
    2134              :                 } else {
    2135            0 :                     if (current_folder && *current_folder)
    2136            0 :                         selected = strdup(current_folder);
    2137              :                 }
    2138            0 :                 goto folders_int_done;
    2139              :             }
    2140            0 :             break;
    2141            0 :         case TERM_KEY_ENTER:
    2142            0 :             if (tree_mode) {
    2143            0 :                 selected = strdup(folders[cursor]);
    2144            0 :                 goto folders_int_done;
    2145            0 :             } else if (display_count > 0) {
    2146            0 :                 int fi = vis[cursor];
    2147            0 :                 if (folder_has_children(folders, count, folders[fi], sep)) {
    2148              :                     /* navigate into subfolder */
    2149            0 :                     strncpy(current_prefix, folders[fi], sizeof(current_prefix) - 1);
    2150            0 :                     current_prefix[sizeof(current_prefix) - 1] = '\0';
    2151            0 :                     cursor = 0; wstart = 0;
    2152              :                 } else {
    2153            0 :                     selected = strdup(folders[fi]);
    2154            0 :                     goto folders_int_done;
    2155              :                 }
    2156              :             }
    2157            0 :             break;
    2158            0 :         case TERM_KEY_NEXT_LINE:
    2159            0 :             if (cursor < display_count - 1) cursor++;
    2160            0 :             break;
    2161            0 :         case TERM_KEY_PREV_LINE:
    2162            0 :             if (cursor > 0) cursor--;
    2163            0 :             break;
    2164            0 :         case TERM_KEY_NEXT_PAGE:
    2165            0 :             cursor += limit;
    2166            0 :             if (cursor >= display_count) cursor = display_count > 0 ? display_count - 1 : 0;
    2167            0 :             break;
    2168            0 :         case TERM_KEY_PREV_PAGE:
    2169            0 :             cursor -= limit;
    2170            0 :             if (cursor < 0) cursor = 0;
    2171            0 :             break;
    2172            0 :         case TERM_KEY_LEFT:
    2173              :         case TERM_KEY_RIGHT:
    2174              :         case TERM_KEY_HOME:
    2175              :         case TERM_KEY_END:
    2176              :         case TERM_KEY_DELETE:
    2177              :         case TERM_KEY_TAB:
    2178              :         case TERM_KEY_SHIFT_TAB:
    2179              :         case TERM_KEY_IGNORE: {
    2180            0 :             int ch = terminal_last_printable();
    2181            0 :             if (ch == 't') {
    2182            0 :                 tree_mode = !tree_mode;
    2183            0 :                 ui_pref_set_int("folder_view_mode", tree_mode);
    2184            0 :                 cursor = 0; wstart = 0;
    2185            0 :                 if (!tree_mode) current_prefix[0] = '\0';
    2186            0 :             } else if (ch == 'h' || ch == '?') {
    2187              :                 static const char *help[][2] = {
    2188              :                     { "\u2191 / \u2193",   "Move cursor up / down"                   },
    2189              :                     { "PgUp / PgDn",        "Move cursor one page up / down"          },
    2190              :                     { "Enter",             "Open folder / navigate into subfolder"   },
    2191              :                     { "t",                 "Toggle tree / flat view"                 },
    2192              :                     { "Backspace",         "Go up one level (or back to accounts)"   },
    2193              :                     { "ESC / q",           "Quit"                                    },
    2194              :                     { "h / ?",             "Show this help"                          },
    2195              :                 };
    2196            0 :                 show_help_popup("Folder browser shortcuts",
    2197              :                                 help, (int)(sizeof(help)/sizeof(help[0])));
    2198              :             }
    2199            0 :             break;
    2200              :         }
    2201              :         }
    2202              :     }
    2203            0 : folders_int_done:
    2204            0 :     free(statuses);
    2205            0 :     free(vis);
    2206            0 :     for (int i = 0; i < count; i++) free(folders[i]);
    2207            0 :     free(folders);
    2208            0 :     return selected;
    2209              : }
    2210              : 
    2211              : /**
    2212              :  * Sum unread and flagged counts across all locally-cached folders for one
    2213              :  * account.  Temporarily switches g_account_base via local_store_init; the
    2214              :  * caller must restore the correct account after iterating all accounts.
    2215              :  */
    2216            0 : static void get_account_totals(const Config *cfg, int *unseen_out, int *flagged_out) {
    2217            0 :     *unseen_out = 0; *flagged_out = 0;
    2218            0 :     if (!cfg || !cfg->host) return;
    2219            0 :     local_store_init(cfg->host, cfg->user);
    2220            0 :     int fcount = 0;
    2221            0 :     char **flist = local_folder_list_load(&fcount, NULL);
    2222            0 :     if (!flist) return;
    2223            0 :     for (int i = 0; i < fcount; i++) {
    2224            0 :         int total = 0, unseen = 0, flagged = 0;
    2225            0 :         manifest_count_folder(flist[i], &total, &unseen, &flagged);
    2226            0 :         *unseen_out  += unseen;
    2227            0 :         *flagged_out += flagged;
    2228            0 :         free(flist[i]);
    2229              :     }
    2230            0 :     free(flist);
    2231              : }
    2232              : 
    2233              : /** Print one account row; cursor=1 draws the selection arrow. */
    2234            0 : static void print_account_row(const Config *cfg, int cursor, int tcols,
    2235              :                                int unseen, int flagged) {
    2236            0 :     const char *user = cfg->user ? cfg->user : "(unknown)";
    2237            0 :     const char *host = cfg->host ? cfg->host : "";
    2238              :     /* Strip protocol prefix for compact display */
    2239            0 :     if (strncmp(host, "imaps://", 8) == 0) host += 8;
    2240            0 :     else if (strncmp(host, "imap://",  7) == 0) host += 7;
    2241              : 
    2242            0 :     char u[16], f[16];
    2243            0 :     fmt_thou(u, sizeof(u), unseen);
    2244            0 :     fmt_thou(f, sizeof(f), flagged);
    2245              : 
    2246            0 :     if (cursor)
    2247            0 :         printf("  \033[1m\u2192 %6s  %7s  %-32.32s  %s\033[0m\n",
    2248              :                u, f, user, host);
    2249              :     else
    2250            0 :         printf("    %6s  %7s  %-32.32s  %s\n",
    2251              :                u, f, user, host);
    2252              :     (void)tcols;
    2253            0 : }
    2254              : 
    2255            0 : int email_service_account_interactive(Config **cfg_out, int *cursor_inout) {
    2256            0 :     *cfg_out = NULL;
    2257            0 :     RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    2258              :     (void)tui_raw;
    2259              : 
    2260            0 :     int cursor = (cursor_inout && *cursor_inout > 0) ? *cursor_inout : 0;
    2261              : 
    2262            0 :     for (;;) {
    2263              :         /* Reload account list on every iteration (list may change after add/delete) */
    2264            0 :         int count = 0;
    2265            0 :         AccountEntry *accounts = config_list_accounts(&count);
    2266              : 
    2267            0 :         int trows = terminal_rows();
    2268            0 :         int tcols = terminal_cols();
    2269            0 :         if (trows <= 0) trows = 24;
    2270            0 :         if (tcols <= 0) tcols = 80;
    2271            0 :         if (cursor >= count) cursor = count > 0 ? count - 1 : 0;
    2272              : 
    2273              :         /* Compute unread/flagged totals for each account (local manifests only) */
    2274            0 :         int *acc_unseen  = calloc(count > 0 ? (size_t)count : 1, sizeof(int));
    2275            0 :         int *acc_flagged = calloc(count > 0 ? (size_t)count : 1, sizeof(int));
    2276            0 :         for (int i = 0; i < count; i++)
    2277            0 :             get_account_totals(accounts[i].cfg, &acc_unseen[i], &acc_flagged[i]);
    2278              : 
    2279            0 :         printf("\033[H\033[2J");
    2280            0 :         printf("  Email Accounts (%d)\n\n", count);
    2281              : 
    2282            0 :         if (count == 0) {
    2283            0 :             printf("  No accounts configured.\n");
    2284              :         } else {
    2285            0 :             printf("  %6s  %7s  %-32s  %s\n", "Unread", "Flagged", "Account", "Server");
    2286            0 :             printf("  \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    2287            0 :             print_dbar(32);
    2288            0 :             printf("  ");
    2289            0 :             print_dbar(tcols > 54 ? tcols - 54 : 10);
    2290            0 :             printf("\n");
    2291            0 :             for (int i = 0; i < count; i++)
    2292            0 :                 print_account_row(accounts[i].cfg, i == cursor, tcols,
    2293            0 :                                   acc_unseen[i], acc_flagged[i]);
    2294              : 
    2295              :             /* Show detail for selected account */
    2296            0 :             printf("\n");
    2297            0 :             const Config *sel = accounts[cursor].cfg;
    2298            0 :             if (sel->smtp_host) {
    2299            0 :                 printf("    SMTP: %s", sel->smtp_host);
    2300            0 :                 if (sel->smtp_port) printf(":%d", sel->smtp_port);
    2301            0 :                 printf("\n");
    2302              :             } else {
    2303            0 :                 printf("    SMTP: \033[33mnot configured\033[0m"
    2304              :                        "  — press \033[1me\033[0m to set up\n");
    2305              :             }
    2306              :         }
    2307            0 :         fflush(stdout);
    2308              : 
    2309            0 :         char sb[256];
    2310            0 :         snprintf(sb, sizeof(sb),
    2311              :                  "  \u2191\u2193=select  Enter=open  n=add  d=delete  i=IMAP  e=SMTP  ESC=quit");
    2312            0 :         print_statusbar(trows, tcols, sb);
    2313              : 
    2314            0 :         TermKey key = terminal_read_key();
    2315            0 :         fprintf(stderr, "\r\033[K"); fflush(stderr);
    2316              : 
    2317            0 :         int ch = terminal_last_printable();
    2318              : 
    2319              : #define ACC_FREE() do { free(acc_unseen); free(acc_flagged); \
    2320              :                         config_free_account_list(accounts, count); } while(0)
    2321              : 
    2322            0 :         if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) {
    2323            0 :             if (cursor_inout) *cursor_inout = cursor;
    2324            0 :             ACC_FREE(); return 0;
    2325              :         }
    2326            0 :         if (key == TERM_KEY_BACK) {
    2327              :             /* Backspace has no meaning at the top-level accounts screen; ignore. */
    2328            0 :             ACC_FREE(); continue;
    2329              :         }
    2330            0 :         if (key == TERM_KEY_NEXT_LINE || key == TERM_KEY_NEXT_PAGE) {
    2331            0 :             if (cursor < count - 1) cursor++;
    2332            0 :             ACC_FREE(); continue;
    2333              :         }
    2334            0 :         if (key == TERM_KEY_PREV_LINE || key == TERM_KEY_PREV_PAGE) {
    2335            0 :             if (cursor > 0) cursor--;
    2336            0 :             ACC_FREE(); continue;
    2337              :         }
    2338            0 :         if (key == TERM_KEY_ENTER && count > 0) {
    2339            0 :             if (cursor_inout) *cursor_inout = cursor;
    2340            0 :             *cfg_out = accounts[cursor].cfg;
    2341            0 :             accounts[cursor].cfg = NULL; /* transfer ownership */
    2342            0 :             ACC_FREE(); return 1;
    2343              :         }
    2344              : 
    2345              :         /* Printable keys */
    2346            0 :         if (ch == 'h' || ch == '?') {
    2347              :             static const char *help[][2] = {
    2348              :                 { "\u2191 / \u2193",   "Move cursor up / down"      },
    2349              :                 { "Enter",            "Open selected account"       },
    2350              :                 { "n",               "Add new account"             },
    2351              :                 { "d",               "Delete selected account"     },
    2352              :                 { "i",               "Edit IMAP for account"       },
    2353              :                 { "e",               "Edit SMTP for account"       },
    2354              :                 { "ESC / q",         "Quit"                        },
    2355              :                 { "h / ?",           "Show this help"              },
    2356              :             };
    2357            0 :             show_help_popup("Accounts shortcuts",
    2358              :                             help, (int)(sizeof(help)/sizeof(help[0])));
    2359            0 :             ACC_FREE(); continue;
    2360              :         }
    2361            0 :         if (ch == 'i' && count > 0) {
    2362            0 :             if (cursor_inout) *cursor_inout = cursor;
    2363            0 :             *cfg_out = accounts[cursor].cfg;
    2364            0 :             accounts[cursor].cfg = NULL;
    2365            0 :             ACC_FREE(); return 4;
    2366              :         }
    2367            0 :         if (ch == 'e' && count > 0) {
    2368            0 :             if (cursor_inout) *cursor_inout = cursor;
    2369            0 :             *cfg_out = accounts[cursor].cfg;
    2370            0 :             accounts[cursor].cfg = NULL;
    2371            0 :             ACC_FREE(); return 2;
    2372              :         }
    2373            0 :         if (ch == 'n') {
    2374            0 :             ACC_FREE(); return 3;  /* caller runs setup wizard */
    2375              :         }
    2376            0 :         if (ch == 'd' && count > 0) {
    2377            0 :             const char *name = accounts[cursor].name;
    2378            0 :             config_delete_account(name);
    2379            0 :             ACC_FREE();
    2380            0 :             if (cursor > 0) cursor--;
    2381            0 :             continue;  /* re-render */
    2382              :         }
    2383              : 
    2384            0 :         ACC_FREE();
    2385              :     }
    2386              : #undef ACC_FREE
    2387              : }
    2388              : 
    2389            4 : int email_service_read(const Config *cfg, int uid, int pager, int page_size) {
    2390            4 :     char *raw = NULL;
    2391              : 
    2392            4 :     if (local_msg_exists(cfg->folder, uid)) {
    2393            0 :         logger_log(LOG_DEBUG, "Cache hit for UID %d in %s", uid, cfg->folder);
    2394            0 :         raw = local_msg_load(cfg->folder, uid);
    2395            4 :     } else if (cfg->sync_interval > 0) {
    2396              :         /* cron/offline mode: serve only from local cache; do not connect */
    2397            0 :         fprintf(stderr, "Could not load message UID %d.\n", uid);
    2398            0 :         return -1;
    2399              :     } else {
    2400            4 :         raw = fetch_uid_content_in(cfg, cfg->folder, uid, 0);
    2401            4 :         if (raw) {
    2402            4 :             local_msg_save(cfg->folder, uid, raw, strlen(raw));
    2403            4 :             local_index_update(cfg->folder, uid, raw);
    2404              :         }
    2405              :     }
    2406              : 
    2407            4 :     if (!raw) { fprintf(stderr, "Could not load message UID %d.\n", uid); return -1; }
    2408              : 
    2409            4 :     char *from_raw = mime_get_header(raw, "From");
    2410            4 :     char *from     = from_raw ? mime_decode_words(from_raw) : NULL;
    2411            4 :     free(from_raw);
    2412            4 :     char *subj_raw = mime_get_header(raw, "Subject");
    2413            4 :     char *subject  = subj_raw ? mime_decode_words(subj_raw) : NULL;
    2414            4 :     free(subj_raw);
    2415            4 :     char *date_raw = mime_get_header(raw, "Date");
    2416            4 :     char *date     = date_raw ? mime_format_date(date_raw) : NULL;
    2417            4 :     free(date_raw);
    2418              : 
    2419            4 :     print_show_headers(from, subject, date);
    2420              : 
    2421            4 :     int term_cols_show = pager ? terminal_cols() : SHOW_WIDTH;
    2422            4 :     int wrap_cols = term_cols_show > SHOW_WIDTH ? SHOW_WIDTH : term_cols_show;
    2423              : 
    2424            4 :     char *body = NULL;
    2425            4 :     char *html_raw = mime_get_html_part(raw);
    2426            4 :     if (html_raw) {
    2427            4 :         body = html_render(html_raw, wrap_cols, pager ? 1 : 0);
    2428            4 :         free(html_raw);
    2429              :     }
    2430            4 :     if (!body) {
    2431            0 :         char *plain = mime_get_text_body(raw);
    2432            0 :         if (plain) {
    2433            0 :             body = word_wrap(plain, wrap_cols);
    2434            0 :             if (!body) body = plain;
    2435            0 :             else free(plain);
    2436              :         }
    2437              :     }
    2438            4 :     const char *body_text = body ? body : "(no readable text body)";
    2439              : 
    2440              : #define SHOW_HDR_LINES 5
    2441            4 :     if (!pager || page_size <= SHOW_HDR_LINES) {
    2442            4 :         printf("%s\n", body_text);
    2443              :     } else {
    2444            0 :         int body_vrows  = count_visual_rows(body_text, term_cols_show);
    2445            0 :         int rows_avail  = page_size - SHOW_HDR_LINES;
    2446            0 :         int total_pages = (body_vrows + rows_avail - 1) / rows_avail;
    2447            0 :         if (total_pages < 1) total_pages = 1;
    2448              : 
    2449              :         /* Enter raw mode for the pager loop; pager_prompt calls terminal_read_key
    2450              :          * which requires raw mode to be already active. */
    2451            0 :         RAII_TERM_RAW TermRawState *show_raw = terminal_raw_enter();
    2452              : 
    2453            0 :         for (int cur_line = 0, show_displayed = 0; ; ) {
    2454            0 :             if (show_displayed) {
    2455            0 :                 printf("\033[0m\033[H\033[2J");   /* reset attrs + clear screen */
    2456            0 :                 print_show_headers(from, subject, date);
    2457              :             }
    2458            0 :             show_displayed = 1;
    2459            0 :             print_body_page(body_text, cur_line, rows_avail, term_cols_show);
    2460            0 :             printf("\033[0m");                     /* close any open ANSI from body */
    2461            0 :             fflush(stdout);
    2462              : 
    2463            0 :             if (cur_line == 0 && cur_line + rows_avail >= body_vrows) break;
    2464              : 
    2465            0 :             int cur_page = cur_line / rows_avail + 1;
    2466            0 :             int delta = pager_prompt(cur_page, total_pages, rows_avail, page_size, wrap_cols);
    2467            0 :             if (delta == 0) break;
    2468            0 :             cur_line += delta;
    2469            0 :             if (cur_line < 0) cur_line = 0;
    2470            0 :             if (cur_line >= body_vrows) break;
    2471              :         }
    2472              :         (void)show_raw; /* cleaned up automatically via RAII_TERM_RAW */
    2473              :     }
    2474              : #undef SHOW_HDR_LINES
    2475              : 
    2476            4 :     free(body); free(from); free(subject); free(date); free(raw);
    2477            4 :     return 0;
    2478              : }
    2479              : 
    2480              : /* ── Sync progress callback ──────────────────────────────────────────────── */
    2481              : 
    2482              : typedef struct {
    2483              :     int    loop_i;     /* 1-based index of current UID in the loop */
    2484              :     int    loop_total; /* total UIDs in this folder */
    2485              :     int    uid;
    2486              : } SyncProgressCtx;
    2487              : 
    2488            0 : static void fmt_size(char *buf, size_t bufsz, size_t bytes) {
    2489            0 :     if (bytes >= 1024 * 1024)
    2490            0 :         snprintf(buf, bufsz, "%.1f MB", (double)bytes / (1024.0 * 1024.0));
    2491              :     else
    2492            0 :         snprintf(buf, bufsz, "%zu KB", bytes / 1024);
    2493            0 : }
    2494              : 
    2495            0 : static void sync_progress_cb(size_t received, size_t total, void *ctx) {
    2496            0 :     SyncProgressCtx *p = ctx;
    2497            0 :     char recv_s[32], total_s[32];
    2498            0 :     fmt_size(recv_s,  sizeof(recv_s),  received);
    2499            0 :     fmt_size(total_s, sizeof(total_s), total);
    2500            0 :     printf("  [%d/%d] UID %d  %s / %s ...\r",
    2501              :            p->loop_i, p->loop_total, p->uid, recv_s, total_s);
    2502            0 :     fflush(stdout);
    2503            0 : }
    2504              : 
    2505            0 : int email_service_sync(const Config *cfg) {
    2506              :     /* ── PID-file lock: exit immediately if another sync is running ──────── */
    2507            0 :     char pid_path[2048] = {0};
    2508            0 :     const char *cache_base = platform_cache_dir();
    2509            0 :     if (cache_base)
    2510            0 :         snprintf(pid_path, sizeof(pid_path),
    2511              :                  "%s/email-cli/sync.pid", cache_base);
    2512              : 
    2513            0 :     if (pid_path[0]) {
    2514            0 :         FILE *pf = fopen(pid_path, "r");
    2515            0 :         if (pf) {
    2516            0 :             int other = 0;
    2517            0 :             if (fscanf(pf, "%d", &other) != 1) other = 0;
    2518            0 :             fclose(pf);
    2519            0 :             if (other > 0 && (pid_t)other != platform_getpid() &&
    2520            0 :                 platform_pid_is_program((pid_t)other, "email-cli")) {
    2521            0 :                 fprintf(stderr,
    2522              :                         "email-cli sync is already running (PID %d). Skipping.\n",
    2523              :                         other);
    2524            0 :                 return 0;
    2525              :             }
    2526              :         }
    2527              :         /* Write our own PID */
    2528            0 :         pf = fopen(pid_path, "w");
    2529            0 :         if (pf) { fprintf(pf, "%d\n", (int)platform_getpid()); fclose(pf); }
    2530              :     }
    2531              : 
    2532            0 :     int folder_count = 0;
    2533            0 :     char sep = '.';
    2534              :     /* Always fetch from server during sync to get the latest folder list */
    2535            0 :     char **folders = fetch_folder_list_from_server(cfg, &folder_count, &sep);
    2536            0 :     if (!folders || folder_count == 0) {
    2537            0 :         fprintf(stderr, "sync: could not retrieve folder list.\n");
    2538            0 :         if (folders) free(folders);
    2539            0 :         if (pid_path[0]) unlink(pid_path);
    2540            0 :         return -1;
    2541              :     }
    2542            0 :     qsort(folders, (size_t)folder_count, sizeof(char *), cmp_str);
    2543              : 
    2544              :     /* Persist folder list so the next 'folders' command is instant */
    2545            0 :     local_folder_list_save((const char **)folders, folder_count, sep);
    2546              : 
    2547            0 :     int total_fetched = 0, total_skipped = 0, errors = 0;
    2548              : 
    2549              :     /* One shared IMAP connection for all folder operations */
    2550            0 :     RAII_IMAP ImapClient *sync_imap = make_imap(cfg);
    2551            0 :     if (!sync_imap) {
    2552            0 :         fprintf(stderr, "sync: could not connect to IMAP server.\n");
    2553            0 :         for (int i = 0; i < folder_count; i++) free(folders[i]);
    2554            0 :         free(folders);
    2555            0 :         if (pid_path[0]) unlink(pid_path);
    2556            0 :         return -1;
    2557              :     }
    2558              : 
    2559            0 :     for (int fi = 0; fi < folder_count; fi++) {
    2560            0 :         const char *folder = folders[fi];
    2561            0 :         printf("Syncing %s ...\n", folder);
    2562            0 :         fflush(stdout);
    2563              : 
    2564            0 :         if (imap_select(sync_imap, folder) != 0) {
    2565            0 :             fprintf(stderr, "  WARN: SELECT failed for %s\n", folder);
    2566            0 :             errors++;
    2567            0 :             continue;
    2568              :         }
    2569              : 
    2570            0 :         int *uids = NULL;
    2571            0 :         int  uid_count = 0;
    2572            0 :         if (imap_uid_search(sync_imap, "ALL", &uids, &uid_count) != 0) {
    2573            0 :             fprintf(stderr, "  WARN: SEARCH ALL failed for %s\n", folder);
    2574            0 :             errors++;
    2575            0 :             continue;
    2576              :         }
    2577            0 :         if (uid_count == 0) {
    2578            0 :             printf("  (empty)\n");
    2579            0 :             free(uids);
    2580            0 :             continue;
    2581              :         }
    2582              : 
    2583              :         /* Load or create manifest for this folder */
    2584            0 :         Manifest *manifest = manifest_load(folder);
    2585            0 :         if (!manifest) {
    2586            0 :             manifest = calloc(1, sizeof(Manifest));
    2587            0 :             if (!manifest) {
    2588            0 :                 fprintf(stderr, "  WARN: out of memory for manifest %s\n", folder);
    2589            0 :                 free(uids);
    2590            0 :                 errors++;
    2591            0 :                 continue;
    2592              :             }
    2593              :         }
    2594              : 
    2595              :         /* Flush pending local flag changes to the server before reading state */
    2596              :         {
    2597            0 :             int pcount = 0;
    2598            0 :             PendingFlag *pending = local_pending_flag_load(folder, &pcount);
    2599            0 :             if (pending && pcount > 0) {
    2600            0 :                 for (int pi = 0; pi < pcount; pi++)
    2601            0 :                     imap_uid_set_flag(sync_imap, pending[pi].uid,
    2602            0 :                                       pending[pi].flag_name, pending[pi].add);
    2603            0 :                 local_pending_flag_clear(folder);
    2604              :             }
    2605            0 :             free(pending);
    2606              :         }
    2607              : 
    2608              :         /* Get UNSEEN set to mark entries */
    2609            0 :         int *unseen_uids = NULL;
    2610            0 :         int  unseen_count = 0;
    2611            0 :         if (imap_uid_search(sync_imap, "UNSEEN", &unseen_uids, &unseen_count) != 0)
    2612            0 :             unseen_count = 0;
    2613              : 
    2614            0 :         int *flagged_uids = NULL, flagged_count = 0;
    2615            0 :         imap_uid_search(sync_imap, "FLAGGED", &flagged_uids, &flagged_count);
    2616              : 
    2617            0 :         int *done_uids = NULL, done_count = 0;
    2618            0 :         imap_uid_search(sync_imap, "KEYWORD $Done", &done_uids, &done_count);
    2619              : 
    2620              :         /* Evict deleted messages from manifest */
    2621            0 :         manifest_retain(manifest, uids, uid_count);
    2622              : 
    2623            0 :         int fetched = 0, skipped = 0;
    2624            0 :         for (int i = 0; i < uid_count; i++) {
    2625            0 :             int uid = uids[i];
    2626            0 :             int uid_flags = 0;
    2627            0 :             for (int j = 0; j < unseen_count;  j++)
    2628            0 :                 if (unseen_uids[j]  == uid) { uid_flags |= MSG_FLAG_UNSEEN;  break; }
    2629            0 :             for (int j = 0; j < flagged_count; j++)
    2630            0 :                 if (flagged_uids[j] == uid) { uid_flags |= MSG_FLAG_FLAGGED; break; }
    2631            0 :             for (int j = 0; j < done_count;    j++)
    2632            0 :                 if (done_uids[j]    == uid) { uid_flags |= MSG_FLAG_DONE;    break; }
    2633              : 
    2634              :             /* Show progress BEFORE the potentially slow network fetch */
    2635            0 :             printf("  [%d/%d] UID %d...\r", i + 1, uid_count, uid);
    2636            0 :             fflush(stdout);
    2637              : 
    2638              :             /* Fetch full body if not cached */
    2639            0 :             if (!local_msg_exists(folder, uid)) {
    2640            0 :                 SyncProgressCtx pctx = { i + 1, uid_count, uid };
    2641            0 :                 imap_set_progress(sync_imap, sync_progress_cb, &pctx);
    2642            0 :                 char *raw = imap_uid_fetch_body(sync_imap, uid);
    2643            0 :                 imap_set_progress(sync_imap, NULL, NULL);
    2644            0 :                 if (raw) {
    2645              :                     /* Cache the header section extracted from the full body so
    2646              :                      * the subsequent manifest update needs no extra IMAP round-trip. */
    2647            0 :                     if (!local_hdr_exists(folder, uid)) {
    2648            0 :                         const char *sep4 = strstr(raw, "\r\n\r\n");
    2649            0 :                         size_t hlen = sep4 ? (size_t)(sep4 - raw + 4) : strlen(raw);
    2650            0 :                         local_hdr_save(folder, uid, raw, hlen);
    2651              :                     }
    2652            0 :                     local_msg_save(folder, uid, raw, strlen(raw));
    2653            0 :                     local_index_update(folder, uid, raw);
    2654            0 :                     free(raw);
    2655            0 :                     fetched++;
    2656              :                 } else {
    2657            0 :                     fprintf(stderr, "  WARN: failed to fetch UID %d in %s\n", uid, folder);
    2658            0 :                     errors++;
    2659            0 :                     continue;
    2660              :                 }
    2661              :             } else {
    2662            0 :                 skipped++;
    2663              :             }
    2664              : 
    2665              :             /* Update manifest entry (headers from local cache — now always warm) */
    2666            0 :             ManifestEntry *me = manifest_find(manifest, uid);
    2667            0 :             if (!me) {
    2668            0 :                 char *hdrs   = fetch_uid_headers_via(sync_imap, folder, uid);
    2669            0 :                 char *fr_raw = hdrs ? mime_get_header(hdrs, "From")    : NULL;
    2670            0 :                 char *fr     = fr_raw ? mime_decode_words(fr_raw)      : strdup("");
    2671            0 :                 free(fr_raw);
    2672            0 :                 char *su_raw = hdrs ? mime_get_header(hdrs, "Subject") : NULL;
    2673            0 :                 char *su     = su_raw ? mime_decode_words(su_raw)      : strdup("");
    2674            0 :                 free(su_raw);
    2675            0 :                 char *dt_raw = hdrs ? mime_get_header(hdrs, "Date")    : NULL;
    2676            0 :                 char *dt     = dt_raw ? mime_format_date(dt_raw)       : strdup("");
    2677            0 :                 free(dt_raw);
    2678            0 :                 free(hdrs);
    2679            0 :                 manifest_upsert(manifest, uid, fr, su, dt, uid_flags);
    2680              :             } else {
    2681              :                 /* update flags on existing entry */
    2682            0 :                 me->flags = uid_flags;
    2683              :             }
    2684              : 
    2685            0 :             printf("  [%d/%d] UID %d   \r", i + 1, uid_count, uid);
    2686            0 :             fflush(stdout);
    2687              :         }
    2688            0 :         free(unseen_uids);
    2689            0 :         free(flagged_uids);
    2690            0 :         free(done_uids);
    2691            0 :         manifest_save(folder, manifest);
    2692            0 :         manifest_free(manifest);
    2693            0 :         free(uids);
    2694              : 
    2695            0 :         printf("  %d fetched, %d already stored%s\n",
    2696              :                fetched, skipped, errors ? " (some errors)" : "");
    2697            0 :         total_fetched += fetched;
    2698            0 :         total_skipped += skipped;
    2699              :     }
    2700              : 
    2701            0 :     for (int i = 0; i < folder_count; i++) free(folders[i]);
    2702            0 :     free(folders);
    2703              : 
    2704            0 :     printf("\nSync complete: %d fetched, %d already stored", total_fetched, total_skipped);
    2705            0 :     if (errors) printf(", %d errors", errors);
    2706            0 :     printf("\n");
    2707              : 
    2708              :     /* Release PID lock */
    2709            0 :     if (pid_path[0]) unlink(pid_path);
    2710              : 
    2711            0 :     return errors ? -1 : 0;
    2712              : }
    2713              : 
    2714            0 : int email_service_sync_all(const char *only_account) {
    2715            0 :     int count = 0;
    2716            0 :     AccountEntry *accounts = config_list_accounts(&count);
    2717            0 :     if (!accounts || count == 0) {
    2718            0 :         fprintf(stderr, "No accounts configured.\n");
    2719            0 :         config_free_account_list(accounts, count);
    2720            0 :         return -1;
    2721              :     }
    2722              : 
    2723            0 :     int errors = 0;
    2724            0 :     int synced = 0;
    2725            0 :     for (int i = 0; i < count; i++) {
    2726            0 :         if (only_account && only_account[0] &&
    2727            0 :             strcmp(accounts[i].name, only_account) != 0)
    2728            0 :             continue;
    2729            0 :         if (count > 1)
    2730            0 :             printf("\n=== Syncing account: %s ===\n", accounts[i].name);
    2731            0 :         local_store_init(accounts[i].cfg->host, accounts[i].cfg->user);
    2732            0 :         if (email_service_sync(accounts[i].cfg) < 0)
    2733            0 :             errors++;
    2734            0 :         synced++;
    2735              :     }
    2736            0 :     config_free_account_list(accounts, count);
    2737              : 
    2738            0 :     if (synced == 0) {
    2739            0 :         fprintf(stderr, "Account '%s' not found.\n",
    2740              :                 only_account ? only_account : "");
    2741            0 :         return -1;
    2742              :     }
    2743            0 :     return errors > 0 ? -1 : 0;
    2744              : }
    2745              : 
    2746            0 : int email_service_cron_setup(const Config *cfg) {
    2747              : 
    2748              :     /* Find the path to this binary */
    2749            0 :     char self_path[1024] = {0};
    2750              : #ifdef __linux__
    2751            0 :     ssize_t n = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
    2752            0 :     if (n < 0) {
    2753            0 :         fprintf(stderr, "Cannot determine binary path.\n");
    2754            0 :         return -1;
    2755              :     }
    2756            0 :     self_path[n] = '\0';
    2757              : #else
    2758              :     /* fallback: use 'which email-cli' */
    2759              :     FILE *wp = popen("which email-cli", "r");
    2760              :     if (!wp || !fgets(self_path, sizeof(self_path), wp)) {
    2761              :         if (wp) pclose(wp);
    2762              :         fprintf(stderr, "Cannot determine binary path.\n");
    2763              :         return -1;
    2764              :     }
    2765              :     if (wp) pclose(wp);
    2766              :     /* trim newline */
    2767              :     self_path[strcspn(self_path, "\n")] = '\0';
    2768              : #endif
    2769              : 
    2770              :     /* Build path to email-sync (same directory as current binary) */
    2771            0 :     char sync_bin[1024] = "email-sync";
    2772            0 :     char *last_slash = strrchr(self_path, '/');
    2773            0 :     if (last_slash)
    2774            0 :         snprintf(sync_bin, sizeof(sync_bin), "%.*s/email-sync",
    2775            0 :                  (int)(last_slash - self_path), self_path);
    2776              : 
    2777              :     /* Build the cron line */
    2778            0 :     char cron_line[2048];
    2779            0 :     snprintf(cron_line, sizeof(cron_line),
    2780              :              "*/%d * * * * %s >> ~/.cache/email-cli/sync.log 2>&1",
    2781            0 :              cfg->sync_interval, sync_bin);
    2782              : 
    2783              :     /* Read existing crontab */
    2784            0 :     FILE *fp = popen("crontab -l 2>/dev/null", "r");
    2785            0 :     char existing[65536] = {0};
    2786            0 :     size_t total = 0;
    2787            0 :     if (fp) {
    2788              :         size_t n2;
    2789            0 :         while ((n2 = fread(existing + total, 1, sizeof(existing) - total - 1, fp)) > 0)
    2790            0 :             total += n2;
    2791            0 :         pclose(fp);
    2792              :     }
    2793            0 :     existing[total] = '\0';
    2794              : 
    2795              :     /* Check if already present (email-sync or legacy email-cli sync) */
    2796            0 :     if (strstr(existing, "email-sync") ||
    2797            0 :         (strstr(existing, "email-cli") && strstr(existing, " sync"))) {
    2798            0 :         printf("Cron job already installed. "
    2799              :                "Run 'email-cli cron remove' first to change the interval.\n");
    2800            0 :         return 0;
    2801              :     }
    2802              : 
    2803              :     /* Append our line (ensure existing ends with newline) */
    2804            0 :     if (total > 0 && existing[total - 1] != '\n')
    2805            0 :         strncat(existing, "\n", sizeof(existing) - total - 1);
    2806            0 :     strncat(existing, cron_line, sizeof(existing) - strlen(existing) - 1);
    2807            0 :     strncat(existing, "\n", sizeof(existing) - strlen(existing) - 1);
    2808              : 
    2809            0 :     FILE *cp = popen("crontab -", "w");
    2810            0 :     if (!cp) {
    2811            0 :         fprintf(stderr, "Failed to update crontab.\n");
    2812            0 :         return -1;
    2813              :     }
    2814            0 :     fputs(existing, cp);
    2815            0 :     int rc = pclose(cp);
    2816            0 :     if (rc != 0) {
    2817            0 :         fprintf(stderr, "crontab update failed (exit %d).\n", rc);
    2818            0 :         return -1;
    2819              :     }
    2820              : 
    2821            0 :     printf("Cron job installed: %s\n", cron_line);
    2822            0 :     return 0;
    2823              : }
    2824              : 
    2825            0 : int email_service_cron_remove(void) {
    2826            0 :     FILE *fp = popen("crontab -l 2>/dev/null", "r");
    2827            0 :     char existing[65536] = {0};
    2828            0 :     size_t total = 0;
    2829            0 :     if (fp) {
    2830              :         size_t n;
    2831            0 :         while ((n = fread(existing + total, 1, sizeof(existing) - total - 1, fp)) > 0)
    2832            0 :             total += n;
    2833            0 :         pclose(fp);
    2834              :     }
    2835            0 :     existing[total] = '\0';
    2836              : 
    2837              : #define IS_SYNC_LINE(s) \
    2838              :     (strstr((s), "email-sync") || \
    2839              :      (strstr((s), "email-cli") && strstr((s), " sync")))
    2840              : 
    2841            0 :     if (!IS_SYNC_LINE(existing)) {
    2842            0 :         printf("No email-sync cron entry found.\n");
    2843            0 :         return 0;
    2844              :     }
    2845              : 
    2846              :     /* Filter out sync cron lines */
    2847            0 :     char filtered[65536] = {0};
    2848            0 :     size_t flen = 0;
    2849            0 :     char *p = existing;
    2850            0 :     while (*p) {
    2851            0 :         char *nl = strchr(p, '\n');
    2852            0 :         char *end = nl ? nl : p + strlen(p);
    2853            0 :         char saved = *end; *end = '\0';
    2854            0 :         if (!IS_SYNC_LINE(p)) {
    2855            0 :             size_t llen = strlen(p);
    2856            0 :             if (flen + llen + 2 < sizeof(filtered)) {
    2857            0 :                 memcpy(filtered + flen, p, llen);
    2858            0 :                 flen += llen;
    2859            0 :                 filtered[flen++] = '\n';
    2860            0 :                 filtered[flen]   = '\0';
    2861              :             }
    2862              :         }
    2863            0 :         *end = saved;
    2864            0 :         p = nl ? nl + 1 : end;
    2865              :     }
    2866              : 
    2867            0 :     FILE *cp = popen("crontab -", "w");
    2868            0 :     if (!cp) {
    2869            0 :         fprintf(stderr, "Failed to update crontab.\n");
    2870            0 :         return -1;
    2871              :     }
    2872            0 :     fputs(filtered, cp);
    2873            0 :     int rc = pclose(cp);
    2874            0 :     if (rc != 0) {
    2875            0 :         fprintf(stderr, "crontab update failed.\n");
    2876            0 :         return -1;
    2877              :     }
    2878              : 
    2879            0 :     printf("Cron job removed.\n");
    2880            0 :     return 0;
    2881              : }
    2882              : 
    2883            0 : int email_service_cron_status(void) {
    2884            0 :     FILE *fp = popen("crontab -l 2>/dev/null", "r");
    2885            0 :     if (!fp) {
    2886            0 :         printf("No crontab found for this user.\n");
    2887            0 :         return 0;
    2888              :     }
    2889            0 :     char line[1024];
    2890            0 :     int found = 0;
    2891            0 :     while (fgets(line, sizeof(line), fp)) {
    2892            0 :         if (IS_SYNC_LINE(line)) {
    2893            0 :             if (!found) printf("Cron entry found:\n");
    2894            0 :             printf("  %s", line);
    2895            0 :             found = 1;
    2896              :         }
    2897              :     }
    2898            0 :     pclose(fp);
    2899            0 :     if (!found)
    2900            0 :         printf("No email-sync cron entry found.\n");
    2901              : #undef IS_SYNC_LINE
    2902            0 :     return 0;
    2903              : }
    2904              : 
    2905              : /* ── Attachment service functions ───────────────────────────────────── */
    2906              : 
    2907              : /* Load raw message for uid (cache or fetch). Returns heap string or NULL. */
    2908            0 : static char *load_raw_message(const Config *cfg, int uid) {
    2909            0 :     if (local_msg_exists(cfg->folder, uid)) {
    2910            0 :         return local_msg_load(cfg->folder, uid);
    2911              :     }
    2912            0 :     char *raw = fetch_uid_content_in(cfg, cfg->folder, uid, 0);
    2913            0 :     if (raw) {
    2914            0 :         local_msg_save(cfg->folder, uid, raw, strlen(raw));
    2915            0 :         local_index_update(cfg->folder, uid, raw);
    2916              :     }
    2917            0 :     return raw;
    2918              : }
    2919              : 
    2920            0 : char *email_service_fetch_raw(const Config *cfg, int uid) {
    2921            0 :     return load_raw_message(cfg, uid);
    2922              : }
    2923              : 
    2924            0 : int email_service_list_attachments(const Config *cfg, int uid) {
    2925            0 :     char *raw = load_raw_message(cfg, uid);
    2926            0 :     if (!raw) {
    2927            0 :         fprintf(stderr, "Could not load message UID %d.\n", uid);
    2928            0 :         return -1;
    2929              :     }
    2930            0 :     int count = 0;
    2931            0 :     MimeAttachment *atts = mime_list_attachments(raw, &count);
    2932            0 :     free(raw);
    2933            0 :     if (count == 0) {
    2934            0 :         printf("No attachments.\n");
    2935            0 :         mime_free_attachments(atts, count);
    2936            0 :         return 0;
    2937              :     }
    2938            0 :     for (int i = 0; i < count; i++) {
    2939            0 :         const char *name = atts[i].filename ? atts[i].filename : "(no name)";
    2940            0 :         size_t sz = atts[i].size;
    2941            0 :         if (sz >= 1024 * 1024)
    2942            0 :             printf("%-40s  %.1f MB\n", name, (double)sz / (1024.0 * 1024.0));
    2943            0 :         else if (sz >= 1024)
    2944            0 :             printf("%-40s  %.0f KB\n", name, (double)sz / 1024.0);
    2945              :         else
    2946            0 :             printf("%-40s  %zu B\n", name, sz);
    2947              :     }
    2948            0 :     mime_free_attachments(atts, count);
    2949            0 :     return 0;
    2950              : }
    2951              : 
    2952            0 : int email_service_save_attachment(const Config *cfg, int uid,
    2953              :                                   const char *name, const char *outdir) {
    2954            0 :     char *raw = load_raw_message(cfg, uid);
    2955            0 :     if (!raw) {
    2956            0 :         fprintf(stderr, "Could not load message UID %d.\n", uid);
    2957            0 :         return -1;
    2958              :     }
    2959            0 :     int count = 0;
    2960            0 :     MimeAttachment *atts = mime_list_attachments(raw, &count);
    2961            0 :     free(raw);
    2962            0 :     if (count == 0) {
    2963            0 :         fprintf(stderr, "Message UID %d has no attachments.\n", uid);
    2964            0 :         mime_free_attachments(atts, count);
    2965            0 :         return -1;
    2966              :     }
    2967              : 
    2968              :     /* Find attachment by filename (case-sensitive). */
    2969            0 :     int idx = -1;
    2970            0 :     for (int i = 0; i < count; i++) {
    2971            0 :         const char *fn = atts[i].filename ? atts[i].filename : "";
    2972            0 :         if (strcmp(fn, name) == 0) { idx = i; break; }
    2973              :     }
    2974            0 :     if (idx < 0) {
    2975            0 :         fprintf(stderr, "Attachment '%s' not found in message UID %d.\n", name, uid);
    2976            0 :         mime_free_attachments(atts, count);
    2977            0 :         return -1;
    2978              :     }
    2979              : 
    2980              :     /* Build destination path. */
    2981            0 :     const char *dir = outdir ? outdir : attachment_save_dir();
    2982            0 :     char *dir_heap = NULL;
    2983            0 :     if (!outdir) dir_heap = (char *)dir; /* attachment_save_dir returns heap */
    2984              : 
    2985            0 :     char *safe = safe_filename_for_path(name);
    2986            0 :     char dest[2048];
    2987            0 :     snprintf(dest, sizeof(dest), "%s/%s", dir, safe ? safe : "attachment");
    2988            0 :     free(safe);
    2989              : 
    2990            0 :     int rc = mime_save_attachment(&atts[idx], dest);
    2991            0 :     if (rc == 0)
    2992            0 :         printf("Saved: %s\n", dest);
    2993              :     else
    2994            0 :         fprintf(stderr, "Failed to save attachment to %s\n", dest);
    2995              : 
    2996            0 :     mime_free_attachments(atts, count);
    2997            0 :     free(dir_heap);
    2998            0 :     return rc;
    2999              : }
    3000              : 
    3001            1 : int email_service_save_sent(const Config *cfg, const char *msg, size_t msg_len) {
    3002            2 :     RAII_IMAP ImapClient *imap = make_imap(cfg);
    3003            1 :     if (!imap) {
    3004            0 :         fprintf(stderr, "Warning: could not connect to save message to Sent folder.\n");
    3005            0 :         return -1;
    3006              :     }
    3007            1 :     const char *sent_folder = cfg->sent_folder ? cfg->sent_folder : "Sent";
    3008            1 :     int rc = imap_append(imap, sent_folder, msg, msg_len);
    3009            1 :     if (rc != 0)
    3010            0 :         fprintf(stderr, "Warning: could not save message to '%s' folder.\n", sent_folder);
    3011            1 :     return rc;
    3012              : }
        

Generated by: LCOV version 2.0-1