LCOV - code coverage report
Current view: top level - libemail/src/domain - email_service.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 76.5 % 4291 3282
Test Date: 2026-08-21 10:11:30 Functions: 94.3 % 105 99

            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 "mail_client.h"
       7              : #include "gmail_sync.h"
       8              : #include "local_store.h"
       9              : #include "mail_rules.h"
      10              : #include "mime_util.h"
      11              : #include "html_render.h"
      12              : #include "imap_util.h"
      13              : #include "raii.h"
      14              : #include "logger.h"
      15              : #include "platform/terminal.h"
      16              : #include "platform/path.h"
      17              : #include "platform/process.h"
      18              : #include <stdio.h>
      19              : #include <stdlib.h>
      20              : #include <string.h>
      21              : #include <ctype.h>
      22              : #include <unistd.h>
      23              : #include <stdint.h>
      24              : #include <poll.h>
      25              : #include <sys/stat.h>
      26              : #include <sys/wait.h>
      27              : #include <fcntl.h>
      28              : #include <signal.h>
      29              : #include <time.h>
      30              : 
      31              : /* ── Verbose mode ────────────────────────────────────────────────────── */
      32              : 
      33              : static int g_verbose = 0;
      34              : 
      35              : /** @brief Set verbose mode for sync and apply-rules operations. */
      36           95 : void email_service_set_verbose(int v) { g_verbose = v; }
      37              : 
      38              : /* ── Column-aware printing ───────────────────────────────────────────── */
      39              : 
      40              : /**
      41              :  * Print a UTF-8 string left-aligned in exactly `width` terminal columns.
      42              :  * Truncates at character boundaries so that the output never exceeds `width`
      43              :  * columns, then pads with spaces to reach exactly `width` columns.
      44              :  * Uses wcwidth(3) for per-character column measurement (handles multi-byte
      45              :  * UTF-8, wide/emoji characters, and combining marks correctly).
      46              :  * Requires setlocale(LC_ALL, "") to have been called in main().
      47              :  */
      48         5076 : static void print_padded_col(const char *s, int width) {
      49         5076 :     if (!s) s = "";
      50              :     /* width == 0: non-TTY batch mode — print full string, no truncation, no padding */
      51         5076 :     if (width <= 0) { fputs(s, stdout); return; }
      52         3498 :     const unsigned char *p = (const unsigned char *)s;
      53         3498 :     int used = 0;
      54              : 
      55        62846 :     while (*p) {
      56              :         /* Decode one UTF-8 code point. */
      57              :         uint32_t cp;
      58              :         int seqlen;
      59        59362 :         if      (*p < 0x80) { cp = *p;        seqlen = 1; }
      60            0 :         else if (*p < 0xC2) { cp = 0xFFFD;    seqlen = 1; } /* invalid lead byte */
      61            0 :         else if (*p < 0xE0) { cp = *p & 0x1F; seqlen = 2; }
      62            0 :         else if (*p < 0xF0) { cp = *p & 0x0F; seqlen = 3; }
      63            0 :         else if (*p < 0xF8) { cp = *p & 0x07; seqlen = 4; }
      64            0 :         else                { cp = 0xFFFD;    seqlen = 1; } /* invalid lead byte */
      65              : 
      66        59362 :         for (int i = 1; i < seqlen; i++) {
      67            0 :             if ((p[i] & 0xC0) != 0x80) { seqlen = i; cp = 0xFFFD; break; }
      68            0 :             cp = (cp << 6) | (p[i] & 0x3F);
      69              :         }
      70              : 
      71        59362 :         int w = terminal_wcwidth(cp);
      72        59362 :         if (w == 0) { p += seqlen; continue; }  /* skip control/non-printable */
      73        59362 :         if (used + w > width) break;             /* doesn't fit — stop here */
      74              : 
      75        59348 :         fwrite(p, 1, (size_t)seqlen, stdout);
      76        59348 :         used += w;
      77        59348 :         p    += seqlen;
      78              :     }
      79              : 
      80        88922 :     for (int i = used; i < width; i++) putchar(' ');
      81              : }
      82              : 
      83              : /** Print n copies of the double-horizontal-bar character ═ (U+2550). */
      84         3682 : static void print_dbar(int n) {
      85       191023 :     for (int i = 0; i < n; i++) fputs("\xe2\x95\x90", stdout);
      86         3682 : }
      87              : 
      88              : /**
      89              :  * Count extra bytes introduced by multi-byte UTF-8 sequences in s.
      90              :  * printf("%-*s", w, s) pads by byte count; adding this value corrects
      91              :  * the width for strings containing accented/non-ASCII characters.
      92              :  */
      93        11030 : static int utf8_extra_bytes(const char *s) {
      94        11030 :     int extra = 0;
      95       210268 :     for (const unsigned char *p = (const unsigned char *)s; *p; p++)
      96       199238 :         if ((*p & 0xC0) == 0x80) extra++;   /* continuation byte */
      97        11030 :     return extra;
      98              : }
      99              : 
     100              : /**
     101              :  * Format an integer with space as thousands separator into buf (size >= 16).
     102              :  * Returns buf.  Zero → empty string (blank cell).
     103              :  */
     104        35619 : static char *fmt_thou(char *buf, size_t sz, int n) {
     105        35619 :     if (n <= 0) { buf[0] = '\0'; return buf; }
     106              :     char tmp[32];
     107        12541 :     snprintf(tmp, sizeof(tmp), "%d", n);
     108        12541 :     int len = (int)strlen(tmp);
     109        12541 :     int out = 0;
     110        25085 :     for (int i = 0; i < len; i++) {
     111        12544 :         int rem = len - i;          /* digits remaining including this one */
     112        12544 :         if (i > 0 && rem % 3 == 0)
     113            0 :             buf[out++] = ' ';
     114        12544 :         buf[out++] = tmp[i];
     115              :     }
     116        12541 :     buf[out] = '\0';
     117              :     (void)sz;
     118        12541 :     return buf;
     119              : }
     120              : 
     121              : /**
     122              :  * Soft-wrap text at word boundaries so no output line exceeds `width`
     123              :  * terminal columns (measured by wcwidth).  Long words that exceed `width`
     124              :  * are emitted on a line of their own.  Returns a heap-allocated string;
     125              :  * caller must free.  Returns strdup(text) on allocation failure.
     126              :  */
     127           20 : static char *word_wrap(const char *text, int width) {
     128           20 :     if (!text) return NULL;
     129           20 :     if (width < 20) width = 20;
     130              : 
     131           20 :     size_t in_len = strlen(text);
     132              :     /* Hard breaks add one '\n' per `width` chars; space-breaks are net-zero. */
     133           20 :     char *out = malloc(in_len + in_len / (size_t)width + 4);
     134           20 :     if (!out) return strdup(text);
     135           20 :     char *wp = out;
     136              : 
     137           20 :     const char *src = text;
     138           43 :     while (*src) {
     139              :         /* Isolate one source line. */
     140           23 :         const char *eol      = strchr(src, '\n');
     141           23 :         const char *line_end = eol ? eol : src + strlen(src);
     142              : 
     143              :         /* Emit the source line as one or more width-limited output lines. */
     144           23 :         const char *seg = src;
     145           47 :         while (seg < line_end) {
     146           24 :             const unsigned char *p = (const unsigned char *)seg;
     147           24 :             int col = 0;
     148           24 :             const char *brk = NULL;   /* last candidate break (space) */
     149              : 
     150          528 :             while ((const char *)p < line_end) {
     151              :                 uint32_t cp; int seqlen;
     152          505 :                 if      (*p < 0x80) { cp = *p;        seqlen = 1; }
     153            3 :                 else if (*p < 0xC2) { cp = 0xFFFD;    seqlen = 1; }
     154            3 :                 else if (*p < 0xE0) { cp = *p & 0x1F; seqlen = 2; }
     155            0 :                 else if (*p < 0xF0) { cp = *p & 0x0F; seqlen = 3; }
     156            0 :                 else if (*p < 0xF8) { cp = *p & 0x07; seqlen = 4; }
     157            0 :                 else                { cp = 0xFFFD;    seqlen = 1; }
     158          508 :                 for (int i = 1; i < seqlen; i++) {
     159            3 :                     if ((p[i] & 0xC0) != 0x80) { seqlen = i; cp = 0xFFFD; break; }
     160            3 :                     cp = (cp << 6) | (p[i] & 0x3F);
     161              :                 }
     162          505 :                 if ((const char *)p + seqlen > line_end) break;
     163              : 
     164          505 :                 int cw = terminal_wcwidth(cp);
     165              :                 /* cw is already 0 for non-printable characters */
     166          505 :                 if (col + cw > width) break;
     167              : 
     168          504 :                 if (*p == ' ') brk = (const char *)p;
     169          504 :                 col += cw;
     170          504 :                 p   += seqlen;
     171              :             }
     172              : 
     173           24 :             const char *chunk_end = (const char *)p;
     174              : 
     175           24 :             if (chunk_end >= line_end) {
     176              :                 /* Rest of line fits. */
     177           23 :                 size_t n = (size_t)(line_end - seg);
     178           23 :                 memcpy(wp, seg, n); wp += n;
     179           23 :                 seg = line_end;
     180            1 :             } else if (brk) {
     181              :                 /* Break at last space (replace space with newline). */
     182            0 :                 size_t n = (size_t)(brk - seg);
     183            0 :                 memcpy(wp, seg, n); wp += n;
     184            0 :                 *wp++ = '\n';
     185            0 :                 seg = brk + 1;
     186              :             } else {
     187              :                 /* No space found: never hard-break a word — emit the whole
     188              :                  * token and let the terminal handle visual wrapping. */
     189            1 :                 const char *word_end = (const char *)p;
     190           92 :                 while (word_end < line_end && !isspace((unsigned char)*word_end))
     191           91 :                     word_end++;
     192            1 :                 size_t n = (size_t)(word_end - seg);
     193            1 :                 if (n == 0) {
     194              :                     /* Single wide char exceeds width: emit it anyway. */
     195            0 :                     const unsigned char *u = (const unsigned char *)seg;
     196            0 :                     int sl = (*u < 0x80) ? 1
     197            0 :                            : (*u < 0xE0) ? 2
     198            0 :                            : (*u < 0xF0) ? 3 : 4;
     199            0 :                     memcpy(wp, seg, (size_t)sl); wp += sl; seg += sl;
     200              :                 } else {
     201            1 :                     memcpy(wp, seg, n); wp += n;
     202            1 :                     seg = word_end;
     203              :                 }
     204              :             }
     205              :         }
     206              : 
     207           23 :         *wp++ = '\n';
     208           23 :         src = eol ? eol + 1 : line_end;
     209              :     }
     210           20 :     *wp = '\0';
     211           20 :     return out;
     212              : }
     213              : 
     214              : /* Forward declaration — defined after visible_line_cols (below). */
     215              : static void print_statusbar(int trows, int width, const char *text);
     216              : static void warn_charset(const MimeTextInfo *info, const char *uid);
     217              : static void show_label_picker(MailClient *mc, const char *uid,
     218              :                                char *feedback_out, int feedback_cap);
     219              : static int is_system_or_special_label(const char *name);
     220              : static void flag_push_background(const Config *cfg, const char *uid,
     221              :                                   const char *flag_name, int add_flag);
     222              : 
     223              : /**
     224              :  * Pager prompt for the standalone `show` command.
     225              :  * Returns scroll delta: 0 = quit, positive = forward N lines, negative = back N.
     226              :  */
     227            0 : static int pager_prompt(int cur_page, int total_pages, int page_size,
     228              :                         int term_rows, int sb_width) {
     229            0 :     for (;;) {
     230              :         char sb[256];
     231            0 :         snprintf(sb, sizeof(sb),
     232              :                  "-- [%d/%d] PgDn/\u2193=scroll  PgUp/\u2191=back  ESC=quit --",
     233              :                  cur_page, total_pages);
     234            0 :         print_statusbar(term_rows, sb_width, sb);
     235            0 :         TermKey key = terminal_read_key();
     236            0 :         fprintf(stderr, "\r\033[K");
     237            0 :         fflush(stderr);
     238              : 
     239            0 :         switch (key) {
     240            0 :         case TERM_KEY_QUIT:
     241              :         case TERM_KEY_ESC:
     242            0 :         case TERM_KEY_BACK:      return 0;
     243            0 :         case TERM_KEY_NEXT_PAGE: return  page_size;
     244            0 :         case TERM_KEY_PREV_PAGE: return -page_size;
     245            0 :         case TERM_KEY_NEXT_LINE: return  1;
     246            0 :         case TERM_KEY_PREV_LINE: return -1;
     247            0 :         case TERM_KEY_ENTER:
     248              :         case TERM_KEY_TAB:
     249              :         case TERM_KEY_SHIFT_TAB:
     250              :         case TERM_KEY_LEFT:
     251              :         case TERM_KEY_RIGHT:
     252              :         case TERM_KEY_HOME:
     253              :         case TERM_KEY_END:
     254              :         case TERM_KEY_DELETE:
     255            0 :         case TERM_KEY_IGNORE:    continue;
     256              :         }
     257              :     }
     258              : }
     259              : 
     260              : /** Count newlines in s (= number of lines). */
     261              : /**
     262              :  * Count visible terminal columns in bytes [p, end), skipping ANSI SGR
     263              :  * and OSC escape sequences.  Uses terminal_wcwidth for multi-byte chars.
     264              :  */
     265         7512 : static int visible_line_cols(const char *p, const char *end) {
     266         7512 :     int cols = 0;
     267       325848 :     while (p < end) {
     268       318336 :         unsigned char c = (unsigned char)*p;
     269              :         /* Skip ANSI CSI sequence: ESC [ ... final_byte (0x40–0x7E) */
     270       318336 :         if (c == 0x1b && p + 1 < end && (unsigned char)*(p + 1) == '[') {
     271         2365 :             p += 2;
     272         7570 :             while (p < end && ((unsigned char)*p < 0x40 || (unsigned char)*p > 0x7e))
     273         5205 :                 p++;
     274         2365 :             if (p < end) p++;
     275         2365 :             continue;
     276              :         }
     277              :         /* Skip OSC sequence: ESC ] ... BEL  or  ESC ] ... ESC \ */
     278       315971 :         if (c == 0x1b && p + 1 < end && (unsigned char)*(p + 1) == ']') {
     279            0 :             p += 2;
     280            0 :             while (p < end) {
     281            0 :                 if ((unsigned char)*p == 0x07) { p++; break; }
     282            0 :                 if ((unsigned char)*p == 0x1b && p + 1 < end &&
     283            0 :                     (unsigned char)*(p + 1) == '\\') { p += 2; break; }
     284            0 :                 p++;
     285              :             }
     286            0 :             continue;
     287              :         }
     288              :         /* Decode one UTF-8 codepoint */
     289              :         uint32_t cp; int sl;
     290       315971 :         if      (c < 0x80) { cp = c;        sl = 1; }
     291         6064 :         else if (c < 0xC2) { cp = 0xFFFD;   sl = 1; }
     292         6064 :         else if (c < 0xE0) { cp = c & 0x1F; sl = 2; }
     293         5860 :         else if (c < 0xF0) { cp = c & 0x0F; sl = 3; }
     294            0 :         else if (c < 0xF8) { cp = c & 0x07; sl = 4; }
     295            0 :         else               { cp = 0xFFFD;   sl = 1; }
     296       327895 :         for (int i = 1; i < sl && p + i < end; i++) {
     297        11924 :             if (((unsigned char)p[i] & 0xC0) != 0x80) { sl = i; cp = 0xFFFD; break; }
     298        11924 :             cp = (cp << 6) | ((unsigned char)p[i] & 0x3F);
     299              :         }
     300       315971 :         int w = terminal_wcwidth((wchar_t)cp);
     301       315971 :         if (w > 0) cols += w;
     302       315971 :         p += sl;
     303              :     }
     304         7512 :     return cols;
     305              : }
     306              : 
     307              : /**
     308              :  * Count total visual (physical terminal) rows that 'body' occupies when
     309              :  * rendered in a terminal of 'term_cols' columns.  A logical line whose
     310              :  * visible width exceeds term_cols wraps onto ceil(width/term_cols) rows.
     311              :  * Semantics mirror count_lines: each newline-terminated segment plus the
     312              :  * final segment (even if empty) each contribute at least 1 visual row.
     313              :  */
     314           66 : static int count_visual_rows(const char *body, int term_cols) {
     315           66 :     if (!body || !*body || term_cols <= 0) return 0;
     316           66 :     int total = 0;
     317           66 :     const char *p = body;
     318         1102 :     for (;;) {
     319         1168 :         const char *eol = strchr(p, '\n');
     320         1168 :         const char *seg_end = eol ? eol : (p + strlen(p));
     321         1168 :         int cols = visible_line_cols(p, seg_end);
     322          908 :         int rows = (cols == 0 || cols <= term_cols) ? 1
     323         2076 :                    : (cols + term_cols - 1) / term_cols;
     324         1168 :         total += rows;
     325         1168 :         if (!eol) break;
     326         1102 :         p = eol + 1;
     327              :     }
     328           66 :     return total;
     329              : }
     330              : 
     331              : /* ── Interactive pager helpers ───────────────────────────────────────── */
     332              : 
     333              : /**
     334              :  * Print a reverse-video status bar at terminal row trows, exactly width columns wide.
     335              :  * text must not contain ANSI escapes that move the cursor off the line.
     336              :  */
     337              : /**
     338              :  * Return a pointer one past the last byte of @p text that still fits in
     339              :  * @p max_cols visible columns, skipping ANSI escape sequences.
     340              :  * The returned slice can be fputs'd directly; its visible width is <= max_cols.
     341              :  */
     342         2043 : static const char *text_end_at_cols(const char *text, int max_cols) {
     343         2043 :     const char *p = text;
     344         2043 :     int cols = 0;
     345       187347 :     while (*p) {
     346       185644 :         unsigned char c = (unsigned char)*p;
     347              :         /* Skip ANSI CSI escape */
     348       185644 :         if (c == 0x1b && (unsigned char)*(p + 1) == '[') {
     349            0 :             const char *q = p + 2;
     350            0 :             while (*q && ((unsigned char)*q < 0x40 || (unsigned char)*q > 0x7e))
     351            0 :                 q++;
     352            0 :             if (*q) q++;
     353            0 :             p = q;
     354            0 :             continue;
     355              :         }
     356              :         /* Decode UTF-8 codepoint width */
     357              :         uint32_t cp; int sl;
     358       185644 :         if      (c < 0x80) { cp = c;        sl = 1; }
     359         4086 :         else if (c < 0xC2) { cp = 0xFFFD;   sl = 1; }
     360         4086 :         else if (c < 0xE0) { cp = c & 0x1F; sl = 2; }
     361         4086 :         else if (c < 0xF0) { cp = c & 0x0F; sl = 3; }
     362            0 :         else if (c < 0xF8) { cp = c & 0x07; sl = 4; }
     363            0 :         else               { cp = 0xFFFD;   sl = 1; }
     364       193816 :         for (int i = 1; i < sl && p[i]; i++) {
     365         8172 :             if (((unsigned char)p[i] & 0xC0) != 0x80) { sl = i; cp = 0xFFFD; break; }
     366         8172 :             cp = (cp << 6) | ((unsigned char)p[i] & 0x3F);
     367              :         }
     368       185644 :         int w = terminal_wcwidth((wchar_t)cp);
     369       185644 :         if (w > 0 && cols + w > max_cols) break;
     370       185304 :         if (w > 0) cols += w;
     371       185304 :         p += sl;
     372              :     }
     373         2043 :     return p;
     374              : }
     375              : 
     376         2044 : static void print_statusbar(int trows, int width, const char *text) {
     377         2044 :     fprintf(stderr, "\033[%d;1H\033[7m", trows);
     378         2043 :     const char *end = text_end_at_cols(text, width);
     379         2043 :     fwrite(text, 1, (size_t)(end - text), stderr);
     380         2043 :     int used = visible_line_cols(text, end);
     381         2043 :     int pad  = width - used;
     382        44864 :     for (int i = 0; i < pad; i++) fputc(' ', stderr);
     383         2039 :     fprintf(stderr, "\033[0m");
     384         2039 :     fflush(stderr);
     385         2039 : }
     386              : 
     387              : /**
     388              :  * Print a plain (non-reverse) info line at terminal row trows-1.
     389              :  * Used as the second-from-bottom status row for persistent informational messages.
     390              :  * If text is empty, the line is cleared to blank.
     391              :  */
     392          367 : static void print_infoline(int trows, int width, const char *text) {
     393          367 :     fprintf(stderr, "\033[%d;1H\033[0m", trows - 1);
     394          424 :     if (text && *text) {
     395           58 :         fputs(text, stderr);
     396           58 :         int used = visible_line_cols(text, text + strlen(text));
     397           58 :         int pad  = width - used;
     398         5584 :         for (int i = 0; i < pad; i++) fputc(' ', stderr);
     399              :     } else {
     400        31955 :         for (int i = 0; i < width; i++) fputc(' ', stderr);
     401              :     }
     402          341 :     fprintf(stderr, "\033[0m");
     403          341 :     fflush(stderr);
     404          341 : }
     405              : 
     406              : /**
     407              :  * Show a two-column help popup overlay and wait for any key to dismiss.
     408              :  *
     409              :  * @param title  Title displayed in the popup header.
     410              :  * @param rows   Array of {key_label, description} string pairs.
     411              :  * @param n      Number of rows.
     412              :  */
     413            7 : static void show_help_popup(const char *title,
     414              :                             const char *rows[][2], int n) {
     415            7 :     int tcols = terminal_cols();
     416            7 :     int trows = terminal_rows();
     417            7 :     if (tcols <= 0) tcols = 80;
     418            7 :     if (trows <= 0) trows = 24;
     419              : 
     420            7 :     int key_col_w  = 12;
     421            7 :     int desc_col_w = 44;
     422            7 :     int inner_w    = key_col_w + 2 + desc_col_w;
     423            7 :     int box_w      = inner_w + 4;
     424              : 
     425              :     /* How many data rows fit: total rows minus top border, title, separator,
     426              :      * bottom border, and footer line. */
     427            7 :     int max_data  = trows - 5;
     428            7 :     if (max_data < 1) max_data = 1;
     429            7 :     int visible_n = (n < max_data) ? n : max_data;
     430            7 :     int box_h     = visible_n + 4;
     431              : 
     432            7 :     int col0 = (tcols - box_w) / 2;
     433            7 :     int row0 = (trows - box_h) / 2;
     434            7 :     if (col0 < 1) col0 = 1;
     435            7 :     if (row0 < 1) row0 = 1;
     436              : 
     437            7 :     int scroll = 0;  /* index of first visible row */
     438              : 
     439            0 :     for (;;) {
     440              :         /* Top border */
     441            7 :         fprintf(stderr, "\033[%d;%dH\033[7m\u250c", row0, col0);
     442          427 :         for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
     443            7 :         fprintf(stderr, "\u2510\033[0m");
     444              : 
     445              :         /* Title row */
     446            7 :         fprintf(stderr, "\033[%d;%dH\033[7m\u2502 ", row0 + 1, col0);
     447            7 :         int tlen      = (int)strlen(title);
     448            7 :         int pad_left  = (box_w - 4 - tlen) / 2;
     449            7 :         int pad_right = (box_w - 4 - tlen) - pad_left;
     450          133 :         for (int i = 0; i < pad_left;  i++) fputc(' ', stderr);
     451            7 :         fprintf(stderr, "%s", title);
     452          133 :         for (int i = 0; i < pad_right; i++) fputc(' ', stderr);
     453            7 :         fprintf(stderr, " \u2502\033[0m");
     454              : 
     455              :         /* Separator */
     456            7 :         fprintf(stderr, "\033[%d;%dH\033[7m\u251c", row0 + 2, col0);
     457          427 :         for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
     458            7 :         fprintf(stderr, "\u2524\033[0m");
     459              : 
     460              :         /* Data rows */
     461          123 :         for (int i = 0; i < visible_n; i++) {
     462          116 :             int ri = scroll + i;
     463          116 :             fprintf(stderr, "\033[%d;%dH\033[7m\u2502 ", row0 + 3 + i, col0);
     464          116 :             fprintf(stderr, "\033[1m%-*.*s\033[22m", key_col_w, key_col_w, rows[ri][0]);
     465          116 :             fprintf(stderr, "  ");
     466          116 :             fprintf(stderr, "%-*.*s", desc_col_w, desc_col_w, rows[ri][1]);
     467          116 :             fprintf(stderr, " \u2502\033[0m");
     468              :         }
     469              : 
     470              :         /* Bottom border */
     471            7 :         fprintf(stderr, "\033[%d;%dH\033[7m\u2514", row0 + 3 + visible_n, col0);
     472          427 :         for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
     473            7 :         fprintf(stderr, "\u2518\033[0m");
     474              : 
     475              :         /* Footer */
     476            7 :         const char *footer = (n > visible_n)
     477              :             ? " \u2191\u2193/j/k=scroll  any other key=close "
     478            7 :             : " Press any key to close ";
     479            7 :         int flen = (int)strlen(footer);
     480            7 :         if (flen < box_w - 2) {
     481            7 :             int fc = col0 + (box_w - flen) / 2;
     482            7 :             fprintf(stderr, "\033[%d;%dH\033[2m%s\033[0m",
     483            7 :                     row0 + 4 + visible_n, fc, footer);
     484              :         }
     485            7 :         fflush(stderr);
     486              : 
     487            7 :         TermKey key = terminal_read_key();
     488            5 :         int ch = terminal_last_printable();
     489            5 :         if (n > visible_n) {
     490            1 :             if (key == TERM_KEY_NEXT_LINE || ch == 'j') {
     491            0 :                 if (scroll + visible_n < n) scroll++;
     492            0 :                 continue;
     493              :             }
     494            1 :             if (key == TERM_KEY_PREV_LINE || ch == 'k') {
     495            0 :                 if (scroll > 0) scroll--;
     496            0 :                 continue;
     497              :             }
     498              :         }
     499            5 :         break;  /* any other key closes */
     500              :     }
     501              : 
     502              :     /* Clear the popup area (include footer row) */
     503          113 :     for (int r = row0; r <= row0 + 5 + visible_n; r++) {
     504          108 :         fprintf(stderr, "\033[%d;%dH\033[K", r, col0);
     505         6804 :         for (int c = 0; c < box_w; c++) fputc(' ', stderr);
     506              :     }
     507            5 :     fflush(stderr);
     508            5 : }
     509              : 
     510              : /**
     511              :  * ANSI SGR state tracked while scanning skipped body lines.
     512              :  * Only the subset emitted by html_render() is handled.
     513              :  */
     514              : typedef struct {
     515              :     int bold, italic, uline, strike;
     516              :     int fg_on; int fg_r, fg_g, fg_b;
     517              :     int bg_on; int bg_r, bg_g, bg_b;
     518              : } AnsiState;
     519              : 
     520              : /** Scan bytes [begin, end) for SGR sequences and update *st. */
     521            5 : static void ansi_scan(const char *begin, const char *end, AnsiState *st)
     522              : {
     523            5 :     const char *p = begin;
     524          689 :     while (p < end) {
     525          684 :         if (*p != '\033' || p + 1 >= end || *(p+1) != '[') { p++; continue; }
     526           60 :         p += 2;
     527           60 :         char seq[64]; int si = 0;
     528          203 :         while (p < end && *p != 'm' && si < 62) seq[si++] = *p++;
     529           60 :         seq[si] = '\0';
     530           60 :         if (p < end && *p == 'm') p++;
     531           60 :         if      (!strcmp(seq,"0"))   { st->bold=0; st->italic=0; st->uline=0;
     532            0 :                                        st->strike=0; st->fg_on=0; st->bg_on=0; }
     533           60 :         else if (!strcmp(seq,"1"))   { st->bold   = 1; }
     534           49 :         else if (!strcmp(seq,"22"))  { st->bold   = 0; }
     535           38 :         else if (!strcmp(seq,"3"))   { st->italic = 1; }
     536           28 :         else if (!strcmp(seq,"23"))  { st->italic = 0; }
     537           19 :         else if (!strcmp(seq,"4"))   { st->uline  = 1; }
     538           15 :         else if (!strcmp(seq,"24"))  { st->uline  = 0; }
     539           14 :         else if (!strcmp(seq,"9"))   { st->strike = 1; }
     540           12 :         else if (!strcmp(seq,"29"))  { st->strike = 0; }
     541           10 :         else if (!strcmp(seq,"39"))  { st->fg_on  = 0; }
     542            5 :         else if (!strcmp(seq,"49"))  { st->bg_on  = 0; }
     543            5 :         else if (!strncmp(seq,"38;2;",5)) {
     544            5 :             st->fg_on = 1;
     545            5 :             sscanf(seq+5, "%d;%d;%d", &st->fg_r, &st->fg_g, &st->fg_b);
     546              :         }
     547            0 :         else if (!strncmp(seq,"48;2;",5)) {
     548            0 :             st->bg_on = 1;
     549            0 :             sscanf(seq+5, "%d;%d;%d", &st->bg_r, &st->bg_g, &st->bg_b);
     550              :         }
     551              :     }
     552            5 : }
     553              : 
     554              : /** Re-emit escapes needed to restore *st on a freshly-reset terminal. */
     555            5 : static void ansi_replay(const AnsiState *st)
     556              : {
     557            5 :     if (st->bold)   printf("\033[1m");
     558            5 :     if (st->italic) printf("\033[3m");
     559            5 :     if (st->uline)  printf("\033[4m");
     560            5 :     if (st->strike) printf("\033[9m");
     561            5 :     if (st->fg_on)  printf("\033[38;2;%d;%d;%dm", st->fg_r, st->fg_g, st->fg_b);
     562            5 :     if (st->bg_on)  printf("\033[48;2;%d;%d;%dm", st->bg_r, st->bg_g, st->bg_b);
     563            5 : }
     564              : 
     565              : /**
     566              :  * Print up to 'vrow_budget' visual rows from 'body', starting at visual
     567              :  * row 'from_vrow'.  A logical line whose visible width exceeds 'term_cols'
     568              :  * counts as ceil(width/term_cols) visual rows.
     569              :  *
     570              :  * Replays any ANSI SGR state accumulated in skipped content so that
     571              :  * multi-line styled spans remain correct across page boundaries.
     572              :  *
     573              :  * At least one logical line is always shown even if it alone exceeds the
     574              :  * budget (ensures very long URLs are never silently skipped).
     575              :  */
     576           41 : static void print_body_page(const char *body, int from_vrow, int vrow_budget,
     577              :                              int term_cols) {
     578           41 :     if (!body) return;
     579              : 
     580              :     /* ── Skip to from_vrow ──────────────────────────────────────────── */
     581           41 :     const char *p = body;
     582           41 :     int vrow = 0;
     583           76 :     while (*p) {
     584           76 :         const char *eol = strchr(p, '\n');
     585           76 :         const char *seg = eol ? eol : (p + strlen(p));
     586           76 :         int cols = visible_line_cols(p, seg);
     587           62 :         int rows = (cols == 0 || (term_cols > 0 && cols <= term_cols)) ? 1
     588          138 :                    : (cols + term_cols - 1) / term_cols;
     589           76 :         if (vrow + rows > from_vrow) break;   /* this line spans from_vrow */
     590           35 :         vrow += rows;
     591           35 :         p = eol ? eol + 1 : seg;
     592           35 :         if (!eol) break;
     593              :     }
     594              : 
     595              :     /* Restore ANSI state that was active at the start of the visible region */
     596           41 :     if (p > body) {
     597            5 :         AnsiState st = {0};
     598            5 :         ansi_scan(body, p, &st);
     599            5 :         ansi_replay(&st);
     600              :     }
     601              : 
     602              :     /* ── Display up to vrow_budget visual rows ───────────────────────── */
     603           41 :     int displayed = 0;
     604           41 :     int any_shown = 0;
     605          551 :     while (*p) {
     606          548 :         const char *eol = strchr(p, '\n');
     607          548 :         const char *seg = eol ? eol : (p + strlen(p));
     608          548 :         int cols = visible_line_cols(p, seg);
     609          439 :         int rows = (cols == 0 || (term_cols > 0 && cols <= term_cols)) ? 1
     610          987 :                    : (cols + term_cols - 1) / term_cols;
     611              : 
     612              :         /* Stop when budget exhausted, but always show at least one line */
     613          548 :         if (any_shown && displayed + rows > vrow_budget) break;
     614              : 
     615          511 :         if (eol) {
     616          509 :             printf("%.*s\n", (int)(eol - p), p);
     617          508 :             p = eol + 1;
     618              :         } else {
     619            2 :             printf("%s\n", p);
     620            2 :             p += strlen(p);
     621              :         }
     622          510 :         displayed += rows;
     623          510 :         any_shown = 1;
     624              :     }
     625              : }
     626              : 
     627              : /* ── Mail client helpers ─────────────────────────────────────────────── */
     628              : 
     629          430 : static MailClient *make_mail(const Config *cfg) {
     630          430 :     return mail_client_connect((Config *)cfg);
     631              : }
     632              : 
     633              : /* ── Folder status ───────────────────────────────────────────────────── */
     634              : 
     635              : typedef struct { int messages; int unseen; int flagged; } FolderStatus;
     636              : 
     637              : /** Read total, unseen and flagged counts for each folder/label from local storage.
     638              :  *  Instant — no server connection needed.
     639              :  *  IMAP: reads per-folder manifests.
     640              :  *  Gmail: total from .idx; unseen = L∩UNREAD, flagged = L∩STARRED (both via
     641              :  *         merge-join on sorted index files — accurate, no server contact needed).
     642              :  *  Returns heap-allocated array; caller must free(). */
     643          207 : static FolderStatus *fetch_all_folder_statuses(const Config *cfg,
     644              :                                                 char **folders, int count) {
     645          207 :     FolderStatus *st = calloc((size_t)count, sizeof(FolderStatus));
     646          207 :     if (!st || count == 0) return st;
     647          207 :     if (cfg->gmail_mode) {
     648              :         /* Load UNREAD and STARRED indexes once; reuse across all labels. */
     649            0 :         char (*unread_uids)[17]  = NULL; int unread_count  = 0;
     650            0 :         char (*starred_uids)[17] = NULL; int starred_count = 0;
     651            0 :         label_idx_load("UNREAD",  &unread_uids,  &unread_count);
     652            0 :         label_idx_load("STARRED", &starred_uids, &starred_count);
     653              : 
     654            0 :         for (int i = 0; i < count; i++) {
     655              :             /* TRASH and SPAM use underscore-prefixed local index names. */
     656            0 :             const char *idx_name = folders[i];
     657            0 :             if (strcmp(folders[i], "TRASH") == 0) idx_name = "_trash";
     658            0 :             else if (strcmp(folders[i], "SPAM") == 0) idx_name = "_spam";
     659              : 
     660              :             /* User labels have internal IDs ("Label_xxxxxxxx") that differ
     661              :              * from their display names ("Felújítás").  .idx files are keyed
     662              :              * by ID, so translate display name → ID for the lookup. */
     663            0 :             char *id_alloc = (idx_name == folders[i])
     664            0 :                              ? local_gmail_label_id_lookup(idx_name)
     665            0 :                              : NULL;
     666            0 :             if (id_alloc) idx_name = id_alloc;
     667              : 
     668            0 :             st[i].messages = label_idx_count(idx_name);
     669            0 :             st[i].unseen   = label_idx_intersect_count(idx_name,
     670              :                                  (const char (*)[17])unread_uids,  unread_count);
     671            0 :             st[i].flagged  = label_idx_intersect_count(idx_name,
     672              :                                  (const char (*)[17])starred_uids, starred_count);
     673            0 :             free(id_alloc);
     674              :         }
     675            0 :         free(unread_uids);
     676            0 :         free(starred_uids);
     677              :     } else {
     678         1863 :         for (int i = 0; i < count; i++)
     679         1656 :             manifest_count_folder(folders[i], &st[i].messages,
     680         1656 :                                   &st[i].unseen, &st[i].flagged);
     681              :     }
     682          207 :     return st;
     683              : }
     684              : 
     685              : /** Fetches headers or full message for a UID in <folder>.  Caller must free.
     686              :  *  Opens a new mail client connection each call.  For bulk fetching (sync), use
     687              :  *  a shared connection. */
     688           55 : static char *fetch_uid_content_in(const Config *cfg, const char *folder,
     689              :                                   const char *uid, int headers_only) {
     690          110 :     RAII_MAIL MailClient *mc = make_mail(cfg);
     691           55 :     if (!mc) return NULL;
     692           38 :     if (mail_client_select(mc, folder) != 0) return NULL;
     693           15 :     return headers_only ? mail_client_fetch_headers(mc, uid)
     694           53 :                         : mail_client_fetch_body(mc, uid);
     695              : }
     696              : 
     697              : /* ── Cached header fetch ─────────────────────────────────────────────── */
     698              : 
     699              : /** Fetches headers for uid/folder, using the header cache. Caller must free. */
     700           74 : static char *fetch_uid_headers_cached(const Config *cfg, const char *folder,
     701              :                                        const char *uid) {
     702           74 :     if (local_hdr_exists(folder, uid))
     703           42 :         return local_hdr_load(folder, uid);
     704           32 :     char *hdrs = fetch_uid_content_in(cfg, folder, uid, 1);
     705           32 :     if (hdrs)
     706           15 :         local_hdr_save(folder, uid, hdrs, strlen(hdrs));
     707           32 :     return hdrs;
     708              : }
     709              : 
     710              : /**
     711              :  * Like fetch_uid_headers_cached but uses an already-connected and folder-selected
     712              :  * MailClient instead of opening a new connection.  Falls back to the cache first.
     713              :  * Caller must free the returned string.
     714              :  */
     715          709 : static char *fetch_uid_headers_via(MailClient *mc, const char *folder, const char *uid) {
     716          709 :     if (local_hdr_exists(folder, uid))
     717          208 :         return local_hdr_load(folder, uid);
     718          501 :     char *hdrs = mail_client_fetch_headers(mc, uid);
     719          501 :     if (hdrs)
     720          501 :         local_hdr_save(folder, uid, hdrs, strlen(hdrs));
     721          501 :     return hdrs;
     722              : }
     723              : 
     724              : /* ── Show helpers ────────────────────────────────────────────────────── */
     725              : 
     726              : #define SHOW_WIDTH 80
     727              : #define SHOW_SEPARATOR \
     728              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     729              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     730              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     731              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     732              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     733              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     734              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" \
     735              :     "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"
     736              : 
     737              : /*
     738              :  * Print s (cleaning control chars), truncating at max_cols display columns.
     739              :  * Falls back to `fallback` if s is NULL.  Uses terminal_wcwidth for accurate
     740              :  * multi-byte / wide-character measurement.
     741              :  */
     742          337 : static void print_clean(const char *s, const char *fallback, int max_cols) {
     743          337 :     const unsigned char *p = (const unsigned char *)(s ? s : fallback);
     744          337 :     int col = 0;
     745        10261 :     while (*p) {
     746              :         uint32_t cp; int sl;
     747         9924 :         if      (*p < 0x80) { cp = *p;        sl = 1; }
     748            0 :         else if (*p < 0xC2) { cp = 0xFFFD;    sl = 1; }
     749            0 :         else if (*p < 0xE0) { cp = *p & 0x1F; sl = 2; }
     750            0 :         else if (*p < 0xF0) { cp = *p & 0x0F; sl = 3; }
     751            0 :         else if (*p < 0xF8) { cp = *p & 0x07; sl = 4; }
     752            0 :         else                { cp = 0xFFFD;    sl = 1; }
     753         9924 :         for (int i = 1; i < sl; i++) {
     754            0 :             if ((p[i] & 0xC0) != 0x80) { sl = i; cp = 0xFFFD; break; }
     755            0 :             cp = (cp << 6) | (p[i] & 0x3F);
     756              :         }
     757         9924 :         int w = terminal_wcwidth(cp);
     758         9924 :         if (w < 0) w = 0;
     759         9924 :         if (col + w > max_cols) break;
     760         9924 :         if (cp < 0x20 && cp != '\t') putchar(' ');
     761         9924 :         else fwrite(p, 1, (size_t)sl, stdout);
     762         9924 :         col += w;
     763         9924 :         p   += sl;
     764              :     }
     765          337 : }
     766              : 
     767           69 : static void print_show_headers(const char *from, const char *to,
     768              :                                 const char *subject,
     769              :                                 const char *date, const char *uid,
     770              :                                 const char *labels,
     771              :                                 const char *file_path,
     772              :                                 const char *dmarc_desc,
     773              :                                 const char *attachments) {
     774              :     /* label = 9 chars ("From:    "), remaining = SHOW_WIDTH - 9 = 71 */
     775           69 :     printf("From:    "); print_clean(from,    "(none)", SHOW_WIDTH - 9); putchar('\n');
     776           69 :     if (to && to[0]) {
     777            6 :         printf("To:      "); print_clean(to, "", SHOW_WIDTH - 9); putchar('\n');
     778              :     }
     779           69 :     printf("Subject: "); print_clean(subject, "(none)", SHOW_WIDTH - 9); putchar('\n');
     780           69 :     printf("Date:    "); print_clean(date,    "(none)", SHOW_WIDTH - 9); putchar('\n');
     781           69 :     printf("UID:     %s\n", uid ? uid : "(none)");
     782           69 :     if (labels && labels[0])
     783            6 :         printf("Labels:  %s\n", labels);
     784           69 :     if (file_path && file_path[0])
     785           69 :         printf("File:    %s\n", file_path);
     786           69 :     if (dmarc_desc && dmarc_desc[0]) {
     787           69 :         printf("DMARC:   "); print_clean(dmarc_desc, "", SHOW_WIDTH - 9); putchar('\n');
     788              :     }
     789           69 :     if (attachments && attachments[0]) {
     790           55 :         printf("Attach:  "); print_clean(attachments, "", SHOW_WIDTH - 9); putchar('\n');
     791              :     }
     792           69 :     printf(SHOW_SEPARATOR);
     793           69 : }
     794              : 
     795              : /* Find the line number (0-based) in body containing term.
     796              :  * Searches forward (dir >= 0) or backward (dir < 0) from from_line.
     797              :  * Wraps around. Returns -1 if no match. */
     798            2 : static int find_match_line(const char *body, const char *term, int from_line, int dir) {
     799            2 :     if (!term || !*term || !body) return -1;
     800            2 :     int matches[8192]; int nm = 0;
     801            2 :     const char *p = body; int ln = 0;
     802           38 :     while (*p && nm < 8192) {
     803           36 :         const char *nl = strchr(p, '\n');
     804           36 :         size_t llen = nl ? (size_t)(nl - p) : strlen(p);
     805              :         char tmp[512];
     806           36 :         size_t cp = llen < sizeof(tmp) - 1 ? llen : sizeof(tmp) - 1;
     807           36 :         memcpy(tmp, p, cp); tmp[cp] = '\0';
     808           36 :         if (strcasestr(tmp, term)) matches[nm++] = ln;
     809           36 :         p = nl ? nl + 1 : p + strlen(p);
     810           36 :         ln++;
     811              :     }
     812            2 :     if (nm == 0) return -1;
     813            1 :     if (dir >= 0) {
     814            1 :         for (int i = 0; i < nm; i++) if (matches[i] > from_line) return matches[i];
     815            0 :         return matches[0]; /* wrap */
     816              :     } else {
     817            0 :         for (int i = nm - 1; i >= 0; i--) if (matches[i] < from_line) return matches[i];
     818            0 :         return matches[nm - 1]; /* wrap */
     819              :     }
     820              : }
     821              : 
     822              : /* ── Attachment picker ───────────────────────────────────────────────── */
     823              : 
     824              : 
     825              : /* Determine the best directory to save attachments into.
     826              :  * Prefers ~/Downloads if it exists, else falls back to ~.
     827              :  * Returns a heap-allocated string the caller must free(). */
     828            5 : static char *attachment_save_dir(void) {
     829            5 :     const char *home = platform_home_dir();
     830            5 :     if (!home) return strdup(".");
     831              :     char dl[1024];
     832            5 :     snprintf(dl, sizeof(dl), "%s/Downloads", home);
     833              :     struct stat st;
     834            5 :     if (stat(dl, &st) == 0 && S_ISDIR(st.st_mode))
     835            0 :         return strdup(dl);
     836            5 :     return strdup(home);
     837              : }
     838              : 
     839              : /* Sanitise a filename component for use in a path (strip path separators). */
     840           10 : static char *safe_filename_for_path(const char *name) {
     841           10 :     if (!name || !*name) return strdup("attachment");
     842           10 :     char *s = strdup(name);
     843           10 :     if (!s) return NULL;
     844          118 :     for (char *p = s; *p; p++)
     845          108 :         if (*p == '/' || *p == '\\') *p = '_';
     846           10 :     return s;
     847              : }
     848              : 
     849              : /* Attachment picker: full-screen list, navigate with arrows, Enter to select.
     850              :  * Returns selected index (0-based), or -1 if Backspace (back), -2 if ESC/Quit. */
     851            3 : static int show_attachment_picker(const MimeAttachment *atts, int count,
     852              :                                   int tcols, int trows) {
     853            3 :     int cursor = 0;
     854            0 :     for (;;) {
     855            3 :         printf("\033[0m\033[H\033[2J");
     856            3 :         printf("  Attachments (%d):\n\n", count);
     857            9 :         for (int i = 0; i < count; i++) {
     858            6 :             const char *name  = atts[i].filename     ? atts[i].filename     : "(no name)";
     859            6 :             const char *ctype = atts[i].content_type ? atts[i].content_type : "";
     860              :             char sz[32];
     861            6 :             if (atts[i].size >= 1024 * 1024)
     862            0 :                 snprintf(sz, sizeof(sz), "%.1f MB",
     863            0 :                          (double)atts[i].size / (1024.0 * 1024.0));
     864            6 :             else if (atts[i].size >= 1024)
     865            0 :                 snprintf(sz, sizeof(sz), "%.0f KB",
     866            0 :                          (double)atts[i].size / 1024.0);
     867              :             else
     868            6 :                 snprintf(sz, sizeof(sz), "%zu B", atts[i].size);
     869              : 
     870            6 :             if (i == cursor)
     871            3 :                 printf("  \033[7m> %-36s  %-28s  %8s\033[0m\n", name, ctype, sz);
     872              :             else
     873            3 :                 printf("    %-36s  %-28s  %8s\n", name, ctype, sz);
     874              :         }
     875            3 :         fflush(stdout);
     876              :         char sb[160];
     877            3 :         snprintf(sb, sizeof(sb),
     878              :                  "  \u2191\u2193=select  Enter=choose  Backspace=back  ESC=quit");
     879            3 :         print_statusbar(trows, tcols, sb);
     880              : 
     881            3 :         TermKey key = terminal_read_key();
     882            3 :         switch (key) {
     883            3 :         case TERM_KEY_BACK:    return -1;
     884            0 :         case TERM_KEY_ESC:
     885            0 :         case TERM_KEY_QUIT:    return -2;
     886            2 :         case TERM_KEY_ENTER:   return cursor;
     887            0 :         case TERM_KEY_NEXT_LINE:
     888              :         case TERM_KEY_NEXT_PAGE:
     889            0 :             if (cursor < count - 1) cursor++;
     890            0 :             break;
     891            0 :         case TERM_KEY_PREV_LINE:
     892              :         case TERM_KEY_PREV_PAGE:
     893            0 :             if (cursor > 0) cursor--;
     894            0 :             break;
     895            0 :         default: break;
     896              :         }
     897              :     }
     898              : }
     899              : 
     900              : /**
     901              :  * Show a message in interactive pager mode.
     902              :  * Returns 0 = back to list (Backspace/ESC/q), 2 = reply, 5 = forward, -1 = error.
     903              :  * mc may be NULL (operations then queue for background sync).
     904              :  * initial_flags: caller-supplied MSG_FLAG_* bitmask (used for IMAP where .hdr
     905              :  *   does not carry a flags field).
     906              :  * flags_out: if non-NULL, receives the final flag state on exit.
     907              :  */
     908           25 : static int show_uid_interactive(const Config *cfg, MailClient *mc,
     909              :                                 const char *folder,
     910              :                                 const char *uid, int page_size,
     911              :                                 int initial_flags, int *flags_out) {
     912           25 :     char *raw = NULL;
     913              :     /* For Gmail (and virtual flag views) the .eml is stored under the empty
     914              :      * folder regardless of which label manifest the entry came from. */
     915           25 :     const char *load_folder = folder;
     916           25 :     if (!local_msg_exists(folder, uid) && cfg->gmail_mode
     917            0 :             && local_msg_exists("", uid))
     918            0 :         load_folder = "";
     919           25 :     if (local_msg_exists(load_folder, uid)) {
     920           25 :         raw = local_msg_load(load_folder, uid);
     921            0 :     } else if (mc) {
     922            0 :         if (mail_client_select(mc, folder) == 0)
     923            0 :             raw = mail_client_fetch_body(mc, uid);
     924            0 :         if (raw) {
     925            0 :             local_msg_save(folder, uid, raw, strlen(raw));
     926            0 :             local_index_update(folder, uid, raw);
     927              :         }
     928              :     } else {
     929            0 :         raw = fetch_uid_content_in(cfg, folder, uid, 0);
     930            0 :         if (raw) {
     931            0 :             local_msg_save(folder, uid, raw, strlen(raw));
     932            0 :             local_index_update(folder, uid, raw);
     933              :         }
     934              :     }
     935           25 :     if (!raw) {
     936            0 :         fprintf(stderr, "Could not load UID %s.\n", uid);
     937            0 :         return -1;
     938              :     }
     939              : 
     940           25 :     char *from_raw = mime_get_header(raw, "From");
     941           25 :     char *from     = from_raw ? mime_decode_words(from_raw) : NULL;
     942           25 :     free(from_raw);
     943           25 :     char *subj_raw = mime_get_header(raw, "Subject");
     944           25 :     char *subject  = subj_raw ? mime_decode_words(subj_raw) : NULL;
     945           25 :     free(subj_raw);
     946           25 :     char *date_raw = mime_get_header(raw, "Date");
     947           25 :     char *date     = date_raw ? mime_format_date(date_raw) : NULL;
     948           25 :     free(date_raw);
     949              :     /* Gmail: load labels from .hdr cache for display in reader header */
     950           25 :     char *show_labels = cfg->gmail_mode ? local_hdr_get_labels("", uid) : NULL;
     951              :     /* To: field for reader header */
     952           25 :     char *to_raw  = mime_get_header(raw, "To");
     953           25 :     char *show_to = to_raw ? mime_decode_words(to_raw) : NULL;
     954           25 :     free(to_raw);
     955              :     /* Local .eml file path and DMARC description for the reader header */
     956           25 :     char *show_path = local_msg_path(folder, uid);
     957           25 :     char *ar_hdr    = mime_get_header(raw, "Authentication-Results");
     958           25 :     char *show_dmarc = mime_describe_dmarc(ar_hdr);
     959           25 :     free(ar_hdr);
     960              :     /* Load current flags for 'f' / 'n' / 'd' toggle operations.
     961              :      * For Gmail: .hdr contains a flags field — use it if available.
     962              :      * For IMAP:  .hdr contains raw RFC 2822 headers without flags — use
     963              :      *            the caller-supplied initial_flags instead. */
     964           25 :     int reader_flags = initial_flags;
     965           25 :     if (cfg->gmail_mode) {
     966            0 :         char *hdr = local_hdr_load("", uid);
     967            0 :         if (hdr) {
     968            0 :             char *last_tab = strrchr(hdr, '\t');
     969            0 :             if (last_tab) reader_flags = atoi(last_tab + 1);
     970            0 :             free(hdr);
     971              :         }
     972              :     }
     973           25 :     int term_cols = terminal_cols();
     974           25 :     int term_rows = terminal_rows();
     975           25 :     if (term_cols <= 0) term_cols = 80;
     976           25 :     if (term_rows <= 0) term_rows = page_size;
     977           25 :     int wrap_cols = term_cols > SHOW_WIDTH ? SHOW_WIDTH : term_cols;
     978           25 :     char *body = NULL;
     979           25 :     char *html_raw = mime_get_html_part(raw);
     980           25 :     if (html_raw) {
     981           24 :         body = html_render(html_raw, wrap_cols, 1);
     982           24 :         free(html_raw);
     983              :     } else {
     984              :         MimeTextInfo tinfo;
     985            1 :         char *plain = mime_get_text_body_ex(raw, &tinfo);
     986            1 :         if (plain) {
     987            1 :             warn_charset(&tinfo, uid);
     988            1 :             char *wrapped = word_wrap(plain, wrap_cols);
     989            1 :             if (wrapped) { free(plain); body = wrapped; }
     990            0 :             else body = plain;
     991              :         }
     992              :     }
     993           25 :     const char *body_text = body ? body : "(no readable text body)";
     994           25 :     char *body_wrapped = NULL; /* kept for free() at cleanup */
     995              : 
     996              :     /* Detect attachments once */
     997           25 :     int att_count = 0;
     998           25 :     MimeAttachment *atts = mime_list_attachments(raw, &att_count);
     999              :     /* Propagate attachment result back to caller via reader_flags */
    1000           25 :     if (att_count > 0)
    1001           24 :         reader_flags |= MSG_FLAG_ATTACH | MSG_FLAG_ATTACH_CHECKED;
    1002              :     else
    1003            1 :         reader_flags |= MSG_FLAG_ATTACH_CHECKED;
    1004              :     /* Build single-line attachment summary for reader header */
    1005           25 :     char attach_buf[256] = "";
    1006           25 :     if (att_count > 0) {
    1007           24 :         int pos = snprintf(attach_buf, sizeof(attach_buf),
    1008           24 :                            "%d file%s: ", att_count, att_count == 1 ? "" : "s");
    1009           72 :         for (int _i = 0; _i < att_count && pos < (int)sizeof(attach_buf) - 2; _i++) {
    1010           48 :             const char *fn = atts[_i].filename ? atts[_i].filename : "?";
    1011           48 :             if (_i > 0) { attach_buf[pos++] = ','; attach_buf[pos++] = ' '; attach_buf[pos] = '\0'; }
    1012           48 :             if (pos + (int)strlen(fn) > SHOW_WIDTH - 12) {
    1013            0 :                 snprintf(attach_buf + pos, sizeof(attach_buf) - (size_t)pos,
    1014              :                          "(+%d more)", att_count - _i);
    1015            0 :                 break;
    1016              :             }
    1017           48 :             size_t av = sizeof(attach_buf) - (size_t)pos - 1;
    1018           48 :             strncpy(attach_buf + pos, fn, av);
    1019           48 :             attach_buf[sizeof(attach_buf) - 1] = '\0';
    1020           48 :             pos += (int)strlen(fn);
    1021              :         }
    1022              :     }
    1023              : 
    1024              : /* Header: From+Subject+Date+UID+separator = 5 base rows; optional To/Labels/File/DMARC/Attach.
    1025              :  * Footer: info line (trows-1) + statusbar (trows) = 2 rows. */
    1026              : #define SHOW_HDR_LINES_INT 8  /* kept for #undef below */
    1027           25 :     int hdr_rows = 5;  /* From + Subject + Date + UID + separator */
    1028           25 :     if (show_to    && show_to[0])    hdr_rows++;
    1029           25 :     if (show_labels && show_labels[0]) hdr_rows++;
    1030           25 :     if (show_path  && show_path[0])  hdr_rows++;
    1031           25 :     if (show_dmarc && show_dmarc[0]) hdr_rows++;
    1032           25 :     if (att_count > 0)               hdr_rows++;
    1033           25 :     int rows_avail  = (term_rows > hdr_rows + 2) ? term_rows - hdr_rows - 2 : 1;
    1034           25 :     int view_raw    = 0; /* 0=rendered, 1=raw source */
    1035           25 :     int body_vrows  = count_visual_rows(body_text, term_cols);
    1036           25 :     int total_pages = (body_vrows + rows_avail - 1) / rows_avail;
    1037           25 :     if (total_pages < 1) total_pages = 1;
    1038              : 
    1039           25 :     char search_buf[256] = ""; /* last search term; empty = none */
    1040              : 
    1041              :     /* Persistent info message — stays until replaced by a newer one */
    1042           25 :     char info_msg[2048] = "";
    1043              : 
    1044           25 :     int result = 0;
    1045           41 :     for (int cur_line = 0;;) {
    1046              :         /* Recompute active body text and pagination for current view mode */
    1047           41 :         body_text  = view_raw ? raw : (body ? body : "(no readable text body)");
    1048           41 :         body_vrows = count_visual_rows(body_text, term_cols);
    1049           41 :         total_pages = (body_vrows + rows_avail - 1) / rows_avail;
    1050           41 :         if (total_pages < 1) total_pages = 1;
    1051              : 
    1052           41 :         printf("\033[0m\033[H\033[2J");     /* reset attrs + clear screen */
    1053           41 :         print_show_headers(from, show_to, subject, date, uid, show_labels, show_path, show_dmarc, attach_buf);
    1054           41 :         print_body_page(body_text, cur_line, rows_avail, term_cols);
    1055           40 :         printf("\033[0m");                  /* close any open ANSI from body */
    1056           40 :         fflush(stdout);
    1057              : 
    1058           40 :         int cur_page = cur_line / rows_avail + 1;
    1059              : 
    1060              :         /* Info line (second from bottom) — persistent until overwritten */
    1061           40 :         print_infoline(term_rows, wrap_cols, info_msg);
    1062              : 
    1063              :         /* Shortcut hints (bottom row) */
    1064              :         {
    1065              :             char sb[256];
    1066           39 :             int is_gmail = cfg->gmail_mode;
    1067           39 :             const char *vtog = view_raw ? "v=rendered" : "v=source";
    1068           39 :             if (is_gmail) {
    1069            0 :                 if (att_count > 0) {
    1070            0 :                     snprintf(sb, sizeof(sb),
    1071              :                              "-- [%d/%d] \u2191\u2193=scroll  F=fwd  r=rm-label  d=rm  D=trash"
    1072              :                              "  f=star  n=unread  a=arch  t=labels  A=save(%d)"
    1073              :                              "  /=search  %s  q=back  ESC=quit --",
    1074              :                              cur_page, total_pages, att_count, vtog);
    1075              :                 } else {
    1076            0 :                     snprintf(sb, sizeof(sb),
    1077              :                              "-- [%d/%d] \u2191\u2193=scroll  F=fwd  r=rm-label  d=rm  D=trash"
    1078              :                              "  f=star  n=unread  a=arch  t=labels"
    1079              :                              "  /=search  %s  q=back  ESC=quit --",
    1080              :                              cur_page, total_pages, vtog);
    1081              :                 }
    1082           39 :             } else if (att_count > 0) {
    1083           38 :                 snprintf(sb, sizeof(sb),
    1084              :                          "-- [%d/%d] \u2191\u2193=scroll  r=reply  F=fwd  f=star  n=unread"
    1085              :                          "  d=done  D=trash  a=save  A=save-all(%d)"
    1086              :                          "  /=search  %s  BS=list  ESC=quit --",
    1087              :                          cur_page, total_pages, att_count, vtog);
    1088              :             } else {
    1089            1 :                 snprintf(sb, sizeof(sb),
    1090              :                          "-- [%d/%d] \u2191\u2193=scroll  r=reply  F=fwd  f=star  n=unread"
    1091              :                          "  d=done  D=trash  /=search  %s  BS=list  ESC=quit --",
    1092              :                          cur_page, total_pages, vtog);
    1093              :             }
    1094           39 :             print_statusbar(term_rows, term_cols, sb);
    1095              :         }
    1096              : 
    1097           38 :         TermKey key = terminal_read_key();
    1098           30 :         fprintf(stderr, "\r\033[K");
    1099           30 :         fflush(stderr);
    1100              : 
    1101           30 :         switch (key) {
    1102            7 :         case TERM_KEY_BACK:
    1103              :         case TERM_KEY_QUIT:
    1104            7 :             result = 0;          /* back to list */
    1105            7 :             goto show_int_done;
    1106            3 :         case TERM_KEY_ESC:
    1107            3 :             result = 1;          /* exit program */
    1108            3 :             goto show_int_done;
    1109            2 :         case TERM_KEY_NEXT_PAGE:
    1110              :         {
    1111            2 :             int next = cur_line + rows_avail;
    1112            2 :             if (next < body_vrows) cur_line = next;
    1113              :         }
    1114            2 :             break;
    1115            0 :         case TERM_KEY_ENTER:
    1116            0 :             break;
    1117            1 :         case TERM_KEY_PREV_PAGE:
    1118            1 :             cur_line -= rows_avail;
    1119            1 :             if (cur_line < 0) cur_line = 0;
    1120            1 :             break;
    1121            1 :         case TERM_KEY_NEXT_LINE:
    1122            1 :             if (cur_line < body_vrows - 1) cur_line++;
    1123            1 :             break;
    1124            1 :         case TERM_KEY_PREV_LINE:
    1125            1 :             if (cur_line > 0) cur_line--;
    1126            1 :             break;
    1127            0 :         case TERM_KEY_HOME:
    1128            0 :             cur_line = 0;
    1129            0 :             break;
    1130            1 :         case TERM_KEY_END:
    1131            1 :             cur_line = body_vrows > rows_avail ? body_vrows - rows_avail : 0;
    1132            1 :             break;
    1133           14 :         case TERM_KEY_LEFT:
    1134              :         case TERM_KEY_RIGHT:
    1135              :         case TERM_KEY_DELETE:
    1136              :         case TERM_KEY_TAB:
    1137              :         case TERM_KEY_SHIFT_TAB:
    1138              :         case TERM_KEY_IGNORE: {
    1139           14 :             int ch = terminal_last_printable();
    1140           14 :             int is_gmail = cfg->gmail_mode;
    1141           14 :             if (ch == 'q') {
    1142            1 :                 result = 0;      /* back to list */
    1143            1 :                 goto show_int_done;
    1144           13 :             } else if (ch == 'v') {
    1145            2 :                 view_raw = !view_raw;
    1146            2 :                 cur_line = 0;
    1147            2 :                 break;
    1148           11 :             } else if (ch == '/') {
    1149              :                 /* Inline search prompt on the status row */
    1150            2 :                 printf("\033[%d;1H\033[2K/", term_rows);
    1151            2 :                 fflush(stdout);
    1152            2 :                 size_t slen = 0; search_buf[0] = '\0';
    1153            2 :                 int srch_cancel = 0;
    1154           22 :                 for (;;) {
    1155           24 :                     TermKey sk = terminal_read_key();
    1156           24 :                     if (sk == TERM_KEY_ESC) { srch_cancel = 1; break; }
    1157           24 :                     if (sk == TERM_KEY_ENTER) break;
    1158           22 :                     if (sk == TERM_KEY_BACK) {
    1159            0 :                         if (slen > 0) search_buf[--slen] = '\0';
    1160           22 :                     } else if (sk == TERM_KEY_IGNORE) {
    1161           22 :                         int sc = terminal_last_printable();
    1162           22 :                         if (sc == 127 || sc == 8) {
    1163            0 :                             if (slen > 0) search_buf[--slen] = '\0';
    1164           22 :                         } else if (sc >= 32 && sc < 127 && slen + 1 < sizeof(search_buf)) {
    1165           22 :                             search_buf[slen++] = (char)sc; search_buf[slen] = '\0';
    1166              :                         }
    1167              :                     }
    1168           22 :                     printf("\033[%d;1H\033[2K/%s_", term_rows, search_buf);
    1169           22 :                     fflush(stdout);
    1170              :                 }
    1171            4 :                 if (!srch_cancel && search_buf[0]) {
    1172            2 :                     int ml = find_match_line(body_text, search_buf, cur_line - 1, 1);
    1173            2 :                     if (ml >= 0) { cur_line = ml; info_msg[0] = '\0'; }
    1174            1 :                     else snprintf(info_msg, sizeof(info_msg), "No match: %s", search_buf);
    1175            0 :                 } else if (srch_cancel) {
    1176            0 :                     search_buf[0] = '\0';
    1177              :                 }
    1178            2 :                 break;
    1179            9 :             } else if (ch == 'n' && search_buf[0]) {
    1180            0 :                 int ml = find_match_line(body_text, search_buf, cur_line, 1);
    1181            0 :                 if (ml >= 0) { cur_line = ml; info_msg[0] = '\0'; }
    1182            0 :                 else snprintf(info_msg, sizeof(info_msg), "No more matches");
    1183            0 :                 break;
    1184            9 :             } else if (ch == 'N' && search_buf[0]) {
    1185            0 :                 int ml = find_match_line(body_text, search_buf, cur_line, -1);
    1186            0 :                 if (ml >= 0) { cur_line = ml; info_msg[0] = '\0'; }
    1187            0 :                 else snprintf(info_msg, sizeof(info_msg), "No more matches");
    1188            0 :                 break;
    1189            9 :             } else if (ch == 'h' || ch == '?') {
    1190            1 :                 if (is_gmail) {
    1191              :                     static const char *ghelp[][2] = {
    1192              :                         { "PgDn / \u2193",   "Scroll down one page / one line"          },
    1193              :                         { "PgUp / \u2191",   "Scroll up one page / one line"            },
    1194              :                         { "Home / End",      "Jump to top / bottom of message"          },
    1195              :                         { "F",              "Forward this message"                      },
    1196              :                         { "r",              "Remove current label"                      },
    1197              :                         { "d",              "Remove current label"                      },
    1198              :                         { "D",              "Move to Trash"                             },
    1199              :                         { "f",              "Toggle Starred label"                      },
    1200              :                         { "n",              "Toggle Unread label"                       },
    1201              :                         { "a",              "Archive (remove all labels)"               },
    1202              :                         { "t",              "Toggle labels (picker)"                    },
    1203              :                         { "A",              "Save attachment"                           },
    1204              :                         { "v",              "Toggle rendered / raw source view"         },
    1205              :                         { "/",              "Search in message body"                    },
    1206              :                         { "n / N",          "Next / previous search match"             },
    1207              :                         { "q / Backspace",  "Back to message list"                     },
    1208              :                         { "ESC",            "Exit program"                              },
    1209              :                         { "h / ?",          "Show this help"                            },
    1210              :                     };
    1211            0 :                     show_help_popup("Message reader shortcuts (Gmail)",
    1212              :                                     ghelp, (int)(sizeof(ghelp)/sizeof(ghelp[0])));
    1213              :                 } else {
    1214              :                     static const char *help[][2] = {
    1215              :                         { "PgDn / \u2193",   "Scroll down one page / one line"          },
    1216              :                         { "PgUp / \u2191",   "Scroll up one page / one line"            },
    1217              :                         { "Home / End",      "Jump to top / bottom of message"          },
    1218              :                         { "r",              "Reply to this message"                     },
    1219              :                         { "F",              "Forward this message"                      },
    1220              :                         { "f",              "Toggle Flagged (starred)"                  },
    1221              :                         { "n",              "Toggle Unread flag"                        },
    1222              :                         { "d",              "Toggle Done flag"                          },
    1223              :                         { "D",              "Move to Trash (del if in Trash)"           },
    1224              :                         { "a",              "Save an attachment"                        },
    1225              :                         { "A",              "Save all attachments"                      },
    1226              :                         { "v",              "Toggle rendered / raw source view"         },
    1227              :                         { "/",              "Search in message body"                    },
    1228              :                         { "n / N",          "Next / previous search match"             },
    1229              :                         { "Backspace / q",  "Back to message list"                     },
    1230              :                         { "ESC",            "Exit program"                              },
    1231              :                         { "h / ?",          "Show this help"                            },
    1232              :                     };
    1233            1 :                     show_help_popup("Message reader shortcuts",
    1234              :                                     help, (int)(sizeof(help)/sizeof(help[0])));
    1235              :                 }
    1236            1 :                 break;
    1237            8 :             } else if (is_gmail && (ch == 'r' || ch == 'd') && folder[0] != '_') {
    1238              :                 /* Remove current label from this message */
    1239            0 :                 const char *lbl = folder;
    1240            0 :                 label_idx_remove(lbl, uid);
    1241            0 :                 local_hdr_update_labels("", uid, NULL, 0, &lbl, 1);
    1242            0 :                 if (mc) mail_client_modify_label(mc, uid, lbl, 0);
    1243            0 :                 snprintf(info_msg, sizeof(info_msg), "Label removed: %s", lbl);
    1244              :                 /* Reload show_labels to reflect the change */
    1245            0 :                 free(show_labels);
    1246            0 :                 show_labels = local_hdr_get_labels("", uid);
    1247            0 :                 break;
    1248            8 :             } else if (is_gmail && ch == 'D') {
    1249            0 :                 if (strcmp(folder, "_trash") == 0) {
    1250              :                     /* In Trash: permanently delete */
    1251            0 :                     if (mc) mail_client_delete(mc, uid);
    1252            0 :                     label_idx_remove("_trash", uid);
    1253            0 :                     result = 6;   /* signal list loop to mark entry removed */
    1254            0 :                     goto show_int_done;
    1255              :                 }
    1256              :                 /* Trash: Gmail compound trash operation */
    1257            0 :                 if (mc) mail_client_trash(mc, uid);
    1258            0 :                 char **all_labels = NULL; int all_count = 0;
    1259            0 :                 label_idx_list(&all_labels, &all_count);
    1260            0 :                 for (int j = 0; j < all_count; j++) {
    1261            0 :                     label_idx_remove(all_labels[j], uid);
    1262            0 :                     free(all_labels[j]);
    1263              :                 }
    1264            0 :                 free(all_labels);
    1265            0 :                 label_idx_add("_trash", uid);
    1266            0 :                 result = 6;   /* go back to list; entry will be marked removed */
    1267            0 :                 goto show_int_done;
    1268            8 :             } else if (is_gmail && ch == 'a') {
    1269              :                 /* Archive: remove all labels from this message */
    1270            0 :                 if (strcmp(folder, "_nolabel") == 0) {
    1271            0 :                     snprintf(info_msg, sizeof(info_msg),
    1272              :                              "Already in Archive \xe2\x80\x94 no change");
    1273            0 :                     break;
    1274              :                 }
    1275            0 :                 char *lbl_str = local_hdr_get_labels("", uid);
    1276            0 :                 if (lbl_str) {
    1277            0 :                     int n = 1;
    1278            0 :                     for (const char *p = lbl_str; *p; p++) if (*p == ',') n++;
    1279            0 :                     char **rm  = malloc((size_t)n * sizeof(char *));
    1280            0 :                     char *copy = strdup(lbl_str);
    1281            0 :                     int   rm_n = 0;
    1282            0 :                     if (rm && copy) {
    1283            0 :                         char *tok = copy, *sep;
    1284            0 :                         while (tok && *tok) {
    1285            0 :                             sep = strchr(tok, ',');
    1286            0 :                             if (sep) *sep = '\0';
    1287            0 :                             if (tok[0] && tok[0] != '_') {
    1288            0 :                                 label_idx_remove(tok, uid);
    1289            0 :                                 rm[rm_n++] = tok;
    1290            0 :                                 if (mc &&
    1291            0 :                                     strcmp(tok, "IMPORTANT") != 0 &&
    1292            0 :                                     strncmp(tok, "CATEGORY_", 9) != 0)
    1293            0 :                                     mail_client_modify_label(mc, uid, tok, 0);
    1294              :                             }
    1295            0 :                             tok = sep ? sep + 1 : NULL;
    1296              :                         }
    1297            0 :                         local_hdr_update_labels("", uid, NULL, 0,
    1298              :                                                 (const char **)rm, rm_n);
    1299              :                     }
    1300            0 :                     free(copy); free(rm); free(lbl_str);
    1301              :                 }
    1302            0 :                 label_idx_remove("UNREAD", uid);
    1303            0 :                 int new_flags = reader_flags & ~MSG_FLAG_UNSEEN;
    1304            0 :                 local_hdr_update_flags("", uid, new_flags);
    1305            0 :                 reader_flags = new_flags;
    1306            0 :                 if (mc) mail_client_set_flag(mc, uid, "\\Seen", 1);
    1307            0 :                 label_idx_add("_nolabel", uid);
    1308            0 :                 free(show_labels);
    1309            0 :                 show_labels = local_hdr_get_labels("", uid);
    1310            0 :                 snprintf(info_msg, sizeof(info_msg), "Archived");
    1311            0 :                 break;
    1312            8 :             } else if (is_gmail && ch == 't') {
    1313            0 :                 show_label_picker(mc, uid, info_msg, sizeof(info_msg));
    1314            0 :                 free(show_labels);
    1315            0 :                 show_labels = local_hdr_get_labels("", uid);
    1316            0 :                 break;
    1317            8 :             } else if (ch == 'f') {
    1318              :                 /* Toggle starred / flagged */
    1319            0 :                 int currently = reader_flags & MSG_FLAG_FLAGGED;
    1320            0 :                 int add_flag  = currently ? 0 : 1;
    1321            0 :                 reader_flags ^= MSG_FLAG_FLAGGED;
    1322            0 :                 if (is_gmail) local_hdr_update_flags("", uid, reader_flags);
    1323            0 :                 local_pending_flag_add(folder, uid, "\\Flagged", add_flag);
    1324            0 :                 if (is_gmail) {
    1325            0 :                     const char *lbl = "STARRED";
    1326            0 :                     if (currently) {
    1327            0 :                         label_idx_remove(lbl, uid);
    1328            0 :                         local_hdr_update_labels("", uid, NULL, 0, &lbl, 1);
    1329              :                     } else {
    1330            0 :                         label_idx_add(lbl, uid);
    1331            0 :                         local_hdr_update_labels("", uid, &lbl, 1, NULL, 0);
    1332              :                     }
    1333            0 :                     flag_push_background(cfg, uid, "\\Flagged", add_flag);
    1334            0 :                     free(show_labels);
    1335            0 :                     show_labels = local_hdr_get_labels("", uid);
    1336            0 :                 } else if (mc) {
    1337            0 :                     mail_client_set_flag(mc, uid, "\\Flagged", add_flag);
    1338              :                 }
    1339            0 :                 snprintf(info_msg, sizeof(info_msg),
    1340              :                          currently ? "Unstarred" : "Starred");
    1341            0 :                 break;
    1342            8 :             } else if (ch == 'n') {
    1343              :                 /* Toggle unread / read */
    1344            0 :                 int currently = reader_flags & MSG_FLAG_UNSEEN;
    1345            0 :                 int add_flag  = currently ? 1 : 0; /* add \\Seen if currently unseen */
    1346            0 :                 reader_flags ^= MSG_FLAG_UNSEEN;
    1347            0 :                 if (is_gmail) local_hdr_update_flags("", uid, reader_flags);
    1348            0 :                 local_pending_flag_add(folder, uid, "\\Seen", add_flag);
    1349            0 :                 if (is_gmail) {
    1350            0 :                     const char *lbl = "UNREAD";
    1351            0 :                     if (currently) {
    1352            0 :                         label_idx_remove(lbl, uid);
    1353            0 :                         local_hdr_update_labels("", uid, NULL, 0, &lbl, 1);
    1354              :                     } else {
    1355            0 :                         label_idx_add(lbl, uid);
    1356            0 :                         local_hdr_update_labels("", uid, &lbl, 1, NULL, 0);
    1357              :                     }
    1358            0 :                     flag_push_background(cfg, uid, "\\Seen", add_flag);
    1359            0 :                     free(show_labels);
    1360            0 :                     show_labels = local_hdr_get_labels("", uid);
    1361            0 :                 } else if (mc) {
    1362            0 :                     mail_client_set_flag(mc, uid, "\\Seen", add_flag);
    1363              :                 }
    1364            0 :                 snprintf(info_msg, sizeof(info_msg),
    1365              :                          currently ? "Marked as read" : "Marked as unread");
    1366            0 :                 break;
    1367            8 :             } else if (!is_gmail && ch == 'd') {
    1368              :                 /* IMAP only: toggle Done flag */
    1369            0 :                 int currently = reader_flags & MSG_FLAG_DONE;
    1370            0 :                 int add_flag  = currently ? 0 : 1;
    1371            0 :                 reader_flags ^= MSG_FLAG_DONE;
    1372              :                 /* .hdr flags field is not used for IMAP; skip local_hdr_update_flags */
    1373            0 :                 local_pending_flag_add(folder, uid, "$Done", add_flag);
    1374            0 :                 if (mc) mail_client_set_flag(mc, uid, "$Done", add_flag);
    1375            0 :                 snprintf(info_msg, sizeof(info_msg),
    1376              :                          currently ? "Marked not done" : "Marked done");
    1377            0 :                 break;
    1378            8 :             } else if (!is_gmail && ch == 'D') {
    1379              :                 /* IMAP trash / permanent delete from reader */
    1380            0 :                 const char *trf = cfg->trash_folder ? cfg->trash_folder : "Trash";
    1381            0 :                 if (strcmp(folder, trf) == 0) {
    1382            0 :                     if (mc) mail_client_delete(mc, uid);
    1383              :                 } else {
    1384            0 :                     if (mc) mail_client_move_to_folder(mc, uid, trf);
    1385              :                 }
    1386            0 :                 result = 6;   /* signal list loop to mark entry removed */
    1387            0 :                 goto show_int_done;
    1388            8 :             } else if (!is_gmail && ch == 'r') {
    1389              :                 /* Ensure the raw message is in the local cache so cmd_reply
    1390              :                  * can reload it without requiring a live IMAP connection. */
    1391            2 :                 if (!local_msg_exists(folder, uid))
    1392            0 :                     local_msg_save(folder, uid, raw, strlen(raw));
    1393            2 :                 result = 2;      /* reply to this message */
    1394            2 :                 goto show_int_done;
    1395            6 :             } else if (ch == 'F') {
    1396              :                 /* Forward: cache the raw message so cmd_forward can read it
    1397              :                  * without a live connection, then signal the list loop. */
    1398            0 :                 if (!local_msg_exists(folder, uid))
    1399            0 :                     local_msg_save(folder, uid, raw, strlen(raw));
    1400            0 :                 result = 5;      /* forward this message */
    1401            0 :                 goto show_int_done;
    1402            8 :             } else if (att_count > 0 && ((is_gmail && ch == 'A') || (!is_gmail && ch == 'a'))) {
    1403            3 :                 int sel = 0;
    1404            3 :                 if (att_count > 1) {
    1405            3 :                     sel = show_attachment_picker(atts, att_count,
    1406              :                                                  term_cols, term_rows);
    1407            3 :                     if (sel == -2) {
    1408            0 :                         break;       /* ESC/q → back to show view */
    1409              :                     }
    1410            3 :                     if (sel < 0) break;  /* Backspace → back to show */
    1411              :                 }
    1412              :                 /* Build suggested path and let user edit it */
    1413              :                 {
    1414            2 :                     char *dir  = attachment_save_dir();
    1415            2 :                     char *fname = safe_filename_for_path(atts[sel].filename);
    1416              :                     char dest[2048];
    1417            2 :                     snprintf(dest, sizeof(dest), "%s/%s",
    1418              :                              dir ? dir : ".", fname ? fname : "attachment");
    1419            2 :                     free(dir);
    1420            2 :                     free(fname);
    1421              :                     InputLine il;
    1422            2 :                     input_line_init(&il, dest, sizeof(dest), dest);
    1423            2 :                     path_complete_attach(&il);
    1424            2 :                     int ok = input_line_run(&il, term_rows - 1, "Save as: ");
    1425            2 :                     path_complete_reset();
    1426              :                     /* Clear the edited line and the completion row */
    1427            2 :                     printf("\033[%d;1H\033[2K\033[%d;1H\033[2K\033[?25l",
    1428              :                            term_rows - 1, term_rows);
    1429            2 :                     if (ok == 1) {
    1430            1 :                         int r = mime_save_attachment(&atts[sel], dest);
    1431            1 :                         snprintf(info_msg, sizeof(info_msg),
    1432              :                                  r == 0 ? "  Saved: %.1900s"
    1433              :                                         : "  Save FAILED: %.1900s", dest);
    1434              :                     }
    1435              :                 }
    1436            3 :             } else if (ch == 'A' && att_count > 0) {
    1437              :                 /* Save ALL attachments to a chosen directory */
    1438            3 :                 char *def_dir = attachment_save_dir();
    1439              :                 char dest_dir[2048];
    1440            3 :                 snprintf(dest_dir, sizeof(dest_dir), "%s",
    1441              :                          def_dir ? def_dir : ".");
    1442            3 :                 free(def_dir);
    1443              :                 InputLine il;
    1444            3 :                 input_line_init(&il, dest_dir, sizeof(dest_dir), dest_dir);
    1445            3 :                 path_complete_attach(&il);
    1446            3 :                 int ok = input_line_run(&il, term_rows - 1, "Save all to: ");
    1447            2 :                 path_complete_reset();
    1448              :                 /* Clear the edited line and the completion row */
    1449            2 :                 printf("\033[%d;1H\033[2K\033[%d;1H\033[2K\033[?25l",
    1450              :                        term_rows - 1, term_rows);
    1451            2 :                 if (ok == 1) {
    1452            1 :                     int saved = 0;
    1453            3 :                     for (int i = 0; i < att_count; i++) {
    1454            2 :                         char *fname = safe_filename_for_path(atts[i].filename);
    1455              :                         char fpath[4096];
    1456            2 :                         snprintf(fpath, sizeof(fpath), "%s/%s",
    1457              :                                  dest_dir, fname ? fname : "attachment");
    1458            2 :                         free(fname);
    1459            2 :                         if (mime_save_attachment(&atts[i], fpath) == 0)
    1460            2 :                             saved++;
    1461              :                     }
    1462            1 :                     snprintf(info_msg, sizeof(info_msg),
    1463            1 :                              saved == att_count
    1464              :                              ? "  Saved %d/%d files to: %.1900s"
    1465              :                              : "  Saved %d/%d (errors) to: %.1900s",
    1466              :                              saved, att_count, dest_dir);
    1467              :                 }
    1468              :             }
    1469            4 :             break;
    1470              :         }
    1471              :         }
    1472              :     }
    1473           13 : show_int_done:
    1474              : #undef SHOW_HDR_LINES_INT
    1475           13 :     mime_free_attachments(atts, att_count);
    1476           13 :     free(show_path); free(show_dmarc); free(show_to);
    1477           13 :     free(body); free(body_wrapped); free(from); free(subject); free(date); free(show_labels); free(raw);
    1478           13 :     if (flags_out) *flags_out = reader_flags;
    1479           13 :     return result;
    1480              : }
    1481              : 
    1482              : /* ── List helpers ────────────────────────────────────────────────────── */
    1483              : 
    1484              : typedef struct { char uid[17]; int flags; time_t epoch; char folder[256]; } MsgEntry;
    1485              : 
    1486              : /* Write s as a JSON string value, escaping what RFC 8259 requires.
    1487              :  * Bytes are emitted as-is: the manifest already holds UTF-8. */
    1488           12 : static void print_json_string(const char *s) {
    1489           12 :     putchar('"');
    1490          166 :     for (const unsigned char *p = (const unsigned char *)(s ? s : ""); *p; p++) {
    1491          154 :         switch (*p) {
    1492            0 :             case '"':  fputs("\\\"", stdout); break;
    1493            0 :             case '\\': fputs("\\\\", stdout); break;
    1494            0 :             case '\n': fputs("\\n", stdout);  break;
    1495            0 :             case '\r': fputs("\\r", stdout);  break;
    1496            0 :             case '\t': fputs("\\t", stdout);  break;
    1497          154 :             default:
    1498          154 :                 if (*p < 0x20) printf("\\u%04x", *p);
    1499          154 :                 else           putchar((char)*p);
    1500              :         }
    1501              :     }
    1502           12 :     putchar('"');
    1503           12 : }
    1504              : 
    1505              : /* True when no batch filter is configured — the common case, kept cheap. */
    1506          378 : static int list_filters_off(const EmailListOpts *o) {
    1507           15 :     return !(o->filter_from   && o->filter_from[0])
    1508          363 :         && !(o->filter_since  && o->filter_since[0])
    1509          756 :         && !(o->filter_before && o->filter_before[0]);
    1510              : }
    1511              : 
    1512              : /* Match one entry against --from / --since / --before.
    1513              :  *
    1514              :  * Dates are compared on the "YYYY-MM-DD" prefix of the manifest's
    1515              :  * "YYYY-MM-DD HH:MM" field, which sorts correctly as text: --since is
    1516              :  * inclusive, --before exclusive.  Entries with no manifest row (headers not
    1517              :  * fetched yet) cannot be judged and are dropped, so a filtered list never
    1518              :  * claims a match it did not verify. */
    1519           14 : static int entry_matches_filters(const Manifest *m, const MsgEntry *e,
    1520              :                                  const EmailListOpts *o) {
    1521           14 :     if (list_filters_off(o)) return 1;
    1522           14 :     const ManifestEntry *me = manifest_find(m, e->uid);
    1523           14 :     if (!me) return 0;
    1524              : 
    1525           14 :     if (o->filter_from && o->filter_from[0] &&
    1526           10 :         (!me->from || !strcasestr(me->from, o->filter_from)))
    1527            6 :         return 0;
    1528            8 :     if (o->filter_since && o->filter_since[0] &&
    1529            3 :         (!me->date || strncmp(me->date, o->filter_since, 10) < 0))
    1530            1 :         return 0;
    1531            7 :     if (o->filter_before && o->filter_before[0] &&
    1532            3 :         (!me->date || strncmp(me->date, o->filter_before, 10) >= 0))
    1533            1 :         return 0;
    1534            6 :     return 1;
    1535              : }
    1536              : 
    1537              : /* Remove non-matching entries in place; returns the remaining count. */
    1538          364 : static int apply_list_filters(const Manifest *m, MsgEntry *entries, int count,
    1539              :                               const EmailListOpts *o) {
    1540          364 :     if (list_filters_off(o)) return count;
    1541            7 :     int kept = 0;
    1542           21 :     for (int i = 0; i < count; i++)
    1543           14 :         if (entry_matches_filters(m, &entries[i], o))
    1544            6 :             entries[kept++] = entries[i];
    1545            7 :     return kept;
    1546              : }
    1547              : 
    1548              : 
    1549              : /* Parse "YYYY-MM-DD HH:MM" (manifest date format) to time_t in local time.
    1550              :  * Returns 0 on failure. */
    1551         2116 : static time_t parse_manifest_date(const char *d) {
    1552         2116 :     if (!d || !*d) return 0;
    1553         2116 :     struct tm tm = {0};
    1554         2116 :     if (sscanf(d, "%d-%d-%d %d:%d",
    1555              :                &tm.tm_year, &tm.tm_mon, &tm.tm_mday,
    1556            0 :                &tm.tm_hour, &tm.tm_min) != 5) return 0;
    1557         2116 :     tm.tm_year -= 1900;
    1558         2116 :     tm.tm_mon  -= 1;
    1559         2116 :     tm.tm_isdst = -1;
    1560         2116 :     return mktime(&tm);
    1561              : }
    1562              : 
    1563              : /* Return 1 if a background sync process is currently running. */
    1564          449 : static int sync_is_running(void) {
    1565          449 :     const char *cache_base = platform_cache_dir();
    1566          449 :     if (!cache_base) return 0;
    1567              :     char pid_path[2048];
    1568          449 :     snprintf(pid_path, sizeof(pid_path), "%s/email-cli/sync.pid", cache_base);
    1569          898 :     RAII_FILE FILE *pf = fopen(pid_path, "r");
    1570          449 :     if (!pf) return 0;
    1571            2 :     int pid = 0;
    1572            2 :     if (fscanf(pf, "%d", &pid) != 1) pid = 0;
    1573            2 :     if (pid <= 0) return 0;
    1574              :     /* Accept any of the binary names that may be running sync */
    1575            4 :     return platform_pid_is_program((pid_t)pid, "email-cli") ||
    1576            4 :            platform_pid_is_program((pid_t)pid, "email-sync") ||
    1577            2 :            platform_pid_is_program((pid_t)pid, "email-tui");
    1578              : }
    1579              : 
    1580              : /* Build path to email-sync binary (same directory as the running binary). */
    1581            1 : static void get_sync_bin_path(char *buf, size_t size) {
    1582            1 :     snprintf(buf, size, "email-sync"); /* fallback: PATH lookup */
    1583            1 :     char self[1024] = {0};
    1584            1 :     if (platform_executable_path(self, sizeof(self)) == 0) {
    1585            1 :         char *slash = strrchr(self, '/');
    1586            1 :         if (slash)
    1587            1 :             snprintf(buf, size, "%.*s/email-sync", (int)(slash - self), self);
    1588              :     }
    1589            1 : }
    1590              : 
    1591              : /* Set by the SIGCHLD handler when the background sync child exits. */
    1592              : static volatile sig_atomic_t bg_sync_done = 0;
    1593              : static pid_t bg_sync_pid = -1;
    1594              : 
    1595            3 : static void bg_sync_sigchld(int sig) {
    1596              :     (void)sig;
    1597              :     int status;
    1598              :     pid_t p;
    1599              :     /* Reap all children; only bg_sync_pid triggers bg_sync_done. */
    1600            9 :     while ((p = waitpid(-1, &status, WNOHANG)) > 0) {
    1601            3 :         if (p == bg_sync_pid) {
    1602            1 :             bg_sync_pid = -1;
    1603            1 :             bg_sync_done = 1;
    1604              :         }
    1605              :     }
    1606            3 : }
    1607              : 
    1608              : /**
    1609              :  * Fork a minimal child to push a single flag change to the mail server.
    1610              :  * The parent returns immediately; the child connects, sets the flag, and exits.
    1611              :  * The pending queue already records the change so failures are retried on sync.
    1612              :  */
    1613            2 : static void flag_push_background(const Config *cfg, const char *uid,
    1614              :                                   const char *flag_name, int add_flag) {
    1615              :     /* Ensure SIGCHLD is handled so the child is reaped without polling. */
    1616            2 :     struct sigaction sa = {0};
    1617            2 :     sa.sa_handler = bg_sync_sigchld;
    1618            2 :     sigemptyset(&sa.sa_mask);
    1619            2 :     sa.sa_flags = 0;
    1620            2 :     sigaction(SIGCHLD, &sa, NULL);
    1621              : 
    1622            2 :     pid_t pid = fork();
    1623            2 :     if (pid < 0) return; /* fork failed; pending queue retries on next sync */
    1624            2 :     if (pid == 0) {
    1625            0 :         int devnull = open("/dev/null", O_RDWR);
    1626            0 :         if (devnull >= 0) {
    1627            0 :             dup2(devnull, STDIN_FILENO);
    1628            0 :             dup2(devnull, STDOUT_FILENO);
    1629            0 :             dup2(devnull, STDERR_FILENO);
    1630            0 :             if (devnull > STDERR_FILENO) close(devnull);
    1631              :         }
    1632            0 :         MailClient *mc = make_mail(cfg);
    1633            0 :         if (mc) {
    1634            0 :             mail_client_set_flag(mc, uid, flag_name, add_flag);
    1635            0 :             mail_client_free(mc);
    1636              :         }
    1637            0 :         _exit(0);
    1638              :     }
    1639              :     /* Parent continues; child reaped by bg_sync_sigchld. */
    1640              : }
    1641              : 
    1642            0 : static void junk_push_background(const Config *cfg, const char *uid, int mark_junk) {
    1643            0 :     struct sigaction sa = {0};
    1644            0 :     sa.sa_handler = bg_sync_sigchld;
    1645            0 :     sigemptyset(&sa.sa_mask);
    1646            0 :     sigaction(SIGCHLD, &sa, NULL);
    1647            0 :     pid_t pid = fork();
    1648            0 :     if (pid < 0) return;
    1649            0 :     if (pid == 0) {
    1650            0 :         int devnull = open("/dev/null", O_RDWR);
    1651            0 :         if (devnull >= 0) {
    1652            0 :             dup2(devnull, STDIN_FILENO); dup2(devnull, STDOUT_FILENO);
    1653            0 :             dup2(devnull, STDERR_FILENO);
    1654            0 :             if (devnull > STDERR_FILENO) close(devnull);
    1655              :         }
    1656            0 :         MailClient *mc = make_mail(cfg);
    1657            0 :         if (mc) {
    1658            0 :             if (mark_junk) mail_client_mark_junk(mc, uid);
    1659            0 :             else           mail_client_mark_notjunk(mc, uid);
    1660            0 :             mail_client_free(mc);
    1661              :         }
    1662            0 :         _exit(0);
    1663              :     }
    1664              : }
    1665              : 
    1666              : /**
    1667              :  * Fork and exec email-sync in the background.
    1668              :  * Installs a SIGCHLD handler (without SA_RESTART) so the blocked read() in
    1669              :  * terminal_read_key() is interrupted when the child exits — this lets the TUI
    1670              :  * react immediately without any polling.
    1671              :  * Returns 1 if the child was spawned, 0 if already running, -1 on error.
    1672              :  */
    1673            1 : static int sync_start_background(void) {
    1674            1 :     if (sync_is_running()) return 0;
    1675              : 
    1676            1 :     struct sigaction sa = {0};
    1677            1 :     sa.sa_handler = bg_sync_sigchld;
    1678            1 :     sigemptyset(&sa.sa_mask);
    1679            1 :     sa.sa_flags = 0; /* no SA_RESTART: read() must be interrupted on SIGCHLD */
    1680            1 :     sigaction(SIGCHLD, &sa, NULL);
    1681              : 
    1682              :     char sync_bin[1024];
    1683            1 :     get_sync_bin_path(sync_bin, sizeof(sync_bin));
    1684              : 
    1685            1 :     pid_t pid = fork();
    1686            2 :     if (pid < 0) return -1;
    1687            2 :     if (pid == 0) {
    1688              :         /* Child: detach from the TUI session */
    1689            1 :         setsid();
    1690            1 :         int devnull = open("/dev/null", O_RDWR);
    1691            1 :         if (devnull >= 0) {
    1692            1 :             dup2(devnull, STDIN_FILENO);
    1693            1 :             dup2(devnull, STDOUT_FILENO);
    1694            1 :             dup2(devnull, STDERR_FILENO);
    1695            1 :             if (devnull > STDERR_FILENO) close(devnull);
    1696              :         }
    1697            1 :         char *args[] = {sync_bin, NULL};
    1698            1 :         execvp(sync_bin, args);
    1699            1 :         _exit(1); /* exec failed */
    1700              :     }
    1701            1 :     bg_sync_pid = pid;
    1702            1 :     return 1;
    1703              : }
    1704              : 
    1705              : /* Sort group: 0=unseen, 1=flagged (read), 2=rest */
    1706        18886 : static int msg_group(int flags) {
    1707        18886 :     if (flags & MSG_FLAG_UNSEEN)  return 0;
    1708        15679 :     if (flags & MSG_FLAG_FLAGGED) return 1;
    1709        15173 :     return 2;
    1710              : }
    1711              : 
    1712         9443 : static int cmp_uid_entry(const void *a, const void *b) {
    1713         9443 :     const MsgEntry *ea = a, *eb = b;
    1714         9443 :     int ga = msg_group(ea->flags);
    1715         9443 :     int gb = msg_group(eb->flags);
    1716         9443 :     if (ga != gb) return ga - gb;         /* group order: unseen, flagged, rest */
    1717              :     /* Within group: newer date first; fall back to UID if date unavailable */
    1718         8268 :     if (eb->epoch != ea->epoch) return (eb->epoch > ea->epoch) ? 1 : -1;
    1719         5641 :     return strcmp(eb->uid, ea->uid);
    1720              : }
    1721              : 
    1722              : /* ── Folder list helpers ─────────────────────────────────────────────── */
    1723              : 
    1724         3042 : static int cmp_str(const void *a, const void *b) {
    1725         3042 :     return strcmp(*(const char **)a, *(const char **)b);
    1726              : }
    1727              : 
    1728              : /* ── Folder tree renderer ────────────────────────────────────────────── */
    1729              : 
    1730              : /**
    1731              :  * Returns 1 if names[i] is the last child of its parent in the sorted list.
    1732              :  * Skips descendants of names[i] before checking for siblings.
    1733              :  */
    1734        10956 : static int is_last_sibling(char **names, int count, int i, char sep) {
    1735        10956 :     const char *name = names[i];
    1736        10956 :     size_t name_len  = strlen(name);
    1737        10956 :     const char *lsep = strrchr(name, sep);
    1738        10956 :     size_t parent_len = lsep ? (size_t)(lsep - name) : 0;
    1739              : 
    1740              :     /* Find the last position that belongs to names[i]'s subtree */
    1741        10956 :     int last = i;
    1742        20581 :     for (int j = i + 1; j < count; j++) {
    1743        17840 :         if (strncmp(names[j], name, name_len) == 0 &&
    1744         9625 :             (names[j][name_len] == sep || names[j][name_len] == '\0'))
    1745         9625 :             last = j;
    1746              :         else
    1747              :             break;
    1748              :     }
    1749              : 
    1750              :     /* After the subtree, look for a sibling */
    1751        10956 :     for (int j = last + 1; j < count; j++) {
    1752         8215 :         if (parent_len == 0)
    1753            0 :             return 0; /* any following item is a root-level sibling */
    1754         8215 :         if (strlen(names[j]) > parent_len &&
    1755         8215 :             strncmp(names[j], name, parent_len) == 0 &&
    1756         8215 :             names[j][parent_len] == sep)
    1757         8215 :             return 0;
    1758            0 :         return 1; /* jumped to a different parent subtree */
    1759              :     }
    1760         2741 :     return 1;
    1761              : }
    1762              : 
    1763              : /**
    1764              :  * Returns 1 if the ancestor of names[i] at indent-level 'level'
    1765              :  * (0 = root component) is the last child of its own parent.
    1766              :  */
    1767         9581 : static int ancestor_is_last(char **names, int count, int i,
    1768              :                              int level, char sep) {
    1769         9581 :     const char *name = names[i];
    1770              : 
    1771              :     /* ancestor prefix length: (level+1) components */
    1772         9581 :     size_t anc_len = 0;
    1773         9581 :     int sep_cnt = 0;
    1774        57486 :     while (name[anc_len]) {
    1775        57486 :         if (name[anc_len] == sep && sep_cnt++ == level) break;
    1776        47905 :         anc_len++;
    1777              :     }
    1778              : 
    1779              :     /* parent prefix length: level components */
    1780         9581 :     size_t parent_len = 0;
    1781         9581 :     sep_cnt = 0;
    1782        57486 :     for (size_t k = 0; k < anc_len; k++) {
    1783        47905 :         if (name[k] == sep) {
    1784            0 :             if (sep_cnt++ == level - 1) { parent_len = k; break; }
    1785              :         }
    1786              :     }
    1787         9581 :     if (level == 0) parent_len = 0;
    1788              : 
    1789              :     /* Last item in ancestor's subtree */
    1790         9581 :     int last = i;
    1791        38370 :     for (int j = i + 1; j < count; j++) {
    1792        28789 :         if (strncmp(names[j], name, anc_len) == 0 &&
    1793        28789 :             (names[j][anc_len] == sep || names[j][anc_len] == '\0'))
    1794        28789 :             last = j;
    1795              :         else
    1796              :             break;
    1797              :     }
    1798              : 
    1799              :     /* After subtree, look for sibling of ancestor */
    1800         9581 :     for (int j = last + 1; j < count; j++) {
    1801            0 :         if (parent_len == 0)
    1802            0 :             return 0; /* another root-level item */
    1803            0 :         if (strlen(names[j]) > parent_len &&
    1804            0 :             strncmp(names[j], name, parent_len) == 0 &&
    1805            0 :             names[j][parent_len] == sep)
    1806            0 :             return 0;
    1807            0 :         return 1;
    1808              :     }
    1809         9581 :     return 1;
    1810              : }
    1811              : 
    1812              : /** Returns 1 if folder `name` has any direct or indirect children. */
    1813           11 : static int folder_has_children(char **names, int count, const char *name, char sep) {
    1814           11 :     size_t len = strlen(name);
    1815           71 :     for (int i = 0; i < count; i++)
    1816           64 :         if (strncmp(names[i], name, len) == 0 && names[i][len] == sep)
    1817            4 :             return 1;
    1818            7 :     return 0;
    1819              : }
    1820              : 
    1821              : /** Sum unseen/flagged/messages for a folder and all its descendants. */
    1822            3 : static void sum_subtree(char **names, int count, char sep,
    1823              :                         const char *prefix, const FolderStatus *statuses,
    1824              :                         int *msgs_out, int *unseen_out, int *flagged_out) {
    1825            3 :     size_t plen = strlen(prefix);
    1826            3 :     int msgs = 0, unseen = 0, flagged = 0;
    1827           27 :     for (int i = 0; i < count; i++) {
    1828           24 :         const char *n = names[i];
    1829           24 :         if (strcmp(n, prefix) == 0 ||
    1830           21 :             (strncmp(n, prefix, plen) == 0 && n[plen] == sep)) {
    1831           24 :             msgs   += statuses ? statuses[i].messages : 0;
    1832           24 :             unseen += statuses ? statuses[i].unseen   : 0;
    1833           24 :             flagged+= statuses ? statuses[i].flagged  : 0;
    1834              :         }
    1835              :     }
    1836            3 :     *msgs_out   = msgs;
    1837            3 :     *unseen_out = unseen;
    1838            3 :     *flagged_out= flagged;
    1839            3 : }
    1840              : 
    1841              : /**
    1842              :  * Build filtered index (into names[]) of direct children of `prefix`.
    1843              :  * prefix="" means root level (folders with no sep in their name).
    1844              :  * Returns number of visible entries written into vis_out[].
    1845              :  */
    1846            4 : static int build_flat_view(char **names, int count, char sep,
    1847              :                            const char *prefix, int *vis_out) {
    1848            4 :     int vcount = 0;
    1849            4 :     size_t plen = strlen(prefix);
    1850           36 :     for (int i = 0; i < count; i++) {
    1851           32 :         const char *name = names[i];
    1852           32 :         if (plen == 0) {
    1853           24 :             if (strchr(name, sep) == NULL)
    1854            3 :                 vis_out[vcount++] = i;
    1855              :         } else {
    1856            8 :             if (strncmp(name, prefix, plen) == 0 && name[plen] == sep &&
    1857            7 :                 strchr(name + plen + 1, sep) == NULL)
    1858            7 :                 vis_out[vcount++] = i;
    1859              :         }
    1860              :     }
    1861            4 :     return vcount;
    1862              : }
    1863              : 
    1864              : /** Print one folder item with its tree/flat prefix and optional selection highlight. */
    1865              : /* Flat mode column layout: Unread | Flagged | Folder | Total
    1866              :  * name_w: width of the folder name column (ignored in tree mode).
    1867              :  * flagged: number of flagged messages (0 = blank cell). */
    1868        10942 : static void print_folder_item(char **names, int count, int i, char sep,
    1869              :                                int tree_mode, int selected, int has_kids,
    1870              :                                int messages, int unseen, int flagged, int name_w) {
    1871        10942 :     if (selected)
    1872          363 :         printf("\033[7m");
    1873        10579 :     else if (messages == 0)
    1874         5692 :         printf("\033[2m");          /* dim: empty folder */
    1875              : 
    1876        10942 :     if (tree_mode) {
    1877              :         /* Build "tree-prefix + component-name" into name_buf for column layout */
    1878              :         char name_buf[512];
    1879        10932 :         int pos = 0;
    1880        10932 :         int depth = 0;
    1881       118853 :         for (const char *p = names[i]; *p; p++)
    1882       107921 :             if (*p == sep) depth++;
    1883        20492 :         for (int lv = 0; lv < depth; lv++) {
    1884         9560 :             int anc_last = ancestor_is_last(names, count, i, lv, sep);
    1885         9560 :             const char *branch = anc_last ? "    " : "\u2502   ";
    1886         9560 :             int blen = (int)strlen(branch);
    1887         9560 :             if (pos + blen < (int)sizeof(name_buf) - 1) {
    1888         9560 :                 memcpy(name_buf + pos, branch, blen);
    1889         9560 :                 pos += blen;
    1890              :             }
    1891              :         }
    1892        10932 :         int last = is_last_sibling(names, count, i, sep);
    1893        10932 :         const char *conn = last ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ";
    1894        10932 :         int clen = (int)strlen(conn);
    1895        10932 :         if (pos + clen < (int)sizeof(name_buf) - 1) {
    1896        10932 :             memcpy(name_buf + pos, conn, clen);
    1897        10932 :             pos += clen;
    1898              :         }
    1899        10932 :         const char *comp = strrchr(names[i], sep);
    1900        10932 :         snprintf(name_buf + pos, sizeof(name_buf) - pos, "%s",
    1901         1372 :                  comp ? comp + 1 : names[i]);
    1902              :         char u[16], f[16], t[16];
    1903        10932 :         fmt_thou(u, sizeof(u), unseen);
    1904        10932 :         fmt_thou(f, sizeof(f), flagged);
    1905        10932 :         fmt_thou(t, sizeof(t), messages);
    1906        10932 :         printf("  %6s  %7s  %-*s  %7s", u, f,
    1907        10932 :                name_w + utf8_extra_bytes(name_buf), name_buf, t);
    1908              :     } else {
    1909              :         /* Flat mode: Unread | Flagged | Folder | Total */
    1910           10 :         const char *comp    = strrchr(names[i], sep);
    1911           10 :         const char *display = comp ? comp + 1 : names[i];
    1912              :         char name_buf[256];
    1913           10 :         snprintf(name_buf, sizeof(name_buf), "%s%s", display, has_kids ? "/" : "");
    1914              :         char u[16], f[16], t[16];
    1915           10 :         fmt_thou(u, sizeof(u), unseen);
    1916           10 :         fmt_thou(f, sizeof(f), flagged);
    1917           10 :         fmt_thou(t, sizeof(t), messages);
    1918           10 :         printf("  %6s  %7s  %-*s  %7s", u, f,
    1919           10 :                name_w + utf8_extra_bytes(name_buf), name_buf, t);
    1920              :     }
    1921              : 
    1922        10942 :     if (selected) printf("\033[K\033[0m");
    1923        10579 :     else if (messages == 0) printf("\033[0m");
    1924        10942 :     printf("\n");
    1925        10941 : }
    1926              : 
    1927            3 : static void render_folder_tree(char **names, int count, char sep,
    1928              :                                 const FolderStatus *statuses) {
    1929            3 :     int name_w = 40;
    1930            3 :     printf("  %6s  %7s  %-*s  %7s\n", "Unread", "Flagged", name_w, "Folder", "Total");
    1931            3 :     printf("  \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    1932            3 :     print_dbar(name_w);
    1933            3 :     printf("  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
    1934              : 
    1935           27 :     for (int i = 0; i < count; i++) {
    1936           24 :         int unseen   = statuses ? statuses[i].unseen   : 0;
    1937           24 :         int flagged  = statuses ? statuses[i].flagged  : 0;
    1938           24 :         int messages = statuses ? statuses[i].messages : 0;
    1939              : 
    1940              :         /* Build "tree-prefix + component-name" */
    1941              :         char name_buf[512];
    1942           24 :         int pos = 0;
    1943           24 :         int depth = 0;
    1944          261 :         for (const char *p = names[i]; *p; p++)
    1945          237 :             if (*p == sep) depth++;
    1946           45 :         for (int lv = 0; lv < depth; lv++) {
    1947           21 :             int anc_last = ancestor_is_last(names, count, i, lv, sep);
    1948           21 :             const char *branch = anc_last ? "    " : "\u2502   ";
    1949           21 :             int blen = (int)strlen(branch);
    1950           21 :             if (pos + blen < (int)sizeof(name_buf) - 1) {
    1951           21 :                 memcpy(name_buf + pos, branch, blen);
    1952           21 :                 pos += blen;
    1953              :             }
    1954              :         }
    1955           24 :         int last = is_last_sibling(names, count, i, sep);
    1956           24 :         const char *conn = last ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ";
    1957           24 :         int clen = (int)strlen(conn);
    1958           24 :         if (pos + clen < (int)sizeof(name_buf) - 1) {
    1959           24 :             memcpy(name_buf + pos, conn, clen);
    1960           24 :             pos += clen;
    1961              :         }
    1962           24 :         const char *comp = strrchr(names[i], sep);
    1963           24 :         snprintf(name_buf + pos, sizeof(name_buf) - pos, "%s",
    1964            3 :                  comp ? comp + 1 : names[i]);
    1965              : 
    1966              :         char u[16], f[16], t[16];
    1967           24 :         fmt_thou(u, sizeof(u), unseen);
    1968           24 :         fmt_thou(f, sizeof(f), flagged);
    1969           24 :         fmt_thou(t, sizeof(t), messages);
    1970           24 :         int nw = name_w + utf8_extra_bytes(name_buf);
    1971           24 :         if (messages == 0)
    1972           14 :             printf("\033[2m  %6s  %7s  %-*s  %7s\033[0m\n", u, f, nw, name_buf, t);
    1973              :         else
    1974           10 :             printf("  %6s  %7s  %-*s  %7s\n", u, f, nw, name_buf, t);
    1975              :     }
    1976            3 : }
    1977              : 
    1978              : /* ── Public API ──────────────────────────────────────────────────────── */
    1979              : 
    1980              : /**
    1981              :  * Case-insensitive match of `name` against the cached server folder list.
    1982              :  * Returns a heap-allocated canonical name if the case differs, or NULL if
    1983              :  * the name is already canonical (or the cache is unavailable).
    1984              :  * Caller must free() the returned string.
    1985              :  */
    1986          385 : static char *resolve_folder_name_dup(const char *name) {
    1987          385 :     int fcount = 0;
    1988          385 :     char **fl = local_folder_list_load(&fcount, NULL);
    1989          385 :     if (!fl) return NULL;
    1990          235 :     char *result = NULL;
    1991         2115 :     for (int i = 0; i < fcount; i++) {
    1992         1880 :         if (strcasecmp(fl[i], name) == 0 && strcmp(fl[i], name) != 0) {
    1993            0 :             result = strdup(fl[i]);   /* canonical differs from input */
    1994            0 :             break;
    1995              :         }
    1996              :     }
    1997         2115 :     for (int i = 0; i < fcount; i++) free(fl[i]);
    1998          235 :     free(fl);
    1999          235 :     return result;
    2000              : }
    2001              : 
    2002              : /* Rebuild filtered entry index.
    2003              :  * fentries[0..fcount-1] holds original indices from entries[] that match fbuf.
    2004              :  * Empty fbuf = identity (all entries match). */
    2005            8 : static void list_filter_rebuild(
    2006              :     const MsgEntry *entries, int show_count,
    2007              :     Manifest *manifest, const Config *cfg,
    2008              :     const char *folder,
    2009              :     const char *fbuf, int fscope,
    2010              :     int *fentries, int *fcount_out)
    2011              : {
    2012            8 :     if (!fbuf || fbuf[0] == '\0') {
    2013            4 :         for (int i = 0; i < show_count; i++) fentries[i] = i;
    2014            2 :         *fcount_out = show_count;
    2015            2 :         return;
    2016              :     }
    2017            6 :     int fc = 0;
    2018           12 :     for (int i = 0; i < show_count; i++) {
    2019            6 :         ManifestEntry *me = manifest_find(manifest, entries[i].uid);
    2020            6 :         int match = 0;
    2021            6 :         if (fscope == 0) {
    2022            4 :             const char *s = (me && me->subject) ? me->subject : "";
    2023            4 :             match = strcasestr(s, fbuf) != NULL;
    2024            2 :         } else if (fscope == 1) {
    2025            2 :             const char *s = (me && me->from) ? me->from : "";
    2026            2 :             match = strcasestr(s, fbuf) != NULL;
    2027            0 :         } else if (fscope == 2) {
    2028            0 :             char *hdrs = fetch_uid_headers_cached(cfg, folder, entries[i].uid);
    2029            0 :             if (hdrs) {
    2030            0 :                 char *to_raw = mime_get_header(hdrs, "To");
    2031            0 :                 if (to_raw) {
    2032            0 :                     char *to_dec = mime_decode_words(to_raw);
    2033            0 :                     if (to_dec) { match = strcasestr(to_dec, fbuf) != NULL; free(to_dec); }
    2034            0 :                     free(to_raw);
    2035              :                 }
    2036            0 :                 free(hdrs);
    2037              :             }
    2038              :         } else {
    2039            0 :             const char *lf = cfg->gmail_mode ? "" : folder;
    2040            0 :             char *body = local_msg_load(lf, entries[i].uid);
    2041            0 :             if (body) { match = strcasestr(body, fbuf) != NULL; free(body); }
    2042              :         }
    2043            6 :         if (match) fentries[fc++] = i;
    2044              :     }
    2045            6 :     *fcount_out = fc;
    2046              : }
    2047              : 
    2048          385 : int email_service_list(const Config *cfg, EmailListOpts *opts) {
    2049              :     /* Always re-initialise the local store so the correct account's manifests
    2050              :      * and header cache are used, regardless of which account was active before. */
    2051          385 :     local_store_init(cfg->host, cfg->user);
    2052              : 
    2053          385 :     const char *raw_folder = opts->folder ? opts->folder : cfg->folder;
    2054              : 
    2055              :     /* Normalise to the server-canonical name so the manifest key matches
    2056              :      * what sync stored (e.g. config "Inbox" → server "INBOX"). */
    2057          652 :     RAII_STRING char *folder_canonical = resolve_folder_name_dup(raw_folder);
    2058          385 :     const char *folder = folder_canonical ? folder_canonical : raw_folder;
    2059              : 
    2060              :     /* Gmail: if the folder value looks like a display name rather than a label ID,
    2061              :      * try to resolve it to the underlying ID via the local label name cache. */
    2062          267 :     RAII_STRING char *gmail_resolved_id = NULL;
    2063          385 :     if (cfg->gmail_mode && folder) {
    2064           59 :         gmail_resolved_id = local_gmail_label_id_lookup(folder);
    2065           59 :         if (gmail_resolved_id)
    2066           46 :             folder = gmail_resolved_id;
    2067              :     }
    2068              : 
    2069              :     /* Friendly display name for the folder/label (used in status bar).
    2070              :      * For Gmail user labels: look up display name from ID.
    2071              :      * For virtual (underscore) labels: use a human-readable name.
    2072              :      * For IMAP or system labels: use the ID as-is. */
    2073          267 :     RAII_STRING char *folder_display_alloc = NULL;
    2074          385 :     if (cfg->gmail_mode && folder)
    2075           59 :         folder_display_alloc = local_gmail_label_name_lookup(folder);
    2076          385 :     const char *folder_display = folder_display_alloc ? folder_display_alloc : folder;
    2077              :     /* Virtual label display names (not in gmail_label_names) */
    2078          385 :     if (cfg->gmail_mode && folder && !folder_display_alloc) {
    2079           13 :         if (strcmp(folder, "_nolabel") == 0) folder_display = "Archive";
    2080            7 :         else if (strcmp(folder, "_trash")   == 0) folder_display = "Trash";
    2081            0 :         else if (strcmp(folder, "_spam")    == 0) folder_display = "Spam";
    2082              :     }
    2083              : 
    2084          385 :     int list_result = 0;
    2085              : 
    2086              :     /* Virtual cross-folder views for IMAP (aggregate all manifests by flag) */
    2087          385 :     int is_virtual_flags = 0;
    2088          385 :     int virtual_flag_mask = 0;
    2089          385 :     if (!cfg->gmail_mode && folder) {
    2090          326 :         if (strcmp(folder, "__unread__")    == 0) { is_virtual_flags = 1; virtual_flag_mask = MSG_FLAG_UNSEEN;    folder_display = "Unread";    }
    2091          326 :         if (strcmp(folder, "__flagged__")   == 0) { is_virtual_flags = 1; virtual_flag_mask = MSG_FLAG_FLAGGED;   folder_display = "Flagged";   }
    2092          326 :         if (strcmp(folder, "__junk__")      == 0) { is_virtual_flags = 1; virtual_flag_mask = MSG_FLAG_JUNK;      folder_display = "Junk";      }
    2093          326 :         if (strcmp(folder, "__phishing__")  == 0) { is_virtual_flags = 1; virtual_flag_mask = MSG_FLAG_PHISHING;  folder_display = "Phishing";  }
    2094          326 :         if (strcmp(folder, "__answered__")  == 0) { is_virtual_flags = 1; virtual_flag_mask = MSG_FLAG_ANSWERED;  folder_display = "Answered";  }
    2095          326 :         if (strcmp(folder, "__forwarded__") == 0) { is_virtual_flags = 1; virtual_flag_mask = MSG_FLAG_FORWARDED; folder_display = "Forwarded"; }
    2096              :         /* Mask 0 = no flag requirement, i.e. every cached message in every
    2097              :          * folder — the cross-folder counterpart of a plain folder listing. */
    2098          326 :         if (strcmp(folder, "__all__")       == 0) { is_virtual_flags = 1; virtual_flag_mask = 0;                    folder_display = "All mail";  }
    2099              :     }
    2100              : 
    2101              :     /* Cross-folder content search: folder = "__search__:<scope>:<query>" */
    2102          385 :     int   is_virtual_search = 0;
    2103          385 :     int   search_scope      = 0;
    2104          385 :     char  search_query[256] = "";
    2105          385 :     char  search_display[320] = "";
    2106          385 :     if (folder && strncmp(folder, "__search__:", 11) == 0) {
    2107           10 :         is_virtual_search = 1;
    2108           10 :         search_scope = (folder[11] >= '0' && folder[11] <= '3') ? (folder[11] - '0') : 0;
    2109            0 :         snprintf(search_query, sizeof(search_query), "%s",
    2110           10 :                  (strlen(folder) > 13) ? folder + 13 : "");
    2111              :         static const char *snames[] = {"Subject","From","To","Body"};
    2112           10 :         snprintf(search_display, sizeof(search_display),
    2113              :                  "Search: \"%s\" [%s]", search_query, snames[search_scope]);
    2114           10 :         folder_display = search_display;
    2115              :     }
    2116              : 
    2117          385 :     logger_log(LOG_INFO, "Listing %s @ %s/%s", cfg->user, cfg->host, folder);
    2118              : 
    2119              :     /* Load manifest (or build synthetic manifest for virtual views) */
    2120          385 :     Manifest *manifest = NULL;
    2121          385 :     if (is_virtual_flags) {
    2122           17 :         manifest = manifest_load_all_with_flag(virtual_flag_mask);
    2123           17 :         if (!manifest) return -1;
    2124          368 :     } else if (is_virtual_search) {
    2125           10 :         manifest = calloc(1, sizeof(Manifest));
    2126           10 :         if (!manifest) return -1;
    2127              :     } else {
    2128          358 :         manifest = manifest_load(folder);
    2129          358 :         if (!manifest) {
    2130          110 :             manifest = calloc(1, sizeof(Manifest));
    2131          110 :             if (!manifest) return -1;
    2132              :         }
    2133              :     }
    2134              : 
    2135          385 :     int show_count = 0;
    2136          385 :     int unseen_count = 0;
    2137          385 :     MsgEntry *entries = NULL;
    2138              : 
    2139              :     /* Shared mail client — populated in online mode, NULL in cron mode.
    2140              :      * Kept alive for the full rendering loop so header fetches reuse it. */
    2141          267 :     RAII_MAIL MailClient *list_mc = NULL;
    2142              : 
    2143          385 :     if (is_virtual_flags) {
    2144              :         /* ── Virtual Unread/Flagged: local manifest aggregate (always cache-only).
    2145              :          * Each entry carries its source folder so Enter, 'n', etc. can route
    2146              :          * back to the correct per-folder manifest and IMAP SELECT. */
    2147           17 :         SearchResult *fr = NULL;
    2148           17 :         int fr_count = 0;
    2149           17 :         local_flag_search(virtual_flag_mask, &fr, &fr_count);
    2150           17 :         show_count = fr_count;
    2151           17 :         entries = calloc((size_t)(fr_count > 0 ? fr_count : 1), sizeof(MsgEntry));
    2152           17 :         if (!entries) { if (fr) free(fr); manifest_free(manifest); return -1; }
    2153           41 :         for (int i = 0; i < fr_count; i++) {
    2154           24 :             memcpy(entries[i].uid, fr[i].uid, 17);
    2155           24 :             snprintf(entries[i].folder, sizeof(entries[i].folder), "%s", fr[i].folder);
    2156           24 :             entries[i].flags = fr[i].flags;
    2157           24 :             entries[i].epoch = fr[i].date ? parse_manifest_date(fr[i].date) : 0;
    2158           24 :             manifest_upsert(manifest, fr[i].uid,
    2159           24 :                             fr[i].from, fr[i].subject, fr[i].date, fr[i].flags);
    2160           24 :             fr[i].from = fr[i].subject = fr[i].date = NULL;
    2161           24 :             if (entries[i].flags & MSG_FLAG_UNSEEN) unseen_count++;
    2162              :         }
    2163           17 :         free(fr);
    2164          368 :     } else if (is_virtual_search) {
    2165              :         /* ── Cross-folder content search (always local data) ────────────── */
    2166           10 :         SearchResult *sr = NULL;
    2167           10 :         int sr_count = 0;
    2168           10 :         local_search(search_query, search_scope, &sr, &sr_count);
    2169           10 :         show_count = sr_count;
    2170           10 :         entries = calloc((size_t)(sr_count > 0 ? sr_count : 1), sizeof(MsgEntry));
    2171           10 :         if (!entries) { local_search_free(sr, sr_count); manifest_free(manifest); return -1; }
    2172           24 :         for (int i = 0; i < sr_count; i++) {
    2173           14 :             memcpy(entries[i].uid, sr[i].uid, 17);
    2174           14 :             snprintf(entries[i].folder, sizeof(entries[i].folder), "%s", sr[i].folder);
    2175           14 :             entries[i].flags = sr[i].flags;
    2176           14 :             entries[i].epoch = sr[i].date ? parse_manifest_date(sr[i].date) : 0;
    2177              :             /* Transfer ownership of strings to manifest; null them in sr so free() is safe */
    2178           14 :             manifest_upsert(manifest, sr[i].uid,
    2179           14 :                             sr[i].from, sr[i].subject, sr[i].date, sr[i].flags);
    2180           14 :             sr[i].from = sr[i].subject = sr[i].date = NULL;
    2181           14 :             if (entries[i].flags & MSG_FLAG_UNSEEN) unseen_count++;
    2182              :         }
    2183           10 :         local_search_free(sr, sr_count);
    2184          358 :     } else if (cfg->sync_interval > 0) {
    2185              :         /* ── Cron / cache-only mode: serve entirely from manifest ──────── */
    2186           25 :         if (manifest->count == 0) {
    2187            3 :             manifest_free(manifest);
    2188            3 :             if (!opts->pager) {
    2189            2 :                 if (opts->json) {
    2190              :                     /* JSON mode must always emit one parseable document; the
    2191              :                      * advice goes to stderr so stdout stays machine-readable. */
    2192            2 :                     printf("[\n]\n");
    2193            2 :                     fprintf(stderr,
    2194              :                             "No cached data for %s. Run 'email-sync' first.\n", folder);
    2195              :                 } else {
    2196            0 :                     printf("No cached data for %s. Run 'email-cli sync' first.\n", folder);
    2197              :                 }
    2198            2 :                 return 0;
    2199              :             }
    2200            2 :             RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    2201              :             {
    2202            1 :                 int tcols = terminal_cols(); int trows = terminal_rows();
    2203            1 :                 if (tcols <= 0) tcols = 80;
    2204            1 :                 if (trows <= 0) trows = 24;
    2205            1 :                 int avail = tcols - 29; if (avail < 40) avail = 40;
    2206            1 :                 int subj_w = avail * 3 / 5, from_w = avail - subj_w;
    2207            1 :                 printf("\033[H\033[2J");
    2208              :                 char cl[512];
    2209            1 :                 snprintf(cl, sizeof(cl),
    2210              :                          "  0 of 0 message(s) in %s (0 unread) [%s].  \u26a0 No cached data \u2014 run 'email-sync' or 's=sync'",
    2211            1 :                          folder_display, cfg->user ? cfg->user : "?");
    2212            1 :                 printf("\033[1;1H\033[7m%s", cl);
    2213            1 :                 int used = visible_line_cols(cl, cl + strlen(cl));
    2214            1 :                 for (int p = used; p < tcols; p++) putchar(' ');
    2215            1 :                 printf("\033[0m");
    2216            1 :                 printf("\033[3;1H  %-16s  %-6s  %-*s  %s\n",
    2217              :                        "Date", "Sts", subj_w, "Subject", "From");
    2218            1 :                 printf("  ");
    2219            1 :                 print_dbar(16); printf("  \u2550\u2550\u2550\u2550\u2550\u2550  ");
    2220            1 :                 print_dbar(subj_w); printf("  "); print_dbar(from_w); printf("\n");
    2221            1 :                 printf("\n  \033[2m(empty)\033[0m\n");
    2222            1 :                 fflush(stdout);
    2223              :                 char sb[256];
    2224            1 :                 snprintf(sb, sizeof(sb),
    2225              :                          "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    2226              :                          "  Backspace=%s  ESC=quit"
    2227              :                          "  s=sync  U=refresh  l=rules  [0/0]",
    2228            1 :                          cfg->gmail_mode ? "labels" : "folders");
    2229            1 :                 print_statusbar(trows, tcols, sb);
    2230              :             }
    2231            0 :             for (;;) {
    2232            1 :                 TermKey key = terminal_read_key();
    2233            1 :                 if (key == TERM_KEY_BACK) return 1;
    2234            0 :                 if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) return 0;
    2235            0 :                 int ch = terminal_last_printable();
    2236            0 :                 if (ch == 's') { sync_start_background(); }
    2237            0 :                 if (ch == 'U') return 4; /* refresh: re-list */
    2238            0 :                 if (ch == 'l') return 7; /* rules editor */
    2239              :             }
    2240              :         }
    2241           22 :         show_count = manifest->count;
    2242           22 :         entries = malloc((size_t)show_count * sizeof(MsgEntry));
    2243           22 :         if (!entries) { manifest_free(manifest); return -1; }
    2244           53 :         for (int i = 0; i < show_count; i++) {
    2245           31 :             memcpy(entries[i].uid, manifest->entries[i].uid, 17);
    2246           31 :             entries[i].folder[0] = '\0';
    2247           31 :             entries[i].flags = manifest->entries[i].flags;
    2248           31 :             entries[i].epoch = parse_manifest_date(manifest->entries[i].date);
    2249              :         }
    2250           53 :         for (int i = 0; i < show_count; i++)
    2251           31 :             if (entries[i].flags & MSG_FLAG_UNSEEN) unseen_count++;
    2252          333 :     } else if (cfg->gmail_mode) {
    2253              :         /* ── Gmail offline mode: load from local .idx + .hdr cache ─────── */
    2254              :         /* SPAM and TRASH are stored under underscore-prefixed local names */
    2255           59 :         const char *idx_folder = folder;
    2256           59 :         if (strcmp(folder, "TRASH") == 0) idx_folder = "_trash";
    2257           59 :         else if (strcmp(folder, "SPAM") == 0) idx_folder = "_spam";
    2258              : 
    2259           59 :         char (*idx_uids)[17] = NULL;
    2260           59 :         int idx_count = 0;
    2261           59 :         label_idx_load(idx_folder, &idx_uids, &idx_count);
    2262              : 
    2263              :         /* Only include entries that have a cached .hdr file.
    2264              :          * UIDs without a .hdr were never fully synced (e.g. sync was
    2265              :          * interrupted, or they are stale data from a previous format).
    2266              :          * Skip them silently; a run of email-sync will fetch them. */
    2267           59 :         entries = malloc((size_t)(idx_count > 0 ? idx_count : 1) * sizeof(MsgEntry));
    2268           59 :         if (!entries) { free(idx_uids); manifest_free(manifest); return -1; }
    2269           59 :         show_count = 0;
    2270              : 
    2271         1228 :         for (int i = 0; i < idx_count; i++) {
    2272              :             /* .hdr format: from\tsubject\tdate\tlabels\tflags */
    2273         1169 :             char *hdr = local_hdr_load("", idx_uids[i]);
    2274         1169 :             if (!hdr) continue;   /* not yet synced — skip */
    2275              : 
    2276         1169 :             MsgEntry *e = &entries[show_count];
    2277         1169 :             memcpy(e->uid, idx_uids[i], 17);
    2278         1169 :             e->folder[0] = '\0';
    2279         1169 :             e->flags = 0;
    2280         1169 :             e->epoch = 0;
    2281              : 
    2282              :             /* Split tab-separated fields */
    2283         1169 :             char *fields[5] = {0};
    2284         1169 :             fields[0] = hdr;
    2285         1169 :             int f = 1;
    2286        91368 :             for (char *p = hdr; *p && f < 5; p++) {
    2287        90199 :                 if (*p == '\t') { *p = '\0'; fields[f++] = p + 1; }
    2288              :             }
    2289         1169 :             const char *from = fields[0] ? fields[0] : "";
    2290         1169 :             const char *subj = fields[1] ? fields[1] : "";
    2291         1169 :             const char *date = fields[2] ? fields[2] : "";
    2292         1169 :             int flags = fields[4] ? atoi(fields[4]) : 0;
    2293         1169 :             e->flags = flags;
    2294         1169 :             e->epoch = parse_manifest_date(date);
    2295              :             /* Populate manifest so the renderer can find from/subject/date.
    2296              :              * manifest_upsert takes ownership of the strings, so strdup
    2297              :              * them — the originals point into the hdr buffer freed below. */
    2298         1169 :             manifest_upsert(manifest, idx_uids[i], strdup(from), strdup(subj), strdup(date), flags);
    2299         1169 :             free(hdr);
    2300              : 
    2301         1169 :             if (e->flags & MSG_FLAG_UNSEEN) unseen_count++;
    2302         1169 :             show_count++;
    2303              :         }
    2304           59 :         free(idx_uids);
    2305              :     } else {
    2306              :         /* ── IMAP online mode: contact the server ──────────────────────── */
    2307              : 
    2308              :         /* Fetch UNSEEN and ALL UID sets via a shared mail client connection. */
    2309          274 :         list_mc = make_mail(cfg);
    2310          274 :         if (!list_mc) {
    2311            0 :             manifest_free(manifest);
    2312            0 :             fprintf(stderr, "Failed to connect.\n");
    2313            5 :             return -1;
    2314              :         }
    2315          274 :         if (mail_client_select(list_mc, folder) != 0) {
    2316            0 :             manifest_free(manifest);
    2317            0 :             fprintf(stderr, "Failed to select folder %s.\n", folder);
    2318            0 :             return -1;
    2319              :         }
    2320              : 
    2321          274 :         char (*unseen_uids)[17] = NULL;
    2322          274 :         int  unseen_uid_count = 0;
    2323          274 :         if (mail_client_search(list_mc, MAIL_SEARCH_UNREAD, &unseen_uids, &unseen_uid_count) != 0) {
    2324            0 :             manifest_free(manifest);
    2325            0 :             fprintf(stderr, "Failed to search mailbox.\n");
    2326            0 :             return -1;
    2327              :         }
    2328              : 
    2329          274 :         char (*flagged_uids)[17] = NULL;
    2330          274 :         int flagged_count = 0;
    2331          274 :         mail_client_search(list_mc, MAIL_SEARCH_FLAGGED, &flagged_uids, &flagged_count);
    2332              :         /* ignore errors — treat as 0 flagged */
    2333              : 
    2334          274 :         char (*done_uids)[17] = NULL;
    2335          274 :         int done_count = 0;
    2336          274 :         mail_client_search(list_mc, MAIL_SEARCH_DONE, &done_uids, &done_count);
    2337              :         /* ignore errors — treat as 0 done */
    2338              : 
    2339          274 :         char (*all_uids)[17] = NULL;
    2340          274 :         int  all_count = 0;
    2341          274 :         if (mail_client_search(list_mc, MAIL_SEARCH_ALL, &all_uids, &all_count) != 0) {
    2342            0 :             free(unseen_uids);
    2343            0 :             free(flagged_uids);
    2344            0 :             free(done_uids);
    2345            0 :             manifest_free(manifest);
    2346            0 :             fprintf(stderr, "Failed to search mailbox.\n");
    2347            0 :             return -1;
    2348              :         }
    2349              :         /* Evict headers for messages deleted from the server */
    2350          274 :         if (all_count > 0)
    2351          269 :             local_hdr_evict_stale(folder, (const char (*)[17])all_uids, all_count);
    2352              : 
    2353              :         /* Remove entries for UIDs deleted from the server */
    2354          274 :         if (all_count > 0)
    2355          269 :             manifest_retain(manifest, (const char (*)[17])all_uids, all_count);
    2356              : 
    2357          274 :         show_count = all_count;
    2358              : 
    2359          274 :         if (show_count == 0) {
    2360            5 :             manifest_free(manifest);
    2361            5 :             free(unseen_uids);
    2362            5 :             free(flagged_uids);
    2363            5 :             free(done_uids);
    2364            5 :             free(all_uids);
    2365            5 :             if (!opts->pager) {
    2366            4 :                 printf("No messages in %s.\n", folder_display);
    2367            4 :                 return 0;
    2368              :             }
    2369            2 :             RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    2370              :             {
    2371            1 :                 int tcols = terminal_cols(); int trows = terminal_rows();
    2372            1 :                 if (tcols <= 0) tcols = 80;
    2373            1 :                 if (trows <= 0) trows = 24;
    2374            1 :                 int avail = tcols - 29; if (avail < 40) avail = 40;
    2375            1 :                 int subj_w = avail * 3 / 5, from_w = avail - subj_w;
    2376            1 :                 printf("\033[H\033[2J");
    2377              :                 char cl[512];
    2378            1 :                 snprintf(cl, sizeof(cl),
    2379              :                          "  0 of 0 message(s) in %s (0 unread) [%s].",
    2380            1 :                          folder_display, cfg->user ? cfg->user : "?");
    2381            1 :                 printf("\033[1;1H\033[7m%s", cl);
    2382            1 :                 int used = visible_line_cols(cl, cl + strlen(cl));
    2383           44 :                 for (int p = used; p < tcols; p++) putchar(' ');
    2384            1 :                 printf("\033[0m");
    2385            1 :                 printf("\033[3;1H  %-16s  %-6s  %-*s  %s\n",
    2386              :                        "Date", "Sts", subj_w, "Subject", "From");
    2387            1 :                 printf("  ");
    2388            1 :                 print_dbar(16); printf("  \u2550\u2550\u2550\u2550\u2550\u2550  ");
    2389            1 :                 print_dbar(subj_w); printf("  "); print_dbar(from_w); printf("\n");
    2390            1 :                 printf("\n  \033[2m(empty)\033[0m\n");
    2391            1 :                 fflush(stdout);
    2392              :                 char sb[256];
    2393            1 :                 if (cfg->gmail_mode) {
    2394            0 :                     snprintf(sb, sizeof(sb),
    2395              :                              "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    2396              :                              "  Backspace=labels  ESC=quit"
    2397              :                              "  c=compose  r=reply  F=fwd  A=r-all  n=unread  f=star"
    2398              :                              "  s=sync  U=refresh  l=rules  [0/0]");
    2399              :                 } else {
    2400            1 :                     snprintf(sb, sizeof(sb),
    2401              :                              "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    2402              :                              "  Backspace=folders  ESC=quit"
    2403              :                              "  c=compose  r=reply  F=fwd  A=r-all  n=new  f=flag  d=done"
    2404              :                              "  D=trash  s=sync  U=refresh  l=rules  [0/0]");
    2405              :                 }
    2406            1 :                 print_statusbar(trows, tcols, sb);
    2407              :             }
    2408            0 :             for (;;) {
    2409            1 :                 TermKey key = terminal_read_key();
    2410            1 :                 if (key == TERM_KEY_BACK)  return 1;
    2411            0 :                 if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) return 0;
    2412            0 :                 int ch = terminal_last_printable();
    2413            0 :                 if (ch == 'c') return 2; /* compose */
    2414            0 :                 if (ch == 's') { sync_start_background(); }
    2415            0 :                 if (ch == 'U') return 4; /* refresh */
    2416            0 :                 if (ch == 'l') return 7; /* rules editor */
    2417              :             }
    2418              :         }
    2419              : 
    2420              :         /* Build tagged entry array */
    2421          269 :         entries = malloc((size_t)show_count * sizeof(MsgEntry));
    2422          269 :         if (!entries) { free(unseen_uids); free(flagged_uids); free(done_uids); free(all_uids); manifest_free(manifest); return -1; }
    2423              : 
    2424         1650 :         for (int i = 0; i < show_count; i++) {
    2425         1381 :             memcpy(entries[i].uid, all_uids[i], 17);
    2426         1381 :             entries[i].folder[0] = '\0';
    2427         1381 :             entries[i].flags = 0;
    2428        22373 :             for (int j = 0; j < unseen_uid_count;  j++)
    2429        21373 :                 if (strcmp(unseen_uids[j],  all_uids[i]) == 0) { entries[i].flags |= MSG_FLAG_UNSEEN;  break; }
    2430         1381 :             for (int j = 0; j < flagged_count; j++)
    2431           11 :                 if (strcmp(flagged_uids[j], all_uids[i]) == 0) { entries[i].flags |= MSG_FLAG_FLAGGED; break; }
    2432         1381 :             for (int j = 0; j < done_count;    j++)
    2433            0 :                 if (strcmp(done_uids[j],    all_uids[i]) == 0) { entries[i].flags |= MSG_FLAG_DONE;    break; }
    2434              :             /* Try to get date from cached manifest (may be 0 if not yet fetched) */
    2435         1381 :             ManifestEntry *me = manifest_find(manifest, all_uids[i]);
    2436         1381 :             entries[i].epoch = me ? parse_manifest_date(me->date) : 0;
    2437              :         }
    2438              :         /* Compute unseen_count for the status line */
    2439         1650 :         for (int i = 0; i < show_count; i++)
    2440         1381 :             if (entries[i].flags & MSG_FLAG_UNSEEN) unseen_count++;
    2441          269 :         free(unseen_uids);
    2442          269 :         free(flagged_uids);
    2443          269 :         free(done_uids);
    2444          269 :         free(all_uids);
    2445              :     }
    2446              : 
    2447          377 :     if (show_count == 0) {
    2448           13 :         manifest_free(manifest);
    2449           13 :         free(entries);
    2450           13 :         if (!opts->pager) {
    2451           10 :             printf("No messages in %s.\n", folder_display);
    2452           10 :             return 0;
    2453              :         }
    2454            3 :         RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    2455              :         {
    2456            3 :             int tcols = terminal_cols(); int trows = terminal_rows();
    2457            3 :             if (tcols <= 0) tcols = 80;
    2458            3 :             if (trows <= 0) trows = 24;
    2459            3 :             int avail = tcols - 29; if (avail < 40) avail = 40;
    2460            3 :             int subj_w = avail * 3 / 5, from_w = avail - subj_w;
    2461            3 :             printf("\033[H\033[2J");
    2462              :             char cl[512];
    2463            3 :             snprintf(cl, sizeof(cl),
    2464              :                      "  0 of 0 message(s) in %s (0 unread) [%s].",
    2465            3 :                      folder_display, cfg->user ? cfg->user : "?");
    2466            3 :             printf("\033[1;1H\033[7m%s", cl);
    2467            3 :             int used = visible_line_cols(cl, cl + strlen(cl));
    2468          178 :             for (int p = used; p < tcols; p++) putchar(' ');
    2469            3 :             printf("\033[0m");
    2470            3 :             printf("\033[3;1H  %-16s  %-6s  %-*s  %s\n",
    2471              :                    "Date", "Sts", subj_w, "Subject", "From");
    2472            3 :             printf("  ");
    2473            3 :             print_dbar(16); printf("  \u2550\u2550\u2550\u2550\u2550\u2550  ");
    2474            3 :             print_dbar(subj_w); printf("  "); print_dbar(from_w); printf("\n");
    2475            3 :             printf("\n  \033[2m(empty)\033[0m\n");
    2476            3 :             fflush(stdout);
    2477              :             char sb[256];
    2478            3 :             if (cfg->gmail_mode) {
    2479            3 :                 int in_trash_empty = (strcmp(folder, "_trash") == 0);
    2480            3 :                 if (in_trash_empty) {
    2481            2 :                     snprintf(sb, sizeof(sb),
    2482              :                              "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    2483              :                              "  Backspace=labels  ESC=quit"
    2484              :                              "  D=del  u=restore  t=labels  n=unread  f=star"
    2485              :                              "  s=sync  U=refresh  l=rules  [0/0]");
    2486              :                 } else {
    2487            1 :                     snprintf(sb, sizeof(sb),
    2488              :                              "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    2489              :                              "  Backspace=labels  ESC=quit"
    2490              :                              "  c=compose  r=reply  F=fwd  A=r-all  n=unread  f=star"
    2491              :                              "  D=trash  s=sync  U=refresh  l=rules  [0/0]");
    2492              :                 }
    2493              :             } else {
    2494            0 :                 snprintf(sb, sizeof(sb),
    2495              :                          "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    2496              :                          "  Backspace=folders  ESC=quit"
    2497              :                          "  c=compose  r=reply  F=fwd  A=r-all  n=new  f=flag  d=done"
    2498              :                          "  D=trash  s=sync  U=refresh  l=rules  [0/0]");
    2499              :             }
    2500            3 :             print_statusbar(trows, tcols, sb);
    2501              :         }
    2502            0 :         for (;;) {
    2503            3 :             TermKey key = terminal_read_key();
    2504            0 :             if (key == TERM_KEY_BACK)  return 1;
    2505            0 :             if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) return 0;
    2506            0 :             int ch = terminal_last_printable();
    2507            0 :             if (ch == 'c') return 2; /* compose */
    2508            0 :             if (ch == 's') { sync_start_background(); }
    2509            0 :             if (ch == 'U') return 4; /* refresh */
    2510            0 :             if (ch == 'l') return 7; /* rules editor */
    2511              :         }
    2512              :     }
    2513              : 
    2514              :     /* Batch filters run before sorting and paging so that --limit/--offset and
    2515              :      * the "N more message(s)" hint all refer to the filtered set. */
    2516          364 :     show_count = apply_list_filters(manifest, entries, show_count, opts);
    2517              : 
    2518              :     /* Sort: unseen → flagged → rest, within each group newest (highest UID) first */
    2519          364 :     qsort(entries, (size_t)show_count, sizeof(MsgEntry), cmp_uid_entry);
    2520              : 
    2521          364 :     int limit  = (opts->limit > 0) ? opts->limit : show_count;
    2522          364 :     int cursor = (opts->offset > 1) ? opts->offset - 1 : 0;
    2523          364 :     if (cursor >= show_count) cursor = 0;
    2524          364 :     int wstart = cursor;   /* top of the visible window */
    2525              : 
    2526              :     /* Track entries with pending operations for immediate visual feedback.
    2527              :      * pending_remove[i] = 1: row will be gone on next refresh (red strikethrough).
    2528              :      *   Set by: 'D' (trash) only.
    2529              :      * pending_label[i] = 1: label removed/archived, row may stay (yellow strikethrough).
    2530              :      *   Set by: 'd' (remove current label), 'a' (archive).
    2531              :      * pending_restore[i] = 1: row will leave this view on next refresh (green strikethrough).
    2532              :      *   Set by: 'u' (untrash/restore), 't' label-picker unarchive.
    2533              :      * Only allocated in TUI (pager) mode; always NULL in CLI/RO mode. */
    2534          728 :     int *pending_remove  = opts->pager
    2535          250 :                            ? calloc((size_t)(show_count > 0 ? show_count : 1), sizeof(int))
    2536          364 :                            : NULL;
    2537          728 :     int *pending_label   = opts->pager
    2538          250 :                            ? calloc((size_t)(show_count > 0 ? show_count : 1), sizeof(int))
    2539          364 :                            : NULL;
    2540          728 :     int *pending_restore = opts->pager
    2541          250 :                            ? calloc((size_t)(show_count > 0 ? show_count : 1), sizeof(int))
    2542          364 :                            : NULL;
    2543              :     /* Feedback message shown on the second-to-last row after each operation.
    2544              :      * Cleared only when the list is re-opened (R/ESC/Backspace restart the view). */
    2545          364 :     char feedback_msg[256] = "";
    2546              : 
    2547              :     /* ── Live filter state ──────────────────────────────────────────── */
    2548          364 :     int   filter_active = 0;   /* filter bar is visible */
    2549          364 :     int   filter_input  = 0;   /* typing mode (vs navigation mode) */
    2550          364 :     int   filter_scope  = 0;   /* 0=Subject  1=From  2=To  3=Body */
    2551          364 :     int   filter_dirty    = 0;   /* rebuild needed after next render */
    2552          364 :     int   filter_scanning = 0;   /* body scan in progress — show progress bar */
    2553          364 :     char  filter_buf[256] = "";
    2554          728 :     int  *fentries = opts->pager
    2555          250 :                      ? calloc((size_t)(show_count > 0 ? show_count : 1), sizeof(int))
    2556          364 :                      : NULL;
    2557          364 :     int   fcount = show_count;
    2558          768 :     if (fentries) { for (int _fi = 0; _fi < show_count; _fi++) fentries[_fi] = _fi; }
    2559              : 
    2560              :     /* Keep the terminal in raw mode for the entire interactive TUI.
    2561              :      * Without this, each terminal_read_key() call would need to briefly enter
    2562              :      * and exit raw mode per keystroke, which causes escape sequence echo and
    2563              :      * ICANON buffering artefacts.  terminal_read_key() requires raw mode to
    2564              :      * already be active — we enter it once here and exit at list_done. */
    2565          364 :     RAII_TERM_RAW TermRawState *tui_raw = opts->pager
    2566          250 :                                           ? terminal_raw_enter()
    2567          364 :                                           : NULL;
    2568              : 
    2569           87 :     for (;;) {
    2570              :         /* Number of rows visible under current filter (= show_count when no filter) */
    2571          451 :         int disp_count = filter_active ? fcount : show_count;
    2572              : 
    2573              :         /* Effective row budget: filter bar occupies 2 rows (separator + input) */
    2574          451 :         int eff_limit = (opts->pager && filter_active) ? limit - 2 : limit;
    2575          451 :         if (eff_limit < 1) eff_limit = 1;
    2576              : 
    2577              :         /* Scroll window to keep cursor visible */
    2578          451 :         if (cursor < wstart)                 wstart = cursor;
    2579          451 :         if (cursor >= wstart + eff_limit)    wstart = cursor - eff_limit + 1;
    2580          451 :         if (wstart < 0)                      wstart = 0;
    2581          451 :         int wend = wstart + eff_limit;
    2582          451 :         if (wend > disp_count)               wend = disp_count;
    2583              : 
    2584              :         /* Compute adaptive column widths.
    2585              :          * email-tui (opts->pager==1): date+sts+subject+from, overhead=29
    2586              :          * email-cli/ro (opts->pager==0): uid+date+sts+subject+from, overhead=47
    2587              :          * Non-TTY CLI: two-pass — pre-load all entries, use max Subject width. */
    2588          451 :         int is_tty   = isatty(STDOUT_FILENO);
    2589          451 :         int tcols    = is_tty ? terminal_cols() : 0;
    2590          451 :         int is_gmail = cfg->gmail_mode;
    2591          451 :         int show_uid = !opts->pager;   /* UID column in CLI/RO mode, not TUI */
    2592              :         /* Cross-folder views draw rows from several mailboxes; without the
    2593              :          * folder the UID is not enough to open the message again. */
    2594          451 :         int show_folder = !opts->pager && (is_virtual_flags || is_virtual_search);
    2595          451 :         int overhead = show_uid ? 48 : 30;  /* 48 = 30 + uid(16) + sep(2) */
    2596          451 :         if (show_folder) overhead += 22;    /* folder(20) + sep(2) */
    2597              :         int subj_w, from_w;
    2598          451 :         if (is_tty) {
    2599          344 :             int avail = tcols - overhead;
    2600          344 :             if (avail < 40) avail = 40;
    2601          344 :             subj_w = avail * 3 / 5;
    2602          344 :             from_w = avail - subj_w;
    2603              :         } else {
    2604          107 :             subj_w = 0;
    2605          107 :             from_w = 0;
    2606              :         }
    2607              : 
    2608              :         /* Non-TTY CLI mode: pre-load all entries to determine exact Subject column width.
    2609              :          * This two-pass approach ensures Subject is padded consistently so From starts
    2610              :          * at a predictable column — required for reliable batch/script processing. */
    2611          451 :         int manifest_dirty = 0;
    2612          451 :         if (!opts->pager && !is_tty) {
    2613         1687 :             for (int i = wstart; i < wend; i++) {
    2614         1580 :                 ManifestEntry *cme = manifest_find(manifest, entries[i].uid);
    2615         1580 :                 int need_dmarc  = cme && !(cme->flags & MSG_FLAG_DMARC_CHECKED);
    2616         1580 :                 int need_attach = cme && !(cme->flags & MSG_FLAG_ATTACH_CHECKED);
    2617         1580 :                 if (cme && !need_dmarc && !need_attach) continue;
    2618              :                 /* For Gmail: .hdr files use from\tsubject\tdate\tlabels\tflags format,
    2619              :                  * not RFC 2822 headers — Content-Type/Authentication-Results are absent.
    2620              :                  * Read the full local message instead (no network access needed). */
    2621              :                 char *hdrs;
    2622          526 :                 if (cfg->gmail_mode) {
    2623            0 :                     hdrs = local_msg_load("", entries[i].uid);
    2624              :                 } else {
    2625          526 :                     hdrs = list_mc
    2626          508 :                            ? fetch_uid_headers_via(list_mc, folder, entries[i].uid)
    2627          526 :                            : fetch_uid_headers_cached(cfg, folder, entries[i].uid);
    2628              :                 }
    2629          526 :                 if (!cme) {
    2630              :                     /* New entry: full header parse */
    2631          496 :                     char *fr_raw = hdrs ? mime_get_header(hdrs, "From")    : NULL;
    2632          496 :                     char *fr     = fr_raw ? mime_decode_words(fr_raw)      : strdup("");
    2633          496 :                     free(fr_raw);
    2634          496 :                     char *su_raw = hdrs ? mime_get_header(hdrs, "Subject") : NULL;
    2635          496 :                     char *su     = su_raw ? mime_decode_words(su_raw)      : strdup("");
    2636          496 :                     free(su_raw);
    2637          496 :                     char *dt_raw = hdrs ? mime_get_header(hdrs, "Date")    : NULL;
    2638          496 :                     char *dt     = dt_raw ? mime_format_date(dt_raw)       : strdup("");
    2639          496 :                     free(dt_raw);
    2640          496 :                     char *ct_raw = hdrs ? mime_get_header(hdrs, "Content-Type") : NULL;
    2641          496 :                     if (ct_raw && strcasestr(ct_raw, "multipart/mixed"))
    2642            0 :                         entries[i].flags |= MSG_FLAG_ATTACH;
    2643          496 :                     entries[i].flags |= MSG_FLAG_ATTACH_CHECKED;
    2644          496 :                     free(ct_raw);
    2645          496 :                     char *ar_raw = hdrs ? mime_get_header(hdrs, "Authentication-Results") : NULL;
    2646          496 :                     int dmarc_st = mime_get_dmarc_status(ar_raw);
    2647          496 :                     free(ar_raw);
    2648          496 :                     if      (dmarc_st ==  1) entries[i].flags |= MSG_FLAG_DMARC_PASS;
    2649          493 :                     else if (dmarc_st == -1) entries[i].flags |= MSG_FLAG_DMARC_FAIL;
    2650          496 :                     if (dmarc_st != -2) entries[i].flags |= MSG_FLAG_DMARC_CHECKED;
    2651          496 :                     free(hdrs);
    2652          496 :                     manifest_upsert(manifest, entries[i].uid, fr, su, dt, entries[i].flags);
    2653              :                 } else {
    2654              :                     /* Existing entry: update DMARC and/or attach bits from local cache */
    2655           30 :                     if (need_attach) {
    2656           30 :                         char *ct2 = hdrs ? mime_get_header(hdrs, "Content-Type") : NULL;
    2657           30 :                         if (ct2 && strcasestr(ct2, "multipart/mixed"))
    2658            4 :                             cme->flags |= MSG_FLAG_ATTACH;
    2659           30 :                         cme->flags |= MSG_FLAG_ATTACH_CHECKED;
    2660           30 :                         free(ct2);
    2661              :                     }
    2662           30 :                     char *ar_raw = hdrs ? mime_get_header(hdrs, "Authentication-Results") : NULL;
    2663           30 :                     int dmarc_st = mime_get_dmarc_status(ar_raw);
    2664           30 :                     free(ar_raw);
    2665           30 :                     free(hdrs);
    2666           30 :                     if      (dmarc_st ==  1) cme->flags |= MSG_FLAG_DMARC_PASS;
    2667           30 :                     else if (dmarc_st == -1) cme->flags |= MSG_FLAG_DMARC_FAIL;
    2668           30 :                     if (dmarc_st != -2) cme->flags |= MSG_FLAG_DMARC_CHECKED;
    2669           30 :                     entries[i].flags = cme->flags;
    2670              :                     /* Persist updated flags so next startup skips backfill */
    2671           30 :                     if (cfg->gmail_mode)
    2672            0 :                         local_hdr_update_flags("", entries[i].uid, cme->flags);
    2673              :                 }
    2674          526 :                 manifest_dirty = 1;
    2675              :             }
    2676              :             /* Compute max Subject display width across all visible entries */
    2677         1687 :             for (int i = wstart; i < wend; i++) {
    2678         1580 :                 ManifestEntry *me = manifest_find(manifest, entries[i].uid);
    2679         1580 :                 const char *sub = (me && me->subject) ? me->subject : "";
    2680         1580 :                 int w = (int)visible_line_cols(sub, sub + strlen(sub));
    2681         1580 :                 if (w > subj_w) subj_w = w;
    2682              :             }
    2683          107 :             from_w = 0; /* From is the last column — no right-padding needed */
    2684              :         }
    2685              : 
    2686          451 :         if (opts->pager) printf("\033[H\033[2J");
    2687              : 
    2688              :         /* Count / status line — suppressed in JSON mode, where stdout must be
    2689              :          * a single parseable document and nothing else. */
    2690          451 :         if (!opts->json) {
    2691              :             char cl[512];
    2692          449 :             int sync = (bg_sync_pid > 0) || sync_is_running();
    2693              :             const char *suffix;
    2694          449 :             if (bg_sync_done)
    2695            1 :                 suffix = "  \u2709 New mail may have arrived!  U=refresh";
    2696          448 :             else if (sync)
    2697            1 :                 suffix = "  \u21bb syncing...";
    2698          447 :             else if (is_gmail && strcmp(folder, "_trash") == 0)
    2699           10 :                 suffix = "  \u26a0 auto-delete: 30 days";
    2700              :             else
    2701          437 :                 suffix = "";
    2702          449 :             if (filter_active) {
    2703              :                 static const char *scn[] = {"Subject","From","To","Body"};
    2704           14 :                 snprintf(cl, sizeof(cl),
    2705              :                          "  Showing %d of %d  [filter:%s]  %s (%d unread) [%s].%s",
    2706              :                          fcount, show_count, scn[filter_scope],
    2707              :                          folder_display, unseen_count,
    2708           14 :                          cfg->user ? cfg->user : "?", suffix);
    2709              :             } else {
    2710          435 :                 snprintf(cl, sizeof(cl),
    2711              :                          "  %d-%d of %d message(s) in %s (%d unread) [%s].%s",
    2712              :                          wstart + 1, wend, show_count, folder_display, unseen_count,
    2713          435 :                          cfg->user ? cfg->user : "?", suffix);
    2714              :             }
    2715          449 :             if (opts->pager) {
    2716              :                 /* TUI mode: reverse-video status bar pinned to row 1 */
    2717          337 :                 printf("\033[1;1H\033[7m%s", cl);
    2718          337 :                 int used = visible_line_cols(cl, cl + strlen(cl));
    2719        17702 :                 for (int p = used; p < tcols; p++) putchar(' ');
    2720          337 :                 printf("\033[0m");
    2721              :             } else {
    2722          112 :                 printf("%s\n\n", cl);
    2723              :             }
    2724              :         }
    2725          451 :         if (opts->json) {
    2726            2 :             printf("[");
    2727              :         } else {
    2728          449 :         if (opts->pager) printf("\033[3;1H");
    2729          449 :         if (show_uid && show_folder)
    2730           10 :             printf("  %-16s  %-16s  %-6s  %-20s  %-*s  %s\n",
    2731              :                    "UID", "Date", "Sts", "Folder", subj_w, "Subject", "From");
    2732          439 :         else if (show_uid)
    2733          102 :             printf("  %-16s  %-16s  %-6s  %-*s  %s\n",
    2734              :                    "UID", "Date", "Sts", subj_w, "Subject", "From");
    2735              :         else
    2736          337 :             printf("  %-16s  %-6s  %-*s  %s\n",
    2737              :                    "Date", "Sts", subj_w, "Subject", "From");
    2738          449 :         printf("  ");
    2739          449 :         if (show_uid) { print_dbar(16); printf("  "); }
    2740          449 :         print_dbar(16); printf("  ");
    2741          449 :         printf("\u2550\u2550\u2550\u2550\u2550\u2550  ");
    2742          449 :         if (show_folder) { print_dbar(20); printf("  "); }
    2743          449 :         print_dbar(subj_w > 0 ? subj_w : 30); printf("  ");
    2744          449 :         print_dbar(from_w > 0 ? from_w : 40); printf("\n");
    2745              :         }
    2746              : 
    2747              :         /* Data rows: fetch-on-demand + immediate render per row */
    2748          451 :         int load_interrupted = 0;
    2749         2686 :         for (int i = wstart; i < wend; i++) {
    2750              :             /* Map display index to original entries[] index */
    2751         2236 :             int ei = (filter_active && fentries) ? fentries[i] : i;
    2752              :             /* Fetch into manifest if missing; always sync unseen flag */
    2753         2236 :             ManifestEntry *cached_me = manifest_find(manifest, entries[ei].uid);
    2754         2236 :             if (!cached_me) {
    2755              :                 /* Check for user interrupt before slow network fetch */
    2756            8 :                 if (opts->pager) {
    2757            6 :                     struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN};
    2758            6 :                     if (poll(&pfd, 1, 0) > 0) {
    2759            0 :                         TermKey key = terminal_read_key();
    2760            0 :                         if (key == TERM_KEY_BACK) {
    2761            0 :                             list_result = 1; load_interrupted = 1; break;
    2762              :                         }
    2763            0 :                         if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) {
    2764            0 :                             list_result = 0; load_interrupted = 1; break;
    2765              :                         }
    2766              :                     }
    2767              :                 }
    2768            8 :                 const char *hf = entries[ei].folder[0] ? entries[ei].folder : folder;
    2769            8 :                 char *hdrs     = list_mc
    2770            7 :                                  ? fetch_uid_headers_via(list_mc, hf, entries[ei].uid)
    2771            8 :                                  : fetch_uid_headers_cached(cfg, hf, entries[ei].uid);
    2772            8 :                 char *fr_raw   = hdrs ? mime_get_header(hdrs, "From")    : NULL;
    2773            8 :                 char *fr       = fr_raw ? mime_decode_words(fr_raw)      : strdup("");
    2774            8 :                 free(fr_raw);
    2775            8 :                 char *su_raw   = hdrs ? mime_get_header(hdrs, "Subject") : NULL;
    2776            8 :                 char *su       = su_raw ? mime_decode_words(su_raw)      : strdup("");
    2777            8 :                 free(su_raw);
    2778            8 :                 char *dt_raw   = hdrs ? mime_get_header(hdrs, "Date")    : NULL;
    2779            8 :                 char *dt       = dt_raw ? mime_format_date(dt_raw)       : strdup("");
    2780            8 :                 free(dt_raw);
    2781              :                 /* Detect attachment and DMARC result from headers */
    2782            8 :                 char *ct_raw = hdrs ? mime_get_header(hdrs, "Content-Type") : NULL;
    2783            8 :                 if (ct_raw && strcasestr(ct_raw, "multipart/mixed"))
    2784            0 :                     entries[ei].flags |= MSG_FLAG_ATTACH;
    2785            8 :                 entries[ei].flags |= MSG_FLAG_ATTACH_CHECKED;
    2786            8 :                 free(ct_raw);
    2787            8 :                 char *ar_raw2 = hdrs ? mime_get_header(hdrs, "Authentication-Results") : NULL;
    2788            8 :                 int dmarc_st2 = mime_get_dmarc_status(ar_raw2);
    2789            8 :                 free(ar_raw2);
    2790            8 :                 if      (dmarc_st2 ==  1) entries[ei].flags |= MSG_FLAG_DMARC_PASS;
    2791            8 :                 else if (dmarc_st2 == -1) entries[ei].flags |= MSG_FLAG_DMARC_FAIL;
    2792            8 :                 if (dmarc_st2 != -2) entries[ei].flags |= MSG_FLAG_DMARC_CHECKED;
    2793            8 :                 free(hdrs);
    2794            8 :                 manifest_upsert(manifest, entries[ei].uid, fr, su, dt, entries[ei].flags);
    2795            8 :                 manifest_dirty = 1;
    2796         2228 :             } else if (!(cached_me->flags & MSG_FLAG_DMARC_CHECKED) ||
    2797         2173 :                        !(cached_me->flags & MSG_FLAG_ATTACH_CHECKED)) {
    2798              :                 /* Cached entry needing DMARC and/or attach check.
    2799              :                  * For Gmail: headers are in `from\tsubject\tdate\tlabels\tflags` format,
    2800              :                  * not RFC 2822 — Content-Type/Authentication-Results are not available
    2801              :                  * there.  Read from the full local message instead (no network).
    2802              :                  * For IMAP: fall back to the per-folder header cache (may download
    2803              :                  * if the header file is missing, but that is IMAP-normal). */
    2804              :                 char *hdrs2;
    2805           55 :                 const char *hf2 = entries[ei].folder[0] ? entries[ei].folder : folder;
    2806           55 :                 if (cfg->gmail_mode) {
    2807            0 :                     hdrs2 = local_msg_load("", entries[ei].uid);
    2808              :                 } else {
    2809           55 :                     hdrs2 = fetch_uid_headers_cached(cfg, hf2, entries[ei].uid);
    2810              :                 }
    2811           55 :                 if (!(cached_me->flags & MSG_FLAG_ATTACH_CHECKED)) {
    2812           34 :                     char *ct3 = hdrs2 ? mime_get_header(hdrs2, "Content-Type") : NULL;
    2813           34 :                     if (ct3 && strcasestr(ct3, "multipart/mixed"))
    2814            5 :                         cached_me->flags |= MSG_FLAG_ATTACH;
    2815           34 :                     cached_me->flags |= MSG_FLAG_ATTACH_CHECKED;
    2816           34 :                     free(ct3);
    2817              :                 }
    2818           55 :                 char *ar_raw3 = hdrs2 ? mime_get_header(hdrs2, "Authentication-Results") : NULL;
    2819           55 :                 int dmarc_st3 = mime_get_dmarc_status(ar_raw3);
    2820           55 :                 free(ar_raw3);
    2821           55 :                 free(hdrs2);
    2822           55 :                 if      (dmarc_st3 ==  1) cached_me->flags |= MSG_FLAG_DMARC_PASS;
    2823           55 :                 else if (dmarc_st3 == -1) cached_me->flags |= MSG_FLAG_DMARC_FAIL;
    2824           55 :                 if (dmarc_st3 != -2) cached_me->flags |= MSG_FLAG_DMARC_CHECKED;
    2825              :                 /* Persist updated flags to the .hdr file so next startup skips backfill */
    2826           55 :                 local_hdr_update_flags(cfg->gmail_mode ? "" : hf2,
    2827           55 :                                        entries[ei].uid, cached_me->flags);
    2828           55 :                 manifest_dirty = 1;
    2829              :             } else {
    2830              :                 /* Sync server-controlled bits; preserve locally-computed bits */
    2831         2173 :                 int computed = MSG_FLAG_ATTACH | MSG_FLAG_ATTACH_CHECKED |
    2832              :                                MSG_FLAG_DMARC_PASS | MSG_FLAG_DMARC_FAIL | MSG_FLAG_DMARC_CHECKED;
    2833         2173 :                 int merged = entries[ei].flags | (cached_me->flags & computed);
    2834         2173 :                 if (merged != cached_me->flags) {
    2835           21 :                     cached_me->flags = merged;
    2836           21 :                     manifest_dirty = 1;
    2837              :                 }
    2838              :             }
    2839              : 
    2840              :             /* Render this row immediately */
    2841         2236 :             ManifestEntry *me = manifest_find(manifest, entries[ei].uid);
    2842         2236 :             const char *from    = (me && me->from    && me->from[0])    ? me->from    : "(no from)";
    2843         2236 :             const char *subject = (me && me->subject && me->subject[0]) ? me->subject : "(no subject)";
    2844         2236 :             const char *date    = (me && me->date)                       ? me->date    : "";
    2845              : 
    2846         2236 :             int sel             = opts->pager && (i == cursor);
    2847         2236 :             int remove_pending  = (pending_remove  != NULL) && pending_remove[ei];
    2848         2236 :             int label_pending   = (pending_label   != NULL) && pending_label[ei];
    2849         2236 :             int restore_pending = (pending_restore != NULL) && pending_restore[ei];
    2850              : 
    2851              :             /* Pending rows: visible marker prefix + colour (no strikethrough).
    2852              :              * The marker character is visible in every terminal and survives
    2853              :              * copy-paste; colour provides additional visual hint where supported.
    2854              :              * Cursor on pending row: add inverse-video so cursor stays visible.
    2855              :              *   D + red    = trash pending (destructive, first char 'D')
    2856              :              *   d + yellow = label-remove / archive pending (neutral)
    2857              :              *   ↩ + green  = restore / unarchive pending (restorative)
    2858              :              *     (space)  = normal row */
    2859              :             const char *row_pfx;  /* 2-byte row prefix (marker + space or 2 spaces) */
    2860         2236 :             if (opts->pager && remove_pending) {
    2861           12 :                 row_pfx = "D ";
    2862           12 :                 printf(sel ? "\033[7m\033[31m" : "\033[31m"); /* red (+ inverse if sel) */
    2863         2224 :             } else if (opts->pager && label_pending) {
    2864           18 :                 row_pfx = "d ";
    2865           18 :                 printf(sel ? "\033[7m\033[33m" : "\033[33m"); /* yellow */
    2866         2206 :             } else if (opts->pager && restore_pending) {
    2867            6 :                 row_pfx = "u ";
    2868            6 :                 printf(sel ? "\033[7m\033[32m" : "\033[32m"); /* green */
    2869              :             } else {
    2870         2200 :                 row_pfx = "  ";
    2871         2200 :                 if (sel) printf("\033[7m");
    2872              :             }
    2873              : 
    2874              :             /* Status column (6 chars): [P/J/N/-][star/-][D/-][A/-][R/F/-][✓/✗/-]
    2875              :              * Position 1: P=phishing(red) > J=junk(yellow) > N=unread(green) > -
    2876              :              * Position 2: star = flagged (yellow)
    2877              :              * Position 3: D = done
    2878              :              * Position 4: A = attachment
    2879              :              * Position 5: R=answered(cyan), F=forwarded(cyan), - = neither
    2880              :              * Position 6: ✓=dmarc pass(green), ✗=dmarc fail(red), - = unknown
    2881              :              *
    2882              :              * TUI: ANSI colours; sel rows exit/re-enter reverse-video around colour. */
    2883              :             char sts[128];
    2884              :             /* Merge server bits (entries[]) with computed bits (manifest) */
    2885         2236 :             ManifestEntry *me_sts = manifest_find(manifest, entries[ei].uid);
    2886         2236 :             int computed_mask = MSG_FLAG_ATTACH | MSG_FLAG_ATTACH_CHECKED | MSG_FLAG_DMARC_PASS | MSG_FLAG_DMARC_FAIL;
    2887         2236 :             int eflags = entries[ei].flags | (me_sts ? (me_sts->flags & computed_mask) : 0);
    2888         2236 :             if (opts->pager && !remove_pending && !label_pending && !restore_pending) {
    2889              :                 const char *n_s;
    2890          613 :                 if      (eflags & MSG_FLAG_PHISHING)
    2891            1 :                     n_s = sel ? "\033[0m\033[1;31mP\033[7m" : "\033[1;31mP\033[0m";
    2892          612 :                 else if (eflags & MSG_FLAG_JUNK)
    2893            2 :                     n_s = sel ? "\033[0m\033[33mJ\033[7m"   : "\033[33mJ\033[0m";
    2894          610 :                 else if (eflags & MSG_FLAG_UNSEEN)
    2895          591 :                     n_s = sel ? "\033[0m\033[32mN\033[7m"   : "\033[32mN\033[0m";
    2896              :                 else
    2897           19 :                     n_s = "-";
    2898         1226 :                 const char *f_s = (eflags & MSG_FLAG_FLAGGED)
    2899              :                     ? (sel ? "\033[0m\033[33m\xe2\x98\x85\033[7m"
    2900              :                            : "\033[33m\xe2\x98\x85\033[0m")
    2901          613 :                     : "-";
    2902              :                 const char *rf_s;
    2903          613 :                 if      (eflags & MSG_FLAG_ANSWERED)
    2904            2 :                     rf_s = sel ? "\033[0m\033[36mR\033[7m" : "\033[36mR\033[0m";
    2905          611 :                 else if (eflags & MSG_FLAG_FORWARDED)
    2906            2 :                     rf_s = sel ? "\033[0m\033[36mF\033[7m" : "\033[36mF\033[0m";
    2907              :                 else
    2908          609 :                     rf_s = "-";
    2909              :                 const char *dm_s;
    2910          613 :                 if      (eflags & MSG_FLAG_DMARC_PASS)
    2911            0 :                     dm_s = sel ? "\033[0m\033[32m\xe2\x9c\x93\033[7m"
    2912            0 :                                : "\033[32m\xe2\x9c\x93\033[0m";
    2913          613 :                 else if (eflags & MSG_FLAG_DMARC_FAIL)
    2914            0 :                     dm_s = sel ? "\033[0m\033[1;31m\xe2\x9c\x97\033[7m"
    2915            0 :                                : "\033[1;31m\xe2\x9c\x97\033[0m";
    2916              :                 else
    2917          613 :                     dm_s = "-";
    2918         1226 :                 snprintf(sts, sizeof(sts), "%s%s%c%c%s%s", n_s, f_s,
    2919          613 :                     (eflags & MSG_FLAG_DONE)   ? 'D' : '-',
    2920          613 :                     (eflags & MSG_FLAG_ATTACH) ? 'A' : '-',
    2921              :                     rf_s, dm_s);
    2922              :             } else {
    2923         3246 :                 sts[0] = (eflags & MSG_FLAG_PHISHING)  ? 'P'
    2924         1623 :                        : (eflags & MSG_FLAG_JUNK)       ? 'J'
    2925         1623 :                        : (eflags & MSG_FLAG_UNSEEN)     ? 'N' : '-';
    2926         1623 :                 sts[1] = (eflags & MSG_FLAG_FLAGGED)   ? '*' : '-';
    2927         1623 :                 sts[2] = (eflags & MSG_FLAG_DONE)      ? 'D' : '-';
    2928         1623 :                 sts[3] = (eflags & MSG_FLAG_ATTACH)    ? 'A' : '-';
    2929         3246 :                 sts[4] = (eflags & MSG_FLAG_ANSWERED)  ? 'R'
    2930         1623 :                        : (eflags & MSG_FLAG_FORWARDED) ? 'F' : '-';
    2931         3240 :                 sts[5] = (eflags & MSG_FLAG_DMARC_PASS) ? 'v'
    2932         1617 :                        : (eflags & MSG_FLAG_DMARC_FAIL)  ? 'x' : '-';
    2933         1623 :                 sts[6] = '\0';
    2934              :             }
    2935         2236 :             if (opts->json) {
    2936              :                 /* One object per message, fields untruncated — the whole point
    2937              :                  * of this mode is that a consumer gets complete values. */
    2938            2 :                 printf("%s\n  {\"uid\": ", (i > wstart) ? "," : "");
    2939            2 :                 print_json_string(entries[ei].uid);
    2940            2 :                 printf(", \"date\": ");    print_json_string(date);
    2941            2 :                 printf(", \"from\": ");    print_json_string(from);
    2942            2 :                 printf(", \"subject\": "); print_json_string(subject);
    2943            2 :                 printf(", \"folder\": ");
    2944            2 :                 print_json_string(entries[ei].folder[0] ? entries[ei].folder : folder);
    2945            2 :                 printf(", \"status\": ");  print_json_string(sts);
    2946            2 :                 printf(", \"flags\": %d", eflags);
    2947            2 :                 printf(", \"unread\": %s",    (eflags & MSG_FLAG_UNSEEN)   ? "true" : "false");
    2948            2 :                 printf(", \"flagged\": %s",   (eflags & MSG_FLAG_FLAGGED)  ? "true" : "false");
    2949            2 :                 printf(", \"attachments\": %s",(eflags & MSG_FLAG_ATTACH)  ? "true" : "false");
    2950            2 :                 printf("}");
    2951            2 :                 fflush(stdout);
    2952            2 :                 continue;
    2953              :             }
    2954         2234 :             if (show_uid)
    2955         1585 :                 printf("%s%-16.16s  %-16.16s  %s  ", row_pfx, entries[ei].uid, date, sts);
    2956              :             else
    2957          649 :                 printf("%s%-16.16s  %s  ", row_pfx, date, sts);
    2958         2234 :             if (show_folder)
    2959           25 :                 printf("%-20.20s  ",
    2960           25 :                        entries[ei].folder[0] ? entries[ei].folder : folder);
    2961         2234 :             print_padded_col(subject, subj_w);
    2962         2234 :             printf("  ");
    2963         2234 :             print_padded_col(from,    from_w);
    2964              : 
    2965         2234 :             if (opts->pager && (sel || remove_pending || label_pending || restore_pending))
    2966          337 :                 printf("\033[K\033[0m");
    2967         2234 :             printf("\n");
    2968         2233 :             fflush(stdout); /* show row immediately as it arrives */
    2969              :         }
    2970          450 :         if (manifest_dirty && !is_virtual_flags && !is_virtual_search) manifest_save(folder, manifest);
    2971          439 :         if (load_interrupted) goto list_done;
    2972              : 
    2973          439 :         if (opts->json) {
    2974            2 :             printf("\n]\n");
    2975            2 :             fflush(stdout);
    2976            2 :             break;
    2977              :         }
    2978          437 :         if (!opts->pager) {
    2979          112 :             if (wend < show_count)
    2980            3 :                 printf("\n  -- %d more message(s) --  use --offset %d for next page\n",
    2981              :                        show_count - wend, wend + 1);
    2982          112 :             break;
    2983              :         }
    2984              : 
    2985              :         /* Filter bar — shown when filter is active (separator + input = 2 rows) */
    2986          325 :         if (filter_active) {
    2987              :             static const char *scope_names[] = {"Subject","From","To","Body"};
    2988           14 :             printf("  \xe2\x94\x80"); /* ─ */
    2989         1344 :             for (int _p = 3; _p < tcols - 2; _p++) printf("\xe2\x94\x80");
    2990           14 :             printf("\n  Filter [");
    2991           70 :             for (int _s = 0; _s < 4; _s++) {
    2992           56 :                 if (_s > 0) printf("|");
    2993           56 :                 if (_s == filter_scope) printf("\033[7m%s\033[0m", scope_names[_s]);
    2994           42 :                 else printf("%s", scope_names[_s]);
    2995              :             }
    2996           14 :             if (filter_scanning)
    2997            0 :                 printf("]: %s  Scanning body...\033[K\n", filter_buf);
    2998              :             else
    2999           14 :                 printf("]: %s%s  Showing %d of %d\033[K\n",
    3000              :                        filter_buf, filter_input ? "_" : " ", fcount, show_count);
    3001              :         }
    3002              : 
    3003              :         /* Navigation hint (status bar) — anchored at last terminal row */
    3004          325 :         fflush(stdout);
    3005              :         {
    3006          325 :             int trows = terminal_rows();
    3007          325 :             if (trows <= 0) trows = limit + 6;
    3008              :             /* Feedback line — second from bottom, shows last operation result */
    3009          325 :             print_infoline(trows, tcols, feedback_msg);
    3010              :             char sb[256];
    3011          301 :             if (is_gmail) {
    3012           96 :                 int in_trash = (strcmp(folder, "_trash") == 0);
    3013           96 :                 if (in_trash) {
    3014           10 :                     snprintf(sb, sizeof(sb),
    3015              :                              "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    3016              :                              "  Backspace=labels  ESC=quit"
    3017              :                              "  D=del  u=restore  t=labels  n=unread  f=star"
    3018              :                              "  s=sync  U=refresh  l=rules  [%d/%d]",
    3019              :                              show_count > 0 ? cursor + 1 : 0, show_count);
    3020              :                 } else {
    3021           86 :                     snprintf(sb, sizeof(sb),
    3022              :                              "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    3023              :                              "  Backspace=labels  ESC=quit"
    3024              :                              "  c=compose  r=reply  F=fwd  A=r-all  n=unread  f=star  a=archive"
    3025              :                              "  d=rm-label  D=trash  t=labels  s=sync  U=refresh  l=rules  [%d/%d]",
    3026              :                              show_count > 0 ? cursor + 1 : 0, show_count);
    3027              :                 }
    3028              :             } else {
    3029          205 :                 const char *imap_trf = cfg->trash_folder ? cfg->trash_folder : "Trash";
    3030          205 :                 int imap_in_trash = (strcmp(folder, imap_trf) == 0);
    3031          205 :                 snprintf(sb, sizeof(sb),
    3032              :                          "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open"
    3033              :                          "  Backspace=folders  ESC=quit"
    3034              :                          "  c=compose  r=reply  F=fwd  A=r-all  n=new  f=flag  d=done"
    3035              :                          "  %s  s=sync  U=refresh  l=rules  [%d/%d]",
    3036              :                          imap_in_trash ? "D=del" : "D=trash",
    3037              :                          cursor + 1, show_count);
    3038              :             }
    3039          301 :             print_statusbar(trows, tcols, sb);
    3040              :         }
    3041              : 
    3042              :         /* After render: if rebuild is needed (new char, backspace, Tab), show
    3043              :          * "Scanning body..." first for body scope, then do the actual rebuild. */
    3044          301 :         if (filter_dirty) {
    3045            6 :             filter_dirty = 0;
    3046            6 :             if (filter_buf[0] && filter_scope == 3) {
    3047            0 :                 filter_scanning = 1;
    3048            0 :                 continue;  /* re-render showing "Scanning body..." */
    3049              :             }
    3050            6 :             if (filter_buf[0])
    3051            6 :                 list_filter_rebuild(entries, show_count, manifest, cfg, folder,
    3052              :                                     filter_buf, filter_scope, fentries, &fcount);
    3053            6 :             cursor = 0;
    3054            6 :             continue;
    3055              :         }
    3056          295 :         if (filter_scanning) {
    3057            0 :             filter_scanning = 0;
    3058            0 :             list_filter_rebuild(entries, show_count, manifest, cfg, folder,
    3059              :                                 filter_buf, filter_scope, fentries, &fcount);
    3060            0 :             cursor = 0;
    3061            0 :             continue;
    3062              :         }
    3063              : 
    3064              :         /* terminal_read_key() blocks in read().  When the background sync child
    3065              :          * exits, SIGCHLD fires (SA_RESTART not set) and interrupts read() with
    3066              :          * EINTR — terminal_read_key() returns TERM_KEY_IGNORE (last_printable=0).
    3067              :          * We detect this by checking whether bg_sync_done changed, and if so
    3068              :          * jump back here to wait for the next real keypress without re-rendering.
    3069              :          * The notification will appear the next time the user presses a key. */
    3070          295 : read_key_again: ;
    3071          296 :         int prev_sync_done = bg_sync_done;
    3072          296 :         TermKey key = terminal_read_key();
    3073          232 :         fprintf(stderr, "\r\033[K"); fflush(stderr);
    3074              :         /* Only loop if read() was actually interrupted by SIGCHLD (EINTR path
    3075              :          * returns TERM_KEY_IGNORE with no printable char).  If SIGCHLD fired
    3076              :          * between prev_sync_done and read(), the real key is not TERM_KEY_IGNORE
    3077              :          * and must be processed — otherwise the keypress is silently consumed. */
    3078          232 :         if (bg_sync_done && !prev_sync_done && key == TERM_KEY_IGNORE
    3079            1 :                 && !terminal_last_printable()) {
    3080            1 :             goto read_key_again;
    3081              :         }
    3082              : 
    3083          231 :         switch (key) {
    3084           18 :         case TERM_KEY_BACK:
    3085           18 :             if (filter_input) {
    3086              :                 /* Backspace: remove last UTF-8 character (skip continuation bytes) */
    3087            1 :                 size_t fl = strlen(filter_buf);
    3088            1 :                 while (fl > 0 && (filter_buf[fl - 1] & 0xC0) == 0x80) fl--;
    3089            1 :                 if (fl > 0) fl--;
    3090            1 :                 filter_buf[fl] = '\0';
    3091            1 :                 filter_dirty = 1;
    3092            1 :                 cursor = 0;
    3093            1 :                 break;
    3094              :             }
    3095           17 :             list_result = 1;
    3096           17 :             goto list_done;
    3097            0 :         case TERM_KEY_QUIT:
    3098            0 :             goto list_done;
    3099            3 :         case TERM_KEY_ESC:
    3100            3 :             if (filter_active) {
    3101              :                 /* First ESC clears the filter; second ESC quits */
    3102            1 :                 filter_active   = 0;
    3103            1 :                 filter_input    = 0;
    3104            1 :                 filter_scanning = 0;
    3105            1 :                 filter_dirty    = 0;
    3106            1 :                 filter_buf[0]   = '\0';
    3107            1 :                 list_filter_rebuild(entries, show_count, manifest, cfg, folder,
    3108              :                                     filter_buf, filter_scope, fentries, &fcount);
    3109            1 :                 cursor = 0;
    3110            1 :                 break;
    3111              :             }
    3112            2 :             goto list_done;
    3113           26 :         case TERM_KEY_ENTER:
    3114           26 :             if (filter_input) {
    3115              :                 /* Enter commits filter text; switch to navigation mode */
    3116            1 :                 filter_input = 0;
    3117            1 :                 break;
    3118              :             }
    3119              :             {
    3120           25 :                 int ei_cur = (filter_active && fentries) ? fentries[cursor] : cursor;
    3121           25 :                 const char *efolder = entries[ei_cur].folder[0] ? entries[ei_cur].folder : folder;
    3122           25 :                 int prev_flags = entries[ei_cur].flags;
    3123           25 :                 int new_flags  = prev_flags;
    3124           25 :                 int ret = show_uid_interactive(cfg, list_mc, efolder,
    3125           25 :                                                entries[ei_cur].uid, opts->limit,
    3126              :                                                prev_flags, &new_flags);
    3127              :                 /* Propagate any flag changes made inside the reader */
    3128           13 :                 if (new_flags != prev_flags) {
    3129           13 :                     entries[ei_cur].flags = new_flags;
    3130           13 :                     ManifestEntry *rme = manifest_find(manifest, entries[ei_cur].uid);
    3131           13 :                     if (rme) rme->flags = new_flags;
    3132           13 :                     if (is_virtual_flags) {
    3133              :                         /* Virtual list: update the per-folder manifest on disk */
    3134            1 :                         Manifest *fm = manifest_load(efolder);
    3135            1 :                         if (fm) {
    3136            1 :                             ManifestEntry *fme = manifest_find(fm, entries[ei_cur].uid);
    3137            1 :                             if (fme) { fme->flags = new_flags; manifest_save(efolder, fm); }
    3138            1 :                             manifest_free(fm);
    3139              :                         }
    3140           12 :                     } else if (!is_virtual_search) {
    3141           12 :                         manifest_save(folder, manifest);
    3142              :                     }
    3143              :                 }
    3144           15 :                 if (ret == 1) { goto list_done; } /* ESC=exit → list_result=0 → quit */
    3145           10 :                 if (ret == 2) {
    3146              :                     /* 'r' pressed in reader → reply to this message */
    3147            2 :                     memcpy(opts->action_uid, entries[ei_cur].uid, 17);
    3148            2 :                     snprintf(opts->action_folder, sizeof(opts->action_folder), "%s", efolder);
    3149            2 :                     list_result = 3;
    3150            2 :                     goto list_done;
    3151              :                 }
    3152            8 :                 if (ret == 5) {
    3153              :                     /* 'F' pressed in reader → forward this message */
    3154            0 :                     memcpy(opts->action_uid, entries[ei_cur].uid, 17);
    3155            0 :                     snprintf(opts->action_folder, sizeof(opts->action_folder), "%s", efolder);
    3156            0 :                     list_result = 5;
    3157            0 :                     goto list_done;
    3158              :                 }
    3159            8 :                 if (ret == 6) {
    3160              :                     /* 'D' in reader → trash or permanent delete; mark entry removed */
    3161            0 :                     const char *del_uid = entries[ei_cur].uid;
    3162            0 :                     manifest_remove(manifest, del_uid);
    3163            0 :                     if (pending_remove) pending_remove[ei_cur] = 1;
    3164            0 :                     if (!is_virtual_flags && !is_virtual_search) manifest_save(efolder, manifest);
    3165              :                 }
    3166              :                 /* ret == 0: Backspace → back to list; ret == -1: error → stay */
    3167              :             }
    3168            8 :             break;
    3169            1 :         case TERM_KEY_HOME:
    3170            1 :             cursor = 0;
    3171            1 :             break;
    3172            1 :         case TERM_KEY_END:
    3173            1 :             cursor = disp_count > 0 ? disp_count - 1 : 0;
    3174            1 :             break;
    3175          176 :         case TERM_KEY_LEFT:
    3176              :         case TERM_KEY_RIGHT:
    3177              :         case TERM_KEY_DELETE:
    3178              :         case TERM_KEY_TAB:
    3179              :         case TERM_KEY_SHIFT_TAB:
    3180              :         case TERM_KEY_IGNORE: {
    3181          176 :             int ch = terminal_last_printable();
    3182              :             /* Physical Delete key → same action as 'D' (trash) outside filter */
    3183          176 :             if (key == TERM_KEY_DELETE && !filter_input && ch == 0) ch = 'D';
    3184              : 
    3185              :             /* ── Filter input mode: Tab cycles scope, printable chars typed ── */
    3186          176 :             if (filter_input && key == TERM_KEY_TAB) {
    3187            1 :                 filter_scope = (filter_scope + 1) % 4;
    3188            1 :                 if (filter_buf[0]) filter_dirty = 1;  /* rebuild after next render */
    3189            1 :                 break;
    3190              :             }
    3191          175 :             if (filter_input && terminal_last_utf8()[0]) {
    3192            4 :                 const char *u8 = terminal_last_utf8();
    3193            4 :                 size_t ulen = strlen(u8);
    3194            4 :                 size_t fl   = strlen(filter_buf);
    3195            4 :                 if (fl + ulen < sizeof(filter_buf)) {
    3196            4 :                     memcpy(filter_buf + fl, u8, ulen + 1);
    3197              :                 }
    3198            4 :                 filter_dirty = 1;
    3199            4 :                 cursor = 0;
    3200            4 :                 break;
    3201              :             }
    3202              :             /* ── Filter activation (/) ──────────────────────────────────── */
    3203          171 :             if (ch == '/' && !filter_input) {
    3204            1 :                 filter_active = 1;
    3205            1 :                 filter_input  = 1;
    3206            1 :                 filter_buf[0] = '\0';
    3207            1 :                 list_filter_rebuild(entries, show_count, manifest, cfg, folder,
    3208              :                                     filter_buf, filter_scope, fentries, &fcount);
    3209            1 :                 cursor = 0;
    3210            1 :                 break;
    3211              :             }
    3212              : 
    3213              :             /* ── Normal action keys ─────────────────────────────────────── */
    3214          170 :             int ei_cur = (filter_active && fentries) ? fentries[cursor] : cursor;
    3215          170 :             const char *efolder = entries[ei_cur].folder[0] ? entries[ei_cur].folder : folder;
    3216          170 :             if (ch == 'c') {
    3217           62 :                 list_result = 2;
    3218           62 :                 goto list_done;
    3219              :             }
    3220          108 :             if (ch == 'r') {
    3221           11 :                 memcpy(opts->action_uid, entries[ei_cur].uid, 17);
    3222           11 :                 snprintf(opts->action_folder, sizeof(opts->action_folder), "%s", efolder);
    3223           11 :                 list_result = 3;
    3224           11 :                 goto list_done;
    3225              :             }
    3226           97 :             if (ch == 'F') {
    3227           10 :                 memcpy(opts->action_uid, entries[ei_cur].uid, 17);
    3228           10 :                 snprintf(opts->action_folder, sizeof(opts->action_folder), "%s", efolder);
    3229           10 :                 list_result = 5;
    3230           10 :                 goto list_done;
    3231              :             }
    3232           87 :             if (ch == 'A') {
    3233            4 :                 memcpy(opts->action_uid, entries[ei_cur].uid, 17);
    3234            4 :                 snprintf(opts->action_folder, sizeof(opts->action_folder), "%s", efolder);
    3235            4 :                 list_result = 6;
    3236            4 :                 goto list_done;
    3237              :             }
    3238           83 :             if (ch == 's') {
    3239            1 :                 sync_start_background();
    3240            1 :                 break; /* re-render: shows ⟳ syncing... indicator */
    3241              :             }
    3242           82 :             if (ch == 'U') {
    3243              :                 /* Explicit refresh after sync notification */
    3244            8 :                 bg_sync_done = 0;
    3245            8 :                 list_result = 4;
    3246            8 :                 goto list_done;
    3247              :             }
    3248           74 :             if (ch == 'l') {
    3249              :                 /* Rules editor */
    3250           16 :                 list_result = 7;
    3251           16 :                 goto list_done;
    3252              :             }
    3253           58 :             if (ch == 'h' || ch == '?') {
    3254            4 :                 if (is_gmail) {
    3255              :                     static const char *ghelp[][2] = {
    3256              :                         { "\u2191 / \u2193",   "Move cursor up / down"           },
    3257              :                         { "PgUp / PgDn",        "Page up / down"                  },
    3258              :                         { "Enter",             "Open selected message"           },
    3259              :                         { "r",                 "Reply to selected message"       },
    3260              :                         { "A",                 "Reply-all to selected message"   },
    3261              :                         { "F",                 "Forward selected message"        },
    3262              :                         { "c",                 "Compose new message"             },
    3263              :                         { "n",                 "Toggle Unread label"             },
    3264              :                         { "f",                 "Toggle Starred label"            },
    3265              :                         { "a",                 "Archive (remove INBOX label)"    },
    3266              :                         { "d",                 "Remove current label"            },
    3267              :                         { "D / Delete",        "Move to Trash"                   },
    3268              :                         { "u",                 "Untrash (restore to INBOX)"      },
    3269              :                         { "t",                 "Toggle labels (picker)"          },
    3270              :                         { "s",                 "Start background sync"           },
    3271              :                         { "U",                 "Refresh after sync"              },
    3272              :                         { "l",                 "Open rules editor"               },
    3273              :                         { "Backspace",         "Open label browser"              },
    3274              :                         { "ESC / q",           "Quit"                            },
    3275              :                         { "h / ?",             "Show this help"                  },
    3276              :                         { "────────────",      "──────────────────────────────" },
    3277              :                         { "Sts col 1:",        "P=phish  J=junk  N=unread  -"   },
    3278              :                         { "Sts col 2:",        "\u2605=starred  -=normal"         },
    3279              :                         { "Sts col 3:",        "D=done  -=normal"               },
    3280              :                         { "Sts col 4:",        "A=attachment  -=none"           },
    3281              :                         { "Sts col 5:",        "R=replied  F=fwd  -=neither"    },
    3282              :                         { "Sts col 6:",        "\u2713=DMARC ok  \u2717=fail  -=?" },
    3283              :                     };
    3284            0 :                     show_help_popup("Message list shortcuts (Gmail)",
    3285              :                                     ghelp, (int)(sizeof(ghelp)/sizeof(ghelp[0])));
    3286              :                 } else {
    3287              :                     static const char *help[][2] = {
    3288              :                         { "\u2191 / \u2193",   "Move cursor up / down"           },
    3289              :                         { "PgUp / PgDn",        "Page up / down"                  },
    3290              :                         { "Enter",             "Open selected message"           },
    3291              :                         { "r",                 "Reply to selected message"       },
    3292              :                         { "A",                 "Reply-all to selected message"   },
    3293              :                         { "F",                 "Forward selected message"        },
    3294              :                         { "c",                 "Compose new message"             },
    3295              :                         { "n",                 "Toggle New (unread) flag"        },
    3296              :                         { "f",                 "Toggle Flagged (starred) flag"   },
    3297              :                         { "j",                 "Toggle Junk (spam) flag"         },
    3298              :                         { "d",                 "Toggle Done flag"                },
    3299              :                         { "D / Delete",        "Move to Trash (del if in Trash)" },
    3300              :                         { "s",                 "Start background sync"           },
    3301              :                         { "U",                 "Refresh after sync"              },
    3302              :                         { "l",                 "Open rules editor"               },
    3303              :                         { "Backspace",         "Open folder browser"             },
    3304              :                         { "ESC / q",           "Quit"                            },
    3305              :                         { "h / ?",             "Show this help"                  },
    3306              :                         { "────────────",      "──────────────────────────────" },
    3307              :                         { "Sts col 1:",        "P=phish  J=junk  N=unread  -"   },
    3308              :                         { "Sts col 2:",        "\u2605=starred  -=normal"         },
    3309              :                         { "Sts col 3:",        "D=done  -=normal"               },
    3310              :                         { "Sts col 4:",        "A=attachment  -=none"           },
    3311              :                         { "Sts col 5:",        "R=replied  F=fwd  -=neither"    },
    3312              :                         { "Sts col 6:",        "\u2713=DMARC ok  \u2717=fail  -=?" },
    3313              :                     };
    3314            4 :                     show_help_popup("Message list shortcuts",
    3315              :                                     help, (int)(sizeof(help)/sizeof(help[0])));
    3316              :                 }
    3317            2 :                 break;
    3318              :             }
    3319           54 :             if (ch == 'a' && is_gmail) {
    3320              :                 /* Archive: remove ALL labels from this message.
    3321              :                  * Gmail "archive" = no labels → message lives only in All Mail.
    3322              :                  * If already in Archive view, the message is already archived — no-op. */
    3323           11 :                 if (strcmp(folder, "_nolabel") == 0) {
    3324            2 :                     snprintf(feedback_msg, sizeof(feedback_msg),
    3325              :                              "Already in Archive \xe2\x80\x94 no change");
    3326            2 :                     break;
    3327              :                 }
    3328            9 :                 const char *uid = entries[ei_cur].uid;
    3329            9 :                 char *lbl_str = local_hdr_get_labels("", uid);
    3330            9 :                 if (lbl_str) {
    3331              :                     /* Build remove array and strip each label from indexes */
    3332            9 :                     int n = 1;
    3333          117 :                     for (const char *p = lbl_str; *p; p++) if (*p == ',') n++;
    3334            9 :                     char **rm   = malloc((size_t)n * sizeof(char *));
    3335            9 :                     char *copy  = strdup(lbl_str);
    3336            9 :                     int   rm_n  = 0;
    3337            9 :                     if (rm && copy) {
    3338            9 :                         char *tok = copy, *sep;
    3339           27 :                         while (tok && *tok) {
    3340           18 :                             sep = strchr(tok, ',');
    3341           18 :                             if (sep) *sep = '\0';
    3342           18 :                             if (tok[0]) {
    3343              :                                 /* Remove from local index (skip meta _* labels) */
    3344           18 :                                 if (tok[0] != '_') label_idx_remove(tok, uid);
    3345           18 :                                 rm[rm_n++] = tok;
    3346              :                                 /* Remove via Gmail API (skip IMPORTANT/CATEGORY_) */
    3347           18 :                                 if (list_mc &&
    3348            0 :                                     strcmp(tok, "IMPORTANT") != 0 &&
    3349            0 :                                     strncmp(tok, "CATEGORY_", 9) != 0)
    3350            0 :                                     mail_client_modify_label(list_mc, uid, tok, 0);
    3351              :                             }
    3352           18 :                             tok = sep ? sep + 1 : NULL;
    3353              :                         }
    3354              :                         /* Clear labels field in .hdr atomically */
    3355            9 :                         local_hdr_update_labels("", uid, NULL, 0,
    3356              :                                                 (const char **)rm, rm_n);
    3357              :                     }
    3358            9 :                     free(copy); free(rm); free(lbl_str);
    3359              :                 }
    3360              :                 /* Ensure UNREAD index is also cleared (belt-and-suspenders) */
    3361            9 :                 label_idx_remove("UNREAD", uid);
    3362              :                 /* Clear UNSEEN bit in .hdr flags field so the message is not
    3363              :                  * displayed as unread when browsing Archive. */
    3364              :                 {
    3365            9 :                     int new_flags = entries[ei_cur].flags & ~MSG_FLAG_UNSEEN;
    3366            9 :                     local_hdr_update_flags("", uid, new_flags);
    3367            9 :                     entries[ei_cur].flags = new_flags;
    3368              :                 }
    3369              :                 /* Mark as read via API */
    3370            9 :                 if (list_mc) mail_client_set_flag(list_mc, uid, "\\Seen", 1);
    3371              :                 /* Put in archive */
    3372            9 :                 label_idx_add("_nolabel", uid);
    3373              :                 /* Mark for immediate visual feedback (yellow strikethrough) */
    3374            9 :                 if (pending_label) pending_label[ei_cur] = 1;
    3375            9 :                 snprintf(feedback_msg, sizeof(feedback_msg), "Archived");
    3376            9 :                 break;
    3377              :             }
    3378           43 :             if (ch == 'D' && is_gmail) {
    3379           14 :                 const char *uid = entries[ei_cur].uid;
    3380              :                 /* In Trash: permanently delete */
    3381           14 :                 if (strcmp(folder, "_trash") == 0) {
    3382            1 :                     if (list_mc) mail_client_delete(list_mc, uid);
    3383            1 :                     label_idx_remove("_trash", uid);
    3384            1 :                     manifest_remove(manifest, uid);
    3385            1 :                     if (pending_remove) pending_remove[ei_cur] = 1;
    3386            1 :                     if (!is_virtual_flags && !is_virtual_search) manifest_save(efolder, manifest);
    3387            1 :                     snprintf(feedback_msg, sizeof(feedback_msg), "Permanently deleted");
    3388            1 :                     break;
    3389              :                 }
    3390           13 :                 if (pending_remove && pending_remove[ei_cur]) {
    3391              :                     /* Undo: second 'D' restores from Trash back to current folder */
    3392            2 :                     label_idx_remove("_trash", uid);
    3393            2 :                     const char *restore_lbl = (folder[0] != '_') ? folder : "INBOX";
    3394            2 :                     label_idx_add(restore_lbl, uid);
    3395              :                     /* Update .hdr: remove TRASH, add current folder label */
    3396              :                     {
    3397            2 :                         const char *add_lbl = restore_lbl;
    3398            2 :                         const char *rm_lbl  = "TRASH";
    3399            2 :                         local_hdr_update_labels("", uid, &add_lbl, 1, &rm_lbl, 1);
    3400              :                     }
    3401              :                     /* Gmail API: remove TRASH label, add folder label back */
    3402            2 :                     if (list_mc) {
    3403            0 :                         mail_client_modify_label(list_mc, uid, "TRASH", 0);
    3404            0 :                         mail_client_modify_label(list_mc, uid, restore_lbl, 1);
    3405              :                     }
    3406            2 :                     pending_remove[ei_cur] = 0;
    3407            2 :                     snprintf(feedback_msg, sizeof(feedback_msg),
    3408              :                              "Undo: %s restored", restore_lbl);
    3409              :                 } else {
    3410              :                     /* First 'D': Gmail compound trash operation */
    3411           11 :                     if (list_mc) mail_client_trash(list_mc, uid);
    3412              :                     /* Remove from all local label indexes */
    3413              :                     {
    3414           11 :                         char **all_labels = NULL;
    3415           11 :                         int all_count = 0;
    3416           11 :                         label_idx_list(&all_labels, &all_count);
    3417           33 :                         for (int j = 0; j < all_count; j++) {
    3418           22 :                             label_idx_remove(all_labels[j], uid);
    3419           22 :                             free(all_labels[j]);
    3420              :                         }
    3421           11 :                         free(all_labels);
    3422              :                     }
    3423           11 :                     label_idx_add("_trash", uid);
    3424              :                     /* Mark for immediate visual feedback (red strikethrough) */
    3425           11 :                     if (pending_remove) pending_remove[ei_cur] = 1;
    3426           11 :                     snprintf(feedback_msg, sizeof(feedback_msg), "Moved to Trash");
    3427              :                 }
    3428           13 :                 break;
    3429              :             }
    3430           29 :             if (ch == 'D' && !is_gmail) {
    3431              :                 /* IMAP trash / permanent delete */
    3432            0 :                 const char *uid = entries[ei_cur].uid;
    3433            0 :                 const char *trf = cfg->trash_folder ? cfg->trash_folder : "Trash";
    3434            0 :                 if (strcmp(efolder, trf) == 0) {
    3435              :                     /* Already in Trash: permanently delete */
    3436            0 :                     if (list_mc) mail_client_delete(list_mc, uid);
    3437              :                 } else {
    3438              :                     /* Move to Trash folder */
    3439            0 :                     if (list_mc) mail_client_move_to_folder(list_mc, uid, trf);
    3440              :                 }
    3441            0 :                 manifest_remove(manifest, uid);
    3442            0 :                 if (pending_remove) pending_remove[ei_cur] = 1;
    3443            0 :                 if (!is_virtual_flags && !is_virtual_search) manifest_save(efolder, manifest);
    3444            0 :                 snprintf(feedback_msg, sizeof(feedback_msg),
    3445            0 :                          strcmp(efolder, trf) == 0 ? "Permanently deleted" : "Moved to Trash");
    3446            0 :                 break;
    3447              :             }
    3448           29 :             if (ch == 'u' && is_gmail) {
    3449              :                 /* Untrash: restore from Trash to INBOX. */
    3450            3 :                 const char *uid = entries[ei_cur].uid;
    3451            3 :                 label_idx_remove("_trash", uid);
    3452            3 :                 label_idx_add("INBOX", uid);
    3453              :                 /* Update .hdr: remove TRASH from labels, add INBOX */
    3454              :                 {
    3455            3 :                     const char *add_lbl = "INBOX";
    3456            3 :                     const char *rm_lbl  = "TRASH";
    3457            3 :                     local_hdr_update_labels("", uid, &add_lbl, 1, &rm_lbl, 1);
    3458              :                 }
    3459              :                 /* Gmail API: remove TRASH label, add INBOX */
    3460            3 :                 if (list_mc) {
    3461            0 :                     mail_client_modify_label(list_mc, uid, "TRASH", 0);
    3462            0 :                     mail_client_modify_label(list_mc, uid, "INBOX", 1);
    3463              :                 }
    3464              :                 /* Mark for immediate visual feedback (green strikethrough) */
    3465            3 :                 if (pending_restore) pending_restore[ei_cur] = 1;
    3466            3 :                 snprintf(feedback_msg, sizeof(feedback_msg), "Restored to Inbox");
    3467            3 :                 break;
    3468              :             }
    3469           26 :             if (ch == 't' && is_gmail) {
    3470            5 :                 const char *uid = entries[ei_cur].uid;
    3471              :                 /* Remember whether this message is currently in Archive or Trash
    3472              :                  * so we can show green feedback if a label addition removes it. */
    3473            5 :                 int was_archived = label_idx_contains("_nolabel", uid);
    3474            5 :                 int was_trashed  = label_idx_contains("_trash",   uid);
    3475            5 :                 show_label_picker(list_mc, uid, feedback_msg, sizeof(feedback_msg));
    3476              :                 /* If the picker added a real label that moved the message out
    3477              :                  * of Archive (_nolabel) or Trash (_trash), mark row green. */
    3478            4 :                 if ((was_archived && !label_idx_contains("_nolabel", uid)) ||
    3479            1 :                     (was_trashed  && !label_idx_contains("_trash",   uid)))
    3480            3 :                     if (pending_restore) pending_restore[ei_cur] = 1;
    3481            4 :                 break;
    3482              :             }
    3483           21 :             if (ch == 'd' && is_gmail) {
    3484              :                 /* Toggle: first 'd' removes the label (yellow pending_label);
    3485              :                  * second 'd' on the same row restores it (undo).
    3486              :                  * Restricted to non-meta labels (no underscore prefix). */
    3487           11 :                 if (folder[0] != '_') {
    3488           11 :                     const char *uid = entries[ei_cur].uid;
    3489           11 :                     if (pending_label && pending_label[ei_cur]) {
    3490              :                         /* Undo: restore the label that was removed */
    3491            2 :                         if (list_mc)
    3492            0 :                             mail_client_modify_label(list_mc, uid, folder, 1);
    3493            2 :                         label_idx_add(folder, uid);
    3494            2 :                         local_hdr_update_labels("", uid, &folder, 1, NULL, 0);
    3495              :                         /* Remove from archive fallback if it was added */
    3496            2 :                         label_idx_remove("_nolabel", uid);
    3497            2 :                         pending_label[ei_cur] = 0;
    3498            2 :                         snprintf(feedback_msg, sizeof(feedback_msg),
    3499              :                                  "Undo: %s restored", folder);
    3500              :                     } else {
    3501              :                         /* Remove label from this message */
    3502            9 :                         if (list_mc)
    3503            0 :                             mail_client_modify_label(list_mc, uid, folder, 0);
    3504            9 :                         label_idx_remove(folder, uid);
    3505            9 :                         local_hdr_update_labels("", uid, NULL, 0, &folder, 1);
    3506              :                         /* If no other real labels remain, put in archive */
    3507            9 :                         char *lbl = local_hdr_get_labels("", uid);
    3508            9 :                         int has_real = 0;
    3509            9 :                         if (lbl) {
    3510            9 :                             char *tok = lbl, *s;
    3511           18 :                             while (tok && *tok) {
    3512            9 :                                 s = strchr(tok, ',');
    3513            9 :                                 size_t tl = s ? (size_t)(s - tok) : strlen(tok);
    3514              :                                 char lb[64];
    3515            9 :                                 if (tl >= sizeof(lb)) tl = sizeof(lb) - 1;
    3516            9 :                                 memcpy(lb, tok, tl); lb[tl] = '\0';
    3517            9 :                                 if (strcmp(lb, folder) != 0 &&
    3518            9 :                                     strcmp(lb, "UNREAD") != 0 &&
    3519            0 :                                     strcmp(lb, "IMPORTANT") != 0 &&
    3520            0 :                                     strncmp(lb, "CATEGORY_", 9) != 0)
    3521            0 :                                     has_real = 1;
    3522            9 :                                 tok = s ? s + 1 : NULL;
    3523              :                             }
    3524            9 :                             free(lbl);
    3525              :                         }
    3526            9 :                         if (!has_real) label_idx_add("_nolabel", uid);
    3527              :                         /* Mark row for immediate visual feedback (yellow strikethrough).
    3528              :                          * Also clear pending_remove so yellow takes priority over red
    3529              :                          * if 'D' was pressed before 'd' on this row. */
    3530            9 :                         if (pending_remove) pending_remove[ei_cur] = 0;
    3531            9 :                         if (pending_label)  pending_label[ei_cur]  = 1;
    3532            9 :                         snprintf(feedback_msg, sizeof(feedback_msg),
    3533              :                                  "Label removed: %s", folder);
    3534              :                     }
    3535              :                 }
    3536           11 :                 break;
    3537              :             }
    3538           10 :             if (ch == 'j') {
    3539              :                 /* Toggle junk: uses dedicated mark_junk / mark_notjunk */
    3540            0 :                 const char *uid = entries[ei_cur].uid;
    3541            0 :                 int is_junk = entries[ei_cur].flags & MSG_FLAG_JUNK;
    3542            0 :                 if (is_junk) {
    3543            0 :                     entries[ei_cur].flags &= ~MSG_FLAG_JUNK;
    3544              :                 } else {
    3545            0 :                     entries[ei_cur].flags |= MSG_FLAG_JUNK;
    3546              :                 }
    3547            0 :                 ManifestEntry *me = manifest_find(manifest, uid);
    3548            0 :                 if (me) me->flags = entries[ei_cur].flags;
    3549            0 :                 if (is_virtual_flags) {
    3550            0 :                     Manifest *fm = manifest_load(efolder);
    3551            0 :                     if (fm) {
    3552            0 :                         ManifestEntry *fme = manifest_find(fm, uid);
    3553            0 :                         if (fme) { fme->flags = entries[ei_cur].flags; manifest_save(efolder, fm); }
    3554            0 :                         manifest_free(fm);
    3555              :                     }
    3556            0 :                 } else if (!is_virtual_search) {
    3557            0 :                     manifest_save(efolder, manifest);
    3558              :                 }
    3559            0 :                 if (is_gmail) {
    3560            0 :                     junk_push_background(cfg, uid, !is_junk);
    3561            0 :                 } else if (list_mc) {
    3562            0 :                     if (is_junk)
    3563            0 :                         mail_client_mark_notjunk(list_mc, uid);
    3564              :                     else
    3565            0 :                         mail_client_mark_junk(list_mc, uid);
    3566              :                 }
    3567            0 :                 snprintf(feedback_msg, sizeof(feedback_msg),
    3568              :                          is_junk ? "Marked as not-junk" : "Marked as junk");
    3569           10 :             } else if (ch == 'n' || ch == 'f' || ch == 'd') {
    3570            8 :                 const char *uid = entries[ei_cur].uid;
    3571              :                 int bit;
    3572              :                 const char *flag_name;
    3573            8 :                 if (ch == 'n') {
    3574            4 :                     bit = MSG_FLAG_UNSEEN;  flag_name = "\\Seen";
    3575            4 :                 } else if (ch == 'f') {
    3576            3 :                     bit = MSG_FLAG_FLAGGED; flag_name = "\\Flagged";
    3577              :                 } else {
    3578            1 :                     bit = MSG_FLAG_DONE;    flag_name = "$Done";
    3579              :                 }
    3580            8 :                 int currently = entries[ei_cur].flags & bit;
    3581              :                 /* Determine the IMAP add/remove direction */
    3582            8 :                 int add_flag = (ch == 'n') ? (currently ? 1 : 0) : (!currently ? 1 : 0);
    3583              : 
    3584              :                 /* Local update first — instant UI response regardless of network */
    3585            8 :                 local_pending_flag_add(efolder, uid, flag_name, add_flag);
    3586            8 :                 entries[ei_cur].flags ^= bit;
    3587            8 :                 ManifestEntry *me = manifest_find(manifest, uid);
    3588            8 :                 if (me) me->flags = entries[ei_cur].flags;
    3589            8 :                 if (is_virtual_flags) {
    3590              :                     /* Virtual list: save the real per-folder manifest, not the
    3591              :                      * synthesized one — only then will manifest_count_all_flags
    3592              :                      * return updated counts for the folder list. */
    3593            1 :                     Manifest *fm = manifest_load(efolder);
    3594            1 :                     if (fm) {
    3595            1 :                         ManifestEntry *fme = manifest_find(fm, uid);
    3596            1 :                         if (fme) { fme->flags = entries[ei_cur].flags; manifest_save(efolder, fm); }
    3597            1 :                         manifest_free(fm);
    3598              :                     }
    3599            7 :                 } else if (!is_virtual_search) {
    3600            7 :                     manifest_save(efolder, manifest);
    3601              :                 }
    3602              : 
    3603              :                 /* Gmail: update local label indexes and .hdr (both labels CSV
    3604              :                  * and flags integer), then kick background sync. */
    3605            8 :                 if (is_gmail) {
    3606            2 :                     const char *lbl = (ch == 'n') ? "UNREAD"
    3607            2 :                                     : (ch == 'f') ? "STARRED" : NULL;
    3608            2 :                     if (lbl) {
    3609              :                         /* n/f: label-backed flag — keep .idx and .hdr in sync */
    3610            2 :                         if (currently) {
    3611            1 :                             label_idx_remove(lbl, uid);
    3612            1 :                             local_hdr_update_labels("", uid, NULL, 0, &lbl, 1);
    3613              :                         } else {
    3614            1 :                             label_idx_add(lbl, uid);
    3615            1 :                             local_hdr_update_labels("", uid, &lbl, 1, NULL, 0);
    3616              :                         }
    3617              :                     } else {
    3618              :                         /* d: $Done is an IMAP keyword, not a Gmail label */
    3619            0 :                         local_hdr_update_flags("", uid, entries[ei_cur].flags);
    3620              :                     }
    3621            2 :                     flag_push_background(cfg, uid, flag_name, add_flag);
    3622            6 :                 } else if (list_mc) {
    3623              :                     /* IMAP online mode: connection already open, push immediately */
    3624            3 :                     mail_client_set_flag(list_mc, uid, flag_name, add_flag);
    3625              :                 }
    3626              :                 /* Feedback message */
    3627            8 :                 if (ch == 'n')
    3628            4 :                     snprintf(feedback_msg, sizeof(feedback_msg),
    3629              :                              currently ? "Marked as read" : "Marked as unread");
    3630            4 :                 else if (ch == 'f')
    3631            3 :                     snprintf(feedback_msg, sizeof(feedback_msg),
    3632              :                              currently ? "Unstarred" : "Starred");
    3633              :                 else /* 'd' IMAP done toggle */
    3634            1 :                     snprintf(feedback_msg, sizeof(feedback_msg),
    3635              :                              currently ? "Marked not done" : "Marked done");
    3636              :             }
    3637           10 :             break;
    3638              :         }
    3639            3 :         case TERM_KEY_NEXT_LINE:
    3640            3 :             if (cursor < disp_count - 1) cursor++;
    3641            3 :             break;
    3642            1 :         case TERM_KEY_PREV_LINE:
    3643            1 :             if (cursor > 0) cursor--;
    3644            1 :             break;
    3645            1 :         case TERM_KEY_NEXT_PAGE:
    3646            1 :             cursor += limit;
    3647            1 :             if (cursor >= disp_count) cursor = disp_count > 0 ? disp_count - 1 : 0;
    3648            1 :             break;
    3649            1 :         case TERM_KEY_PREV_PAGE:
    3650            1 :             cursor -= limit;
    3651            1 :             if (cursor < 0) cursor = 0;
    3652            1 :             break;
    3653              :         }
    3654              :     }
    3655          249 : list_done:
    3656          249 :     free(pending_remove);
    3657          249 :     free(pending_label);
    3658          249 :     free(pending_restore);
    3659          249 :     free(fentries);
    3660              :     /* tui_raw / folder_canonical cleaned up automatically via RAII macros */
    3661          249 :     manifest_free(manifest);
    3662          249 :     free(entries);
    3663          249 :     return list_result;
    3664              : }
    3665              : 
    3666              : /** Fetch the folder list into a heap-allocated array; caller owns entries and array. */
    3667           32 : static char **fetch_folder_list_from_server(const Config *cfg,
    3668              :                                              int *count_out, char *sep_out) {
    3669           64 :     RAII_MAIL MailClient *mc = make_mail(cfg);
    3670           32 :     if (!mc) return NULL;
    3671              : 
    3672           32 :     char **folders = NULL;
    3673           32 :     int count = 0;
    3674           32 :     char sep = '.';
    3675           32 :     if (mail_client_list(mc, &folders, &count, &sep) != 0) return NULL;
    3676              : 
    3677           32 :     *count_out = count;
    3678           32 :     if (sep_out) *sep_out = sep;
    3679           32 :     return folders;
    3680              : }
    3681              : 
    3682          207 : static char **fetch_folder_list(const Config *cfg, int *count_out, char *sep_out) {
    3683              :     /* Try local cache first (populated by sync). */
    3684          207 :     char **cached = local_folder_list_load(count_out, sep_out);
    3685          207 :     if (cached && *count_out > 0) return cached;
    3686            6 :     if (cached) { free(cached); }
    3687              : 
    3688              :     /* Fall back to server. */
    3689            6 :     char sep = '.';
    3690            6 :     char **folders = fetch_folder_list_from_server(cfg, count_out, &sep);
    3691            6 :     if (folders && *count_out > 0) {
    3692            6 :         local_folder_list_save((const char **)folders, *count_out, sep);
    3693            6 :         if (sep_out) *sep_out = sep;
    3694              :     }
    3695            6 :     return folders;
    3696              : }
    3697              : 
    3698           12 : int email_service_list_folders(const Config *cfg, int tree) {
    3699           12 :     if (cfg->gmail_mode) {
    3700            1 :         fprintf(stderr, "Error: 'list-folders' is IMAP-only. Use 'list-labels' for Gmail.\n");
    3701            1 :         return -1;
    3702              :     }
    3703           11 :     int count = 0;
    3704           11 :     char sep = '.';
    3705           11 :     char **folders = fetch_folder_list(cfg, &count, &sep);
    3706              : 
    3707           11 :     if (!folders || count == 0) {
    3708            0 :         printf("No folders found.\n");
    3709            0 :         if (folders) free(folders);
    3710            0 :         return folders ? 0 : -1;
    3711              :     }
    3712              : 
    3713           11 :     qsort(folders, (size_t)count, sizeof(char *), cmp_str);
    3714              : 
    3715           11 :     FolderStatus *statuses = fetch_all_folder_statuses(cfg, folders, count);
    3716              : 
    3717           11 :     if (tree) {
    3718            3 :         render_folder_tree(folders, count, sep, statuses);
    3719              :     } else {
    3720              :         /* Batch flat view: Unread | Flagged | Folder | Total */
    3721            8 :         int name_w = 40;
    3722            8 :         printf("  %6s  %7s  %-*s  %7s\n",
    3723              :                "Unread", "Flagged", name_w, "Folder", "Total");
    3724            8 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    3725            8 :         print_dbar(name_w);
    3726            8 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
    3727           72 :         for (int i = 0; i < count; i++) {
    3728           64 :             int unseen   = statuses ? statuses[i].unseen   : 0;
    3729           64 :             int flagged  = statuses ? statuses[i].flagged  : 0;
    3730           64 :             int messages = statuses ? statuses[i].messages : 0;
    3731              :             char u[16], f[16], t[16];
    3732           64 :             fmt_thou(u, sizeof(u), unseen);
    3733           64 :             fmt_thou(f, sizeof(f), flagged);
    3734           64 :             fmt_thou(t, sizeof(t), messages);
    3735           64 :             int nw = name_w + utf8_extra_bytes(folders[i]);
    3736           64 :             if (messages == 0)
    3737           25 :                 printf("\033[2m  %6s  %7s  %-*s  %7s\033[0m\n",
    3738           25 :                        u, f, nw, folders[i], t);
    3739              :             else
    3740           39 :                 printf("  %6s  %7s  %-*s  %7s\n",
    3741           39 :                        u, f, nw, folders[i], t);
    3742              :         }
    3743              :     }
    3744              : 
    3745           11 :     free(statuses);
    3746           99 :     for (int i = 0; i < count; i++) free(folders[i]);
    3747           11 :     free(folders);
    3748           11 :     return 0;
    3749              : }
    3750              : 
    3751          196 : char *email_service_list_folders_interactive(const Config *cfg,
    3752              :                                              const char *current_folder,
    3753              :                                              int *go_up) {
    3754          196 :     local_store_init(cfg->host, cfg->user);
    3755          196 :     if (go_up) *go_up = 0;
    3756          196 :     int count = 0;
    3757          196 :     char sep = '.';
    3758          196 :     char **folders = fetch_folder_list(cfg, &count, &sep);
    3759          196 :     if (!folders || count == 0) {
    3760            0 :         if (folders) free(folders);
    3761            0 :         return NULL;
    3762              :     }
    3763              : 
    3764          196 :     qsort(folders, (size_t)count, sizeof(char *), cmp_str);
    3765              : 
    3766          196 :     FolderStatus *statuses = fetch_all_folder_statuses(cfg, folders, count);
    3767              : 
    3768          196 :     int *vis = malloc((size_t)count * sizeof(int));
    3769          196 :     if (!vis) {
    3770            0 :         free(statuses);
    3771            0 :         for (int i = 0; i < count; i++) free(folders[i]);
    3772            0 :         free(folders);
    3773            0 :         return NULL;
    3774              :     }
    3775              : 
    3776              :     /* Virtual prefix rows: [0] "Tags / Flags" header, [1] Unread,
    3777              :      *                      [2] Flagged,              [3] "Folders" header */
    3778              :     enum { VP_HDR_FLAGS=0, VP_UNREAD=1, VP_FLAGGED=2, VP_JUNK=3, VP_PHISHING=4,
    3779              :            VP_ANSWERED=5, VP_FORWARDED=6, VP_HDR_FOLD=7, VPREFIX=8 };
    3780          196 :     int vf_unread=0, vf_flagged=0, vf_junk=0, vf_phishing=0, vf_answered=0, vf_forwarded=0;
    3781          196 :     manifest_count_all_flags(&vf_unread, &vf_flagged, &vf_junk, &vf_phishing,
    3782              :                              &vf_answered, &vf_forwarded);
    3783              : 
    3784          196 :     int cursor = VPREFIX, wstart = 0;  /* default: first real folder */
    3785          196 :     int tree_mode = ui_pref_get_int("folder_view_mode", 1);
    3786          196 :     char current_prefix[512] = "";   /* flat mode: current navigation level */
    3787              : 
    3788              :     /* Pre-position cursor on current_folder (offset by VPREFIX).
    3789              :      * Virtual folders (__unread__, __flagged__, …) map to their prefix row.
    3790              :      * Real folders: INBOX is case-insensitive per RFC 3501. */
    3791          196 :     if (current_folder && *current_folder) {
    3792          196 :         if      (strcmp(current_folder, "__unread__")    == 0) cursor = VP_UNREAD;
    3793          188 :         else if (strcmp(current_folder, "__flagged__")   == 0) cursor = VP_FLAGGED;
    3794          187 :         else if (strcmp(current_folder, "__junk__")      == 0) cursor = VP_JUNK;
    3795          186 :         else if (strcmp(current_folder, "__phishing__")  == 0) cursor = VP_PHISHING;
    3796          186 :         else if (strcmp(current_folder, "__answered__")  == 0) cursor = VP_ANSWERED;
    3797          185 :         else if (strcmp(current_folder, "__forwarded__") == 0) cursor = VP_FORWARDED;
    3798          184 :         else if (tree_mode) {
    3799          191 :             for (int i = 0; i < count; i++) {
    3800          191 :                 if (strcasecmp(folders[i], current_folder) == 0) {
    3801          184 :                     cursor = VPREFIX + i; break;
    3802              :                 }
    3803              :             }
    3804              :         } else {
    3805            0 :             const char *last = strrchr(current_folder, sep);
    3806            0 :             if (last) {
    3807            0 :                 size_t plen = (size_t)(last - current_folder);
    3808            0 :                 if (plen < sizeof(current_prefix)) {
    3809            0 :                     memcpy(current_prefix, current_folder, plen);
    3810            0 :                     current_prefix[plen] = '\0';
    3811              :                 }
    3812              :             }
    3813              :             int tmp_vis[1024];
    3814            0 :             int tv = build_flat_view(folders, count, sep, current_prefix, tmp_vis);
    3815            0 :             for (int i = 0; i < tv; i++) {
    3816            0 :                 if (strcasecmp(folders[tmp_vis[i]], current_folder) == 0) {
    3817            0 :                     cursor = VPREFIX + i; break;
    3818              :                 }
    3819              :             }
    3820              :         }
    3821              :     }
    3822          196 :     int vcount = 0;                  /* flat view: number of visible entries */
    3823          196 :     char *selected = NULL;
    3824              : 
    3825          196 :     RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    3826              : 
    3827         1180 :     for (;;) {
    3828         1376 :         int rows  = terminal_rows();
    3829         1376 :         int limit = (rows > 5) ? rows - 5 : 3;
    3830              : 
    3831              :         /* Rebuild flat view on each iteration (alphabetical order) */
    3832              :         int display_count;
    3833         1376 :         if (tree_mode) {
    3834         1372 :             display_count = VPREFIX + count;
    3835              :         } else {
    3836            4 :             vcount = build_flat_view(folders, count, sep, current_prefix, vis);
    3837            4 :             display_count = VPREFIX + vcount;
    3838              :         }
    3839         1376 :         if (cursor >= display_count && display_count > 0)
    3840            0 :             cursor = display_count - 1;
    3841              :         /* Never land on a section header */
    3842         1376 :         if (cursor == VP_HDR_FLAGS || cursor == VP_HDR_FOLD) cursor = VP_UNREAD;
    3843              : 
    3844         1376 :         if (cursor < wstart) wstart = cursor;
    3845         1376 :         if (cursor >= wstart + limit) wstart = cursor - limit + 1;
    3846         1376 :         int wend = wstart + limit;
    3847         1376 :         if (wend > display_count) wend = display_count;
    3848              : 
    3849              :         /* Compute name column width for flat mode */
    3850         1376 :         int tcols_f = terminal_cols();
    3851              :         /* Fixed: "  " + 6 (unread) + "  " + 7 (flagged) + "  " + name_w + "  " + 7 (total) = name_w + 28 */
    3852         1376 :         int name_w = tcols_f - 28;
    3853         1376 :         if (name_w < 20) name_w = 20;
    3854              : 
    3855         1376 :         printf("\033[H\033[2J");
    3856              :         {
    3857              :             char cl[1024];
    3858         1376 :             if (!tree_mode && current_prefix[0])
    3859            1 :                 snprintf(cl, sizeof(cl), "  Folders \u2014 %s  \u203a %s/  (%d)",
    3860            1 :                          cfg->user ? cfg->user : "?",
    3861              :                          current_prefix, display_count);
    3862              :             else
    3863         1375 :                 snprintf(cl, sizeof(cl), "  Folders \u2014 %s  (%d)",
    3864         1375 :                          cfg->user ? cfg->user : "?",
    3865              :                          display_count);
    3866              :             /* Explicit row 1: avoids pending-wrap shifting layout when title fills the row */
    3867         1376 :             printf("\033[1;1H\033[7m%s", cl);
    3868         1376 :             int used = visible_line_cols(cl, cl + strlen(cl));
    3869       109881 :             for (int p = used; p < tcols_f; p++) putchar(' ');
    3870         1376 :             printf("\033[0m");
    3871              :         }
    3872              : 
    3873              :         /* Column header and separator pinned at rows 3 and 4 */
    3874         1376 :         printf("\033[3;1H  %6s  %7s  %-*s  %7s\n", "Unread", "Flagged", name_w, "Folder", "Total");
    3875         1376 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    3876         1376 :         print_dbar(name_w);
    3877         1376 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
    3878              : 
    3879        23325 :         for (int i = wstart; i < wend; i++) {
    3880              :             /* Virtual prefix rows */
    3881        21950 :             if (i < VPREFIX) {
    3882        13760 :                 if (i == VP_HDR_FLAGS || i == VP_HDR_FOLD) {
    3883         2752 :                     const char *htitle = (i == VP_HDR_FLAGS) ? "Tags / Flags" : "Folders";
    3884         2752 :                     printf("  \033[2m\u2500\u2500 %s ", htitle);
    3885         2752 :                     int used = 6 + (int)strlen(htitle) + 1;
    3886       257600 :                     for (int s = used; s < name_w + 28 - 2; s++) fputs("\u2500", stdout);
    3887         2752 :                     printf("\033[0m\n");
    3888              :                 } else {
    3889              :                     const char *vname, *vcolor;
    3890              :                     int vc;
    3891         8256 :                     switch (i) {
    3892         1376 :                         case VP_UNREAD:    vc=vf_unread;    vname="Unread";    vcolor="\033[32m"; break;
    3893         1376 :                         case VP_FLAGGED:   vc=vf_flagged;   vname="Flagged";   vcolor="\033[33m"; break;
    3894         1376 :                         case VP_JUNK:      vc=vf_junk;      vname="Junk";      vcolor="\033[33m"; break;
    3895         1376 :                         case VP_PHISHING:  vc=vf_phishing;  vname="Phishing";  vcolor="\033[31m"; break;
    3896         1376 :                         case VP_ANSWERED:  vc=vf_answered;  vname="Answered";  vcolor="\033[36m"; break;
    3897         1376 :                         case VP_FORWARDED: vc=vf_forwarded; vname="Forwarded"; vcolor="\033[36m"; break;
    3898            0 :                         default:           vc=0; vname="?"; vcolor=""; break;
    3899              :                     }
    3900              :                     char cnt[16];
    3901              :                     /* Virtual rows always show the count, even when zero */
    3902         8256 :                     if (vc == 0) snprintf(cnt, sizeof(cnt), "0");
    3903         1413 :                     else fmt_thou(cnt, sizeof(cnt), vc);
    3904         8256 :                     if (i == cursor) printf("\033[7m");
    3905         7243 :                     else if (vc == 0) printf("\033[2m");
    3906         1230 :                     else printf("%s", vcolor);
    3907         8256 :                     printf("  %6s  %7s  %-*s  %7s",
    3908              :                            cnt, "-", name_w, vname, "-");
    3909         8256 :                     if (i == cursor) printf("\033[K\033[0m");
    3910         7243 :                     else printf("\033[0m");
    3911         8256 :                     printf("\n");
    3912              :                 }
    3913        11008 :                 continue;
    3914              :             }
    3915              :             /* Real folder rows (offset by VPREFIX) */
    3916        10942 :             int ri = i - VPREFIX;
    3917        10942 :             if (tree_mode) {
    3918        10932 :                 int msgs = statuses ? statuses[ri].messages : 0;
    3919        10932 :                 int unsn = statuses ? statuses[ri].unseen   : 0;
    3920        10932 :                 int flgd = statuses ? statuses[ri].flagged  : 0;
    3921        10932 :                 print_folder_item(folders, count, ri, sep, 1, i == cursor, 0,
    3922              :                                   msgs, unsn, flgd, name_w);
    3923              :             } else {
    3924           10 :                 int fi = vis[ri];
    3925           10 :                 int hk = folder_has_children(folders, count, folders[fi], sep);
    3926              :                 int msgs, unsn, flgd;
    3927           10 :                 if (hk) {
    3928              :                     /* Aggregate own + all descendant counts so the user can see
    3929              :                      * total unread/flagged even when children are not expanded. */
    3930            3 :                     sum_subtree(folders, count, sep, folders[fi], statuses,
    3931              :                                 &msgs, &unsn, &flgd);
    3932              :                 } else {
    3933            7 :                     msgs = statuses ? statuses[fi].messages : 0;
    3934            7 :                     unsn = statuses ? statuses[fi].unseen   : 0;
    3935            7 :                     flgd = statuses ? statuses[fi].flagged  : 0;
    3936              :                 }
    3937           10 :                 print_folder_item(folders, count, fi, sep, 0, i == cursor, hk,
    3938              :                                   msgs, unsn, flgd, name_w);
    3939              :             }
    3940              :         }
    3941              : 
    3942         1375 :         fflush(stdout);
    3943              :         {
    3944         1375 :             int trows_f = terminal_rows();
    3945         1375 :             if (trows_f <= 0) trows_f = limit + 4;
    3946         1375 :             int tcols_f = terminal_cols();
    3947              :             char sb[256];
    3948         1375 :             if (!tree_mode && current_prefix[0])
    3949            1 :                 snprintf(sb, sizeof(sb),
    3950              :                          "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open/select"
    3951              :                          "  t=tree  Backspace=up  ESC=quit  [%d/%d]",
    3952              :                          display_count > 0 ? cursor + 1 : 0, display_count);
    3953              :             else
    3954         1374 :                 snprintf(sb, sizeof(sb),
    3955              :                          "  \u2191\u2193=step  PgDn/PgUp=page  Enter=open/select"
    3956              :                          "  t=%s  Backspace=back  ESC=quit  [%d/%d]",
    3957              :                          tree_mode ? "flat" : "tree",
    3958              :                          display_count > 0 ? cursor + 1 : 0, display_count);
    3959         1375 :             print_statusbar(trows_f, tcols_f, sb);
    3960              :         }
    3961              : 
    3962         1374 :         TermKey key = terminal_read_key();
    3963              : 
    3964         1364 :         switch (key) {
    3965            1 :         case TERM_KEY_QUIT:
    3966              :         case TERM_KEY_ESC:
    3967            1 :             goto folders_int_done;
    3968            4 :         case TERM_KEY_BACK:
    3969            4 :             if (!tree_mode && current_prefix[0]) {
    3970              :                 /* navigate up one level */
    3971            1 :                 char *last_sep = strrchr(current_prefix, sep);
    3972            1 :                 if (last_sep) *last_sep = '\0';
    3973            1 :                 else          current_prefix[0] = '\0';
    3974            1 :                 cursor = VPREFIX; wstart = 0;
    3975              :             } else {
    3976            3 :                 if (go_up) {
    3977            3 :                     *go_up = 1;
    3978              :                 } else {
    3979            0 :                     if (current_folder && *current_folder)
    3980            0 :                         selected = strdup(current_folder);
    3981              :                 }
    3982            3 :                 goto folders_int_done;
    3983              :             }
    3984            1 :             break;
    3985          177 :         case TERM_KEY_ENTER:
    3986          177 :             if (cursor == VP_UNREAD) {
    3987            3 :                 selected = strdup("__unread__");    goto folders_int_done;
    3988          174 :             } else if (cursor == VP_FLAGGED) {
    3989            1 :                 selected = strdup("__flagged__");   goto folders_int_done;
    3990          173 :             } else if (cursor == VP_JUNK) {
    3991            1 :                 selected = strdup("__junk__");      goto folders_int_done;
    3992          172 :             } else if (cursor == VP_PHISHING) {
    3993            0 :                 selected = strdup("__phishing__");  goto folders_int_done;
    3994          172 :             } else if (cursor == VP_ANSWERED) {
    3995            1 :                 selected = strdup("__answered__");  goto folders_int_done;
    3996          171 :             } else if (cursor == VP_FORWARDED) {
    3997            1 :                 selected = strdup("__forwarded__"); goto folders_int_done;
    3998          170 :             } else if (cursor == VP_HDR_FLAGS || cursor == VP_HDR_FOLD) {
    3999              :                 break; /* header row — ignore */
    4000          170 :             } else if (tree_mode) {
    4001          169 :                 selected = strdup(folders[cursor - VPREFIX]);
    4002          169 :                 goto folders_int_done;
    4003            1 :             } else if (display_count > VPREFIX) {
    4004            1 :                 int ri = cursor - VPREFIX;
    4005            1 :                 int fi = vis[ri];
    4006            1 :                 if (folder_has_children(folders, count, folders[fi], sep)) {
    4007            1 :                     strncpy(current_prefix, folders[fi], sizeof(current_prefix) - 1);
    4008            1 :                     current_prefix[sizeof(current_prefix) - 1] = '\0';
    4009            1 :                     cursor = VPREFIX; wstart = 0;
    4010              :                 } else {
    4011            0 :                     selected = strdup(folders[fi]);
    4012            0 :                     goto folders_int_done;
    4013              :                 }
    4014              :             }
    4015            1 :             break;
    4016          996 :         case TERM_KEY_NEXT_LINE:
    4017          996 :             if (cursor < display_count - 1) {
    4018          996 :                 cursor++;
    4019          996 :                 if (cursor == VP_HDR_FLAGS || cursor == VP_HDR_FOLD) cursor++;
    4020              :             }
    4021          996 :             break;
    4022            4 :         case TERM_KEY_PREV_LINE:
    4023            4 :             if (cursor > 0) {
    4024            4 :                 cursor--;
    4025            4 :                 if (cursor == VP_HDR_FLAGS || cursor == VP_HDR_FOLD) {
    4026            1 :                     if (cursor > 0) cursor--;
    4027              :                 }
    4028              :             }
    4029            4 :             break;
    4030            1 :         case TERM_KEY_NEXT_PAGE:
    4031            1 :             cursor += limit;
    4032            1 :             if (cursor >= display_count) cursor = display_count > 0 ? display_count - 1 : 0;
    4033            1 :             if (cursor == VP_HDR_FLAGS || cursor == VP_HDR_FOLD) cursor++;
    4034            1 :             break;
    4035            1 :         case TERM_KEY_PREV_PAGE:
    4036            1 :             cursor -= limit;
    4037            1 :             if (cursor < 0) cursor = 0;
    4038            1 :             if (cursor == VP_HDR_FLAGS || cursor == VP_HDR_FOLD) cursor++;
    4039            1 :             break;
    4040          171 :         case TERM_KEY_HOME:
    4041          171 :             cursor = VP_UNREAD; wstart = 0;
    4042          171 :             break;
    4043            0 :         case TERM_KEY_END:
    4044            0 :             cursor = display_count > 0 ? display_count - 1 : VP_UNREAD;
    4045            0 :             break;
    4046            9 :         case TERM_KEY_LEFT:
    4047              :         case TERM_KEY_RIGHT:
    4048              :         case TERM_KEY_DELETE:
    4049              :         case TERM_KEY_TAB:
    4050              :         case TERM_KEY_SHIFT_TAB:
    4051              :         case TERM_KEY_IGNORE: {
    4052            9 :             int ch = terminal_last_printable();
    4053            9 :             if (ch == '/') {
    4054              :                 /* Cross-folder content search */
    4055              :                 static const char *snames[] = {"Subject","From","To","Body"};
    4056            1 :                 int sscope = 0;
    4057            1 :                 char sbuf[256] = "";
    4058            1 :                 int slen = 0;
    4059            1 :                 int srows = terminal_rows(), scols = terminal_cols();
    4060            1 :                 if (srows <= 0) srows = 24;
    4061            7 :                 for (;;) {
    4062            8 :                     printf("\033[%d;1H\033[K  Search [%s]: %s_",
    4063              :                            srows - 1, snames[sscope], sbuf);
    4064            8 :                     fflush(stdout);
    4065            8 :                     TermKey ikey = terminal_read_key();
    4066            7 :                     if (ikey == TERM_KEY_ESC || ikey == TERM_KEY_QUIT) break;
    4067            7 :                     if (ikey == TERM_KEY_ENTER) {
    4068            0 :                         if (sbuf[0]) {
    4069              :                             char sfolder[512];
    4070            0 :                             snprintf(sfolder, sizeof(sfolder),
    4071              :                                      "__search__:%d:%s", sscope, sbuf);
    4072            0 :                             selected = strdup(sfolder);
    4073            0 :                             goto folders_int_done;
    4074              :                         }
    4075            0 :                         break;
    4076              :                     }
    4077            7 :                     if (ikey == TERM_KEY_TAB) {
    4078            1 :                         sscope = (sscope + 1) % 4;
    4079            6 :                     } else if (ikey == TERM_KEY_BACK) {
    4080              :                         /* Remove last UTF-8 character */
    4081            1 :                         while (slen > 0 && (sbuf[slen - 1] & 0xC0) == 0x80) slen--;
    4082            1 :                         if (slen > 0) sbuf[--slen] = '\0';
    4083            5 :                     } else if (terminal_last_utf8()[0]) {
    4084            5 :                         const char *u8 = terminal_last_utf8();
    4085            5 :                         size_t ulen = strlen(u8);
    4086            5 :                         if (slen + (int)ulen < (int)sizeof(sbuf)) {
    4087            5 :                             memcpy(sbuf + slen, u8, ulen + 1);
    4088            5 :                             slen += (int)ulen;
    4089              :                         }
    4090              :                     }
    4091              :                 }
    4092              :                 (void)scols;
    4093            8 :             } else if (ch == 't') {
    4094            4 :                 tree_mode = !tree_mode;
    4095            4 :                 ui_pref_set_int("folder_view_mode", tree_mode);
    4096            4 :                 cursor = VPREFIX; wstart = 0;
    4097            4 :                 if (!tree_mode) current_prefix[0] = '\0';
    4098            4 :             } else if (ch == 'c') {
    4099            3 :                 selected = strdup("__compose__");
    4100            3 :                 goto folders_int_done;
    4101            1 :             } else if (ch == 'h' || ch == '?') {
    4102              :                 static const char *help[][2] = {
    4103              :                     { "\u2191 / \u2193",   "Move cursor up / down"                   },
    4104              :                     { "PgUp / PgDn",        "Move cursor one page up / down"          },
    4105              :                     { "Enter",             "Open folder / navigate into subfolder"   },
    4106              :                     { "/",                 "Cross-folder content search"             },
    4107              :                     { "c",                 "Compose new message"                     },
    4108              :                     { "t",                 "Toggle tree / flat view"                 },
    4109              :                     { "Backspace",         "Go up one level (or back to accounts)"   },
    4110              :                     { "ESC / q",           "Quit"                                    },
    4111              :                     { "h / ?",             "Show this help"                          },
    4112              :                 };
    4113            1 :                 show_help_popup("Folder browser shortcuts",
    4114              :                                 help, (int)(sizeof(help)/sizeof(help[0])));
    4115              :             }
    4116            5 :             break;
    4117              :         }
    4118              :         }
    4119              :     }
    4120          183 : folders_int_done:
    4121          183 :     free(statuses);
    4122          183 :     free(vis);
    4123         1647 :     for (int i = 0; i < count; i++) free(folders[i]);
    4124          183 :     free(folders);
    4125          183 :     return selected;
    4126              : }
    4127              : 
    4128              : /* ── Gmail Label Picker Popup ────────────────────────────────────────── */
    4129              : 
    4130              : /**
    4131              :  * Show a popup overlay with checkboxes for each label.
    4132              :  * The user can toggle labels on/off with Enter/Space, navigate with arrows.
    4133              :  * Applies changes via mail_client_set_flag and updates local .idx.
    4134              :  * Returns when the user presses ESC/Backspace/q.
    4135              :  */
    4136            5 : static void show_label_picker(MailClient *mc, const char *uid,
    4137              :                                char *feedback_out, int feedback_cap) {
    4138              :     /* Collect available labels from local .idx files */
    4139            5 :     char **all_labels = NULL;
    4140            5 :     int all_count = 0;
    4141            5 :     label_idx_list(&all_labels, &all_count);
    4142              : 
    4143              :     /* Build display: system labels (UNREAD, INBOX, STARRED, SENT, DRAFTS) first,
    4144              :      * then user-defined labels.  Skip _nolabel, _spam, _trash (system-managed). */
    4145            5 :     char **pick_ids   = NULL;
    4146            5 :     char **pick_names = NULL;
    4147            5 :     int  *pick_on     = NULL;
    4148            5 :     int  pick_count = 0, pick_cap = 0;
    4149              : 
    4150              :     /* Add system labels first */
    4151              :     static const char *sys_pick[] = {"UNREAD", "INBOX", "STARRED", "SENT", "DRAFTS"};
    4152           30 :     for (int s = 0; s < (int)(sizeof(sys_pick)/sizeof(sys_pick[0])); s++) {
    4153           25 :         if (pick_count == pick_cap) {
    4154            5 :             int nc = pick_cap ? pick_cap * 2 : 16;
    4155            5 :             pick_ids   = realloc(pick_ids,   (size_t)nc * sizeof(char *));
    4156            5 :             pick_names = realloc(pick_names, (size_t)nc * sizeof(char *));
    4157            5 :             pick_on    = realloc(pick_on,    (size_t)nc * sizeof(int));
    4158            5 :             pick_cap   = nc;
    4159              :         }
    4160           25 :         pick_ids[pick_count]   = strdup(sys_pick[s]);
    4161           25 :         pick_names[pick_count] = strdup(sys_pick[s]);
    4162           25 :         pick_on[pick_count]    = label_idx_contains(sys_pick[s], uid);
    4163           25 :         pick_count++;
    4164              :     }
    4165              :     /* Add user labels */
    4166           18 :     for (int i = 0; i < all_count; i++) {
    4167           13 :         if (is_system_or_special_label(all_labels[i])) {
    4168           13 :             free(all_labels[i]);
    4169           13 :             continue;
    4170              :         }
    4171            0 :         if (pick_count == pick_cap) {
    4172            0 :             int nc = pick_cap ? pick_cap * 2 : 16;
    4173            0 :             pick_ids   = realloc(pick_ids,   (size_t)nc * sizeof(char *));
    4174            0 :             pick_names = realloc(pick_names, (size_t)nc * sizeof(char *));
    4175            0 :             pick_on    = realloc(pick_on,    (size_t)nc * sizeof(int));
    4176            0 :             pick_cap   = nc;
    4177              :         }
    4178            0 :         pick_ids[pick_count]   = all_labels[i]; /* transfer ownership */
    4179            0 :         char *resolved = local_gmail_label_name_lookup(all_labels[i]);
    4180            0 :         pick_names[pick_count] = resolved ? resolved : strdup(all_labels[i]);
    4181            0 :         pick_on[pick_count]    = label_idx_contains(all_labels[i], uid);
    4182            0 :         pick_count++;
    4183              :     }
    4184            5 :     free(all_labels);
    4185              : 
    4186            5 :     if (pick_count == 0) {
    4187            0 :         free(pick_ids); free(pick_names); free(pick_on);
    4188            0 :         return;
    4189              :     }
    4190              : 
    4191              :     /* Remember initial label state for post-picker feedback computation */
    4192            5 :     int *pick_initial = malloc((size_t)pick_count * sizeof(int));
    4193            5 :     if (pick_initial)
    4194            5 :         memcpy(pick_initial, pick_on, (size_t)pick_count * sizeof(int));
    4195              :     /* Capture virtual-folder membership before any changes */
    4196            5 :     int was_in_nolabel = label_idx_contains("_nolabel", uid);
    4197            5 :     int was_in_trash   = label_idx_contains("_trash", uid);
    4198              : 
    4199            5 :     int pcursor = 0;
    4200            5 :     int tcols = terminal_cols();
    4201            5 :     int trows = terminal_rows();
    4202            5 :     if (tcols <= 0) tcols = 80;
    4203            5 :     if (trows <= 0) trows = 24;
    4204              : 
    4205            5 :     int inner_w = 30;
    4206            5 :     int box_w = inner_w + 4;
    4207            5 :     int box_h = pick_count + 4;
    4208            5 :     int col0 = (tcols - box_w) / 2;
    4209            5 :     int row0 = (trows - box_h) / 2;
    4210            5 :     if (col0 < 1) col0 = 1;
    4211            5 :     if (row0 < 1) row0 = 1;
    4212              : 
    4213            9 :     for (;;) {
    4214              :         /* Draw popup */
    4215           14 :         fprintf(stderr, "\033[%d;%dH\033[7m\u250c", row0, col0);
    4216          462 :         for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
    4217           14 :         fprintf(stderr, "\u2510\033[0m");
    4218              : 
    4219           14 :         const char *title = "Toggle Labels";
    4220           14 :         int tlen = (int)strlen(title);
    4221           14 :         fprintf(stderr, "\033[%d;%dH\033[7m\u2502 ", row0 + 1, col0);
    4222           14 :         int pl = (box_w - 4 - tlen) / 2;
    4223           14 :         int pr = (box_w - 4 - tlen) - pl;
    4224          126 :         for (int i = 0; i < pl; i++) fputc(' ', stderr);
    4225           14 :         fprintf(stderr, "%s", title);
    4226          140 :         for (int i = 0; i < pr; i++) fputc(' ', stderr);
    4227           14 :         fprintf(stderr, " \u2502\033[0m");
    4228              : 
    4229           14 :         fprintf(stderr, "\033[%d;%dH\033[7m\u251c", row0 + 2, col0);
    4230          462 :         for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
    4231           14 :         fprintf(stderr, "\u2524\033[0m");
    4232              : 
    4233           84 :         for (int i = 0; i < pick_count; i++) {
    4234           70 :             int sel = (i == pcursor);
    4235           70 :             fprintf(stderr, "\033[%d;%dH", row0 + 3 + i, col0);
    4236           70 :             if (sel) fprintf(stderr, "\033[7m\033[1m");
    4237           56 :             else     fprintf(stderr, "\033[7m");
    4238           70 :             fprintf(stderr, "\u2502 [%c] %-*.*s \u2502",
    4239           70 :                     pick_on[i] ? 'x' : ' ',
    4240              :                     inner_w - 5, inner_w - 5,
    4241           70 :                     pick_names[i]);
    4242           70 :             fprintf(stderr, "\033[0m");
    4243              :         }
    4244              : 
    4245           14 :         fprintf(stderr, "\033[%d;%dH\033[7m\u2514", row0 + 3 + pick_count, col0);
    4246          462 :         for (int i = 0; i < box_w - 2; i++) fprintf(stderr, "\u2500");
    4247           14 :         fprintf(stderr, "\u2518\033[0m");
    4248              : 
    4249           14 :         const char *foot = " \u2191\u2193=move  Enter=toggle  ESC=done ";
    4250           14 :         int flen = (int)strlen(foot);
    4251           14 :         if (flen < box_w) {
    4252            0 :             int fc = col0 + (box_w - flen) / 2;
    4253            0 :             fprintf(stderr, "\033[%d;%dH\033[2m%s\033[0m",
    4254            0 :                     row0 + 4 + pick_count, fc, foot);
    4255              :         }
    4256           14 :         fflush(stderr);
    4257              : 
    4258           14 :         TermKey key = terminal_read_key();
    4259           13 :         if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC || key == TERM_KEY_BACK)
    4260              :             break;
    4261            9 :         if (key == TERM_KEY_NEXT_LINE && pcursor < pick_count - 1) pcursor++;
    4262            9 :         if (key == TERM_KEY_PREV_LINE && pcursor > 0) pcursor--;
    4263            9 :         if (key == TERM_KEY_ENTER || terminal_last_printable() == ' ') {
    4264              :             /* Toggle the label */
    4265            4 :             pick_on[pcursor] = !pick_on[pcursor];
    4266            4 :             const char *lid = pick_ids[pcursor];
    4267            4 :             int adding = pick_on[pcursor];
    4268            4 :             if (adding) {
    4269            4 :                 label_idx_add(lid, uid);
    4270            4 :                 local_hdr_update_labels("", uid, &lid, 1, NULL, 0);
    4271              :                 /* Adding a real label (not UNREAD/STARRED/CATEGORY_) implicitly
    4272              :                  * moves the message out of virtual archive (_nolabel) and/or trash.
    4273              :                  * Remove the old virtual location from local index + .hdr + API. */
    4274            4 :                 if (strcmp(lid, "UNREAD") != 0 &&
    4275            4 :                     strcmp(lid, "STARRED") != 0 &&
    4276            3 :                     strncmp(lid, "CATEGORY_", 9) != 0) {
    4277              :                     /* Unarchive: remove from _nolabel virtual archive index */
    4278            3 :                     if (label_idx_contains("_nolabel", uid))
    4279            2 :                         label_idx_remove("_nolabel", uid);
    4280              :                     /* Untrash: remove TRASH label from local index, .hdr, API */
    4281            3 :                     if (label_idx_contains("_trash", uid)) {
    4282            1 :                         const char *trash_id = "TRASH";
    4283            1 :                         label_idx_remove("_trash", uid);
    4284            1 :                         local_hdr_update_labels("", uid, NULL, 0, &trash_id, 1);
    4285            1 :                         if (mc) mail_client_modify_label(mc, uid, "TRASH", 0);
    4286              :                     }
    4287              :                 }
    4288              :             } else {
    4289            0 :                 label_idx_remove(lid, uid);
    4290            0 :                 local_hdr_update_labels("", uid, NULL, 0, &lid, 1);
    4291              :             }
    4292            4 :             if (mc) {
    4293            0 :                 if (strcmp(lid, "STARRED") == 0)
    4294            0 :                     mail_client_set_flag(mc, uid, "\\Flagged", adding);
    4295              :                 else
    4296            0 :                     mail_client_modify_label(mc, uid, lid, adding);
    4297              :             }
    4298              :         }
    4299              :     }
    4300              : 
    4301              :     /* Compute feedback: diff pick_initial vs pick_on */
    4302            4 :     if (feedback_out && feedback_cap > 0 && pick_initial) {
    4303            4 :         int added = 0, removed = 0;
    4304            4 :         char added_name[64]   = "";
    4305            4 :         char removed_name[64] = "";
    4306            4 :         int real_added = 0;
    4307           24 :         for (int i = 0; i < pick_count; i++) {
    4308           20 :             if (pick_on[i] && !pick_initial[i]) {
    4309            4 :                 added++;
    4310            4 :                 if (!added_name[0]) {
    4311            4 :                     strncpy(added_name, pick_names[i], sizeof(added_name) - 1);
    4312            4 :                     added_name[sizeof(added_name) - 1] = '\0';
    4313              :                 }
    4314              :                 /* "Real" label = not UNREAD/STARRED/CATEGORY_ */
    4315            4 :                 if (strcmp(pick_ids[i], "UNREAD")    != 0 &&
    4316            4 :                     strcmp(pick_ids[i], "STARRED")   != 0 &&
    4317            3 :                     strncmp(pick_ids[i], "CATEGORY_", 9) != 0)
    4318            3 :                     real_added = 1;
    4319              :             }
    4320           20 :             if (!pick_on[i] && pick_initial[i]) {
    4321            0 :                 removed++;
    4322            0 :                 if (!removed_name[0]) {
    4323            0 :                     strncpy(removed_name, pick_names[i], sizeof(removed_name) - 1);
    4324            0 :                     removed_name[sizeof(removed_name) - 1] = '\0';
    4325              :                 }
    4326              :             }
    4327              :         }
    4328            4 :         if (added + removed > 0) {
    4329            4 :             if (was_in_trash && real_added)
    4330            1 :                 snprintf(feedback_out, (size_t)feedback_cap,
    4331              :                          "%s added \xe2\x80\x94 restored from Trash", added_name);
    4332            3 :             else if (was_in_nolabel && real_added)
    4333            2 :                 snprintf(feedback_out, (size_t)feedback_cap,
    4334              :                          "%s added \xe2\x80\x94 moved out of Archive", added_name);
    4335            1 :             else if (added + removed == 1) {
    4336            1 :                 if (added == 1)
    4337            1 :                     snprintf(feedback_out, (size_t)feedback_cap,
    4338              :                              "Label added: %s", added_name);
    4339              :                 else
    4340            0 :                     snprintf(feedback_out, (size_t)feedback_cap,
    4341              :                              "Label removed: %s", removed_name);
    4342              :             } else {
    4343            0 :                 snprintf(feedback_out, (size_t)feedback_cap, "Labels updated");
    4344              :             }
    4345              :         }
    4346              :         /* If no changes, leave feedback_out unchanged (criterion 7) */
    4347              :     }
    4348              : 
    4349            4 :     free(pick_initial);
    4350           24 :     for (int i = 0; i < pick_count; i++) { free(pick_ids[i]); free(pick_names[i]); }
    4351            4 :     free(pick_ids); free(pick_names); free(pick_on);
    4352              : }
    4353              : 
    4354              : /* ── Gmail Label List (interactive) ──────────────────────────────────── */
    4355              : 
    4356              : /* System labels in display order.  id = .idx filename, name = display name. */
    4357              : static const struct { const char *id; const char *name; } gmail_system_labels[] = {
    4358              :     { "UNREAD",   "Unread"  },
    4359              :     { "STARRED",  "Flagged" },
    4360              :     { "INBOX",    "Inbox"   },
    4361              :     { "SENT",     "Sent"    },
    4362              :     { "DRAFTS",   "Drafts"  },
    4363              : };
    4364              : #define GMAIL_SYS_COUNT ((int)(sizeof(gmail_system_labels)/sizeof(gmail_system_labels[0])))
    4365              : /* First 2 system labels are Tags/Flags; the rest (INBOX, SENT, DRAFTS) are Folders */
    4366              : #define GMAIL_SYS_FLAGS   2
    4367              : #define GMAIL_SYS_FOLDERS (GMAIL_SYS_COUNT - GMAIL_SYS_FLAGS)
    4368              : 
    4369              : /* Gmail automatic inbox category labels (shown as a separate section) */
    4370              : static const struct { const char *id; const char *name; } gmail_cat_labels[] = {
    4371              :     { "CATEGORY_PERSONAL",   "Personal"   },
    4372              :     { "CATEGORY_SOCIAL",     "Social"     },
    4373              :     { "CATEGORY_PROMOTIONS", "Promotions" },
    4374              :     { "CATEGORY_UPDATES",    "Updates"    },
    4375              :     { "CATEGORY_FORUMS",     "Forums"     },
    4376              : };
    4377              : #define GMAIL_CAT_COUNT ((int)(sizeof(gmail_cat_labels)/sizeof(gmail_cat_labels[0])))
    4378              : 
    4379              : static const struct { const char *id; const char *name; } gmail_special_labels[] = {
    4380              :     { "_nolabel", "Archive" },
    4381              :     { "_spam",    "Spam"    },
    4382              :     { "_trash",   "Trash (auto-delete: 30 days)" },
    4383              : };
    4384              : #define GMAIL_SPECIAL_COUNT ((int)(sizeof(gmail_special_labels)/sizeof(gmail_special_labels[0])))
    4385              : 
    4386              : /* Build a flat label display list.  Returns count.
    4387              :  * Layout: "── Tags / Flags ──" header, UNREAD+STARRED, user labels,
    4388              :  *         "── Folders ──" header, INBOX+SENT+DRAFTS, categories, special.
    4389              :  * is_header[i]=1 marks non-selectable section header rows. */
    4390           46 : static int build_label_display(
    4391              :     char ***ids_out, char ***names_out, int **sep_out, int **is_header_out,
    4392              :     char **user_labels, int user_count,
    4393              :     char **cat_labels,  int cat_count)
    4394              : {
    4395              :     /* 2 section headers + flags + user + folders + cats + special */
    4396           46 :     int total = 2 + GMAIL_SYS_FLAGS + user_count
    4397           46 :               + GMAIL_SYS_FOLDERS + cat_count + GMAIL_SPECIAL_COUNT;
    4398           46 :     char **ids   = calloc((size_t)total, sizeof(char *));
    4399           46 :     char **names = calloc((size_t)total, sizeof(char *));
    4400           46 :     int  *seps   = calloc((size_t)total, sizeof(int));
    4401           46 :     int  *hdrs   = calloc((size_t)total, sizeof(int));
    4402           46 :     if (!ids || !names || !seps || !hdrs) {
    4403            0 :         free(ids); free(names); free(seps); free(hdrs); return 0;
    4404              :     }
    4405              : 
    4406           46 :     int n = 0;
    4407              : 
    4408              :     /* ── Tags / Flags section ──────────────────────────────────────────── */
    4409           46 :     ids[n] = strdup("__header__"); names[n] = strdup("Tags / Flags"); hdrs[n] = 1; n++;
    4410          138 :     for (int i = 0; i < GMAIL_SYS_FLAGS; i++) {
    4411           92 :         ids[n]   = strdup(gmail_system_labels[i].id);
    4412           92 :         names[n] = strdup(gmail_system_labels[i].name);
    4413           92 :         n++;
    4414              :     }
    4415           46 :     for (int i = 0; i < user_count; i++) {
    4416            0 :         ids[n]   = strdup(user_labels[i]);
    4417            0 :         char *disp = local_gmail_label_name_lookup(user_labels[i]);
    4418            0 :         names[n] = disp ? disp : strdup(user_labels[i]);
    4419            0 :         n++;
    4420              :     }
    4421              : 
    4422              :     /* ── Folders section ───────────────────────────────────────────────── */
    4423           46 :     ids[n] = strdup("__header__"); names[n] = strdup("Folders"); hdrs[n] = 1; n++;
    4424          184 :     for (int i = GMAIL_SYS_FLAGS; i < GMAIL_SYS_COUNT; i++) {
    4425          138 :         ids[n]   = strdup(gmail_system_labels[i].id);
    4426          138 :         names[n] = strdup(gmail_system_labels[i].name);
    4427          138 :         n++;
    4428              :     }
    4429           46 :     for (int i = 0; i < cat_count; i++) {
    4430            0 :         ids[n] = strdup(cat_labels[i]);
    4431            0 :         const char *disp = cat_labels[i];
    4432            0 :         for (int k = 0; k < GMAIL_CAT_COUNT; k++)
    4433            0 :             if (strcmp(cat_labels[i], gmail_cat_labels[k].id) == 0)
    4434            0 :                 { disp = gmail_cat_labels[k].name; break; }
    4435            0 :         names[n] = strdup(disp);
    4436            0 :         n++;
    4437              :     }
    4438              :     /* Special labels with a thin separator before the first */
    4439           46 :     seps[n] = 1;
    4440          184 :     for (int i = 0; i < GMAIL_SPECIAL_COUNT; i++) {
    4441          138 :         ids[n]   = strdup(gmail_special_labels[i].id);
    4442          138 :         names[n] = strdup(gmail_special_labels[i].name);
    4443          138 :         n++;
    4444              :     }
    4445              : 
    4446           46 :     *ids_out        = ids;
    4447           46 :     *names_out      = names;
    4448           46 :     *sep_out        = seps;
    4449           46 :     *is_header_out  = hdrs;
    4450           46 :     return n;
    4451              : }
    4452              : 
    4453           45 : static void free_label_display(char **ids, char **names, int *seps, int *hdrs, int count) {
    4454          495 :     for (int i = 0; i < count; i++) { free(ids[i]); free(names[i]); }
    4455           45 :     free(ids); free(names); free(seps); free(hdrs);
    4456           45 : }
    4457              : 
    4458              : /* Check if a label name is a system or special label (skip for user list). */
    4459          114 : static int is_system_or_special_label(const char *name) {
    4460          281 :     for (int i = 0; i < GMAIL_SYS_COUNT; i++)
    4461          268 :         if (strcmp(name, gmail_system_labels[i].id) == 0) return 1;
    4462           25 :     for (int i = 0; i < GMAIL_SPECIAL_COUNT; i++)
    4463           25 :         if (strcmp(name, gmail_special_labels[i].id) == 0) return 1;
    4464              :     /* Also filter out IMPORTANT and CATEGORY_* which gmail_sync already filters */
    4465            0 :     if (strcmp(name, "IMPORTANT") == 0) return 1;
    4466            0 :     if (strncmp(name, "CATEGORY_", 9) == 0) return 1;
    4467            0 :     if (strcmp(name, "TRASH") == 0 || strcmp(name, "SPAM") == 0) return 1;
    4468            0 :     return 0;
    4469              : }
    4470              : 
    4471           46 : char *email_service_list_labels_interactive(const Config *cfg,
    4472              :                                             const char *current_label,
    4473              :                                             int *go_up) {
    4474           46 :     local_store_init(cfg->host, cfg->user);
    4475           46 :     if (go_up) *go_up = 0;
    4476              : 
    4477              :     /* Collect user and category labels from locally synced .idx files. */
    4478           46 :     char **user_labels = NULL;
    4479           46 :     int user_count = 0;
    4480           46 :     char **cat_labels = NULL;
    4481           46 :     int cat_count = 0;
    4482              :     {
    4483           46 :         char **all_labels = NULL;
    4484           46 :         int all_count = 0;
    4485           46 :         label_idx_list(&all_labels, &all_count);
    4486           46 :         user_labels = calloc(all_count > 0 ? (size_t)all_count : 1, sizeof(char *));
    4487           46 :         cat_labels  = calloc(all_count > 0 ? (size_t)all_count : 1, sizeof(char *));
    4488          147 :         for (int i = 0; i < all_count; i++) {
    4489          101 :             if (strncmp(all_labels[i], "CATEGORY_", 9) == 0)
    4490            0 :                 cat_labels[cat_count++] = strdup(all_labels[i]);
    4491          101 :             else if (!is_system_or_special_label(all_labels[i]))
    4492            0 :                 user_labels[user_count++] = strdup(all_labels[i]);
    4493          101 :             free(all_labels[i]);
    4494              :         }
    4495           46 :         free(all_labels);
    4496              :     }
    4497              : 
    4498              :     /* Sort user labels alphabetically */
    4499           46 :     if (user_count > 1)
    4500            0 :         qsort(user_labels, (size_t)user_count, sizeof(char *), cmp_str);
    4501              : 
    4502           46 :     char **lbl_ids = NULL, **lbl_names = NULL;
    4503           46 :     int *lbl_seps = NULL, *lbl_hdr = NULL;
    4504           46 :     int lbl_count = build_label_display(&lbl_ids, &lbl_names, &lbl_seps, &lbl_hdr,
    4505              :                                         user_labels, user_count,
    4506              :                                         cat_labels, cat_count);
    4507           46 :     for (int i = 0; i < user_count; i++) free(user_labels[i]);
    4508           46 :     free(user_labels);
    4509           46 :     for (int i = 0; i < cat_count; i++) free(cat_labels[i]);
    4510           46 :     free(cat_labels);
    4511              : 
    4512           46 :     if (lbl_count == 0) {
    4513            0 :         free_label_display(lbl_ids, lbl_names, lbl_seps, lbl_hdr, 0);
    4514              :         /* Show an empty "Labels" screen so the user can press Backspace to return. */
    4515            0 :         RAII_TERM_RAW TermRawState *_raw = terminal_raw_enter();
    4516              :         (void)_raw;
    4517            0 :         int _tc = terminal_cols(), _tr = terminal_rows();
    4518            0 :         if (_tc <= 0) _tc = 80;
    4519            0 :         if (_tr <= 0) _tr = 24;
    4520            0 :         printf("\033[H\033[2J");
    4521              :         {
    4522              :             char _cl[256];
    4523            0 :             snprintf(_cl, sizeof(_cl), "  Labels \u2014 %s  (0)",
    4524            0 :                      cfg->user ? cfg->user : "?");
    4525            0 :             printf("\033[7m%s", _cl);
    4526            0 :             int _used = visible_line_cols(_cl, _cl + strlen(_cl));
    4527            0 :             for (int _p = _used; _p < _tc; _p++) putchar(' ');
    4528            0 :             printf("\033[0m\n\n");
    4529              :         }
    4530            0 :         printf("  No labels synced yet. Run 'email-sync' to populate.\n");
    4531            0 :         fflush(stdout);
    4532            0 :         print_statusbar(_tr, _tc, "  Backspace=back  ESC=quit");
    4533            0 :         for (;;) {
    4534            0 :             TermKey _k = terminal_read_key();
    4535            0 :             if (_k == TERM_KEY_BACK) { if (go_up) *go_up = 1; return NULL; }
    4536            0 :             if (_k == TERM_KEY_QUIT || _k == TERM_KEY_ESC) return NULL;
    4537              :         }
    4538              :     }
    4539              : 
    4540           46 :     int cursor = 0, wstart = 0;
    4541           46 :     char *selected = NULL;
    4542              : 
    4543              :     /* Pre-position cursor on current_label; skip header rows */
    4544           46 :     if (current_label && *current_label) {
    4545          241 :         for (int i = 0; i < lbl_count; i++) {
    4546          240 :             if (!lbl_hdr[i] && strcmp(lbl_ids[i], current_label) == 0) {
    4547           45 :                 cursor = i; break;
    4548              :             }
    4549              :         }
    4550              :     }
    4551              :     /* Ensure initial cursor is not on a header row */
    4552           47 :     while (cursor < lbl_count - 1 && lbl_hdr[cursor]) cursor++;
    4553              : 
    4554           46 :     RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    4555              : 
    4556           30 :     for (;;) {
    4557           76 :         int trows = terminal_rows();
    4558           76 :         int tcols = terminal_cols();
    4559           76 :         if (trows <= 0) trows = 24;
    4560           76 :         if (tcols <= 0) tcols = 80;
    4561           76 :         int wend = 0;  /* computed below, after avail is known */
    4562              :         /* Name column width: "  " + 6(count) + "  " + name_w = name_w + 10 */
    4563           76 :         int name_w = tcols - 12;
    4564           76 :         if (name_w < 20) name_w = 20;
    4565              : 
    4566              :         /* Fixed overhead: 1 title + 1 blank + 1 col-header + 1 separator + 1 statusbar = 5.
    4567              :          * Remaining rows are shared between items and section-separator lines. */
    4568           76 :         int avail = (trows > 6) ? trows - 5 : 5;
    4569              : 
    4570           76 :         if (cursor >= lbl_count) cursor = lbl_count - 1;
    4571           76 :         if (cursor < 0) cursor = 0;
    4572              :         /* Never land on a section header: header rows are drawn without the
    4573              :          * selection highlight, so a cursor parked there would be invisible. */
    4574           76 :         while (cursor < lbl_count - 1 && lbl_hdr[cursor]) cursor++;
    4575           76 :         if (cursor < wstart) wstart = cursor;
    4576              :         /* Advance wstart until cursor is within the visible window, counting
    4577              :          * separator rows so the window never overflows the terminal. */
    4578            0 :         for (;;) {
    4579           76 :             int rows = 0, found = 0;
    4580          445 :             for (int i = wstart; i < lbl_count && rows < avail; i++) {
    4581          445 :                 if (lbl_seps[i] && i > wstart) rows++;   /* separator line */
    4582          445 :                 rows++;                                    /* item line */
    4583          445 :                 if (i == cursor) { found = 1; break; }
    4584              :             }
    4585           76 :             if (found) break;
    4586            0 :             wstart++;
    4587              :         }
    4588              : 
    4589              :         /* Compute actual wend given avail rows */
    4590           76 :         int rows_counted = 0;
    4591           76 :         wend = wstart;
    4592          836 :         for (int i = wstart; i < lbl_count; i++) {
    4593          760 :             int extra = (lbl_seps[i] && i > wstart) ? 1 : 0;
    4594          760 :             if (rows_counted + extra + 1 > avail) break;
    4595          760 :             rows_counted += extra + 1;
    4596          760 :             wend = i + 1;
    4597              :         }
    4598              : 
    4599           76 :         printf("\033[H\033[2J");
    4600              :         {
    4601              :             char cl[512];
    4602           76 :             snprintf(cl, sizeof(cl), "  Labels \u2014 %s  (%d)",
    4603           76 :                      cfg->user ? cfg->user : "?", lbl_count);
    4604              :             /* Explicit row 1: avoids pending-wrap shifting layout when title fills the row */
    4605           76 :             printf("\033[1;1H\033[7m%s", cl);
    4606           76 :             int used = visible_line_cols(cl, cl + strlen(cl));
    4607         6515 :             for (int p = used; p < tcols; p++) putchar(' ');
    4608           76 :             printf("\033[0m");
    4609              :         }
    4610              : 
    4611              :         /* Column header and separator pinned at rows 3 and 4 */
    4612           76 :         printf("\033[3;1H  %6s  %-*s\n", "Count", name_w, "Label");
    4613           76 :         printf("  \u2550\u2550\u2550\u2550\u2550\u2550  ");
    4614           76 :         print_dbar(name_w);
    4615           76 :         printf("\n");
    4616              : 
    4617          836 :         for (int i = wstart; i < wend; i++) {
    4618              :             /* Section header row */
    4619          760 :             if (lbl_hdr[i]) {
    4620          152 :                 printf("  \033[2m\u2500\u2500 %s ", lbl_names[i]);
    4621          152 :                 int used = 6 + (int)strlen(lbl_names[i]) + 1;
    4622        15540 :                 for (int s = used; s < tcols - 2; s++) fputs("\u2500", stdout);
    4623          152 :                 printf("\033[0m\n");
    4624          152 :                 continue;
    4625              :             }
    4626              :             /* Thin separator before certain groups (not before the first visible item) */
    4627          608 :             if (lbl_seps[i] && i > wstart) {
    4628           76 :                 printf("  \033[2m");
    4629         9024 :                 for (int s = 0; s < tcols - 2; s++) fputs("\u2504", stdout);
    4630           76 :                 printf("\033[0m\n");
    4631              :             }
    4632              : 
    4633          608 :             int cnt = label_idx_count(lbl_ids[i]);
    4634              :             char cnt_buf[16];
    4635          608 :             fmt_thou(cnt_buf, sizeof(cnt_buf), cnt);
    4636              : 
    4637          608 :             int sel = (i == cursor);
    4638          608 :             if (sel) printf("\033[7m");
    4639          608 :             printf("  %6s  ", cnt_buf);
    4640          608 :             print_padded_col(lbl_names[i], name_w);
    4641          608 :             if (sel) printf("\033[K\033[0m");
    4642          608 :             printf("\n");
    4643              :         }
    4644           76 :         fflush(stdout);
    4645              : 
    4646              :         {
    4647              :             char sb[256];
    4648           76 :             snprintf(sb, sizeof(sb),
    4649              :                      "  \u2191\u2193=select  Enter=open  c=create  d=delete  Backspace=accounts  ESC=quit"
    4650              :                      "  h=help  [%d/%d]",
    4651              :                      cursor + 1, lbl_count);
    4652           76 :             print_statusbar(trows, tcols, sb);
    4653              :         }
    4654              : 
    4655           76 :         TermKey key = terminal_read_key();
    4656           75 :         fprintf(stderr, "\r\033[K"); fflush(stderr);
    4657              : 
    4658           75 :         switch (key) {
    4659            1 :         case TERM_KEY_BACK:
    4660            1 :             if (go_up) *go_up = 1;
    4661            1 :             goto labels_done;
    4662            0 :         case TERM_KEY_QUIT:
    4663              :         case TERM_KEY_ESC:
    4664            0 :             goto labels_done;
    4665            1 :         case TERM_KEY_HOME:
    4666            1 :             cursor = 0; wstart = 0;
    4667            2 :             while (cursor < lbl_count - 1 && lbl_hdr[cursor]) cursor++;
    4668            1 :             break;
    4669           11 :         case TERM_KEY_END:
    4670           11 :             cursor = lbl_count > 0 ? lbl_count - 1 : 0;
    4671           11 :             while (cursor > 0 && lbl_hdr[cursor]) cursor--;
    4672           11 :             break;
    4673            0 :         case TERM_KEY_NEXT_LINE: {
    4674              :             /* Move to the next selectable row; stay put if there is none. */
    4675            0 :             int p = cursor + 1;
    4676            0 :             while (p < lbl_count && lbl_hdr[p]) p++;
    4677            0 :             if (p < lbl_count) cursor = p;
    4678            0 :             break;
    4679              :         }
    4680           18 :         case TERM_KEY_PREV_LINE: {
    4681              :             /* Move to the previous selectable row; stay put if there is none —
    4682              :              * without this the cursor lands on the leading section header. */
    4683           18 :             int p = cursor - 1;
    4684           26 :             while (p >= 0 && lbl_hdr[p]) p--;
    4685           18 :             if (p >= 0) cursor = p;
    4686           18 :             break;
    4687              :         }
    4688            0 :         case TERM_KEY_NEXT_PAGE:
    4689            0 :             cursor += avail;
    4690            0 :             if (cursor >= lbl_count) cursor = lbl_count - 1;
    4691            0 :             while (cursor > 0 && lbl_hdr[cursor]) cursor--;
    4692            0 :             break;
    4693            0 :         case TERM_KEY_PREV_PAGE:
    4694            0 :             cursor -= avail;
    4695            0 :             if (cursor < 0) cursor = 0;
    4696            0 :             while (cursor < lbl_count - 1 && lbl_hdr[cursor]) cursor++;
    4697            0 :             break;
    4698           44 :         case TERM_KEY_ENTER:
    4699           44 :             if (!lbl_hdr[cursor])
    4700           44 :                 selected = strdup(lbl_ids[cursor]);
    4701           44 :             goto labels_done;
    4702            0 :         default: {
    4703            0 :             int ch = terminal_last_printable();
    4704            0 :             if (ch == '/') {
    4705              :                 /* Cross-folder content search */
    4706              :                 static const char *snames[] = {"Subject","From","To","Body"};
    4707            0 :                 int sscope = 0;
    4708            0 :                 char sbuf[256] = "";
    4709            0 :                 int slen = 0;
    4710            0 :                 int srows = trows;
    4711            0 :                 for (;;) {
    4712            0 :                     printf("\033[%d;1H\033[K  Search [%s]: %s_",
    4713              :                            srows - 1, snames[sscope], sbuf);
    4714            0 :                     fflush(stdout);
    4715            0 :                     TermKey ikey = terminal_read_key();
    4716            0 :                     if (ikey == TERM_KEY_ESC || ikey == TERM_KEY_QUIT) break;
    4717            0 :                     if (ikey == TERM_KEY_ENTER) {
    4718            0 :                         if (sbuf[0]) {
    4719              :                             char sfolder[512];
    4720            0 :                             snprintf(sfolder, sizeof(sfolder),
    4721              :                                      "__search__:%d:%s", sscope, sbuf);
    4722            0 :                             selected = strdup(sfolder);
    4723            0 :                             goto labels_done;
    4724              :                         }
    4725            0 :                         break;
    4726              :                     }
    4727            0 :                     if (ikey == TERM_KEY_TAB) {
    4728            0 :                         sscope = (sscope + 1) % 4;
    4729            0 :                     } else if (ikey == TERM_KEY_BACK) {
    4730            0 :                         while (slen > 0 && (sbuf[slen - 1] & 0xC0) == 0x80) slen--;
    4731            0 :                         if (slen > 0) sbuf[--slen] = '\0';
    4732            0 :                     } else if (terminal_last_utf8()[0]) {
    4733            0 :                         const char *u8 = terminal_last_utf8();
    4734            0 :                         size_t ulen = strlen(u8);
    4735            0 :                         if (slen + (int)ulen < (int)sizeof(sbuf)) {
    4736            0 :                             memcpy(sbuf + slen, u8, ulen + 1);
    4737            0 :                             slen += (int)ulen;
    4738              :                         }
    4739              :                     }
    4740              :                 }
    4741            0 :                 break;
    4742              :             }
    4743            0 :             if (ch == 'h' || ch == '?') {
    4744              :                 static const char *help[][2] = {
    4745              :                     { "\u2191 / \u2193",   "Move cursor up / down"      },
    4746              :                     { "PgUp / PgDn",       "Page up / down"             },
    4747              :                     { "Enter",             "Open selected label"        },
    4748              :                     { "/",                 "Cross-folder content search"},
    4749              :                     { "c",                 "Create new label"           },
    4750              :                     { "d",                 "Delete selected label"      },
    4751              :                     { "Backspace",         "Back to accounts"           },
    4752              :                     { "ESC / q",           "Quit"                       },
    4753              :                     { "h / ?",             "Show this help"             },
    4754              :                 };
    4755            0 :                 show_help_popup("Label browser shortcuts",
    4756              :                                 help, (int)(sizeof(help)/sizeof(help[0])));
    4757              :             }
    4758            0 :             if (ch == 'c') {
    4759              :                 /* Create new label */
    4760            0 :                 char new_name[256] = "";
    4761              :                 InputLine il;
    4762            0 :                 input_line_init(&il, new_name, sizeof(new_name), "");
    4763            0 :                 int confirmed = input_line_run(&il, trows - 2, "New label name: ");
    4764            0 :                 if (confirmed && new_name[0]) {
    4765            0 :                     if (email_service_create_label(cfg, new_name) == 0) {
    4766              :                         /* Reload label list on next iteration */
    4767            0 :                         free_label_display(lbl_ids, lbl_names, lbl_seps, lbl_hdr, lbl_count);
    4768            0 :                         lbl_hdr = NULL;
    4769              :                         /* Rebuild label list */
    4770            0 :                         char **ul2 = NULL, **cl2 = NULL;
    4771            0 :                         int uc2 = 0, cc2 = 0;
    4772              :                         {
    4773            0 :                             char **al2 = NULL;
    4774            0 :                             int ac2 = 0;
    4775            0 :                             label_idx_list(&al2, &ac2);
    4776            0 :                             ul2 = calloc(ac2 > 0 ? (size_t)ac2 : 1, sizeof(char *));
    4777            0 :                             cl2 = calloc(ac2 > 0 ? (size_t)ac2 : 1, sizeof(char *));
    4778            0 :                             for (int i = 0; i < ac2; i++) {
    4779            0 :                                 if (strncmp(al2[i], "CATEGORY_", 9) == 0)
    4780            0 :                                     cl2[cc2++] = strdup(al2[i]);
    4781            0 :                                 else if (!is_system_or_special_label(al2[i]))
    4782            0 :                                     ul2[uc2++] = strdup(al2[i]);
    4783            0 :                                 free(al2[i]);
    4784              :                             }
    4785            0 :                             free(al2);
    4786              :                         }
    4787            0 :                         if (uc2 > 1)
    4788            0 :                             qsort(ul2, (size_t)uc2, sizeof(char *), cmp_str);
    4789            0 :                         lbl_count = build_label_display(&lbl_ids, &lbl_names, &lbl_seps, &lbl_hdr,
    4790              :                                                         ul2, uc2, cl2, cc2);
    4791            0 :                         for (int i = 0; i < uc2; i++) free(ul2[i]);
    4792            0 :                         free(ul2);
    4793            0 :                         for (int i = 0; i < cc2; i++) free(cl2[i]);
    4794            0 :                         free(cl2);
    4795            0 :                         if (lbl_count == 0) {
    4796            0 :                             free_label_display(lbl_ids, lbl_names, lbl_seps, lbl_hdr, 0);
    4797            0 :                             goto labels_done;
    4798              :                         }
    4799              :                     }
    4800              :                 }
    4801              :             }
    4802            0 :             if (ch == 'd' && lbl_count > 0 && !lbl_hdr[cursor]) {
    4803              :                 /* Delete selected label — use the label name as ID (best effort).
    4804              :                  * TODO: use label ID instead of name for Gmail (ID != name for
    4805              :                  *       user-defined labels). For IMAP this is correct (name == ID). */
    4806            0 :                 const char *del_id = lbl_names[cursor];
    4807            0 :                 if (del_id) {
    4808            0 :                     email_service_delete_label(cfg, del_id);
    4809              :                     /* Rebuild display after deletion */
    4810            0 :                     free_label_display(lbl_ids, lbl_names, lbl_seps, lbl_hdr, lbl_count);
    4811            0 :                     lbl_hdr = NULL;
    4812            0 :                     char **ul3 = NULL, **cl3 = NULL;
    4813            0 :                     int uc3 = 0, cc3 = 0;
    4814              :                     {
    4815            0 :                         char **al3 = NULL;
    4816            0 :                         int ac3 = 0;
    4817            0 :                         label_idx_list(&al3, &ac3);
    4818            0 :                         ul3 = calloc(ac3 > 0 ? (size_t)ac3 : 1, sizeof(char *));
    4819            0 :                         cl3 = calloc(ac3 > 0 ? (size_t)ac3 : 1, sizeof(char *));
    4820            0 :                         for (int i = 0; i < ac3; i++) {
    4821            0 :                             if (strncmp(al3[i], "CATEGORY_", 9) == 0)
    4822            0 :                                 cl3[cc3++] = strdup(al3[i]);
    4823            0 :                             else if (!is_system_or_special_label(al3[i]))
    4824            0 :                                 ul3[uc3++] = strdup(al3[i]);
    4825            0 :                             free(al3[i]);
    4826              :                         }
    4827            0 :                         free(al3);
    4828              :                     }
    4829            0 :                     if (uc3 > 1)
    4830            0 :                         qsort(ul3, (size_t)uc3, sizeof(char *), cmp_str);
    4831            0 :                     lbl_count = build_label_display(&lbl_ids, &lbl_names, &lbl_seps, &lbl_hdr,
    4832              :                                                     ul3, uc3, cl3, cc3);
    4833            0 :                     for (int i = 0; i < uc3; i++) free(ul3[i]);
    4834            0 :                     free(ul3);
    4835            0 :                     for (int i = 0; i < cc3; i++) free(cl3[i]);
    4836            0 :                     free(cl3);
    4837            0 :                     if (cursor >= lbl_count) cursor = lbl_count - 1;
    4838            0 :                     if (cursor < 0) cursor = 0;
    4839            0 :                     while (cursor < lbl_count - 1 && lbl_hdr[cursor]) cursor++;
    4840            0 :                     if (lbl_count == 0) {
    4841            0 :                         free_label_display(lbl_ids, lbl_names, lbl_seps, lbl_hdr, 0);
    4842            0 :                         goto labels_done;
    4843              :                     }
    4844              :                 }
    4845              :             }
    4846            0 :             break;
    4847              :         }
    4848              :         }
    4849              :     }
    4850           45 : labels_done:
    4851           45 :     free_label_display(lbl_ids, lbl_names, lbl_seps, lbl_hdr, lbl_count);
    4852           45 :     return selected;
    4853              : }
    4854              : 
    4855              : /**
    4856              :  * Sum unread and flagged counts across all locally-cached folders for one
    4857              :  * account.  Temporarily switches g_account_base via local_store_init; the
    4858              :  * caller must restore the correct account after iterating all accounts.
    4859              :  */
    4860          254 : static void get_account_totals(const Config *cfg, int *unseen_out, int *flagged_out) {
    4861          254 :     *unseen_out = 0; *flagged_out = 0;
    4862          309 :     if (!cfg) return;
    4863          254 :     local_store_init(cfg->host, cfg->user);
    4864          254 :     if (cfg->gmail_mode) {
    4865              :         /* Gmail: count from local label index files */
    4866           46 :         *unseen_out  = label_idx_count("UNREAD");
    4867           46 :         *flagged_out = label_idx_count("STARRED");
    4868           46 :         return;
    4869              :     }
    4870          208 :     if (!cfg->host) return;
    4871          208 :     int fcount = 0;
    4872          208 :     char **flist = local_folder_list_load(&fcount, NULL);
    4873          208 :     if (!flist) return;
    4874         1791 :     for (int i = 0; i < fcount; i++) {
    4875         1592 :         int total = 0, unseen = 0, flagged = 0;
    4876         1592 :         manifest_count_folder(flist[i], &total, &unseen, &flagged);
    4877         1592 :         *unseen_out  += unseen;
    4878         1592 :         *flagged_out += flagged;
    4879         1592 :         free(flist[i]);
    4880              :     }
    4881          199 :     free(flist);
    4882              : }
    4883              : 
    4884              : /** Print one account row; cursor=1 draws the selection arrow. */
    4885              : /**
    4886              :  * Format a URL for display, appending the default port if none is present.
    4887              :  * defport is used when the URL has no ":port" after the host.
    4888              :  */
    4889          302 : static void fmt_url_with_port(const char *url, int defport, char *out, size_t size) {
    4890          302 :     if (!url || !url[0]) { out[0] = '\0'; return; }
    4891          302 :     const char *proto_end = strstr(url, "://");
    4892          302 :     const char *host = proto_end ? proto_end + 3 : url;
    4893          302 :     if (strchr(host, ':')) {
    4894              :         /* Port already present in URL */
    4895          302 :         snprintf(out, size, "%s", url);
    4896              :     } else {
    4897            0 :         snprintf(out, size, "%s:%d", url, defport);
    4898              :     }
    4899              : }
    4900              : 
    4901          254 : static void print_account_row(const Config *cfg, int cursor,
    4902              :                                int unseen, int flagged,
    4903              :                                int imap_w, int smtp_w) {
    4904          254 :     const char *user = cfg->user ? cfg->user : "(unknown)";
    4905          254 :     const char *type = cfg->gmail_mode ? "Gmail" : "IMAP";
    4906              : 
    4907              :     /* Server: Gmail shows "Gmail API", IMAP shows host:port */
    4908              :     char server_buf[256];
    4909          254 :     if (cfg->gmail_mode) {
    4910           46 :         snprintf(server_buf, sizeof(server_buf), "Gmail API");
    4911              :     } else {
    4912          208 :         fmt_url_with_port(cfg->host, 993, server_buf, sizeof(server_buf));
    4913              :     }
    4914              : 
    4915              :     /* Build SMTP display string (no ANSI — safe to truncate with %.*s) */
    4916              :     char smtp_buf[256];
    4917              :     int smtp_configured;
    4918          254 :     if (cfg->gmail_mode) {
    4919           46 :         snprintf(smtp_buf, sizeof(smtp_buf), "Gmail API");
    4920           46 :         smtp_configured = 1;
    4921              :     } else {
    4922          208 :         smtp_configured = cfg->smtp_host && cfg->smtp_host[0];
    4923          208 :         if (smtp_configured) {
    4924          186 :             if (cfg->smtp_port) {
    4925           92 :                 const char *proto_end = strstr(cfg->smtp_host, "://");
    4926           92 :                 const char *smtp_host_part = proto_end ? proto_end + 3 : cfg->smtp_host;
    4927           92 :                 if (strchr(smtp_host_part, ':'))
    4928           92 :                     snprintf(smtp_buf, sizeof(smtp_buf), "%s", cfg->smtp_host);
    4929              :                 else
    4930            0 :                     snprintf(smtp_buf, sizeof(smtp_buf), "%s:%d",
    4931            0 :                              cfg->smtp_host, cfg->smtp_port);
    4932              :             } else {
    4933           94 :                 int defport = (strncmp(cfg->smtp_host, "smtps://", 8) == 0) ? 465 : 587;
    4934           94 :                 fmt_url_with_port(cfg->smtp_host, defport, smtp_buf, sizeof(smtp_buf));
    4935              :             }
    4936              :         } else {
    4937           22 :             snprintf(smtp_buf, sizeof(smtp_buf), "\u2014");
    4938              :         }
    4939              :     }
    4940              : 
    4941              :     char u[16], f[16];
    4942          254 :     fmt_thou(u, sizeof(u), unseen);
    4943          254 :     fmt_thou(f, sizeof(f), flagged);
    4944              : 
    4945          254 :     if (cursor) {
    4946          245 :         printf("  \033[1m\u2192 %6s  %7s  %-32.32s  %-5s  %-*.*s  %.*s\033[0m\n",
    4947              :                u, f, user, type, imap_w, imap_w, server_buf, smtp_w, smtp_buf);
    4948            9 :     } else if (!smtp_configured) {
    4949            4 :         printf("    %6s  %7s  %-32.32s  %-5s  %-*.*s  \033[2m%.*s\033[0m\n",
    4950              :                u, f, user, type, imap_w, imap_w, server_buf, smtp_w, smtp_buf);
    4951              :     } else {
    4952            5 :         printf("    %6s  %7s  %-32.32s  %-5s  %-*.*s  %.*s\n",
    4953              :                u, f, user, type, imap_w, imap_w, server_buf, smtp_w, smtp_buf);
    4954              :     }
    4955          254 : }
    4956              : 
    4957          239 : int email_service_account_interactive(Config **cfg_out, int *cursor_inout,
    4958              :                                       const char *flash_msg) {
    4959          239 :     *cfg_out = NULL;
    4960          239 :     RAII_TERM_RAW TermRawState *tui_raw = terminal_raw_enter();
    4961              :     (void)tui_raw;
    4962              : 
    4963          239 :     int cursor = (cursor_inout && *cursor_inout > 0) ? *cursor_inout : 0;
    4964              : 
    4965            6 :     for (;;) {
    4966              :         /* Reload account list on every iteration (list may change after add/delete) */
    4967          245 :         int count = 0;
    4968          245 :         AccountEntry *accounts = config_list_accounts(&count);
    4969              : 
    4970          245 :         int trows = terminal_rows();
    4971          245 :         int tcols = terminal_cols();
    4972          245 :         if (trows <= 0) trows = 24;
    4973          245 :         if (tcols <= 0) tcols = 80;
    4974          245 :         if (cursor >= count) cursor = count > 0 ? count - 1 : 0;
    4975              : 
    4976              :         /* Compute unread/flagged totals for each account (local manifests only) */
    4977          245 :         int *acc_unseen  = calloc(count > 0 ? (size_t)count : 1, sizeof(int));
    4978          245 :         int *acc_flagged = calloc(count > 0 ? (size_t)count : 1, sizeof(int));
    4979          499 :         for (int i = 0; i < count; i++)
    4980          254 :             get_account_totals(accounts[i].cfg, &acc_unseen[i], &acc_flagged[i]);
    4981              : 
    4982              :         /* Column widths:
    4983              :          * Fixed overhead: 4(indent) + 6(unread) + 2 + 7(flagged) + 2
    4984              :          *                 + 32(account) + 2 + 5(type) + 2 + 2(sep) = 64
    4985              :          * Remaining split evenly between Server and SMTP columns. */
    4986          245 :         int avail = tcols - 64;
    4987          245 :         if (avail < 0) avail = 0;
    4988          245 :         int imap_w = avail / 2;
    4989          245 :         int smtp_w = avail - imap_w;
    4990          245 :         if (imap_w < 10) imap_w = 10;
    4991          245 :         if (smtp_w <  8) smtp_w =  8;
    4992              : 
    4993          245 :         printf("\033[H\033[2J");
    4994              :         {
    4995              :             char cl[128];
    4996          245 :             snprintf(cl, sizeof(cl), "  Email Accounts (%d)", count);
    4997          245 :             printf("\033[7m%s", cl);
    4998          245 :             int used = visible_line_cols(cl, cl + strlen(cl));
    4999        22665 :             for (int p = used; p < tcols; p++) putchar(' ');
    5000          245 :             printf("\033[0m\n\n");
    5001              :         }
    5002              : 
    5003          245 :         if (count == 0) {
    5004            0 :             printf("  No accounts configured.\n");
    5005              :         } else {
    5006          245 :             printf("    %6s  %7s  %-32s  %-5s  %-*s  %s\n",
    5007              :                    "Unread", "Flagged", "Account", "Type", imap_w, "Server", "Send via");
    5008          245 :             printf("    \u2550\u2550\u2550\u2550\u2550\u2550  \u2550\u2550\u2550\u2550\u2550\u2550\u2550  ");
    5009          245 :             print_dbar(32);
    5010          245 :             printf("  \u2550\u2550\u2550\u2550\u2550  ");
    5011          245 :             print_dbar(imap_w);
    5012          245 :             printf("  ");
    5013          245 :             print_dbar(smtp_w);
    5014          245 :             printf("\n");
    5015          499 :             for (int i = 0; i < count; i++)
    5016          254 :                 print_account_row(accounts[i].cfg, i == cursor,
    5017          254 :                                   acc_unseen[i], acc_flagged[i],
    5018              :                                   imap_w, smtp_w);
    5019              :         }
    5020          245 :         fflush(stdout);
    5021              : 
    5022              :         char sb[256];
    5023          245 :         snprintf(sb, sizeof(sb),
    5024              :                  "  \u2191\u2193=select  Enter=open  n=add  d=delete*  i=IMAP  e=SMTP  ESC=quit  (*keeps local data)");
    5025          245 :         print_statusbar(trows, tcols, sb);
    5026          242 :         if (flash_msg) {
    5027            1 :             print_infoline(trows, tcols, flash_msg);
    5028            0 :             flash_msg = NULL;
    5029              :         }
    5030              : 
    5031          241 :         TermKey key = terminal_read_key();
    5032          231 :         fprintf(stderr, "\r\033[K"); fflush(stderr);
    5033              : 
    5034          231 :         int ch = terminal_last_printable();
    5035              : 
    5036              : #define ACC_FREE() do { free(acc_unseen); free(acc_flagged); \
    5037              :                         config_free_account_list(accounts, count); } while(0)
    5038              : 
    5039          231 :         if (key == TERM_KEY_QUIT || key == TERM_KEY_ESC) {
    5040            1 :             if (cursor_inout) *cursor_inout = cursor;
    5041          225 :             ACC_FREE(); return 0;
    5042              :         }
    5043          230 :         if (key == TERM_KEY_BACK) {
    5044              :             /* Backspace has no meaning at the top-level accounts screen; ignore. */
    5045            6 :             ACC_FREE(); continue;
    5046              :         }
    5047          229 :         if (key == TERM_KEY_HOME) {
    5048            2 :             cursor = 0;
    5049            2 :             ACC_FREE(); continue;
    5050              :         }
    5051          227 :         if (key == TERM_KEY_END) {
    5052            0 :             cursor = count > 0 ? count - 1 : 0;
    5053            0 :             ACC_FREE(); continue;
    5054              :         }
    5055          227 :         if (key == TERM_KEY_NEXT_LINE || key == TERM_KEY_NEXT_PAGE) {
    5056            1 :             if (cursor < count - 1) cursor++;
    5057            1 :             ACC_FREE(); continue;
    5058              :         }
    5059          226 :         if (key == TERM_KEY_PREV_LINE || key == TERM_KEY_PREV_PAGE) {
    5060            0 :             if (cursor > 0) cursor--;
    5061            0 :             ACC_FREE(); continue;
    5062              :         }
    5063          226 :         if (key == TERM_KEY_ENTER && count > 0) {
    5064          222 :             if (cursor_inout) *cursor_inout = cursor;
    5065          222 :             *cfg_out = accounts[cursor].cfg;
    5066          222 :             accounts[cursor].cfg = NULL; /* transfer ownership */
    5067          222 :             ACC_FREE(); return 1;
    5068              :         }
    5069              : 
    5070              :         /* Printable keys */
    5071            4 :         if (ch == 'h' || ch == '?') {
    5072              :             static const char *help[][2] = {
    5073              :                 { "\u2191 / \u2193",   "Move cursor up / down"      },
    5074              :                 { "Enter",            "Open selected account"       },
    5075              :                 { "n",               "Add new account"             },
    5076              :                 { "d",               "Delete selected account"     },
    5077              :                 { "i",               "Edit IMAP for account"       },
    5078              :                 { "e",               "Edit SMTP for account"       },
    5079              :                 { "ESC / q",         "Quit"                        },
    5080              :                 { "h / ?",           "Show this help"              },
    5081              :             };
    5082            1 :             show_help_popup("Accounts shortcuts",
    5083              :                             help, (int)(sizeof(help)/sizeof(help[0])));
    5084            1 :             ACC_FREE(); continue;
    5085              :         }
    5086            3 :         if (ch == 'i' && count > 0) {
    5087            1 :             if (cursor_inout) *cursor_inout = cursor;
    5088            1 :             *cfg_out = accounts[cursor].cfg;
    5089            1 :             accounts[cursor].cfg = NULL;
    5090            1 :             ACC_FREE(); return 4;
    5091              :         }
    5092            2 :         if (ch == 'e' && count > 0) {
    5093            0 :             if (cursor_inout) *cursor_inout = cursor;
    5094            0 :             *cfg_out = accounts[cursor].cfg;
    5095            0 :             accounts[cursor].cfg = NULL;
    5096            0 :             ACC_FREE(); return 2;
    5097              :         }
    5098            2 :         if (ch == 'n') {
    5099            1 :             ACC_FREE(); return 3;  /* caller runs setup wizard */
    5100              :         }
    5101            1 :         if (ch == 'd' && count > 0) {
    5102            1 :             const char *name = accounts[cursor].name;
    5103              : 
    5104              :             /* Compute local data directory (NOT deleted) */
    5105            1 :             const char *data_base = platform_data_dir();
    5106            1 :             char data_path[2048] = "";
    5107            1 :             if (data_base && name && name[0])
    5108            1 :                 snprintf(data_path, sizeof(data_path),
    5109              :                          "%s/email-cli/accounts/%s", data_base, name);
    5110              : 
    5111            1 :             config_delete_account(name);
    5112            1 :             ACC_FREE();
    5113            1 :             if (cursor > 0) cursor--;
    5114              : 
    5115              :             /* Show preservation notice */
    5116            1 :             if (data_path[0]) {
    5117            1 :                 int trows2 = terminal_rows();
    5118            1 :                 int tcols2 = terminal_cols();
    5119              :                 char notice[2200];
    5120            1 :                 snprintf(notice, sizeof(notice),
    5121              :                          "Account removed. Local messages preserved: %s", data_path);
    5122            1 :                 print_infoline(trows2, tcols2, notice);
    5123              :             }
    5124            1 :             continue;  /* re-render */
    5125              :         }
    5126              : 
    5127            0 :         ACC_FREE();
    5128              :     }
    5129              : #undef ACC_FREE
    5130              : }
    5131              : 
    5132              : /* Load one message: local store first, server on a miss.
    5133              :  * Returns a heap-allocated RFC 2822 message, or NULL (error already reported). */
    5134              : /* Locate the folder holding UID when the caller named none.
    5135              :  *
    5136              :  * IMAP UIDs are unique per mailbox, not per account, so the same UID routinely
    5137              :  * names a different message in every folder — an unqualified UID is genuinely
    5138              :  * under-specified.  "prefer" (the configured folder) is the user's implicit
    5139              :  * context and wins when it holds a copy, but the other candidates are named on
    5140              :  * stderr so the choice is never silent: picking one quietly is what made a
    5141              :  * search hit in one folder open a different message from another.
    5142              :  *
    5143              :  * Returns a heap-allocated folder name, or NULL.  *fatal is set when the
    5144              :  * caller must stop: the UID is ambiguous and no copy sits in "prefer". */
    5145           30 : static char *resolve_folder_for_uid(const char *uid, const char *prefer,
    5146              :                                     int *fatal) {
    5147           30 :     *fatal = 0;
    5148           30 :     char **folders = NULL;
    5149           30 :     int n = 0;
    5150           30 :     local_msg_find_folders(uid, &folders, &n);
    5151              : 
    5152           30 :     if (n == 1) {
    5153            9 :         char *only = folders[0];
    5154            9 :         folders[0] = NULL;
    5155            9 :         local_folder_list_free(folders, n);
    5156            9 :         return only;
    5157              :     }
    5158           21 :     if (n > 1) {
    5159           10 :         int pick = -1;
    5160           10 :         if (prefer && prefer[0])
    5161           16 :             for (int i = 0; i < n; i++)
    5162           13 :                 if (strcasecmp(folders[i], prefer) == 0) { pick = i; break; }
    5163              : 
    5164           10 :         fprintf(stderr, "%s: UID %s exists in %d folders:",
    5165              :                 pick >= 0 ? "Warning" : "Error", uid, n);
    5166           46 :         for (int i = 0; i < n; i++)
    5167           36 :             fprintf(stderr, "%s %s", i ? "," : "", folders[i]);
    5168           10 :         if (pick >= 0)
    5169            7 :             fprintf(stderr, "\n         Showing the copy in %s; "
    5170              :                             "use --folder <name> to pick another.\n",
    5171            7 :                     folders[pick]);
    5172              :         else
    5173            3 :             fprintf(stderr, "\n       Re-run with --folder <name> "
    5174              :                             "to say which one.\n");
    5175              : 
    5176           10 :         char *chosen = NULL;
    5177           10 :         if (pick >= 0) { chosen = folders[pick]; folders[pick] = NULL; }
    5178            3 :         else           { *fatal = 1; }
    5179           10 :         local_folder_list_free(folders, n);
    5180           10 :         return chosen;
    5181              :     }
    5182           11 :     local_folder_list_free(folders, n);
    5183           11 :     return NULL;
    5184              : }
    5185              : 
    5186              : /* A virtual name is a view, not a mailbox: no message is stored under it. */
    5187            2 : static int is_virtual_folder_name(const char *f) {
    5188            2 :     return f && f[0] == '_' && f[1] == '_';
    5189              : }
    5190              : 
    5191           31 : static char *load_message(const Config *cfg, const char *folder, const char *uid,
    5192              :                           char **used_folder_out) {
    5193           31 :     char *raw = NULL;
    5194           31 :     RAII_STRING char *located = NULL;
    5195              : 
    5196              :     /* No folder given, or a virtual view that stores nothing: resolve it. */
    5197           31 :     if (!folder || !folder[0] || is_virtual_folder_name(folder)) {
    5198           30 :         int fatal = 0;
    5199           30 :         located = resolve_folder_for_uid(uid, cfg ? cfg->folder : NULL, &fatal);
    5200           30 :         if (fatal) return NULL;
    5201           27 :         if (located) folder = located;
    5202           11 :         else         folder = cfg->folder;   /* not cached — try the default */
    5203              :     }
    5204              : 
    5205           28 :     if (local_msg_exists(folder, uid)) {
    5206           10 :         logger_log(LOG_DEBUG, "Cache hit for UID %s in %s", uid, folder);
    5207           10 :         raw = local_msg_load(folder, uid);
    5208           18 :     } else if (cfg->sync_interval > 0) {
    5209              :         /* cron/offline mode: serve only from local cache; do not connect */
    5210            0 :         fprintf(stderr, "Could not load message UID %s in folder '%s'.\n", uid, folder);
    5211            0 :         return NULL;
    5212              :     } else {
    5213           18 :         raw = fetch_uid_content_in(cfg, folder, uid, 0);
    5214           18 :         if (raw) {
    5215           18 :             local_msg_save(folder, uid, raw, strlen(raw));
    5216           18 :             local_index_update(folder, uid, raw);
    5217              :         }
    5218              :     }
    5219              : 
    5220           28 :     if (!raw)
    5221            0 :         fprintf(stderr, "Could not load message UID %s in folder '%s'.\n", uid, folder);
    5222           28 :     else if (used_folder_out)
    5223           28 :         *used_folder_out = strdup(folder);
    5224           28 :     return raw;
    5225              : }
    5226              : 
    5227              : /* Report a charset problem once, on stderr, so the caller sees that the text
    5228              :  * below is not what the sender wrote.  The core layer records the fact; saying
    5229              :  * it out loud belongs here, in the layer that owns the user interaction. */
    5230           20 : static void warn_charset(const MimeTextInfo *info, const char *uid) {
    5231           20 :     if (!info) return;
    5232           20 :     if (info->unknown_charset)
    5233            0 :         fprintf(stderr,
    5234              :                 "Warning: UID %s declares charset '%s', which iconv does not "
    5235              :                 "know — showing undecoded bytes.\n",
    5236            0 :                 uid ? uid : "?", info->declared_charset);
    5237           20 :     else if (info->invalid_sequence)
    5238            0 :         fprintf(stderr,
    5239              :                 "Warning: UID %s is not valid %s — conversion failed, showing "
    5240              :                 "undecoded bytes.\n",
    5241            0 :                 uid ? uid : "?", info->declared_charset);
    5242              : }
    5243              : 
    5244            0 : int email_service_read_raw(const Config *cfg, const char *folder, const char *uid) {
    5245            0 :     char *raw = load_message(cfg, folder, uid, NULL);
    5246            0 :     if (!raw) return -1;
    5247              : 
    5248              :     /* Write the message exactly as stored: headers and body, no MIME parsing,
    5249              :      * no charset conversion, no rendering.  This is the view needed to debug
    5250              :      * encoding problems, where the point is what the sender actually sent. */
    5251            0 :     fwrite(raw, 1, strlen(raw), stdout);
    5252            0 :     if (strlen(raw) > 0 && raw[strlen(raw) - 1] != '\n') putchar('\n');
    5253            0 :     fflush(stdout);
    5254              : 
    5255            0 :     free(raw);
    5256            0 :     return 0;
    5257              : }
    5258              : 
    5259           31 : int email_service_read(const Config *cfg, const char *folder, const char *uid, int pager, int page_size) {
    5260              :     /* load_message resolves the folder when none was given; take its answer so
    5261              :      * that everything below (the File: line, flag updates) refers to the
    5262              :      * mailbox the message actually came from. */
    5263           31 :     RAII_STRING char *used_folder = NULL;
    5264           31 :     char *raw = load_message(cfg, folder, uid, &used_folder);
    5265           31 :     if (!raw) return -1;
    5266           28 :     if (used_folder) folder = used_folder;
    5267              : 
    5268           28 :     char *from_raw = mime_get_header(raw, "From");
    5269           28 :     char *from     = from_raw ? mime_decode_words(from_raw) : NULL;
    5270           28 :     free(from_raw);
    5271           28 :     char *subj_raw = mime_get_header(raw, "Subject");
    5272           28 :     char *subject  = subj_raw ? mime_decode_words(subj_raw) : NULL;
    5273           28 :     free(subj_raw);
    5274           28 :     char *date_raw = mime_get_header(raw, "Date");
    5275           28 :     char *date     = date_raw ? mime_format_date(date_raw) : NULL;
    5276           28 :     free(date_raw);
    5277           28 :     char *ro_labels  = cfg->gmail_mode ? local_hdr_get_labels("", uid) : NULL;
    5278           28 :     char *ro_path    = local_msg_path(folder, uid);
    5279           28 :     char *ro_ar      = mime_get_header(raw, "Authentication-Results");
    5280           28 :     char *ro_dmarc   = mime_describe_dmarc(ro_ar);
    5281           28 :     free(ro_ar);
    5282           28 :     char *ro_to_raw  = mime_get_header(raw, "To");
    5283           28 :     char *ro_to      = ro_to_raw ? mime_decode_words(ro_to_raw) : NULL;
    5284           28 :     free(ro_to_raw);
    5285              :     /* Build attachment summary */
    5286           28 :     int ro_att_count = 0;
    5287           28 :     MimeAttachment *ro_atts = mime_list_attachments(raw, &ro_att_count);
    5288           28 :     char ro_attach_buf[256] = "";
    5289           28 :     if (ro_att_count > 0) {
    5290           15 :         int pos = snprintf(ro_attach_buf, sizeof(ro_attach_buf),
    5291           15 :                            "%d file%s: ", ro_att_count, ro_att_count == 1 ? "" : "s");
    5292           39 :         for (int _i = 0; _i < ro_att_count && pos < (int)sizeof(ro_attach_buf) - 2; _i++) {
    5293           24 :             const char *fn = ro_atts[_i].filename ? ro_atts[_i].filename : "?";
    5294           24 :             if (_i > 0) { ro_attach_buf[pos++] = ','; ro_attach_buf[pos++] = ' '; ro_attach_buf[pos] = '\0'; }
    5295           24 :             if (pos + (int)strlen(fn) > SHOW_WIDTH - 12) {
    5296            0 :                 snprintf(ro_attach_buf + pos, sizeof(ro_attach_buf) - (size_t)pos,
    5297              :                          "(+%d more)", ro_att_count - _i);
    5298            0 :                 break;
    5299              :             }
    5300           24 :             size_t av = sizeof(ro_attach_buf) - (size_t)pos - 1;
    5301           24 :             strncpy(ro_attach_buf + pos, fn, av);
    5302           24 :             ro_attach_buf[sizeof(ro_attach_buf) - 1] = '\0';
    5303           24 :             pos += (int)strlen(fn);
    5304              :         }
    5305              :     }
    5306           28 :     mime_free_attachments(ro_atts, ro_att_count);
    5307              : 
    5308           28 :     print_show_headers(from, ro_to, subject, date, uid, ro_labels, ro_path, ro_dmarc, ro_attach_buf);
    5309              : 
    5310           28 :     int term_cols_show = pager ? terminal_cols() : SHOW_WIDTH;
    5311           28 :     int wrap_cols = term_cols_show > SHOW_WIDTH ? SHOW_WIDTH : term_cols_show;
    5312              : 
    5313           28 :     char *body = NULL;
    5314           28 :     char *html_raw = mime_get_html_part(raw);
    5315           28 :     if (html_raw) {
    5316            9 :         body = html_render(html_raw, wrap_cols, pager ? 1 : 0);
    5317            9 :         free(html_raw);
    5318              :     }
    5319           28 :     if (!body) {
    5320              :         MimeTextInfo tinfo;
    5321           19 :         char *plain = mime_get_text_body_ex(raw, &tinfo);
    5322           19 :         if (plain) {
    5323           19 :             warn_charset(&tinfo, uid);
    5324           19 :             body = word_wrap(plain, wrap_cols);
    5325           19 :             if (!body) body = plain;
    5326           19 :             else free(plain);
    5327              :         }
    5328              :     }
    5329           28 :     const char *body_text = body ? body : "(no readable text body)";
    5330              : 
    5331              : #define SHOW_HDR_LINES 8
    5332           28 :     if (!pager || page_size <= SHOW_HDR_LINES) {
    5333           28 :         printf("%s\n", body_text);
    5334              :     } else {
    5335            0 :         int body_vrows  = count_visual_rows(body_text, term_cols_show);
    5336            0 :         int rows_avail  = page_size - SHOW_HDR_LINES;
    5337            0 :         int total_pages = (body_vrows + rows_avail - 1) / rows_avail;
    5338            0 :         if (total_pages < 1) total_pages = 1;
    5339              : 
    5340              :         /* Enter raw mode for the pager loop; pager_prompt calls terminal_read_key
    5341              :          * which requires raw mode to be already active. */
    5342            0 :         RAII_TERM_RAW TermRawState *show_raw = terminal_raw_enter();
    5343              : 
    5344            0 :         for (int cur_line = 0, show_displayed = 0; ; ) {
    5345            0 :             if (show_displayed) {
    5346            0 :                 printf("\033[0m\033[H\033[2J");   /* reset attrs + clear screen */
    5347            0 :                 print_show_headers(from, ro_to, subject, date, uid, ro_labels, ro_path, ro_dmarc, ro_attach_buf);
    5348              :             }
    5349            0 :             show_displayed = 1;
    5350            0 :             print_body_page(body_text, cur_line, rows_avail, term_cols_show);
    5351            0 :             printf("\033[0m");                     /* close any open ANSI from body */
    5352            0 :             fflush(stdout);
    5353              : 
    5354            0 :             if (cur_line == 0 && cur_line + rows_avail >= body_vrows) break;
    5355              : 
    5356            0 :             int cur_page = cur_line / rows_avail + 1;
    5357            0 :             int delta = pager_prompt(cur_page, total_pages, rows_avail, page_size, wrap_cols);
    5358            0 :             if (delta == 0) break;
    5359            0 :             cur_line += delta;
    5360            0 :             if (cur_line < 0) cur_line = 0;
    5361            0 :             if (cur_line >= body_vrows) break;
    5362              :         }
    5363              :         (void)show_raw; /* cleaned up automatically via RAII_TERM_RAW */
    5364              :     }
    5365              : #undef SHOW_HDR_LINES
    5366              : 
    5367           28 :     free(ro_path); free(ro_dmarc); free(ro_to);
    5368           28 :     free(body); free(from); free(subject); free(date); free(ro_labels); free(raw);
    5369           28 :     return 0;
    5370              : }
    5371              : 
    5372              : /* ── Sync progress callback ──────────────────────────────────────────────── */
    5373              : 
    5374              : typedef struct {
    5375              :     int    loop_i;     /* 1-based index of current UID in the loop */
    5376              :     int    loop_total; /* total UIDs in this folder */
    5377              :     char   uid[17];
    5378              : } SyncProgressCtx;
    5379              : 
    5380            0 : static void fmt_size(char *buf, size_t bufsz, size_t bytes) {
    5381            0 :     if (bytes >= 1024 * 1024)
    5382            0 :         snprintf(buf, bufsz, "%.1f MB", (double)bytes / (1024.0 * 1024.0));
    5383              :     else
    5384            0 :         snprintf(buf, bufsz, "%zu KB", bytes / 1024);
    5385            0 : }
    5386              : 
    5387            0 : static void sync_progress_cb(size_t received, size_t total, void *ctx) {
    5388            0 :     SyncProgressCtx *p = ctx;
    5389              :     char recv_s[32], total_s[32];
    5390            0 :     fmt_size(recv_s,  sizeof(recv_s),  received);
    5391            0 :     fmt_size(total_s, sizeof(total_s), total);
    5392            0 :     printf("  [%d/%d] UID %s  %s / %s ...\r",
    5393            0 :            p->loop_i, p->loop_total, p->uid, recv_s, total_s);
    5394            0 :     fflush(stdout);
    5395            0 : }
    5396              : 
    5397              : /** Convert bare LF to CRLF throughout msg, and ensure a trailing CRLF.
    5398              :  *  RFC 3501 §4.3 requires message literals to use CRLF line endings.
    5399              :  *  Returns a heap-allocated NUL-terminated string; sets *len_out.
    5400              :  *  Returns NULL on allocation failure. */
    5401            1 : static char *msg_to_crlf(const char *msg, size_t *len_out) {
    5402            1 :     size_t in_len = strlen(msg);
    5403            1 :     size_t bare_lf = 0;
    5404          289 :     for (size_t i = 0; i < in_len; i++)
    5405          288 :         if (msg[i] == '\n' && (i == 0 || msg[i-1] != '\r'))
    5406            0 :             bare_lf++;
    5407              :     /* need_trail: does the output need a final CRLF appended?
    5408              :      * If the input ends with \n (bare or as part of \r\n), the CRLF loop
    5409              :      * already produces a trailing \r\n in the output — no extra needed.
    5410              :      * Only add \r\n when the input has no terminal newline at all. */
    5411            1 :     int need_trail = (in_len == 0 || msg[in_len - 1] != '\n');
    5412            1 :     size_t out_len = in_len + bare_lf + (need_trail ? 2 : 0);
    5413            1 :     char *out = malloc(out_len + 1);
    5414            1 :     if (!out) return NULL;
    5415            1 :     size_t j = 0;
    5416          289 :     for (size_t i = 0; i < in_len; i++) {
    5417          288 :         if (msg[i] == '\n' && (i == 0 || msg[i-1] != '\r'))
    5418            0 :             out[j++] = '\r';
    5419          288 :         out[j++] = msg[i];
    5420              :     }
    5421            1 :     if (need_trail) { out[j++] = '\r'; out[j++] = '\n'; }
    5422            1 :     out[j] = '\0';
    5423            1 :     *len_out = j;
    5424            1 :     return out;
    5425              : }
    5426              : 
    5427           95 : int email_service_sync(const Config *cfg, int force_reconcile) {
    5428              :     /* ── PID-file lock: exit immediately if another sync is running ──────── */
    5429           95 :     char pid_path[2048] = {0};
    5430           95 :     const char *cache_base = platform_cache_dir();
    5431           95 :     if (cache_base)
    5432           95 :         snprintf(pid_path, sizeof(pid_path),
    5433              :                  "%s/email-cli/sync.pid", cache_base);
    5434              : 
    5435           95 :     if (pid_path[0]) {
    5436              :         {
    5437          190 :             RAII_FILE FILE *pf = fopen(pid_path, "r");
    5438           95 :             if (pf) {
    5439            0 :                 int other = 0;
    5440            0 :                 if (fscanf(pf, "%d", &other) != 1) other = 0;
    5441            0 :                 if (other > 0 && (pid_t)other != platform_getpid() &&
    5442            0 :                     platform_pid_is_program((pid_t)other, "email-cli")) {
    5443            0 :                     fprintf(stderr,
    5444              :                             "email-cli sync is already running (PID %d). Skipping.\n",
    5445              :                             other);
    5446            0 :                     return 0;
    5447              :                 }
    5448              :             }
    5449              :         }
    5450              :         /* Write our own PID */
    5451          190 :         RAII_FILE FILE *pf = fopen(pid_path, "w");
    5452           95 :         if (pf) fprintf(pf, "%d\n", (int)platform_getpid());
    5453              :     }
    5454              : 
    5455              :     /* ── Gmail: delegate to gmail_sync (flat store + label indexes) ────── */
    5456           95 :     if (cfg->gmail_mode) {
    5457           69 :         GmailClient *gc = gmail_connect((Config *)cfg);
    5458           69 :         if (!gc) {
    5459            0 :             fprintf(stderr, "sync: could not connect to Gmail API.\n");
    5460            0 :             if (pid_path[0]) unlink(pid_path);
    5461            0 :             return -1;
    5462              :         }
    5463           69 :         int rc = force_reconcile ? gmail_sync_full(gc) : gmail_sync(gc);
    5464           69 :         gmail_disconnect(gc);
    5465           69 :         if (pid_path[0]) unlink(pid_path);
    5466           69 :         return rc;
    5467              :     }
    5468              : 
    5469              :     /* ── IMAP: sync all folders individually ─────────────────────────── */
    5470           26 :     int folder_count = 0;
    5471           26 :     char sep = '.';
    5472              :     /* Always fetch from server during sync to get the latest folder list */
    5473           26 :     char **folders = fetch_folder_list_from_server(cfg, &folder_count, &sep);
    5474           26 :     if (!folders || folder_count == 0) {
    5475            0 :         fprintf(stderr, "sync: could not retrieve folder list.\n");
    5476            0 :         if (folders) free(folders);
    5477            0 :         if (pid_path[0]) unlink(pid_path);
    5478            0 :         return -1;
    5479              :     }
    5480           26 :     qsort(folders, (size_t)folder_count, sizeof(char *), cmp_str);
    5481              : 
    5482              :     /* Persist folder list so the next 'folders' command is instant */
    5483           26 :     local_folder_list_save((const char **)folders, folder_count, sep);
    5484              : 
    5485           26 :     int total_fetched = 0, total_skipped = 0, errors = 0;
    5486              : 
    5487              :     /* Upload locally-queued outgoing messages (sent/draft) to the server.
    5488              :      * A dedicated connection is used so that a slow or failed APPEND never
    5489              :      * corrupts the main sync connection used for folder operations. */
    5490              :     {
    5491           26 :         int pac = 0;
    5492           26 :         PendingAppend *pa = local_pending_append_load(&pac);
    5493           26 :         if (pa && pac > 0) {
    5494            1 :             printf("Uploading %d pending message(s)...\n", pac);
    5495            1 :             fflush(stdout);
    5496            2 :             RAII_MAIL MailClient *append_mc = make_mail(cfg);
    5497            1 :             if (!append_mc) {
    5498            0 :                 printf("  (Upload skipped: cannot connect; will retry on next sync.)\n");
    5499              :             } else {
    5500            2 :                 for (int i = 0; i < pac; i++) {
    5501            1 :                     char *raw = local_msg_load(pa[i].folder, pa[i].uid);
    5502            1 :                     if (!raw) {
    5503            0 :                         local_pending_append_remove(pa[i].folder, pa[i].uid);
    5504            0 :                         continue;
    5505              :                     }
    5506              :                     /* RFC 3501 requires CRLF line endings throughout the message
    5507              :                      * body.  Normalise bare LF so strict servers accept the literal. */
    5508            1 :                     size_t append_len = 0;
    5509            1 :                     char *append_msg = msg_to_crlf(raw, &append_len);
    5510            1 :                     if (!append_msg) { append_msg = raw; append_len = strlen(raw); }
    5511            1 :                     printf("  → %s ...", pa[i].folder); fflush(stdout);
    5512            1 :                     if (mail_client_append(append_mc, pa[i].folder, append_msg, append_len) == 0) {
    5513            1 :                         local_msg_delete(pa[i].folder, pa[i].uid);
    5514            1 :                         Manifest *mf = manifest_load(pa[i].folder);
    5515            1 :                         if (mf) {
    5516            1 :                             manifest_remove(mf, pa[i].uid);
    5517            1 :                             manifest_save(pa[i].folder, mf);
    5518            1 :                             manifest_free(mf);
    5519              :                         }
    5520            1 :                         local_pending_append_remove(pa[i].folder, pa[i].uid);
    5521            1 :                         printf(" uploaded.\n");
    5522              :                     } else {
    5523            0 :                         printf(" failed (retry on next sync).\n");
    5524              :                     }
    5525            1 :                     if (append_msg != raw) free(append_msg);
    5526            1 :                     free(raw);
    5527              :                 }
    5528              :             }
    5529              :         }
    5530           26 :         free(pa);
    5531              :     }
    5532              : 
    5533              :     /* One shared mail client connection for all folder operations */
    5534           52 :     RAII_MAIL MailClient *sync_mc = make_mail(cfg);
    5535           26 :     if (!sync_mc) {
    5536            0 :         fprintf(stderr, "sync: could not connect to mail server.\n");
    5537            0 :         for (int i = 0; i < folder_count; i++) free(folders[i]);
    5538            0 :         free(folders);
    5539            0 :         if (pid_path[0]) unlink(pid_path);
    5540            0 :         return -1;
    5541              :     }
    5542              : 
    5543           26 :     MailRules *imap_rules = mail_rules_load(local_store_account_name());
    5544              : 
    5545          234 :     for (int fi = 0; fi < folder_count; fi++) {
    5546          208 :         const char *folder = folders[fi];
    5547          208 :         printf("Syncing %s ...\n", folder);
    5548          208 :         fflush(stdout);
    5549              : 
    5550              :         /* ── Load saved CONDSTORE sync state ──────────────────────────── */
    5551          208 :         FolderSyncState saved_state = {0, 0};
    5552          416 :         int have_saved = (!force_reconcile &&
    5553          208 :                           local_sync_state_load(folder, &saved_state) == 0);
    5554              : 
    5555          208 :         ImapSelectResult sel = {0};
    5556          208 :         if (mail_client_select_ext(sync_mc, folder,
    5557              :                                    have_saved ? saved_state.uidvalidity : 0,
    5558              :                                    have_saved ? saved_state.highestmodseq : 0,
    5559              :                                    &sel) != 0) {
    5560            0 :             fprintf(stderr, "  WARN: SELECT failed for %s\n", folder);
    5561            0 :             errors++;
    5562           47 :             continue;
    5563              :         }
    5564              : 
    5565              :         /* ── Fast path: no changes since last sync ─────────────────────── */
    5566          208 :         if (have_saved && sel.highestmodseq != 0 &&
    5567           40 :             sel.highestmodseq == saved_state.highestmodseq &&
    5568           24 :             sel.uidvalidity   == saved_state.uidvalidity) {
    5569           24 :             printf("  (up to date, modseq=%llu)\n",
    5570           24 :                    (unsigned long long)sel.highestmodseq);
    5571           24 :             free(sel.vanished_uids);
    5572           24 :             continue;
    5573              :         }
    5574              : 
    5575              :         /* ── UIDVALIDITY changed: clear saved state, force full resync ── */
    5576          184 :         if (have_saved && sel.uidvalidity != 0 &&
    5577           16 :             sel.uidvalidity != saved_state.uidvalidity) {
    5578            8 :             fprintf(stderr,
    5579              :                     "  WARN: UIDVALIDITY changed for %s (%u→%u) — full resync\n",
    5580              :                     folder, saved_state.uidvalidity, sel.uidvalidity);
    5581            8 :             local_sync_state_clear(folder);
    5582            8 :             have_saved = 0;
    5583            8 :             saved_state.uidvalidity   = 0;
    5584            8 :             saved_state.highestmodseq = 0;
    5585              :         }
    5586              : 
    5587              :         /* incremental=1 when we have a valid saved modseq to use */
    5588          192 :         int incremental = (have_saved && saved_state.highestmodseq != 0 &&
    5589            8 :                            sel.highestmodseq != 0);
    5590              : 
    5591              :         /* ── SEARCH ALL: current UID set (needed in all paths) ─────────── */
    5592          184 :         char (*uids)[17] = NULL;
    5593          184 :         int  uid_count = 0;
    5594          184 :         if (mail_client_search(sync_mc, MAIL_SEARCH_ALL, &uids, &uid_count) != 0) {
    5595            0 :             fprintf(stderr, "  WARN: SEARCH ALL failed for %s\n", folder);
    5596            0 :             free(sel.vanished_uids);
    5597            0 :             errors++;
    5598            0 :             continue;
    5599              :         }
    5600          184 :         if (uid_count == 0) {
    5601           23 :             printf("  (empty)\n");
    5602           23 :             free(uids);
    5603           23 :             free(sel.vanished_uids);
    5604              :             /* Persist sync state even for empty folders */
    5605           23 :             if (sel.uidvalidity && sel.highestmodseq) {
    5606            5 :                 FolderSyncState ns = { sel.uidvalidity, sel.highestmodseq };
    5607            5 :                 local_sync_state_save(folder, &ns);
    5608              :             }
    5609           23 :             continue;
    5610              :         }
    5611              : 
    5612              :         /* Load or create manifest */
    5613          161 :         Manifest *manifest = manifest_load(folder);
    5614          161 :         if (!manifest) {
    5615          117 :             manifest = calloc(1, sizeof(Manifest));
    5616          117 :             if (!manifest) {
    5617            0 :                 fprintf(stderr, "  WARN: out of memory for manifest %s\n", folder);
    5618            0 :                 free(uids);
    5619            0 :                 free(sel.vanished_uids);
    5620            0 :                 errors++;
    5621            0 :                 continue;
    5622              :             }
    5623              :         }
    5624              : 
    5625              :         /* Flush pending folder moves before reading server state */
    5626              :         {
    5627          161 :             int mcount = 0;
    5628          161 :             PendingMove *moves = local_pending_move_load(folder, &mcount);
    5629          161 :             if (moves && mcount > 0) {
    5630            0 :                 for (int mi = 0; mi < mcount; mi++)
    5631            0 :                     mail_client_move_to_folder(sync_mc,
    5632            0 :                                                moves[mi].uid,
    5633            0 :                                                moves[mi].target_folder);
    5634            0 :                 local_pending_move_clear(folder);
    5635              :             }
    5636          161 :             free(moves);
    5637              :         }
    5638              : 
    5639              :         /* Flush pending local flag changes before reading server state */
    5640              :         {
    5641          161 :             int pcount = 0;
    5642          161 :             PendingFlag *pending = local_pending_flag_load(folder, &pcount);
    5643          161 :             if (pending && pcount > 0) {
    5644            7 :                 for (int pi = 0; pi < pcount; pi++)
    5645            5 :                     mail_client_set_flag(sync_mc, pending[pi].uid,
    5646            5 :                                         pending[pi].flag_name, pending[pi].add);
    5647            2 :                 local_pending_flag_clear(folder);
    5648              :             }
    5649          161 :             free(pending);
    5650              :         }
    5651              : 
    5652              :         /* Evict deleted messages from manifest */
    5653          161 :         manifest_retain(manifest, (const char (*)[17])uids, uid_count);
    5654              : 
    5655              :         /* ── Flag acquisition ─────────────────────────────────────────── */
    5656          161 :         ImapFlagUpdate *change_updates = NULL;
    5657          161 :         int             change_count   = 0;
    5658              : 
    5659          161 :         char (*unseen_uids)[17]  = NULL; int unseen_count  = 0;
    5660          161 :         char (*flagged_uids)[17] = NULL; int flagged_count = 0;
    5661          161 :         char (*done_uids)[17]    = NULL; int done_count    = 0;
    5662              : 
    5663          161 :         if (incremental) {
    5664              :             /* CONDSTORE path: CHANGEDSINCE replaces three SEARCH commands */
    5665            7 :             mail_client_fetch_flags_changedsince(sync_mc,
    5666              :                                                  saved_state.highestmodseq,
    5667              :                                                  &change_updates, &change_count);
    5668              :             /* Apply flag updates to existing manifest entries now */
    5669           14 :             for (int ui = 0; ui < change_count; ui++) {
    5670            7 :                 ManifestEntry *me = manifest_find(manifest, change_updates[ui].uid);
    5671            7 :                 if (me) me->flags = change_updates[ui].flags;
    5672              :             }
    5673              :         } else {
    5674              :             /* Full path: three SEARCH commands */
    5675          154 :             if (mail_client_search(sync_mc, MAIL_SEARCH_UNREAD,
    5676              :                                    &unseen_uids, &unseen_count) != 0)
    5677            0 :                 unseen_count = 0;
    5678          154 :             mail_client_search(sync_mc, MAIL_SEARCH_FLAGGED,
    5679              :                                &flagged_uids, &flagged_count);
    5680          154 :             mail_client_search(sync_mc, MAIL_SEARCH_DONE,
    5681              :                                &done_uids, &done_count);
    5682              :         }
    5683              : 
    5684          161 :         int fetched = 0, skipped = 0;
    5685          399 :         for (int i = 0; i < uid_count; i++) {
    5686          238 :             const char *uid = uids[i];
    5687          238 :             int uid_flags = 0;
    5688              : 
    5689          238 :             if (incremental) {
    5690            7 :                 ManifestEntry *me = manifest_find(manifest, uid);
    5691            7 :                 if (me) {
    5692              :                     /* Existing entry: flags already updated from CHANGEDSINCE above */
    5693            7 :                     skipped++;
    5694            7 :                     printf("  [%d/%d] UID %s\r", i + 1, uid_count, uid);
    5695            7 :                     fflush(stdout);
    5696            7 :                     continue;
    5697              :                 }
    5698              :                 /* New message: look up flags in change_updates */
    5699            0 :                 for (int ui = 0; ui < change_count; ui++) {
    5700            0 :                     if (strcmp(change_updates[ui].uid, uid) == 0) {
    5701            0 :                         uid_flags = change_updates[ui].flags;
    5702            0 :                         break;
    5703              :                     }
    5704              :                 }
    5705              :                 /* Default: new message is unseen */
    5706            0 :                 if (uid_flags == 0) uid_flags = MSG_FLAG_UNSEEN;
    5707              :             } else {
    5708          497 :                 for (int j = 0; j < unseen_count;  j++)
    5709          497 :                     if (strcmp(unseen_uids[j],  uid) == 0) { uid_flags |= MSG_FLAG_UNSEEN;  break; }
    5710          231 :                 for (int j = 0; j < flagged_count; j++)
    5711           14 :                     if (strcmp(flagged_uids[j], uid) == 0) { uid_flags |= MSG_FLAG_FLAGGED; break; }
    5712          231 :                 for (int j = 0; j < done_count;    j++)
    5713            0 :                     if (strcmp(done_uids[j],    uid) == 0) { uid_flags |= MSG_FLAG_DONE;    break; }
    5714              :             }
    5715              : 
    5716              :             /* Show progress BEFORE the potentially slow network fetch */
    5717          231 :             printf("  [%d/%d] UID %s...\r", i + 1, uid_count, uid);
    5718          231 :             fflush(stdout);
    5719              : 
    5720              :             /* Fetch full body if not cached */
    5721          231 :             if (!local_msg_exists(folder, uid)) {
    5722          195 :                 SyncProgressCtx pctx = { i + 1, uid_count, {0} };
    5723          195 :                 memcpy(pctx.uid, uid, 17);
    5724          195 :                 mail_client_set_progress(sync_mc, sync_progress_cb, &pctx);
    5725          195 :                 char *raw = mail_client_fetch_body(sync_mc, uid);
    5726          195 :                 mail_client_set_progress(sync_mc, NULL, NULL);
    5727          195 :                 if (raw) {
    5728              :                     /* Cache the header section extracted from the full body so
    5729              :                      * the subsequent manifest update needs no extra IMAP round-trip. */
    5730          195 :                     if (!local_hdr_exists(folder, uid)) {
    5731          194 :                         const char *sep4 = strstr(raw, "\r\n\r\n");
    5732          194 :                         size_t hlen = sep4 ? (size_t)(sep4 - raw + 4) : strlen(raw);
    5733          194 :                         local_hdr_save(folder, uid, raw, hlen);
    5734              :                     }
    5735          195 :                     local_msg_save(folder, uid, raw, strlen(raw));
    5736          195 :                     local_index_update(folder, uid, raw);
    5737              :                     /* Update contact suggestions from newly downloaded message */
    5738              :                     {
    5739          195 :                         char *from_h = mime_get_header(raw, "From");
    5740          195 :                         char *to_h   = mime_get_header(raw, "To");
    5741          195 :                         char *cc_h   = mime_get_header(raw, "Cc");
    5742          195 :                         local_contacts_update(from_h, to_h, cc_h);
    5743          195 :                         free(from_h); free(to_h); free(cc_h);
    5744              :                     }
    5745              :                     /* Apply sorting rules to new message */
    5746          195 :                     if (imap_rules && imap_rules->count > 0) {
    5747           70 :                         char *from_r = mime_get_header(raw, "From");
    5748           70 :                         char *subj_r = mime_get_header(raw, "Subject");
    5749           70 :                         char *to_r   = mime_get_header(raw, "To");
    5750           70 :                         char *fr_dec = from_r ? mime_decode_words(from_r) : NULL;
    5751           70 :                         char *su_dec = subj_r ? mime_decode_words(subj_r) : NULL;
    5752           70 :                         char **add_labels = NULL; int add_count = 0;
    5753           70 :                         char **rm_labels  = NULL; int rm_count  = 0;
    5754           70 :                         int fired_count = mail_rules_apply(imap_rules,
    5755              :                                              fr_dec ? fr_dec : "",
    5756              :                                              su_dec ? su_dec : "",
    5757              :                                              to_r   ? to_r   : "",
    5758              :                                              NULL,  /* no label-based rules during sync */
    5759              :                                              NULL, (time_t)0, /* body/date unavailable */
    5760              :                                              &add_labels, &add_count,
    5761              :                                              &rm_labels,  &rm_count);
    5762           70 :                         if (fired_count > 0) {
    5763           49 :                             if (g_verbose) {
    5764           14 :                                 for (int ri = 0; ri < imap_rules->count; ri++) {
    5765            7 :                                     const MailRule *mr = &imap_rules->rules[ri];
    5766            7 :                                     if (mail_rule_matches(mr,
    5767              :                                                           fr_dec ? fr_dec : "",
    5768              :                                                           su_dec ? su_dec : "",
    5769              :                                                           to_r   ? to_r   : "",
    5770              :                                                           NULL, NULL, (time_t)0)) {
    5771            7 :                                         printf("  [rule] \"%s\" \xe2\x86\x92 uid:%s",
    5772            7 :                                                mr->name ? mr->name : "?", uid);
    5773           14 :                                         for (int j = 0; j < mr->then_add_count; j++)
    5774            7 :                                             printf("  +%s", mr->then_add_label[j]);
    5775            7 :                                         for (int j = 0; j < mr->then_rm_count; j++)
    5776            0 :                                             printf("  -%s", mr->then_rm_label[j]);
    5777            7 :                                         if (mr->then_move_folder)
    5778            0 :                                             printf("  \xe2\x86\x92%s", mr->then_move_folder);
    5779            7 :                                         printf("\n");
    5780              :                                     }
    5781              :                                 }
    5782              :                             }
    5783              :                             /* Map label names to IMAP flags for local storage */
    5784              :                             static const struct { const char *label; int flag; } lmap[] = {
    5785              :                                 { "_junk",      MSG_FLAG_JUNK      },
    5786              :                                 { "_spam",      MSG_FLAG_JUNK      },
    5787              :                                 { "_phishing",  MSG_FLAG_PHISHING  },
    5788              :                                 { "_done",      MSG_FLAG_DONE      },
    5789              :                                 { "_flagged",   MSG_FLAG_FLAGGED   },
    5790              :                             };
    5791          105 :                             for (int ai = 0; ai < add_count; ai++) {
    5792          336 :                                 for (int li = 0; li < (int)(sizeof(lmap)/sizeof(lmap[0])); li++) {
    5793          280 :                                     if (strcasecmp(add_labels[ai], lmap[li].label) == 0)
    5794           21 :                                         uid_flags |= lmap[li].flag;
    5795              :                                 }
    5796           56 :                                 free(add_labels[ai]);
    5797              :                             }
    5798           49 :                             for (int ri = 0; ri < rm_count; ri++) free(rm_labels[ri]);
    5799           49 :                             free(add_labels); free(rm_labels);
    5800              :                         }
    5801           70 :                         free(from_r); free(subj_r); free(to_r);
    5802           70 :                         free(fr_dec); free(su_dec);
    5803              :                     }
    5804          195 :                     free(raw);
    5805          195 :                     fetched++;
    5806              :                 } else {
    5807            0 :                     fprintf(stderr, "  WARN: failed to fetch UID %s in %s\n", uid, folder);
    5808            0 :                     errors++;
    5809            0 :                     continue;
    5810              :                 }
    5811              :             } else {
    5812           36 :                 skipped++;
    5813              :             }
    5814              : 
    5815              :             /* Update manifest entry (headers from local cache — now always warm) */
    5816          231 :             ManifestEntry *me = manifest_find(manifest, uid);
    5817          231 :             if (!me) {
    5818          194 :                 char *hdrs   = fetch_uid_headers_via(sync_mc, folder, uid);
    5819          194 :                 char *fr_raw = hdrs ? mime_get_header(hdrs, "From")    : NULL;
    5820          194 :                 char *fr     = fr_raw ? mime_decode_words(fr_raw)      : strdup("");
    5821          194 :                 free(fr_raw);
    5822          194 :                 char *su_raw = hdrs ? mime_get_header(hdrs, "Subject") : NULL;
    5823          194 :                 char *su     = su_raw ? mime_decode_words(su_raw)      : strdup("");
    5824          194 :                 free(su_raw);
    5825          194 :                 char *dt_raw = hdrs ? mime_get_header(hdrs, "Date")    : NULL;
    5826          194 :                 char *dt     = dt_raw ? mime_format_date(dt_raw)       : strdup("");
    5827          194 :                 free(dt_raw);
    5828          194 :                 free(hdrs);
    5829          194 :                 manifest_upsert(manifest, uid, fr, su, dt, uid_flags);
    5830              :             } else {
    5831              :                 /* update flags on existing entry */
    5832           37 :                 me->flags = uid_flags;
    5833              :             }
    5834              : 
    5835          231 :             printf("  [%d/%d] UID %s   \r", i + 1, uid_count, uid);
    5836          231 :             fflush(stdout);
    5837              :         }
    5838          161 :         free(change_updates);
    5839          161 :         free(unseen_uids);
    5840          161 :         free(flagged_uids);
    5841          161 :         free(done_uids);
    5842          161 :         free(sel.vanished_uids);
    5843          161 :         manifest_save(folder, manifest);
    5844          161 :         manifest_free(manifest);
    5845          161 :         free(uids);
    5846              : 
    5847              :         /* Persist sync state for incremental sync on next run */
    5848          161 :         if (sel.uidvalidity && sel.highestmodseq) {
    5849           35 :             FolderSyncState new_state = { sel.uidvalidity, sel.highestmodseq };
    5850           35 :             local_sync_state_save(folder, &new_state);
    5851              :         }
    5852              : 
    5853          161 :         printf("\r\033[K  %d fetched, %d already stored%s\n",
    5854              :                fetched, skipped, errors ? " (some errors)" : "");
    5855          161 :         total_fetched += fetched;
    5856          161 :         total_skipped += skipped;
    5857              :     }
    5858              : 
    5859          234 :     for (int i = 0; i < folder_count; i++) free(folders[i]);
    5860           26 :     free(folders);
    5861              : 
    5862           26 :     printf("\nSync complete: %d fetched, %d already stored", total_fetched, total_skipped);
    5863           26 :     if (errors) printf(", %d errors", errors);
    5864           26 :     printf("\n");
    5865              : 
    5866           26 :     mail_rules_free(imap_rules);
    5867           26 :     imap_rules = NULL;
    5868              : 
    5869              :     /* Release PID lock */
    5870           26 :     if (pid_path[0]) unlink(pid_path);
    5871              : 
    5872           26 :     return errors ? -1 : 0;
    5873              : }
    5874              : 
    5875           95 : int email_service_sync_all(const char *only_account, int force_reconcile) {
    5876           95 :     int count = 0;
    5877           95 :     AccountEntry *accounts = config_list_accounts(&count);
    5878           95 :     if (!accounts || count == 0) {
    5879            0 :         fprintf(stderr, "No accounts configured.\n");
    5880            0 :         config_free_account_list(accounts, count);
    5881            0 :         return -1;
    5882              :     }
    5883              : 
    5884           95 :     int errors = 0;
    5885           95 :     int synced = 0;
    5886          197 :     for (int i = 0; i < count; i++) {
    5887          102 :         if (only_account && only_account[0] &&
    5888           24 :             strcmp(accounts[i].name, only_account) != 0)
    5889            7 :             continue;
    5890           95 :         if (count > 1)
    5891            7 :             printf("\n=== Syncing account: %s ===\n", accounts[i].name);
    5892           95 :         local_store_init(accounts[i].cfg->host, accounts[i].cfg->user);
    5893           95 :         if (email_service_sync(accounts[i].cfg, force_reconcile) < 0)
    5894            0 :             errors++;
    5895           95 :         synced++;
    5896              :     }
    5897           95 :     config_free_account_list(accounts, count);
    5898              : 
    5899           95 :     if (synced == 0) {
    5900            0 :         fprintf(stderr, "Account '%s' not found.\n",
    5901              :                 only_account ? only_account : "");
    5902            0 :         return -1;
    5903              :     }
    5904           95 :     return errors > 0 ? -1 : 0;
    5905              : }
    5906              : 
    5907            8 : int email_service_rebuild_indexes(const char *only_account) {
    5908            8 :     int count = 0;
    5909            8 :     AccountEntry *accounts = config_list_accounts(&count);
    5910            8 :     if (!accounts || count == 0) {
    5911            0 :         fprintf(stderr, "No accounts configured.\n");
    5912            0 :         config_free_account_list(accounts, count);
    5913            0 :         return -1;
    5914              :     }
    5915              : 
    5916            8 :     int errors = 0, done = 0;
    5917           24 :     for (int i = 0; i < count; i++) {
    5918           16 :         if (only_account && only_account[0] &&
    5919           14 :             strcmp(accounts[i].name, only_account) != 0)
    5920            7 :             continue;
    5921            9 :         if (!accounts[i].cfg->gmail_mode) {
    5922            0 :             printf("Account %s: IMAP accounts do not use label indexes — skipping.\n",
    5923            0 :                    accounts[i].name);
    5924            0 :             done++;
    5925            0 :             continue;
    5926              :         }
    5927            9 :         printf("=== Rebuilding indexes: %s ===\n", accounts[i].name);
    5928            9 :         local_store_init(accounts[i].cfg->host, accounts[i].cfg->user);
    5929            9 :         if (gmail_sync_rebuild_indexes() < 0)
    5930            0 :             errors++;
    5931            9 :         done++;
    5932              :     }
    5933            8 :     config_free_account_list(accounts, count);
    5934              : 
    5935            8 :     if (done == 0) {
    5936            0 :         fprintf(stderr, "Account '%s' not found.\n",
    5937              :                 only_account ? only_account : "");
    5938            0 :         return -1;
    5939              :     }
    5940            8 :     return errors > 0 ? -1 : 0;
    5941              : }
    5942              : 
    5943              : /* ── IMAP per-account custom label helpers for apply_rules ──────────── */
    5944              : 
    5945              : typedef struct { char uid[17]; char *labels; } UidLabel;
    5946              : 
    5947            6 : static void ul_free_all(UidLabel *arr, int count) {
    5948           12 :     for (int i = 0; i < count; i++) free(arr[i].labels);
    5949            6 :     free(arr);
    5950            6 : }
    5951              : 
    5952            1 : static const char *ul_get(const UidLabel *arr, int count, const char *uid) {
    5953            1 :     for (int i = 0; i < count; i++)
    5954            1 :         if (strcmp(arr[i].uid, uid) == 0) return arr[i].labels;
    5955            0 :     return NULL;
    5956              : }
    5957              : 
    5958            5 : static void ul_set(UidLabel **arr, int *count, int *cap, const char *uid, const char *lbl) {
    5959            5 :     for (int i = 0; i < *count; i++) {
    5960            0 :         if (strcmp((*arr)[i].uid, uid) != 0) continue;
    5961            0 :         free((*arr)[i].labels);
    5962            0 :         (*arr)[i].labels = strdup(lbl);
    5963            0 :         return;
    5964              :     }
    5965            5 :     if (*count >= *cap) {
    5966            5 :         int nc = *cap ? *cap * 2 : 64;
    5967            5 :         UidLabel *tmp = realloc(*arr, (size_t)nc * sizeof(UidLabel));
    5968            5 :         if (!tmp) return;
    5969            5 :         *arr = tmp; *cap = nc;
    5970              :     }
    5971            5 :     snprintf((*arr)[*count].uid, sizeof((*arr)[*count].uid), "%s", uid);
    5972            5 :     (*arr)[*count].labels = strdup(lbl);
    5973            5 :     (*count)++;
    5974              : }
    5975              : 
    5976            8 : static UidLabel *ul_load(const char *path, int *count_out) {
    5977            8 :     *count_out = 0;
    5978            8 :     FILE *fp = fopen(path, "r");
    5979            8 :     if (!fp) return NULL;
    5980            1 :     int cap = 64;
    5981            1 :     UidLabel *arr = malloc((size_t)cap * sizeof(UidLabel));
    5982            1 :     if (!arr) { fclose(fp); return NULL; }
    5983              :     char line[4096];
    5984            2 :     while (fgets(line, sizeof(line), fp)) {
    5985            1 :         char *tab = strchr(line, '\t');
    5986            1 :         if (!tab) continue;
    5987            1 :         *tab = '\0';
    5988            1 :         char *lbl = tab + 1;
    5989            1 :         char *nl = strchr(lbl, '\n');
    5990            1 :         if (nl) *nl = '\0';
    5991            1 :         if (*count_out >= cap) {
    5992            0 :             cap *= 2;
    5993            0 :             UidLabel *tmp = realloc(arr, (size_t)cap * sizeof(UidLabel));
    5994            0 :             if (!tmp) break;
    5995            0 :             arr = tmp;
    5996              :         }
    5997            1 :         snprintf(arr[*count_out].uid, 17, "%.16s", line);
    5998            1 :         arr[*count_out].labels = strdup(lbl);
    5999            1 :         (*count_out)++;
    6000              :     }
    6001            1 :     fclose(fp);
    6002            1 :     return arr;
    6003              : }
    6004              : 
    6005            5 : static int ul_save(const char *path, const UidLabel *arr, int count) {
    6006            5 :     FILE *fp = fopen(path, "w");
    6007            5 :     if (!fp) return -1;
    6008           10 :     for (int i = 0; i < count; i++)
    6009            5 :         fprintf(fp, "%s\t%s\n", arr[i].uid, arr[i].labels ? arr[i].labels : "");
    6010            5 :     fclose(fp);
    6011            5 :     return 0;
    6012              : }
    6013              : 
    6014              : /* Return 1 if label s is present in comma-separated labels_csv */
    6015           12 : static int csv_has_label(const char *csv, const char *s) {
    6016           12 :     if (!csv || !s || !*s) return 0;
    6017            6 :     size_t slen = strlen(s);
    6018            6 :     const char *p = csv;
    6019            6 :     while (*p) {
    6020            1 :         const char *comma = strchr(p, ',');
    6021            1 :         size_t len = comma ? (size_t)(comma - p) : strlen(p);
    6022            1 :         if (len == slen && strncasecmp(p, s, slen) == 0) return 1;
    6023            0 :         if (!comma) break;
    6024            0 :         p = comma + 1;
    6025              :     }
    6026            5 :     return 0;
    6027              : }
    6028              : 
    6029              : /* Build updated labels CSV: existing + add - rm */
    6030            5 : static char *csv_update_labels(const char *existing,
    6031              :                                 char **add, int add_n,
    6032              :                                 char **rm,  int rm_n) {
    6033            5 :     char buf[4096] = "";
    6034              :     /* Keep existing labels that are not in rm */
    6035            5 :     if (existing && existing[0]) {
    6036            0 :         char *copy = strdup(existing);
    6037            0 :         char *tok = copy, *s;
    6038            0 :         while (tok && *tok) {
    6039            0 :             s = strchr(tok, ',');
    6040            0 :             if (s) *s = '\0';
    6041            0 :             int do_rm = 0;
    6042            0 :             for (int i = 0; i < rm_n; i++)
    6043            0 :                 if (rm[i] && strcasecmp(tok, rm[i]) == 0) { do_rm = 1; break; }
    6044            0 :             if (!do_rm) {
    6045            0 :                 if (buf[0]) strncat(buf, ",", sizeof(buf) - strlen(buf) - 1);
    6046            0 :                 strncat(buf, tok, sizeof(buf) - strlen(buf) - 1);
    6047              :             }
    6048            0 :             tok = s ? s + 1 : NULL;
    6049              :         }
    6050            0 :         free(copy);
    6051              :     }
    6052              :     /* Append add labels (skip duplicates) */
    6053           10 :     for (int i = 0; i < add_n; i++) {
    6054            5 :         if (!add[i] || !add[i][0]) continue;
    6055            5 :         if (!csv_has_label(buf, add[i])) {
    6056            5 :             if (buf[0]) strncat(buf, ",", sizeof(buf) - strlen(buf) - 1);
    6057            5 :             strncat(buf, add[i], sizeof(buf) - strlen(buf) - 1);
    6058              :         }
    6059              :     }
    6060            5 :     return strdup(buf);
    6061              : }
    6062              : 
    6063              : /* ── apply_rules: print rule match lines ─────────────────────────────── */
    6064            4 : static void print_rule_matches(const MailRules *rules,
    6065              :                                 const char *from, const char *subject,
    6066              :                                 const char *to, const char *labels,
    6067              :                                 const char *uid, int dry_run) {
    6068           13 :     for (int r = 0; r < rules->count; r++) {
    6069            9 :         if (!mail_rule_matches(&rules->rules[r], from, subject, to, labels,
    6070              :                                NULL, (time_t)0))
    6071            5 :             continue;
    6072            4 :         const MailRule *mr = &rules->rules[r];
    6073            4 :         printf("  %s \"%s\" \xe2\x86\x92 uid:%s",
    6074              :                dry_run ? "[dry-run]" : "[rule]",
    6075            4 :                mr->name ? mr->name : "?", uid);
    6076            8 :         for (int j = 0; j < mr->then_add_count; j++)
    6077            4 :             printf("  +%s", mr->then_add_label[j]);
    6078            4 :         for (int j = 0; j < mr->then_rm_count; j++)
    6079            0 :             printf("  -%s", mr->then_rm_label[j]);
    6080            4 :         if (mr->then_move_folder)
    6081            0 :             printf("  \xe2\x86\x92%s", mr->then_move_folder);
    6082            4 :         printf("\n");
    6083              :     }
    6084            4 : }
    6085              : 
    6086            8 : int email_service_apply_rules(const char *only_account, int dry_run, int verbose) {
    6087            8 :     int count = 0;
    6088            8 :     AccountEntry *accounts = config_list_accounts(&count);
    6089            8 :     if (!accounts || count == 0) {
    6090            0 :         fprintf(stderr, "No accounts configured.\n");
    6091            0 :         config_free_account_list(accounts, count);
    6092            0 :         return -1;
    6093              :     }
    6094              : 
    6095            8 :     int errors = 0, done = 0;
    6096            8 :     int total_fired = 0;
    6097           16 :     for (int i = 0; i < count; i++) {
    6098            8 :         if (only_account && only_account[0] &&
    6099            0 :             strcmp(accounts[i].name, only_account) != 0)
    6100            0 :             continue;
    6101              : 
    6102            8 :         printf("=== %s rules: %s ===\n",
    6103            8 :                dry_run ? "Dry-run" : "Applying", accounts[i].name);
    6104            8 :         local_store_init(accounts[i].cfg->host, accounts[i].cfg->user);
    6105              : 
    6106            8 :         MailRules *rules = mail_rules_load(accounts[i].name);
    6107            8 :         if (!rules || rules->count == 0) {
    6108            0 :             printf("  No rules found for %s.\n", accounts[i].name);
    6109            0 :             mail_rules_free(rules);
    6110            0 :             done++;
    6111            0 :             continue;
    6112              :         }
    6113              : 
    6114            8 :         int fired_total = 0;
    6115              : 
    6116            8 :         if (accounts[i].cfg->gmail_mode) {
    6117              :             /* ── Gmail path: .hdr files are tab-separated ── */
    6118            0 :             char (*uids)[17] = NULL;
    6119            0 :             int uid_count = 0;
    6120            0 :             local_hdr_list_all_uids("", &uids, &uid_count);
    6121              : 
    6122            0 :             for (int u = 0; u < uid_count; u++) {
    6123            0 :                 const char *uid = uids[u];
    6124            0 :                 char *hdr = local_hdr_load("", uid);
    6125            0 :                 if (!hdr) continue;
    6126              : 
    6127              :                 /* Parse: from\tsubject\tdate\tlabels\tflags */
    6128            0 :                 char *fields[5] = {NULL};
    6129            0 :                 char *p = hdr;
    6130            0 :                 for (int f = 0; f < 5; f++) {
    6131            0 :                     fields[f] = p;
    6132            0 :                     char *tab = strchr(p, '\t');
    6133            0 :                     if (tab) { *tab = '\0'; p = tab + 1; }
    6134            0 :                     else     { p += strlen(p); }
    6135              :                 }
    6136            0 :                 for (int f = 0; f < 5; f++) {
    6137            0 :                     if (fields[f]) {
    6138            0 :                         char *nl = strchr(fields[f], '\n');
    6139            0 :                         if (nl) *nl = '\0';
    6140              :                     }
    6141              :                 }
    6142              : 
    6143            0 :                 char **add_out = NULL; int add_count = 0;
    6144            0 :                 char **rm_out  = NULL; int rm_count  = 0;
    6145            0 :                 int fired = mail_rules_apply(rules,
    6146            0 :                                               fields[0], fields[1], NULL, fields[3],
    6147              :                                               NULL, (time_t)0,
    6148              :                                               &add_out, &add_count,
    6149              :                                               &rm_out,  &rm_count);
    6150            0 :                 if (fired > 0) {
    6151              :                     /* Idempotency: skip if all changes already applied */
    6152            0 :                     int has_new = 0;
    6153            0 :                     for (int j = 0; j < add_count && !has_new; j++)
    6154            0 :                         if (!csv_has_label(fields[3], add_out[j])) has_new = 1;
    6155            0 :                     for (int j = 0; j < rm_count && !has_new; j++)
    6156            0 :                         if (csv_has_label(fields[3], rm_out[j])) has_new = 1;
    6157              : 
    6158            0 :                     if (has_new) {
    6159            0 :                         if (verbose || dry_run)
    6160            0 :                             print_rule_matches(rules, fields[0], fields[1],
    6161            0 :                                                NULL, fields[3], uid, dry_run);
    6162            0 :                         fired_total++;
    6163            0 :                         if (!dry_run) {
    6164            0 :                             local_hdr_update_labels("", uid,
    6165              :                                                      (const char **)add_out, add_count,
    6166              :                                                      (const char **)rm_out,  rm_count);
    6167            0 :                             for (int j = 0; j < add_count; j++) label_idx_add(add_out[j], uid);
    6168            0 :                             for (int j = 0; j < rm_count;  j++) label_idx_remove(rm_out[j], uid);
    6169              :                         }
    6170              :                     }
    6171            0 :                     for (int j = 0; j < add_count; j++) free(add_out[j]);
    6172            0 :                     for (int j = 0; j < rm_count;  j++) free(rm_out[j]);
    6173            0 :                     free(add_out); free(rm_out);
    6174              :                 }
    6175            0 :                 free(hdr);
    6176              :             }
    6177            0 :             free(uids);
    6178              : 
    6179              :         } else {
    6180              :             /* ── IMAP path: use manifest + per-account applied_labels.tsv ── */
    6181            8 :             const char *imap_folder = (accounts[i].cfg->folder && accounts[i].cfg->folder[0])
    6182           16 :                                       ? accounts[i].cfg->folder : "INBOX";
    6183              : 
    6184              :             /* Path to applied labels persistence file */
    6185            8 :             const char *data_dir = platform_data_dir();
    6186            8 :             char lpath[8192] = "";
    6187            8 :             if (data_dir && accounts[i].cfg->user && accounts[i].cfg->user[0])
    6188            8 :                 snprintf(lpath, sizeof(lpath), "%s/email-cli/accounts/%s/applied_labels.tsv",
    6189            8 :                          data_dir, accounts[i].cfg->user);
    6190              : 
    6191              :             /* Load existing custom labels */
    6192            8 :             int ul_count = 0, ul_cap = 0;
    6193            8 :             UidLabel *ul_arr = NULL;
    6194            8 :             if (lpath[0]) ul_arr = ul_load(lpath, &ul_count);
    6195            8 :             int ul_dirty = 0;
    6196              : 
    6197              :             /* Iterate over manifest entries (decoded from/subject + flags) */
    6198            8 :             Manifest *mf = manifest_load(imap_folder);
    6199            8 :             if (!mf || mf->count == 0) {
    6200            0 :                 printf("  No messages found in folder %s.\n", imap_folder);
    6201            0 :                 manifest_free(mf);
    6202            0 :                 if (ul_arr) ul_free_all(ul_arr, ul_count);
    6203            0 :                 mail_rules_free(rules);
    6204            0 :                 done++;
    6205            0 :                 continue;
    6206              :             }
    6207              : 
    6208            8 :             int mf_dirty = 0;
    6209              :             /* Standard label→flag mapping */
    6210              :             static const struct { const char *lbl; int flag; } lmap[] = {
    6211              :                 { "_junk",     MSG_FLAG_JUNK     },
    6212              :                 { "_spam",     MSG_FLAG_JUNK     },
    6213              :                 { "_phishing", MSG_FLAG_PHISHING },
    6214              :                 { "_done",     MSG_FLAG_DONE     },
    6215              :                 { "_flagged",  MSG_FLAG_FLAGGED  },
    6216              :             };
    6217            8 :             const int lmap_n = (int)(sizeof(lmap) / sizeof(lmap[0]));
    6218              : 
    6219              :             /* Label→IMAP flag mapping for pending_flags queue */
    6220              :             static const struct {
    6221              :                 const char *lbl;
    6222              :                 const char *imap_flag;
    6223              :                 int add;
    6224              :             } fmap[] = {
    6225              :                 { "UNREAD",    "\\Seen",     0 },
    6226              :                 { "_flagged",  "\\Flagged",  1 },
    6227              :                 { "_junk",     "$Junk",      1 },
    6228              :                 { "_spam",     "$Junk",      1 },
    6229              :                 { "_done",     "$Done",      1 },
    6230              :                 { "_trash",    "\\Deleted",  1 },
    6231              :             };
    6232            8 :             const int fmap_n = (int)(sizeof(fmap) / sizeof(fmap[0]));
    6233              : 
    6234            8 :             char *move_folder = NULL;
    6235              : 
    6236           16 :             for (int u = 0; u < mf->count; u++) {
    6237            8 :                 ManifestEntry *e = &mf->entries[u];
    6238              : 
    6239              :                 /* Existing labels: custom (from persistence file) */
    6240            8 :                 const char *existing = ul_arr ? ul_get(ul_arr, ul_count, e->uid) : NULL;
    6241              : 
    6242            8 :                 char **add_out = NULL; int add_count = 0;
    6243            8 :                 char **rm_out  = NULL; int rm_count  = 0;
    6244           16 :                 int fired = mail_rules_apply_ex(rules,
    6245            8 :                                                 e->from    ? e->from    : "",
    6246            8 :                                                 e->subject ? e->subject : "",
    6247              :                                                 NULL, existing,
    6248              :                                                 NULL, (time_t)0,
    6249              :                                                 &add_out, &add_count,
    6250              :                                                 &rm_out,  &rm_count,
    6251              :                                                 &move_folder);
    6252            8 :                 if (fired > 0) {
    6253              :                     /* Check if any new custom labels would be added/removed */
    6254            7 :                     int has_new = 0;
    6255           14 :                     for (int j = 0; j < add_count && !has_new; j++)
    6256            7 :                         if (!csv_has_label(existing, add_out[j])) has_new = 1;
    6257            7 :                     for (int j = 0; j < rm_count && !has_new; j++)
    6258            0 :                         if (csv_has_label(existing, rm_out[j])) has_new = 1;
    6259              : 
    6260              :                     /* Also check if any standard flag labels would change */
    6261            7 :                     int new_flags = e->flags;
    6262           14 :                     for (int j = 0; j < add_count; j++)
    6263           42 :                         for (int k = 0; k < lmap_n; k++)
    6264           35 :                             if (strcasecmp(add_out[j], lmap[k].lbl) == 0)
    6265            1 :                                 new_flags |= lmap[k].flag;
    6266            7 :                     for (int j = 0; j < rm_count; j++)
    6267            0 :                         for (int k = 0; k < lmap_n; k++)
    6268            0 :                             if (strcasecmp(rm_out[j], lmap[k].lbl) == 0)
    6269            0 :                                 new_flags &= ~lmap[k].flag;
    6270            7 :                     if (new_flags != e->flags) has_new = 1;
    6271              : 
    6272              :                     /* Also fire if a folder move is requested */
    6273            7 :                     if (move_folder) has_new = 1;
    6274              : 
    6275            7 :                     if (has_new) {
    6276            6 :                         if (verbose || dry_run)
    6277            8 :                             print_rule_matches(rules,
    6278            4 :                                                e->from    ? e->from    : "",
    6279            4 :                                                e->subject ? e->subject : "",
    6280            4 :                                                NULL, existing, e->uid, dry_run);
    6281            6 :                         fired_total++;
    6282            6 :                         if (!dry_run) {
    6283              :                             /* Persist custom labels */
    6284            5 :                             char *new_lbl = csv_update_labels(existing,
    6285              :                                                                add_out, add_count,
    6286              :                                                                rm_out,  rm_count);
    6287            5 :                             ul_set(&ul_arr, &ul_count, &ul_cap, e->uid,
    6288              :                                    new_lbl ? new_lbl : "");
    6289            5 :                             free(new_lbl);
    6290            5 :                             ul_dirty = 1;
    6291              : 
    6292              :                             /* Update manifest flags for standard labels */
    6293            5 :                             if (new_flags != e->flags) {
    6294            1 :                                 e->flags = new_flags;
    6295            1 :                                 local_hdr_update_flags(imap_folder, e->uid, new_flags);
    6296            1 :                                 mf_dirty = 1;
    6297              :                             }
    6298              : 
    6299              :                             /* Queue pending IMAP operations for server push */
    6300           10 :                             for (int j = 0; j < add_count; j++) {
    6301            5 :                                 if (strcmp(add_out[j], "UNREAD") == 0)
    6302            0 :                                     local_pending_flag_add(imap_folder, e->uid, "\\Seen", 0);
    6303           30 :                                 for (int k = 1; k < fmap_n; k++)
    6304           25 :                                     if (strcasecmp(add_out[j], fmap[k].lbl) == 0)
    6305            1 :                                         local_pending_flag_add(imap_folder, e->uid,
    6306            1 :                                                                fmap[k].imap_flag, 1);
    6307              :                             }
    6308            5 :                             for (int j = 0; j < rm_count; j++) {
    6309            0 :                                 if (strcmp(rm_out[j], "UNREAD") == 0)
    6310            0 :                                     local_pending_flag_add(imap_folder, e->uid, "\\Seen", 1);
    6311            0 :                                 if (strcasecmp(rm_out[j], "_flagged") == 0)
    6312            0 :                                     local_pending_flag_add(imap_folder, e->uid, "\\Flagged", 0);
    6313              :                             }
    6314            5 :                             if (move_folder)
    6315            0 :                                 local_pending_move_add(imap_folder, e->uid, move_folder);
    6316              :                         }
    6317              :                     }
    6318           14 :                     for (int j = 0; j < add_count; j++) free(add_out[j]);
    6319            7 :                     for (int j = 0; j < rm_count;  j++) free(rm_out[j]);
    6320            7 :                     free(add_out); free(rm_out);
    6321              :                 }
    6322            8 :                 free(move_folder); move_folder = NULL;
    6323              :             }
    6324              : 
    6325            8 :             if (!dry_run && mf_dirty) manifest_save(imap_folder, mf);
    6326            8 :             if (!dry_run && ul_dirty && lpath[0]) ul_save(lpath, ul_arr, ul_count);
    6327            8 :             manifest_free(mf);
    6328            8 :             if (ul_arr) ul_free_all(ul_arr, ul_count);
    6329              :         }
    6330              : 
    6331            8 :         mail_rules_free(rules);
    6332            8 :         if (dry_run)
    6333            1 :             printf("  Rules dry-run: %d message(s) would be modified.\n", fired_total);
    6334              :         else
    6335            7 :             printf("  Rules applied: %d message(s) modified.\n", fired_total);
    6336            8 :         total_fired += fired_total;
    6337            8 :         done++;
    6338              :     }
    6339            8 :     config_free_account_list(accounts, count);
    6340              : 
    6341            8 :     if (done == 0) {
    6342            0 :         fprintf(stderr, "Account '%s' not found.\n",
    6343              :                 only_account ? only_account : "");
    6344            0 :         return -1;
    6345              :     }
    6346            8 :     return errors > 0 ? -1 : total_fired;
    6347              : }
    6348              : 
    6349            1 : int email_service_rebuild_contacts(const char *only_account) {
    6350            1 :     int count = 0;
    6351            1 :     AccountEntry *accounts = config_list_accounts(&count);
    6352            1 :     if (!accounts || count == 0) {
    6353            0 :         fprintf(stderr, "No accounts configured.\n");
    6354            0 :         config_free_account_list(accounts, count);
    6355            0 :         return -1;
    6356              :     }
    6357              : 
    6358            1 :     int done = 0;
    6359            2 :     for (int i = 0; i < count; i++) {
    6360            1 :         if (only_account && only_account[0] &&
    6361            0 :             strcmp(accounts[i].name, only_account) != 0)
    6362            0 :             continue;
    6363            1 :         printf("=== Rebuilding contacts: %s ===\n", accounts[i].name);
    6364            1 :         local_store_init(accounts[i].cfg->host, accounts[i].cfg->user);
    6365            1 :         local_contacts_rebuild();
    6366            1 :         printf("Contacts rebuilt for %s\n", accounts[i].name);
    6367            1 :         done++;
    6368              :     }
    6369            1 :     config_free_account_list(accounts, count);
    6370              : 
    6371            1 :     if (done == 0) {
    6372            0 :         fprintf(stderr, "Account '%s' not found.\n",
    6373              :                 only_account ? only_account : "");
    6374            0 :         return -1;
    6375              :     }
    6376            1 :     return 0;
    6377              : }
    6378              : 
    6379            0 : int email_service_cron_setup(const Config *cfg) {
    6380              : 
    6381              :     /* Find the path to this binary */
    6382            0 :     char self_path[1024] = {0};
    6383            0 :     if (platform_executable_path(self_path, sizeof(self_path)) != 0) {
    6384            0 :         fprintf(stderr, "Cannot determine binary path.\n");
    6385            0 :         return -1;
    6386              :     }
    6387              : 
    6388              :     /* Build path to email-sync (same directory as current binary) */
    6389            0 :     char sync_bin[1024] = "email-sync";
    6390            0 :     char *last_slash = strrchr(self_path, '/');
    6391            0 :     if (last_slash)
    6392            0 :         snprintf(sync_bin, sizeof(sync_bin), "%.*s/email-sync",
    6393            0 :                  (int)(last_slash - self_path), self_path);
    6394              : 
    6395              :     /* Build the cron line */
    6396              :     char cron_line[2048];
    6397            0 :     snprintf(cron_line, sizeof(cron_line),
    6398              :              "*/%d * * * * %s >> ~/.cache/email-cli/sync.log 2>&1",
    6399            0 :              cfg->sync_interval, sync_bin);
    6400              : 
    6401              :     /* Read existing crontab */
    6402            0 :     char existing[65536] = {0};
    6403            0 :     size_t total = 0;
    6404              :     {
    6405            0 :         RAII_PFILE FILE *fp = popen("crontab -l 2>/dev/null", "r");
    6406            0 :         if (fp) {
    6407              :             size_t n2;
    6408            0 :             while ((n2 = fread(existing + total, 1, sizeof(existing) - total - 1, fp)) > 0)
    6409            0 :                 total += n2;
    6410              :         }
    6411              :     }
    6412            0 :     existing[total] = '\0';
    6413              : 
    6414              :     /* Check if already present (email-sync or legacy email-cli sync) */
    6415            0 :     if (strstr(existing, "email-sync") ||
    6416            0 :         (strstr(existing, "email-cli") && strstr(existing, " sync"))) {
    6417            0 :         printf("Cron job already installed. "
    6418              :                "Run 'email-sync cron remove' first to change the interval.\n");
    6419            0 :         return 0;
    6420              :     }
    6421              : 
    6422              :     /* Append our line (ensure existing ends with newline) */
    6423            0 :     if (total > 0 && existing[total - 1] != '\n')
    6424            0 :         strncat(existing, "\n", sizeof(existing) - total - 1);
    6425            0 :     strncat(existing, cron_line, sizeof(existing) - strlen(existing) - 1);
    6426            0 :     strncat(existing, "\n", sizeof(existing) - strlen(existing) - 1);
    6427              : 
    6428            0 :     RAII_PFILE FILE *cp = popen("crontab -", "w");
    6429            0 :     if (!cp) {
    6430            0 :         fprintf(stderr, "Failed to update crontab.\n");
    6431            0 :         return -1;
    6432              :     }
    6433            0 :     fputs(existing, cp);
    6434            0 :     int rc = pclose(cp);
    6435            0 :     cp = NULL; /* prevent RAII double-close */
    6436            0 :     if (rc != 0) {
    6437            0 :         fprintf(stderr, "crontab update failed (exit %d).\n", rc);
    6438            0 :         return -1;
    6439              :     }
    6440              : 
    6441            0 :     printf("Cron job installed: %s\n", cron_line);
    6442            0 :     return 0;
    6443              : }
    6444              : 
    6445            2 : int email_service_cron_remove(void) {
    6446            2 :     char existing[65536] = {0};
    6447            2 :     size_t total = 0;
    6448              :     {
    6449            4 :         RAII_PFILE FILE *fp = popen("crontab -l 2>/dev/null", "r");
    6450            2 :         if (fp) {
    6451              :             size_t n;
    6452            2 :             while ((n = fread(existing + total, 1, sizeof(existing) - total - 1, fp)) > 0)
    6453            0 :                 total += n;
    6454              :         }
    6455              :     }
    6456            2 :     existing[total] = '\0';
    6457              : 
    6458              : #define IS_SYNC_LINE(s) \
    6459              :     (strstr((s), "email-sync") || \
    6460              :      (strstr((s), "email-cli") && strstr((s), " sync")))
    6461              : 
    6462            2 :     if (!IS_SYNC_LINE(existing)) {
    6463            2 :         printf("No email-sync cron entry found.\n");
    6464            2 :         return 0;
    6465              :     }
    6466              : 
    6467              :     /* Filter out sync cron lines */
    6468            0 :     char filtered[65536] = {0};
    6469            0 :     size_t flen = 0;
    6470            0 :     char *p = existing;
    6471            0 :     while (*p) {
    6472            0 :         char *nl = strchr(p, '\n');
    6473            0 :         char *end = nl ? nl : p + strlen(p);
    6474            0 :         char saved = *end; *end = '\0';
    6475            0 :         if (!IS_SYNC_LINE(p)) {
    6476            0 :             size_t llen = strlen(p);
    6477            0 :             if (flen + llen + 2 < sizeof(filtered)) {
    6478            0 :                 memcpy(filtered + flen, p, llen);
    6479            0 :                 flen += llen;
    6480            0 :                 filtered[flen++] = '\n';
    6481            0 :                 filtered[flen]   = '\0';
    6482              :             }
    6483              :         }
    6484            0 :         *end = saved;
    6485            0 :         p = nl ? nl + 1 : end;
    6486              :     }
    6487              : 
    6488            0 :     RAII_PFILE FILE *cp = popen("crontab -", "w");
    6489            0 :     if (!cp) {
    6490            0 :         fprintf(stderr, "Failed to update crontab.\n");
    6491            0 :         return -1;
    6492              :     }
    6493            0 :     fputs(filtered, cp);
    6494            0 :     int rc = pclose(cp);
    6495            0 :     cp = NULL; /* prevent RAII double-close */
    6496            0 :     if (rc != 0) {
    6497            0 :         fprintf(stderr, "crontab update failed.\n");
    6498            0 :         return -1;
    6499              :     }
    6500              : 
    6501            0 :     printf("Cron job removed.\n");
    6502            0 :     return 0;
    6503              : }
    6504              : 
    6505            1 : int email_service_cron_status(void) {
    6506            2 :     RAII_PFILE FILE *fp = popen("crontab -l 2>/dev/null", "r");
    6507            1 :     if (!fp) {
    6508            0 :         printf("No crontab found for this user.\n");
    6509            0 :         return 0;
    6510              :     }
    6511              :     char line[1024];
    6512            1 :     int found = 0;
    6513            1 :     while (fgets(line, sizeof(line), fp)) {
    6514            0 :         if (IS_SYNC_LINE(line)) {
    6515            0 :             if (!found) printf("Cron entry found:\n");
    6516            0 :             printf("  %s", line);
    6517            0 :             found = 1;
    6518              :         }
    6519              :     }
    6520            1 :     if (!found)
    6521            1 :         printf("No email-sync cron entry found.\n");
    6522              : #undef IS_SYNC_LINE
    6523            1 :     return 0;
    6524              : }
    6525              : 
    6526              : /* ── Attachment service functions ───────────────────────────────────── */
    6527              : 
    6528              : /* Load raw message for uid (cache or fetch). Returns heap string or NULL. */
    6529           19 : static char *load_raw_message(const Config *cfg, const char *uid) {
    6530           19 :     if (local_msg_exists(cfg->folder, uid)) {
    6531           14 :         return local_msg_load(cfg->folder, uid);
    6532              :     }
    6533            5 :     char *raw = fetch_uid_content_in(cfg, cfg->folder, uid, 0);
    6534            5 :     if (raw) {
    6535            5 :         local_msg_save(cfg->folder, uid, raw, strlen(raw));
    6536            5 :         local_index_update(cfg->folder, uid, raw);
    6537              :     }
    6538            5 :     return raw;
    6539              : }
    6540              : 
    6541            3 : char *email_service_fetch_raw(const Config *cfg, const char *uid) {
    6542            3 :     return load_raw_message(cfg, uid);
    6543              : }
    6544              : 
    6545            9 : int email_service_list_attachments(const Config *cfg, const char *uid) {
    6546            9 :     char *raw = load_raw_message(cfg, uid);
    6547            9 :     if (!raw) {
    6548            0 :         fprintf(stderr, "Could not load message UID %s.\n", uid);
    6549            0 :         return -1;
    6550              :     }
    6551            9 :     int count = 0;
    6552            9 :     MimeAttachment *atts = mime_list_attachments(raw, &count);
    6553            9 :     free(raw);
    6554            9 :     if (count == 0) {
    6555            2 :         printf("No attachments.\n");
    6556            2 :         mime_free_attachments(atts, count);
    6557            2 :         return 0;
    6558              :     }
    6559           17 :     for (int i = 0; i < count; i++) {
    6560           10 :         const char *name = atts[i].filename ? atts[i].filename : "(no name)";
    6561           10 :         size_t sz = atts[i].size;
    6562           10 :         if (sz >= 1024 * 1024)
    6563            0 :             printf("%-40s  %.1f MB\n", name, (double)sz / (1024.0 * 1024.0));
    6564           10 :         else if (sz >= 1024)
    6565            0 :             printf("%-40s  %.0f KB\n", name, (double)sz / 1024.0);
    6566              :         else
    6567           10 :             printf("%-40s  %zu B\n", name, sz);
    6568              :     }
    6569            7 :     mime_free_attachments(atts, count);
    6570            7 :     return 0;
    6571              : }
    6572              : 
    6573            7 : int email_service_save_attachment(const Config *cfg, const char *uid,
    6574              :                                   const char *name, const char *outdir) {
    6575            7 :     char *raw = load_raw_message(cfg, uid);
    6576            7 :     if (!raw) {
    6577            0 :         fprintf(stderr, "Could not load message UID %s.\n", uid);
    6578            0 :         return -1;
    6579              :     }
    6580            7 :     int count = 0;
    6581            7 :     MimeAttachment *atts = mime_list_attachments(raw, &count);
    6582            7 :     free(raw);
    6583            7 :     if (count == 0) {
    6584            1 :         fprintf(stderr, "Message UID %s has no attachments.\n", uid);
    6585            1 :         mime_free_attachments(atts, count);
    6586            1 :         return -1;
    6587              :     }
    6588              : 
    6589              :     /* Find attachment by filename (case-sensitive). */
    6590            6 :     int idx = -1;
    6591            6 :     for (int i = 0; i < count; i++) {
    6592            6 :         const char *fn = atts[i].filename ? atts[i].filename : "";
    6593            6 :         if (strcmp(fn, name) == 0) { idx = i; break; }
    6594              :     }
    6595            6 :     if (idx < 0) {
    6596            0 :         fprintf(stderr, "Attachment '%s' not found in message UID %s.\n", name, uid);
    6597            0 :         mime_free_attachments(atts, count);
    6598            0 :         return -1;
    6599              :     }
    6600              : 
    6601              :     /* Build destination path. */
    6602            6 :     const char *dir = outdir ? outdir : attachment_save_dir();
    6603            6 :     char *dir_heap = NULL;
    6604            6 :     if (!outdir) dir_heap = (char *)dir; /* attachment_save_dir returns heap */
    6605              : 
    6606            6 :     char *safe = safe_filename_for_path(name);
    6607              :     char dest[2048];
    6608            6 :     snprintf(dest, sizeof(dest), "%s/%s", dir, safe ? safe : "attachment");
    6609            6 :     free(safe);
    6610              : 
    6611            6 :     int rc = mime_save_attachment(&atts[idx], dest);
    6612            6 :     if (rc == 0)
    6613            6 :         printf("Saved: %s\n", dest);
    6614              :     else
    6615            0 :         fprintf(stderr, "Failed to save attachment to %s\n", dest);
    6616              : 
    6617            6 :     mime_free_attachments(atts, count);
    6618            6 :     free(dir_heap);
    6619            6 :     return rc;
    6620              : }
    6621              : 
    6622              : /* ── Flag / label service functions ─────────────────────────────────── */
    6623              : 
    6624           24 : int email_service_set_flag(const Config *cfg, const char *uid,
    6625              :                            const char *folder, int flag_bit, int add) {
    6626           24 :     const char *use_folder = folder ? folder : (cfg->folder ? cfg->folder : "INBOX");
    6627              : 
    6628              :     /* Determine IMAP flag name and effective add direction */
    6629              :     const char *flag_name;
    6630              :     int imap_add;
    6631           24 :     if (flag_bit == MSG_FLAG_UNSEEN) {
    6632           12 :         flag_name = "\\Seen";
    6633           12 :         imap_add  = !add;  /* add UNSEEN = remove \Seen */
    6634           12 :     } else if (flag_bit == MSG_FLAG_FLAGGED) {
    6635           12 :         flag_name = "\\Flagged";
    6636           12 :         imap_add  = add;
    6637            0 :     } else if (flag_bit == MSG_FLAG_DONE) {
    6638            0 :         flag_name = "$Done";
    6639            0 :         imap_add  = add;
    6640              :     } else {
    6641            0 :         fprintf(stderr, "Error: Unknown flag bit %d.\n", flag_bit);
    6642            0 :         return -1;
    6643              :     }
    6644              : 
    6645              :     /* Update local manifest */
    6646           24 :     Manifest *m = manifest_load(use_folder);
    6647           24 :     if (m) {
    6648            7 :         ManifestEntry *me = manifest_find(m, uid);
    6649            7 :         if (me) {
    6650            7 :             if (add)
    6651            3 :                 me->flags |= flag_bit;
    6652              :             else
    6653            4 :                 me->flags &= ~flag_bit;
    6654              :         }
    6655            7 :         manifest_save(use_folder, m);
    6656            7 :         manifest_free(m);
    6657              :     }
    6658              : 
    6659              :     /* Update Gmail label indexes and .hdr labels CSV if in Gmail mode.
    6660              :      * Both .idx AND the labels field in .hdr must be kept in sync so that
    6661              :      * rebuild_label_indexes() (run during every full sync) does not undo
    6662              :      * locally applied flag changes. */
    6663           24 :     if (cfg->gmail_mode) {
    6664           16 :         const char *lbl = NULL;
    6665           16 :         if (flag_bit == MSG_FLAG_UNSEEN)  lbl = "UNREAD";
    6666            8 :         else if (flag_bit == MSG_FLAG_FLAGGED) lbl = "STARRED";
    6667              : 
    6668           16 :         if (lbl) {
    6669           16 :             if (add) {
    6670            8 :                 label_idx_add(lbl, uid);
    6671            8 :                 local_hdr_update_labels("", uid, &lbl, 1, NULL, 0);
    6672              :             } else {
    6673            8 :                 label_idx_remove(lbl, uid);
    6674            8 :                 local_hdr_update_labels("", uid, NULL, 0, &lbl, 1);
    6675              :             }
    6676              :         }
    6677              : 
    6678              :         /* Also update the flags integer field in .hdr */
    6679           16 :         Manifest *m2 = manifest_load(use_folder);
    6680           16 :         if (m2) {
    6681            0 :             ManifestEntry *me2 = manifest_find(m2, uid);
    6682            0 :             if (me2)
    6683            0 :                 local_hdr_update_flags("", uid, me2->flags);
    6684            0 :             manifest_free(m2);
    6685              :         }
    6686              :     }
    6687              : 
    6688              :     /* Enqueue to pending flag queue */
    6689           24 :     local_pending_flag_add(use_folder, uid, flag_name, imap_add);
    6690              : 
    6691              :     /* Synchronous server push */
    6692           24 :     MailClient *mc = make_mail(cfg);
    6693           24 :     if (mc) {
    6694           24 :         if (mail_client_select(mc, use_folder) == 0)
    6695           24 :             mail_client_set_flag(mc, uid, flag_name, imap_add);
    6696           24 :         mail_client_free(mc);
    6697              :     } else {
    6698            0 :         fprintf(stderr, "Warning: Could not connect. Change queued for next sync.\n");
    6699              :     }
    6700              : 
    6701           24 :     return 0;
    6702              : }
    6703              : 
    6704            7 : int email_service_set_label(const Config *cfg, const char *uid,
    6705              :                             const char *label, int add) {
    6706            7 :     if (!cfg->gmail_mode) {
    6707            1 :         fprintf(stderr, "Error: label operations require Gmail mode.\n");
    6708            1 :         return -1;
    6709              :     }
    6710              : 
    6711            6 :     MailClient *mc = make_mail(cfg);
    6712            6 :     if (!mc) {
    6713            0 :         fprintf(stderr, "Error: Could not connect to server.\n");
    6714            0 :         return -1;
    6715              :     }
    6716            6 :     int rc = mail_client_modify_label(mc, uid, label, add);
    6717            6 :     mail_client_free(mc);
    6718              : 
    6719            6 :     if (add) {
    6720            3 :         label_idx_add(label, uid);
    6721            3 :         local_hdr_update_labels("", uid, &label, 1, NULL, 0);
    6722              :     } else {
    6723            3 :         label_idx_remove(label, uid);
    6724            3 :         local_hdr_update_labels("", uid, NULL, 0, &label, 1);
    6725              :     }
    6726              : 
    6727            6 :     return rc;
    6728              : }
    6729              : 
    6730            5 : int email_service_list_labels(const Config *cfg) {
    6731            5 :     if (!cfg->gmail_mode) {
    6732            1 :         fprintf(stderr, "Error: 'list-labels' is Gmail-only. Use 'list-folders' for IMAP.\n");
    6733            1 :         return -1;
    6734              :     }
    6735            4 :     MailClient *mc = make_mail(cfg);
    6736            4 :     if (!mc) {
    6737            0 :         fprintf(stderr, "Error: Could not connect.\n");
    6738            0 :         return -1;
    6739              :     }
    6740              : 
    6741            4 :     char **names = NULL, **ids = NULL;
    6742            4 :     int count = 0;
    6743            4 :     int rc = mail_client_list_with_ids(mc, &names, &ids, &count);
    6744            4 :     mail_client_free(mc);
    6745              : 
    6746            4 :     if (rc != 0 || count == 0) {
    6747            0 :         if (rc == 0) printf("No labels found.\n");
    6748              :         /* free any partial results */
    6749            0 :         for (int i = 0; i < count; i++) {
    6750            0 :             if (names) free(names[i]);
    6751            0 :             if (ids)   free(ids[i]);
    6752              :         }
    6753            0 :         free(names);
    6754            0 :         free(ids);
    6755            0 :         return rc;
    6756              :     }
    6757              : 
    6758            4 :     printf("%-30s  %s\n", "Label", "ID");
    6759            4 :     printf("%-30s  %s\n", "------------------------------",
    6760              :            "------------------------------");
    6761           40 :     for (int i = 0; i < count; i++) {
    6762           36 :         printf("%-30s  %s\n",
    6763           36 :                names[i] ? names[i] : "",
    6764           36 :                ids[i]   ? ids[i]   : "");
    6765              :     }
    6766              : 
    6767           40 :     for (int i = 0; i < count; i++) {
    6768           36 :         free(names[i]);
    6769           36 :         free(ids[i]);
    6770              :     }
    6771            4 :     free(names);
    6772            4 :     free(ids);
    6773            4 :     return 0;
    6774              : }
    6775              : 
    6776            2 : int email_service_create_label(const Config *cfg, const char *name) {
    6777            2 :     if (!cfg->gmail_mode) {
    6778            1 :         fprintf(stderr, "Error: 'create-label' is Gmail-only. Use 'create-folder' for IMAP.\n");
    6779            1 :         return -1;
    6780              :     }
    6781            1 :     MailClient *mc = make_mail(cfg);
    6782            1 :     if (!mc) {
    6783            0 :         fprintf(stderr, "Error: Could not connect.\n");
    6784            0 :         return -1;
    6785              :     }
    6786            1 :     char *new_id = NULL;
    6787            1 :     int rc = mail_client_create_label(mc, name, &new_id);
    6788            1 :     mail_client_free(mc);
    6789              : 
    6790            1 :     if (rc == 0)
    6791            1 :         printf("Label '%s' created (ID: %s).\n", name, new_id ? new_id : name);
    6792            1 :     free(new_id);
    6793            1 :     return rc;
    6794              : }
    6795              : 
    6796            2 : int email_service_delete_label(const Config *cfg, const char *label_id) {
    6797            2 :     if (!cfg->gmail_mode) {
    6798            1 :         fprintf(stderr, "Error: 'delete-label' is Gmail-only. Use 'delete-folder' for IMAP.\n");
    6799            1 :         return -1;
    6800              :     }
    6801            1 :     MailClient *mc = make_mail(cfg);
    6802            1 :     if (!mc) {
    6803            0 :         fprintf(stderr, "Error: Could not connect.\n");
    6804            0 :         return -1;
    6805              :     }
    6806            1 :     int rc = mail_client_delete_label(mc, label_id);
    6807            1 :     mail_client_free(mc);
    6808              : 
    6809            1 :     if (rc == 0)
    6810            1 :         printf("Label '%s' deleted.\n", label_id);
    6811            1 :     return rc;
    6812              : }
    6813              : 
    6814            2 : int email_service_mark_junk(const Config *cfg, const char *uid) {
    6815            2 :     local_store_init(cfg->host, cfg->user);
    6816            2 :     MailClient *mc = make_mail(cfg);
    6817            2 :     if (!mc) { fprintf(stderr, "Error: Could not connect.\n"); return -1; }
    6818            2 :     int rc = mail_client_mark_junk(mc, uid);
    6819            2 :     mail_client_free(mc);
    6820            2 :     if (rc == 0) printf("Message %s marked as junk.\n", uid);
    6821            2 :     return rc;
    6822              : }
    6823              : 
    6824            2 : int email_service_mark_notjunk(const Config *cfg, const char *uid) {
    6825            2 :     local_store_init(cfg->host, cfg->user);
    6826            2 :     MailClient *mc = make_mail(cfg);
    6827            2 :     if (!mc) { fprintf(stderr, "Error: Could not connect.\n"); return -1; }
    6828            2 :     int rc = mail_client_mark_notjunk(mc, uid);
    6829            2 :     mail_client_free(mc);
    6830            2 :     if (rc == 0) printf("Message %s marked as not-junk.\n", uid);
    6831            2 :     return rc;
    6832              : }
    6833              : 
    6834            2 : int email_service_create_folder(const Config *cfg, const char *name) {
    6835            2 :     if (cfg->gmail_mode) {
    6836            1 :         fprintf(stderr, "Error: 'create-folder' is IMAP-only. Use 'create-label' for Gmail.\n");
    6837            1 :         return -1;
    6838              :     }
    6839            1 :     MailClient *mc = make_mail(cfg);
    6840            1 :     if (!mc) {
    6841            0 :         fprintf(stderr, "Error: Could not connect.\n");
    6842            0 :         return -1;
    6843              :     }
    6844            1 :     int rc = mail_client_create_folder(mc, name);
    6845            1 :     mail_client_free(mc);
    6846              : 
    6847            1 :     if (rc == 0)
    6848            1 :         printf("Folder '%s' created.\n", name);
    6849            1 :     return rc;
    6850              : }
    6851              : 
    6852            2 : int email_service_delete_folder(const Config *cfg, const char *name) {
    6853            2 :     if (cfg->gmail_mode) {
    6854            1 :         fprintf(stderr, "Error: 'delete-folder' is IMAP-only. Use 'delete-label' for Gmail.\n");
    6855            1 :         return -1;
    6856              :     }
    6857            1 :     MailClient *mc = make_mail(cfg);
    6858            1 :     if (!mc) {
    6859            0 :         fprintf(stderr, "Error: Could not connect.\n");
    6860            0 :         return -1;
    6861              :     }
    6862            1 :     int rc = mail_client_delete_folder(mc, name);
    6863            1 :     mail_client_free(mc);
    6864              : 
    6865            1 :     if (rc == 0)
    6866            1 :         printf("Folder '%s' deleted.\n", name);
    6867            1 :     return rc;
    6868              : }
    6869              : 
    6870           13 : int email_service_save_sent(const Config *cfg, const char *msg, size_t msg_len) {
    6871           13 :     local_store_init(cfg->host, cfg->user);
    6872           13 :     const char *folder = cfg->sent_folder ? cfg->sent_folder : "Sent";
    6873           13 :     return local_save_outgoing(folder, msg, msg_len);
    6874              : }
    6875              : 
    6876            1 : int email_service_save_draft(const Config *cfg, const char *msg, size_t msg_len) {
    6877            1 :     local_store_init(cfg->host, cfg->user);
    6878            1 :     return local_save_outgoing("Drafts", msg, msg_len);
    6879              : }
        

Generated by: LCOV version 2.0-1