LCOV - code coverage report
Current view: top level - libemail/src/domain - email_service.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 66.9 % 3942 2638
Test Date: 2026-05-07 15:53:08 Functions: 90.6 % 96 87

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

Generated by: LCOV version 2.0-1