LCOV - code coverage report
Current view: top level - tests/unit - test_email_service.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 99.6 % 2038 2030
Test Date: 2026-05-07 15:53:07 Functions: 100.0 % 1 1

            Line data    Source code
       1              : #include "test_helpers.h"
       2              : #include <string.h>
       3              : #include <stdlib.h>
       4              : #include <stdint.h>
       5              : #include <locale.h>
       6              : #include <fcntl.h>
       7              : #include <unistd.h>
       8              : #include <sys/stat.h>
       9              : 
      10              : /*
      11              :  * Include the full domain source so all static helpers are visible in
      12              :  * this translation unit.  email_service.c is NOT added to CMakeLists.txt
      13              :  * as a separate source — the #include below is the only compilation unit
      14              :  * that defines its symbols.
      15              :  */
      16              : #include "../../libemail/src/domain/email_service.c"
      17              : 
      18            1 : void test_email_service(void) {
      19              : 
      20            1 :     setlocale(LC_ALL, "");
      21            1 :     local_store_init("imaps://test.example.com", "testuser");
      22              : 
      23              :     /* ── count_visual_rows ───────────────────────────────────────────── */
      24              : 
      25              :     /* Short lines: visual rows == logical lines (all fit within term_cols) */
      26            1 :     ASSERT(count_visual_rows(NULL,  80) == 0, "cvr: NULL → 0");
      27            1 :     ASSERT(count_visual_rows("",    80) == 0, "cvr: empty → 0");
      28            1 :     ASSERT(count_visual_rows("abc", 80) == 1, "cvr: single line → 1");
      29            1 :     ASSERT(count_visual_rows("a\nb", 80) == 2, "cvr: two lines → 2");
      30            1 :     ASSERT(count_visual_rows("a\nb\nc\n", 80) == 4, "cvr: trailing newline → 4");
      31              : 
      32              :     /* A line exactly term_cols wide → 1 visual row */
      33              :     {
      34            1 :         char exact[81]; memset(exact, 'X', 80); exact[80] = '\0';
      35            1 :         ASSERT(count_visual_rows(exact, 80) == 1, "cvr: 80-char line → 1 row");
      36              :     }
      37              : 
      38              :     /* A line wider than term_cols → multiple visual rows */
      39              :     {
      40            1 :         char wide[161]; memset(wide, 'X', 160); wide[160] = '\0';
      41              :         /* 160-char line on 80-col terminal → 2 visual rows (+ terminating segment) */
      42            1 :         char body[163]; snprintf(body, sizeof(body), "%s\n", wide);
      43            1 :         int vr = count_visual_rows(body, 80);
      44            1 :         ASSERT(vr == 3, "cvr: 160-char line+\\n → 3 rows (2 for URL, 1 trailing)");
      45              :     }
      46              : 
      47              :     /* A long URL (no newline) → single logical line counted as multiple visual rows */
      48              :     {
      49            1 :         char url[201]; memset(url, 'x', 200); url[200] = '\0';
      50              :         /* 200 chars on 80-col terminal = ceil(200/80) = 3 visual rows */
      51            1 :         ASSERT(count_visual_rows(url, 80) == 3, "cvr: 200-char url → 3 rows");
      52              :     }
      53              : 
      54              :     /* With ANSI escapes: invisible bytes not counted toward visible cols */
      55            1 :     ASSERT(count_visual_rows("\033[1mhello\033[22m", 80) == 1,
      56              :            "cvr: ANSI-wrapped line → 1 row");
      57              : 
      58              :     /* ── word_wrap ───────────────────────────────────────────────────── */
      59              : 
      60              :     /* NULL input → NULL */
      61              :     {
      62            1 :         char *r = word_wrap(NULL, 40);
      63            1 :         ASSERT(r == NULL, "word_wrap: NULL input → NULL");
      64              :     }
      65              : 
      66              :     /* Short text that fits entirely — no wrapping needed */
      67              :     {
      68            1 :         char *r = word_wrap("Hello world", 40);
      69            1 :         ASSERT(r != NULL, "word_wrap: short text not NULL");
      70            1 :         ASSERT(strstr(r, "Hello world") != NULL, "word_wrap: short text passthrough");
      71            1 :         free(r);
      72              :     }
      73              : 
      74              :     /* Word break at space (lines 169-174): width=25, long text with spaces */
      75              :     {
      76            1 :         char *r = word_wrap("The quick brown fox jumps over the lazy dog", 25);
      77            1 :         ASSERT(r != NULL, "word_wrap: word break not NULL");
      78            1 :         ASSERT(strstr(r, "\n") != NULL, "word_wrap: word break produces newline");
      79            1 :         free(r);
      80              :     }
      81              : 
      82              :     /* Long word without spaces: emitted whole (terminal wraps, not us) */
      83              :     {
      84            1 :         char *r = word_wrap("aaaaaaaaaaaaaaaaaaaaaaaaa", 20);
      85            1 :         ASSERT(r != NULL, "word_wrap: long word not NULL");
      86            1 :         ASSERT(strstr(r, "aaaaaaaaaaaaaaaaaaaaaaaaa") != NULL,
      87              :                "word_wrap: long word emitted intact");
      88            1 :         free(r);
      89              :     }
      90              : 
      91              :     /* URL longer than width must not be broken mid-URL */
      92              :     {
      93            1 :         const char *url = "https://www.example.com/very/long/path/that/exceeds/"
      94              :                           "the/wrap/width/limit/by/far/and/keeps/going";
      95            1 :         char *r = word_wrap(url, 40);
      96            1 :         ASSERT(r != NULL, "word_wrap: long URL not NULL");
      97            1 :         ASSERT(strstr(r, url) != NULL, "word_wrap: long URL emitted intact");
      98            1 :         free(r);
      99              :     }
     100              : 
     101              :     /* 2-byte UTF-8 lead byte (line 143: *p < 0xE0): é = \xC3\xA9 */
     102              :     {
     103            1 :         char *r = word_wrap("\xC3\xA9\xC3\xA9\xC3\xA9 test", 40);
     104            1 :         ASSERT(r != NULL, "word_wrap: 2-byte UTF-8 not NULL");
     105            1 :         free(r);
     106              :     }
     107              : 
     108              :     /* 3-byte UTF-8 lead byte (line 144: *p < 0xF0): 中 = \xE4\xB8\xAD */
     109              :     {
     110            1 :         char *r = word_wrap("\xE4\xB8\xAD text", 40);
     111            1 :         ASSERT(r != NULL, "word_wrap: 3-byte UTF-8 not NULL");
     112            1 :         free(r);
     113              :     }
     114              : 
     115              :     /* 4-byte UTF-8 lead byte (line 145: *p < 0xF8): U+10000 = \xF0\x90\x80\x80 */
     116              :     {
     117            1 :         char *r = word_wrap("\xF0\x90\x80\x80 test", 40);
     118            1 :         ASSERT(r != NULL, "word_wrap: 4-byte UTF-8 not NULL");
     119            1 :         free(r);
     120              :     }
     121              : 
     122              :     /* Invalid lead byte < 0xC2 (line 142: continuation byte as lead) */
     123              :     {
     124            1 :         char *r = word_wrap("\x80 bad", 40);
     125            1 :         ASSERT(r != NULL, "word_wrap: 0x80 lead byte not NULL");
     126            1 :         free(r);
     127              :     }
     128              : 
     129              :     /* Invalid lead byte >= 0xF8 (line 146: else branch) */
     130              :     {
     131            1 :         char *r = word_wrap("\xFE bad", 40);
     132            1 :         ASSERT(r != NULL, "word_wrap: 0xFE lead byte not NULL");
     133            1 :         free(r);
     134              :     }
     135              : 
     136              :     /* Continuation byte mismatch (line 148): 2-byte start \xC3 + non-continuation \x41 */
     137              :     {
     138            1 :         char *r = word_wrap("\xC3\x41 bad", 40);
     139            1 :         ASSERT(r != NULL, "word_wrap: truncated multibyte not NULL");
     140            1 :         free(r);
     141              :     }
     142              : 
     143              :     /* Multi-line input — exercises the outer loop past eol */
     144              :     {
     145            1 :         char *r = word_wrap("first line\nsecond line\n", 40);
     146            1 :         ASSERT(r != NULL, "word_wrap: multi-line not NULL");
     147            1 :         ASSERT(strstr(r, "first line") != NULL, "word_wrap: multi-line first");
     148            1 :         ASSERT(strstr(r, "second line") != NULL, "word_wrap: multi-line second");
     149            1 :         free(r);
     150              :     }
     151              : 
     152              :     /* ── ansi_scan ───────────────────────────────────────────────────── */
     153              : 
     154              :     /* Empty content → all zeros */
     155              :     {
     156            1 :         AnsiState st = {0};
     157            1 :         ansi_scan("", "", &st);
     158            1 :         ASSERT(st.bold==0 && st.italic==0 && st.uline==0 && st.strike==0,
     159              :                "ansi_scan: empty → no state");
     160            1 :         ASSERT(st.fg_on==0 && st.bg_on==0, "ansi_scan: empty → no color");
     161              :     }
     162              : 
     163              :     /* Bold on/off */
     164              :     {
     165            1 :         AnsiState st = {0};
     166            1 :         const char *s = "\033[1mtext\033[22m";
     167            1 :         ansi_scan(s, s + strlen(s), &st);
     168            1 :         ASSERT(st.bold == 0, "ansi_scan: bold on then off → 0");
     169              : 
     170            1 :         AnsiState st2 = {0};
     171            1 :         const char *s2 = "\033[1mtext";
     172            1 :         ansi_scan(s2, s2 + strlen(s2), &st2);
     173            1 :         ASSERT(st2.bold == 1, "ansi_scan: bold on, no off → 1");
     174              :     }
     175              : 
     176              :     /* Italic on/off */
     177              :     {
     178            1 :         AnsiState st = {0};
     179            1 :         const char *s = "\033[3m";
     180            1 :         ansi_scan(s, s + strlen(s), &st);
     181            1 :         ASSERT(st.italic == 1, "ansi_scan: italic on → 1");
     182              : 
     183            1 :         st.italic = 1;
     184            1 :         const char *s2 = "\033[23m";
     185            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     186            1 :         ASSERT(st.italic == 0, "ansi_scan: italic off → 0");
     187              :     }
     188              : 
     189              :     /* Underline on/off */
     190              :     {
     191            1 :         AnsiState st = {0};
     192            1 :         const char *s = "\033[4m";
     193            1 :         ansi_scan(s, s + strlen(s), &st);
     194            1 :         ASSERT(st.uline == 1, "ansi_scan: uline on → 1");
     195              : 
     196            1 :         const char *s2 = "\033[24m";
     197            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     198            1 :         ASSERT(st.uline == 0, "ansi_scan: uline off → 0");
     199              :     }
     200              : 
     201              :     /* Strikethrough on/off */
     202              :     {
     203            1 :         AnsiState st = {0};
     204            1 :         const char *s = "\033[9m";
     205            1 :         ansi_scan(s, s + strlen(s), &st);
     206            1 :         ASSERT(st.strike == 1, "ansi_scan: strike on → 1");
     207              : 
     208            1 :         const char *s2 = "\033[29m";
     209            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     210            1 :         ASSERT(st.strike == 0, "ansi_scan: strike off → 0");
     211              :     }
     212              : 
     213              :     /* Foreground color set and reset */
     214              :     {
     215            1 :         AnsiState st = {0};
     216            1 :         const char *s = "\033[38;2;255;0;128m";
     217            1 :         ansi_scan(s, s + strlen(s), &st);
     218            1 :         ASSERT(st.fg_on == 1, "ansi_scan: fg on → 1");
     219            1 :         ASSERT(st.fg_r == 255 && st.fg_g == 0 && st.fg_b == 128,
     220              :                "ansi_scan: fg RGB correct");
     221              : 
     222            1 :         const char *s2 = "\033[39m";
     223            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     224            1 :         ASSERT(st.fg_on == 0, "ansi_scan: fg reset → 0");
     225              :     }
     226              : 
     227              :     /* Background color set and reset */
     228              :     {
     229            1 :         AnsiState st = {0};
     230            1 :         const char *s = "\033[48;2;0;64;255m";
     231            1 :         ansi_scan(s, s + strlen(s), &st);
     232            1 :         ASSERT(st.bg_on == 1, "ansi_scan: bg on → 1");
     233            1 :         ASSERT(st.bg_r == 0 && st.bg_g == 64 && st.bg_b == 255,
     234              :                "ansi_scan: bg RGB correct");
     235              : 
     236            1 :         const char *s2 = "\033[49m";
     237            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     238            1 :         ASSERT(st.bg_on == 0, "ansi_scan: bg reset → 0");
     239              :     }
     240              : 
     241              :     /* Full reset \033[0m clears all accumulated state */
     242              :     {
     243            1 :         AnsiState st = {0};
     244            1 :         const char *s = "\033[1m\033[3m\033[38;2;255;0;0m\033[0m";
     245            1 :         ansi_scan(s, s + strlen(s), &st);
     246            1 :         ASSERT(st.bold==0 && st.italic==0 && st.fg_on==0,
     247              :                "ansi_scan: full reset clears all");
     248              :     }
     249              : 
     250              :     /* Partial scan: only up to a mid-point in the string */
     251              :     {
     252              :         /* Scan only the first segment (bold+color open), stop before close */
     253            1 :         const char *body = "\033[1m\033[38;2;255;0;0mLine 0\nLine 1\n\033[22m\033[39m";
     254            1 :         const char *nl   = strchr(body, '\n');  /* end of "Line 0" */
     255            1 :         AnsiState st = {0};
     256            1 :         ansi_scan(body, nl, &st);
     257            1 :         ASSERT(st.bold == 1,  "ansi_scan: partial scan bold open");
     258            1 :         ASSERT(st.fg_on == 1, "ansi_scan: partial scan fg open");
     259              :     }
     260              : 
     261              :     /* ── print_body_page ─────────────────────────────────────────────── */
     262              :     /*
     263              :      * Redirect stdout to /dev/null so the printed lines do not pollute
     264              :      * the test runner output.  Restore after.
     265              :      */
     266              :     {
     267            1 :         fflush(stdout);
     268            1 :         int saved_fd = dup(STDOUT_FILENO);
     269            1 :         int null_fd  = open("/dev/null", O_WRONLY);
     270            1 :         if (null_fd >= 0) dup2(null_fd, STDOUT_FILENO);
     271            1 :         if (null_fd >= 0) close(null_fd);
     272              : 
     273              :         /* Print lines 1-2 of a 4-line body (normal newline path) */
     274            1 :         print_body_page("Line 0\nLine 1\nLine 2\nLine 3\n", 1, 2, 80);
     275              : 
     276              :         /* Body does not end with '\n': last segment hits the else branch
     277              :          * (printf("%s\n", p); break;) at lines 255-257 */
     278            1 :         print_body_page("Line 0\nNo newline here", 1, 5, 80);
     279              : 
     280              :         /* from_line == 0, single print */
     281            1 :         print_body_page("only line", 0, 1, 80);
     282              : 
     283            1 :         fflush(stdout);
     284            1 :         dup2(saved_fd, STDOUT_FILENO);
     285            1 :         close(saved_fd);
     286              :     }
     287              : 
     288              :     /*
     289              :      * Regression test: ANSI state must be replayed at page boundaries.
     290              :      *
     291              :      * A multi-line styled span (e.g. <div style="color:red">) produces:
     292              :      *   \033[38;2;255;0;0mLine 0\nLine 1\nLine 2\n\n\033[39m
     293              :      *
     294              :      * When paginating from line 1 onward, the fg-color escape from line 0
     295              :      * would have been SKIPPED.  Without the fix, Line 1 and Line 2 appeared
     296              :      * in the terminal's default color — and if the terminal had a dark theme
     297              :      * and the email also set background:white, the result was white-on-white.
     298              :      *
     299              :      * The fix (ansi_scan + ansi_replay) re-emits the color escape before the
     300              :      * first visible line.  This test captures stdout via a pipe and asserts
     301              :      * the replayed escape is present.
     302              :      */
     303              :     {
     304              :         /* Body that html_render() would produce for a multi-line color span */
     305            1 :         const char *body =
     306              :             "\033[38;2;255;0;0mLine 0\n"   /* fg red open on line 0 */
     307              :             "Line 1\n"
     308              :             "Line 2\n"
     309              :             "\033[39m";                    /* fg reset after last line */
     310              : 
     311              :         int pipefd[2];
     312            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "page ANSI replay: pipe failed"); goto skip_replay_fg; }
     313            1 :         fflush(stdout);
     314            1 :         int saved = dup(STDOUT_FILENO);
     315            1 :         dup2(pipefd[1], STDOUT_FILENO);
     316            1 :         close(pipefd[1]);
     317              : 
     318              :         /* Skip line 0; print lines 1-2 */
     319            1 :         print_body_page(body, 1, 2, 80);
     320              : 
     321            1 :         fflush(stdout);
     322            1 :         dup2(saved, STDOUT_FILENO);
     323            1 :         close(saved);
     324              : 
     325            1 :         char buf[256] = {0};
     326            1 :         ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
     327            1 :         close(pipefd[0]);
     328            1 :         buf[n > 0 ? n : 0] = '\0';
     329              : 
     330              :         /* The replayed fg-red escape must appear before "Line 1" */
     331            1 :         const char *esc  = strstr(buf, "\033[38;2;255;0;0m");
     332            1 :         const char *line1 = strstr(buf, "Line 1");
     333            1 :         ASSERT(esc != NULL,
     334              :                "page ANSI replay: fg color escape present in page-2 output");
     335            1 :         ASSERT(line1 != NULL,
     336              :                "page ANSI replay: Line 1 present in output");
     337            1 :         ASSERT(esc < line1,
     338              :                "page ANSI replay: fg escape precedes Line 1");
     339            1 :         skip_replay_fg:;
     340              :     }
     341              : 
     342              :     /*
     343              :      * Regression test: background color must also be replayed.
     344              :      * This models the exact scenario that caused white-on-white:
     345              :      * a <div style="background-color:white"> spanning multiple lines.
     346              :      */
     347              :     {
     348            1 :         const char *body =
     349              :             "\033[48;2;255;255;255mLine 0\n"   /* bg white on line 0 */
     350              :             "Line 1\n"
     351              :             "\033[49m";
     352              : 
     353              :         int pipefd[2];
     354            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "page ANSI replay bg: pipe failed"); goto skip_replay_bg; }
     355            1 :         fflush(stdout);
     356            1 :         int saved = dup(STDOUT_FILENO);
     357            1 :         dup2(pipefd[1], STDOUT_FILENO);
     358            1 :         close(pipefd[1]);
     359              : 
     360            1 :         print_body_page(body, 1, 1, 80);
     361              : 
     362            1 :         fflush(stdout);
     363            1 :         dup2(saved, STDOUT_FILENO);
     364            1 :         close(saved);
     365              : 
     366            1 :         char buf[256] = {0};
     367            1 :         ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
     368            1 :         close(pipefd[0]);
     369            1 :         buf[n > 0 ? n : 0] = '\0';
     370              : 
     371            1 :         const char *esc   = strstr(buf, "\033[48;2;255;255;255m");
     372            1 :         const char *line1 = strstr(buf, "Line 1");
     373            1 :         ASSERT(esc != NULL,
     374              :                "page ANSI replay bg: bg color escape present in page-2 output");
     375            1 :         ASSERT(esc < line1,
     376              :                "page ANSI replay bg: bg escape precedes Line 1");
     377            1 :         skip_replay_bg:;
     378              :     }
     379              : 
     380              :     /* ── print_padded_col (non-ASCII paths, lines 83-91) ─────────────── */
     381              :     /*
     382              :      * Redirect stdout to /dev/null to avoid polluting test output.
     383              :      * print_padded_col writes to stdout via fwrite/putchar.
     384              :      */
     385              :     {
     386            1 :         fflush(stdout);
     387            1 :         int saved_fd2 = dup(STDOUT_FILENO);
     388            1 :         int null_fd2  = open("/dev/null", O_WRONLY);
     389            1 :         if (null_fd2 >= 0) dup2(null_fd2, STDOUT_FILENO);
     390            1 :         if (null_fd2 >= 0) close(null_fd2);
     391              : 
     392              :         /* 0x80 = invalid lead byte → line 83 */
     393            1 :         print_padded_col("\x80 bad", 20);
     394              : 
     395              :         /* 2-byte UTF-8: é = \xC3\xA9 → line 84 */
     396            1 :         print_padded_col("\xC3\xA9 cafe", 20);
     397              : 
     398              :         /* 3-byte UTF-8: 中 = \xE4\xB8\xAD → line 85 */
     399            1 :         print_padded_col("\xE4\xB8\xAD word", 20);
     400              : 
     401              :         /* 4-byte UTF-8: U+10000 = \xF0\x90\x80\x80 → line 86 */
     402            1 :         print_padded_col("\xF0\x90\x80\x80 hi", 20);
     403              : 
     404              :         /* 0xFE = invalid lead byte >= 0xF8 → line 87 */
     405            1 :         print_padded_col("\xFE bad", 20);
     406              : 
     407              :         /* Truncated 2-byte: \xC3 then 'A' (not continuation) → lines 90-91 */
     408            1 :         print_padded_col("\xC3\x41 trunc", 20);
     409              : 
     410            1 :         fflush(stdout);
     411            1 :         dup2(saved_fd2, STDOUT_FILENO);
     412            1 :         close(saved_fd2);
     413              :     }
     414              : 
     415              :     /*
     416              :      * Regression: visual row budget in print_body_page.
     417              :      *
     418              :      * Body has 1 normal line + 1 very wide line (wider than term_cols) +
     419              :      * 2 more normal lines.  With a visual row budget of 3 on a 40-col
     420              :      * terminal, the wide line consumes multiple visual rows, so the 3rd
     421              :      * normal line should NOT appear in the output.
     422              :      *
     423              :      * This proves print_body_page stops at the visual row budget, not the
     424              :      * logical line count.
     425              :      */
     426              :     {
     427              :         /* Build a 120-char URL-like token (fits on 1 logical line, 3 visual rows on 40-col) */
     428            1 :         char wide[121]; memset(wide, 'W', 120); wide[120] = '\0';
     429              :         char body_vr[256];
     430            1 :         snprintf(body_vr, sizeof(body_vr),
     431              :                  "NormalA\n%s\nNormalB\nNormalC\n", wide);
     432              : 
     433              :         int pipefd[2];
     434            1 :         if (pipe(pipefd) != 0) {
     435            0 :             ASSERT(0, "visual rows: pipe failed");
     436              :             goto skip_vr_test;
     437              :         }
     438            1 :         fflush(stdout);
     439            1 :         int saved_vr = dup(STDOUT_FILENO);
     440            1 :         dup2(pipefd[1], STDOUT_FILENO);
     441            1 :         close(pipefd[1]);
     442              : 
     443              :         /* budget = 4 visual rows on 40-col terminal:
     444              :          *   NormalA  = 1 row  (total 1)
     445              :          *   wide 120 = 3 rows (total 4) → fits in budget
     446              :          *   NormalB  = 1 row  (total 5 > 4) → should NOT appear
     447              :          *   NormalC  → should NOT appear */
     448            1 :         print_body_page(body_vr, 0, 4, 40);
     449              : 
     450            1 :         fflush(stdout);
     451            1 :         dup2(saved_vr, STDOUT_FILENO);
     452            1 :         close(saved_vr);
     453              : 
     454            1 :         char buf_vr[512] = {0};
     455            1 :         ssize_t n_vr = read(pipefd[0], buf_vr, sizeof(buf_vr) - 1);
     456            1 :         close(pipefd[0]);
     457            1 :         buf_vr[n_vr > 0 ? n_vr : 0] = '\0';
     458              : 
     459            1 :         ASSERT(strstr(buf_vr, "NormalA")  != NULL,
     460              :                "visual rows: NormalA shown (fits in budget)");
     461            1 :         ASSERT(strstr(buf_vr, wide)        != NULL,
     462              :                "visual rows: wide line shown (fits in budget)");
     463            1 :         ASSERT(strstr(buf_vr, "NormalB")  == NULL,
     464              :                "visual rows: NormalB NOT shown (budget exhausted)");
     465            1 :         ASSERT(strstr(buf_vr, "NormalC")  == NULL,
     466              :                "visual rows: NormalC NOT shown (budget exhausted)");
     467            1 :         skip_vr_test:;
     468              :     }
     469              : 
     470              :     /* ── print_clean — truncation at max_cols ───────────────────────── */
     471              :     /*
     472              :      * Regression test for ce09877: print_clean must stop emitting characters
     473              :      * once the visible column count reaches max_cols, so that header values
     474              :      * (From/Subject/Date) never overflow the 80-column display width.
     475              :      *
     476              :      * We capture stdout via a pipe, call print_clean with a 200-char ASCII
     477              :      * string and max_cols=10, then verify the captured output is ≤ 10 bytes.
     478              :      */
     479              :     {
     480              :         char long_str[201];
     481            1 :         memset(long_str, 'A', 200);
     482            1 :         long_str[200] = '\0';
     483              : 
     484              :         int pipefd[2];
     485            1 :         if (pipe(pipefd) != 0) {
     486            0 :             ASSERT(0, "print_clean truncation: pipe failed");
     487              :             goto skip_print_clean;
     488              :         }
     489            1 :         fflush(stdout);
     490            1 :         int saved_pc = dup(STDOUT_FILENO);
     491            1 :         dup2(pipefd[1], STDOUT_FILENO);
     492            1 :         close(pipefd[1]);
     493              : 
     494            1 :         print_clean(long_str, "(none)", 10);
     495              : 
     496            1 :         fflush(stdout);
     497            1 :         dup2(saved_pc, STDOUT_FILENO);
     498            1 :         close(saved_pc);
     499              : 
     500            1 :         char buf_pc[256] = {0};
     501            1 :         ssize_t n_pc = read(pipefd[0], buf_pc, sizeof(buf_pc) - 1);
     502            1 :         close(pipefd[0]);
     503            1 :         buf_pc[n_pc > 0 ? n_pc : 0] = '\0';
     504              : 
     505            1 :         ASSERT((int)strlen(buf_pc) <= 10,
     506              :                "print_clean: output truncated to max_cols=10");
     507            1 :         ASSERT(strlen(buf_pc) > 0,
     508              :                "print_clean: output is non-empty");
     509            1 :         skip_print_clean:;
     510              :     }
     511              : 
     512              :     /* NULL input falls back to fallback string */
     513              :     {
     514              :         int pipefd[2];
     515            1 :         if (pipe(pipefd) != 0) {
     516            0 :             ASSERT(0, "print_clean fallback: pipe failed");
     517              :             goto skip_print_clean_fb;
     518              :         }
     519            1 :         fflush(stdout);
     520            1 :         int saved_fb = dup(STDOUT_FILENO);
     521            1 :         dup2(pipefd[1], STDOUT_FILENO);
     522            1 :         close(pipefd[1]);
     523              : 
     524            1 :         print_clean(NULL, "(none)", 20);
     525              : 
     526            1 :         fflush(stdout);
     527            1 :         dup2(saved_fb, STDOUT_FILENO);
     528            1 :         close(saved_fb);
     529              : 
     530            1 :         char buf_fb[64] = {0};
     531            1 :         ssize_t n_fb = read(pipefd[0], buf_fb, sizeof(buf_fb) - 1);
     532            1 :         close(pipefd[0]);
     533            1 :         buf_fb[n_fb > 0 ? n_fb : 0] = '\0';
     534              : 
     535            1 :         ASSERT(strcmp(buf_fb, "(none)") == 0,
     536              :                "print_clean: NULL input uses fallback");
     537            1 :         skip_print_clean_fb:;
     538              :     }
     539              : 
     540              :     /* ── cmp_uid_entry ───────────────────────────────────────────────── */
     541              :     {
     542            1 :         MsgEntry a = {"0000000000000100", MSG_FLAG_UNSEEN, 1000, ""};  /* unseen */
     543            1 :         MsgEntry b = {"0000000000000200", 0,               2000, ""};  /* seen   */
     544              :         /* unseen before seen regardless of date */
     545            1 :         ASSERT(cmp_uid_entry(&a, &b) < 0, "cmp_uid_entry: unseen before seen");
     546            1 :         ASSERT(cmp_uid_entry(&b, &a) > 0, "cmp_uid_entry: seen after unseen");
     547              :     }
     548              :     {
     549            1 :         MsgEntry c = {"0000000000000100", MSG_FLAG_UNSEEN, 1000, ""};
     550            1 :         MsgEntry d = {"0000000000000200", MSG_FLAG_UNSEEN, 2000, ""};
     551              :         /* both unseen: newer date (higher epoch) first */
     552            1 :         ASSERT(cmp_uid_entry(&c, &d) > 0, "cmp_uid_entry: older date after newer");
     553            1 :         ASSERT(cmp_uid_entry(&d, &c) < 0, "cmp_uid_entry: newer date before older");
     554              :     }
     555              :     {
     556            1 :         MsgEntry e = {"0000000000000100", MSG_FLAG_FLAGGED, 500, ""};
     557            1 :         MsgEntry f = {"0000000000000200", 0,                500, ""};
     558              :         /* flagged (read) before plain read */
     559            1 :         ASSERT(cmp_uid_entry(&e, &f) < 0, "cmp_uid_entry: flagged before rest");
     560              :     }
     561              :     {
     562            1 :         MsgEntry g = {"0000000000000100", 0, 0, ""};
     563            1 :         MsgEntry h = {"0000000000000100", 0, 0, ""};
     564              :         /* equal: cmp == 0 */
     565            1 :         ASSERT(cmp_uid_entry(&g, &h) == 0, "cmp_uid_entry: equal entries → 0");
     566              :     }
     567              : 
     568              :     /* ── is_last_sibling ─────────────────────────────────────────────── */
     569              : 
     570              :     /* Root-level two items: first is not last, second is */
     571              :     {
     572            1 :         char *names[] = {"A", "B"};
     573            1 :         ASSERT(is_last_sibling(names, 2, 0, '.') == 0,
     574              :                "is_last_sibling: A not last (B follows)");
     575            1 :         ASSERT(is_last_sibling(names, 2, 1, '.') == 1,
     576              :                "is_last_sibling: B is last");
     577              :     }
     578              : 
     579              :     /* parent_len == 0 path (line 582): root-level item with multiple followers */
     580              :     {
     581            1 :         char *names[] = {"A", "B", "C"};
     582            1 :         ASSERT(is_last_sibling(names, 3, 0, '.') == 0,
     583              :                "is_last_sibling: root level, A not last");
     584              :     }
     585              : 
     586              :     /* line 587: jumped to a different parent subtree → return 1 */
     587              :     {
     588              :         /* INBOX, INBOX.A, INBOX.B, Other — sorted */
     589            1 :         char *names[] = {"INBOX", "INBOX.A", "INBOX.B", "Other"};
     590              :         /* INBOX.A: sibling INBOX.B follows → not last */
     591            1 :         ASSERT(is_last_sibling(names, 4, 1, '.') == 0,
     592              :                "is_last_sibling: INBOX.A not last");
     593              :         /* INBOX.B: next is Other (different parent subtree) → last */
     594            1 :         ASSERT(is_last_sibling(names, 4, 2, '.') == 1,
     595              :                "is_last_sibling: INBOX.B is last (diff parent)");
     596              :     }
     597              : 
     598              :     /* Single item → always last */
     599              :     {
     600            1 :         char *names[] = {"INBOX"};
     601            1 :         ASSERT(is_last_sibling(names, 1, 0, '.') == 1,
     602              :                "is_last_sibling: single item is last");
     603              :     }
     604              : 
     605              :     /* ── ancestor_is_last ────────────────────────────────────────────── */
     606              : 
     607              :     /* Root-level ancestor with a sibling following: parent_len == 0, return 0 (line 630) */
     608              :     {
     609            1 :         char *names[] = {"INBOX", "INBOX.A", "INBOX.B", "Sent"};
     610              :         /* For INBOX.A at level=0: ancestor "INBOX" is NOT the last root (Sent follows) */
     611            1 :         int r = ancestor_is_last(names, 4, 1, 0, '.');
     612            1 :         ASSERT(r == 0, "ancestor_is_last: INBOX not last root");
     613              :     }
     614              : 
     615              :     /* Root-level ancestor that IS last */
     616              :     {
     617            1 :         char *names[] = {"INBOX", "INBOX.A", "Sent"};
     618              :         /* For Sent (index=2) at level=0: nothing after it → last */
     619            1 :         int r = ancestor_is_last(names, 3, 2, 0, '.');
     620            1 :         ASSERT(r == 1, "ancestor_is_last: Sent is last root");
     621              :     }
     622              : 
     623              :     /* level=0 with follower: parent_len==0 → return 0 (covers line 630) */
     624              :     {
     625            1 :         char *names[] = {"A.X", "A.Y", "B.Z"};
     626              :         /* A.Y's root-level ancestor is "A"; "B.Z" follows at root → return 0 */
     627            1 :         int r = ancestor_is_last(names, 3, 1, 0, '.');
     628            1 :         ASSERT(r == 0, "ancestor_is_last: level=0, another root item follows → 0");
     629              :     }
     630              : 
     631              :     /* line 636: jumped to different parent subtree → return 1 (level > 0) */
     632              :     {
     633              :         /* A.B.Y's ancestor at level=1 is "A.B"; parent of "A.B" is "A".
     634              :          * After A.B.Y's subtree, C.D has parent "C" ≠ "A" → return 1. */
     635            1 :         char *names[] = {"A.B.X", "A.B.Y", "C.D"};
     636            1 :         int r = ancestor_is_last(names, 3, 1, 1, '.');
     637            1 :         ASSERT(r == 1, "ancestor_is_last: level=1, different grandparent → 1");
     638              :     }
     639              : 
     640              :     /* Only one root-level folder (INBOX) → ancestor is last */
     641              :     {
     642            1 :         char *names[] = {"INBOX.A", "INBOX.A.X", "INBOX.A.Y", "INBOX.B"};
     643              :         /* All entries share root "INBOX"; nothing at a different root → last=1 */
     644            1 :         int r = ancestor_is_last(names, 4, 2, 0, '.');
     645            1 :         ASSERT(r == 1, "ancestor_is_last: INBOX is only root → 1");
     646              :     }
     647              : 
     648              :     /* ── HTML-only MIME: CSS must not leak into rendered output ──────── */
     649              :     /*
     650              :      * Regression test for show_uid_interactive: when an email has only a
     651              :      * text/html part (no text/plain), the body must be rendered through
     652              :      * html_render(), not passed through as raw text.  In particular, any
     653              :      * <style> block must be suppressed and visible body text must appear.
     654              :      */
     655              :     {
     656              :         /* Minimal MIME message: HTML-only, with an embedded <style> block */
     657            1 :         const char *mime_msg =
     658              :             "MIME-Version: 1.0\r\n"
     659              :             "Content-Type: text/html; charset=UTF-8\r\n"
     660              :             "\r\n"
     661              :             "<html>"
     662              :             "<head><style>body { color: red; font-family: Arial; }</style></head>"
     663              :             "<body><b>Visible Text</b></body>"
     664              :             "</html>";
     665              : 
     666            1 :         char *html = mime_get_html_part(mime_msg);
     667            1 :         ASSERT(html != NULL, "html-only mime: html part found");
     668              : 
     669            1 :         char *rendered = html_render(html, 0, 0);
     670            1 :         free(html);
     671            1 :         ASSERT(rendered != NULL, "html-only mime: render not NULL");
     672              : 
     673              :         /* Visible content must appear */
     674            1 :         ASSERT(strstr(rendered, "Visible Text") != NULL,
     675              :                "html-only mime: body text present in output");
     676              : 
     677              :         /* CSS must be suppressed */
     678            1 :         ASSERT(strstr(rendered, "color") == NULL,
     679              :                "html-only mime: CSS property 'color' not in output");
     680            1 :         ASSERT(strstr(rendered, "font-family") == NULL,
     681              :                "html-only mime: CSS property 'font-family' not in output");
     682            1 :         ASSERT(strstr(rendered, "Arial") == NULL,
     683              :                "html-only mime: CSS value 'Arial' not in output");
     684              : 
     685            1 :         free(rendered);
     686              :     }
     687              : 
     688              :     /* ── show_uid_interactive: uses correct folder, not cfg->folder ──── */
     689              :     /*
     690              :      * Regression test for subfolder message open bug.
     691              :      *
     692              :      * When the user presses Enter on a message in a subfolder (e.g. "munka/ai"),
     693              :      * show_uid_interactive must look up the message in that subfolder's cache —
     694              :      * NOT in cfg->folder (which is always "INBOX").
     695              :      *
     696              :      * Setup:
     697              :      *   - Pre-populate cache under "test_subfolder" with UID 7777.
     698              :      *   - Config has .folder = "INBOX" (wrong folder — the bug).
     699              :      *   - Inject ESC via pipe into STDIN_FILENO so the function exits cleanly.
     700              :      *
     701              :      * If the function uses cfg->folder ("INBOX"):
     702              :      *   local_msg_exists("INBOX", 7777) → false → fetch fails → returns -1.
     703              :      * If the function uses the correct folder ("test_subfolder"):
     704              :      *   local_msg_exists("test_subfolder", 7777) → true → loads OK → ESC → returns 1.
     705              :      */
     706              :     {
     707              :         /* Minimal plain-text MIME message */
     708            1 :         const char *sf_mime =
     709              :             "MIME-Version: 1.0\r\n"
     710              :             "Content-Type: text/plain; charset=UTF-8\r\n"
     711              :             "Subject: Subfolder test\r\n"
     712              :             "From: test@example.com\r\n"
     713              :             "\r\n"
     714              :             "Subfolder message body.\r\n";
     715              : 
     716              :         /* Pre-populate cache under the correct subfolder */
     717            1 :         int saved_rc = local_msg_save("test_subfolder", "0000000000007777",
     718              :                                   sf_mime, strlen(sf_mime));
     719            1 :         if (saved_rc != 0) {
     720            0 :             ASSERT(0, "show_uid_interactive subfolder: local_msg_save failed");
     721              :             goto skip_subfolder_test;
     722              :         }
     723              : 
     724              :         /* Config intentionally has the wrong folder (the bug) */
     725              :         Config sf_cfg;
     726            1 :         memset(&sf_cfg, 0, sizeof(sf_cfg));
     727            1 :         sf_cfg.folder = "INBOX";
     728              : 
     729              :         /* Inject ESC (\033) into stdin via pipe so the function exits */
     730              :         int sf_pipe[2];
     731            1 :         if (pipe(sf_pipe) != 0) {
     732            0 :             ASSERT(0, "show_uid_interactive subfolder: pipe failed");
     733              :             goto skip_subfolder_test;
     734              :         }
     735            1 :         unsigned char esc_byte = '\033';
     736            1 :         ssize_t _w = write(sf_pipe[1], &esc_byte, 1);
     737              :         (void)_w;
     738            1 :         close(sf_pipe[1]);
     739              : 
     740              :         /* Redirect stdin to pipe read end */
     741            1 :         int saved_stdin = dup(STDIN_FILENO);
     742            1 :         dup2(sf_pipe[0], STDIN_FILENO);
     743            1 :         close(sf_pipe[0]);
     744              : 
     745              :         /* Redirect stdout + stderr to /dev/null (suppress TUI output) */
     746            1 :         fflush(stdout); fflush(stderr);
     747            1 :         int sf_null = open("/dev/null", O_WRONLY);
     748            1 :         int saved_stdout = dup(STDOUT_FILENO);
     749            1 :         int saved_stderr = dup(STDERR_FILENO);
     750            1 :         if (sf_null >= 0) {
     751            1 :             dup2(sf_null, STDOUT_FILENO);
     752            1 :             dup2(sf_null, STDERR_FILENO);
     753            1 :             close(sf_null);
     754              :         }
     755              : 
     756              :         /* Call with new signature: explicit folder parameter */
     757            1 :         int sf_ret = show_uid_interactive(&sf_cfg, NULL, "test_subfolder", "0000000000007777", 25, 0, NULL);
     758              : 
     759              :         /* Restore stdin, stdout, stderr — ALWAYS, even if ASSERT would fail */
     760            1 :         fflush(stdout); fflush(stderr);
     761            1 :         dup2(saved_stdin,  STDIN_FILENO);  close(saved_stdin);
     762            1 :         dup2(saved_stdout, STDOUT_FILENO); close(saved_stdout);
     763            1 :         dup2(saved_stderr, STDERR_FILENO); close(saved_stderr);
     764              : 
     765              :         /* ESC → returns 1 (exit program); "not found in INBOX" → returns -1 */
     766            1 :         ASSERT(sf_ret == 1,
     767              :                "show_uid_interactive: uses correct folder (not cfg->folder)");
     768              : 
     769            1 :         skip_subfolder_test:;
     770              :     }
     771              : 
     772              :     /* ── email_service_set_flag ─────────────────────────────────────── */
     773              : 
     774              :     /* test_set_flag_mark_read: create manifest with UNSEEN message, call set_flag
     775              :      * with MSG_FLAG_UNSEEN,0, verify manifest has UNSEEN cleared */
     776              :     {
     777            1 :         const char *test_folder = "test_set_flag_folder";
     778            1 :         const char *test_uid    = "0000000000009001";
     779              : 
     780              :         /* Build a manifest with one UNSEEN message */
     781            1 :         Manifest *m = calloc(1, sizeof(Manifest));
     782            1 :         ASSERT(m != NULL, "set_flag mark_read: manifest alloc");
     783            1 :         manifest_upsert(m, test_uid,
     784              :                         strdup("test@example.com"),
     785              :                         strdup("Test Subject"),
     786              :                         strdup("2024-01-01 00:00"),
     787              :                         MSG_FLAG_UNSEEN);
     788            1 :         ASSERT(manifest_save(test_folder, m) == 0, "set_flag mark_read: manifest_save");
     789            1 :         manifest_free(m);
     790              : 
     791              :         /* Fake config (no real server) */
     792              :         Config fcfg;
     793            1 :         memset(&fcfg, 0, sizeof(fcfg));
     794            1 :         fcfg.folder     = (char *)test_folder;
     795            1 :         fcfg.gmail_mode = 0;
     796              : 
     797              :         /* Call set_flag to clear UNSEEN (mark as read) — server push will fail,
     798              :          * but we only care about the local manifest update */
     799            1 :         email_service_set_flag(&fcfg, test_uid, test_folder, MSG_FLAG_UNSEEN, 0);
     800              : 
     801              :         /* Reload manifest and verify flag was cleared */
     802            1 :         Manifest *m2 = manifest_load(test_folder);
     803            1 :         ASSERT(m2 != NULL, "set_flag mark_read: manifest_load after");
     804            1 :         ManifestEntry *me = manifest_find(m2, test_uid);
     805            1 :         ASSERT(me != NULL, "set_flag mark_read: entry found");
     806            1 :         ASSERT(!(me->flags & MSG_FLAG_UNSEEN), "set_flag mark_read: UNSEEN cleared");
     807            1 :         manifest_free(m2);
     808              :     }
     809              : 
     810              :     /* test_set_flag_mark_starred: create manifest without FLAGGED, call set_flag
     811              :      * with MSG_FLAG_FLAGGED,1, verify manifest has FLAGGED set */
     812              :     {
     813            1 :         const char *test_folder = "test_set_flag_star_folder";
     814            1 :         const char *test_uid    = "0000000000009002";
     815              : 
     816              :         /* Build a manifest with one message (not flagged) */
     817            1 :         Manifest *m = calloc(1, sizeof(Manifest));
     818            1 :         ASSERT(m != NULL, "set_flag star: manifest alloc");
     819            1 :         manifest_upsert(m, test_uid,
     820              :                         strdup("test@example.com"),
     821              :                         strdup("Star Test"),
     822              :                         strdup("2024-01-01 00:00"),
     823              :                         0 /* no flags */);
     824            1 :         ASSERT(manifest_save(test_folder, m) == 0, "set_flag star: manifest_save");
     825            1 :         manifest_free(m);
     826              : 
     827              :         Config fcfg;
     828            1 :         memset(&fcfg, 0, sizeof(fcfg));
     829            1 :         fcfg.folder     = (char *)test_folder;
     830            1 :         fcfg.gmail_mode = 0;
     831              : 
     832            1 :         email_service_set_flag(&fcfg, test_uid, test_folder, MSG_FLAG_FLAGGED, 1);
     833              : 
     834            1 :         Manifest *m2 = manifest_load(test_folder);
     835            1 :         ASSERT(m2 != NULL, "set_flag star: manifest_load after");
     836            1 :         ManifestEntry *me = manifest_find(m2, test_uid);
     837            1 :         ASSERT(me != NULL, "set_flag star: entry found");
     838            1 :         ASSERT(me->flags & MSG_FLAG_FLAGGED, "set_flag star: FLAGGED set");
     839            1 :         manifest_free(m2);
     840              :     }
     841              : 
     842              :     /* test_remove_account_preserves_local: config_delete_account removes config
     843              :      * but does not touch local store directory */
     844              :     {
     845              :         /* Create a minimal config for a test account */
     846              :         Config tmp_cfg;
     847            1 :         memset(&tmp_cfg, 0, sizeof(tmp_cfg));
     848            1 :         tmp_cfg.user = "testremoveaccount@example.com";
     849            1 :         tmp_cfg.host = "imaps://imap.example.com";
     850            1 :         tmp_cfg.pass = "testpass";
     851            1 :         tmp_cfg.folder = "INBOX";
     852              : 
     853              :         /* Save the account */
     854            1 :         int save_rc = config_save_account(&tmp_cfg);
     855              :         /* If save fails (e.g. permissions in test env), skip gracefully */
     856            1 :         if (save_rc == 0) {
     857              :             /* Verify it was saved */
     858            1 :             int cnt = 0;
     859            1 :             AccountEntry *entries = config_list_accounts(&cnt);
     860            1 :             int found_before = 0;
     861            2 :             for (int i = 0; i < cnt; i++) {
     862            1 :                 if (entries[i].name &&
     863            1 :                     strcmp(entries[i].name, "testremoveaccount@example.com") == 0)
     864            1 :                     found_before = 1;
     865              :             }
     866            1 :             config_free_account_list(entries, cnt);
     867              : 
     868            1 :             if (found_before) {
     869              :                 /* Delete account */
     870            1 :                 config_delete_account("testremoveaccount@example.com");
     871              : 
     872              :                 /* Verify config entry is gone */
     873            1 :                 int cnt2 = 0;
     874            1 :                 AccountEntry *entries2 = config_list_accounts(&cnt2);
     875            1 :                 int found_after = 0;
     876            1 :                 for (int i = 0; i < cnt2; i++) {
     877            0 :                     if (entries2[i].name &&
     878            0 :                         strcmp(entries2[i].name, "testremoveaccount@example.com") == 0)
     879            0 :                         found_after = 1;
     880              :                 }
     881            1 :                 config_free_account_list(entries2, cnt2);
     882            1 :                 ASSERT(!found_after, "remove_account: config entry deleted");
     883              :             }
     884              :         }
     885              :         /* Local store is NOT deleted — this is a policy test, not a file-system test */
     886            1 :         ASSERT(1, "remove_account: local data preservation is policy (no file ops here)");
     887              :     }
     888              : 
     889              :     /* ── print_dbar ─────────────────────────────────────────────────── */
     890              :     {
     891              :         int pipefd[2];
     892            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "print_dbar: pipe failed"); goto skip_print_dbar; }
     893            1 :         fflush(stdout);
     894            1 :         int saved_dbar = dup(STDOUT_FILENO);
     895            1 :         dup2(pipefd[1], STDOUT_FILENO); close(pipefd[1]);
     896              : 
     897            1 :         print_dbar(3);
     898              : 
     899            1 :         fflush(stdout);
     900            1 :         dup2(saved_dbar, STDOUT_FILENO); close(saved_dbar);
     901            1 :         char buf_dbar[64] = {0};
     902            1 :         ssize_t n_dbar = read(pipefd[0], buf_dbar, sizeof(buf_dbar) - 1);
     903            1 :         close(pipefd[0]);
     904            1 :         buf_dbar[n_dbar > 0 ? n_dbar : 0] = '\0';
     905              : 
     906              :         /* Each U+2550 (═) is 3 UTF-8 bytes: 0xE2 0x95 0x90 */
     907            1 :         ASSERT(n_dbar == 9, "print_dbar(3): 3 * 3 bytes = 9");
     908            1 :         ASSERT((unsigned char)buf_dbar[0] == 0xE2 &&
     909              :                (unsigned char)buf_dbar[1] == 0x95 &&
     910              :                (unsigned char)buf_dbar[2] == 0x90,
     911              :                "print_dbar(3): first char is U+2550");
     912            1 :         skip_print_dbar:;
     913              :     }
     914              : 
     915              :     /* print_dbar(0) produces no output */
     916              :     {
     917              :         int pipefd[2];
     918            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "print_dbar(0): pipe failed"); goto skip_print_dbar0; }
     919            1 :         fflush(stdout);
     920            1 :         int saved_db0 = dup(STDOUT_FILENO);
     921            1 :         dup2(pipefd[1], STDOUT_FILENO); close(pipefd[1]);
     922              : 
     923            1 :         print_dbar(0);
     924              : 
     925            1 :         fflush(stdout);
     926            1 :         dup2(saved_db0, STDOUT_FILENO); close(saved_db0);
     927            1 :         char buf_db0[16] = {0};
     928            1 :         ssize_t n_db0 = read(pipefd[0], buf_db0, sizeof(buf_db0) - 1);
     929            1 :         close(pipefd[0]);
     930              : 
     931            1 :         ASSERT(n_db0 == 0, "print_dbar(0): no output");
     932            1 :         skip_print_dbar0:;
     933              :     }
     934              : 
     935              :     /* ── utf8_extra_bytes ────────────────────────────────────────────── */
     936              :     {
     937              :         /* NULL: the function dereferences directly, so test empty string instead */
     938            1 :         ASSERT(utf8_extra_bytes("") == 0, "utf8_extra_bytes: empty → 0");
     939              : 
     940              :         /* Pure ASCII: no continuation bytes */
     941            1 :         ASSERT(utf8_extra_bytes("hello") == 0, "utf8_extra_bytes: ASCII → 0");
     942              : 
     943              :         /* "é" = 0xC3 0xA9: 1 continuation byte */
     944            1 :         ASSERT(utf8_extra_bytes("\xC3\xA9") == 1,
     945              :                "utf8_extra_bytes: é (2-byte) → 1");
     946              : 
     947              :         /* "中" = 0xE4 0xB8 0xAD: 2 continuation bytes */
     948            1 :         ASSERT(utf8_extra_bytes("\xE4\xB8\xAD") == 2,
     949              :                "utf8_extra_bytes: 中 (3-byte) → 2");
     950              : 
     951              :         /* "😀" = 0xF0 0x9F 0x98 0x80: 3 continuation bytes */
     952            1 :         ASSERT(utf8_extra_bytes("\xF0\x9F\x98\x80") == 3,
     953              :                "utf8_extra_bytes: 😀 (4-byte) → 3");
     954              : 
     955              :         /* Mixed: ASCII + 2-byte + 3-byte → 1+2 = 3 extra bytes */
     956            1 :         ASSERT(utf8_extra_bytes("a\xC3\xA9\xE4\xB8\xAD") == 3,
     957              :                "utf8_extra_bytes: mixed → 3");
     958              :     }
     959              : 
     960              :     /* ── fmt_thou ────────────────────────────────────────────────────── */
     961              :     {
     962              :         char buf[32];
     963              : 
     964              :         /* n=0: early return → empty string */
     965            1 :         fmt_thou(buf, sizeof(buf), 0);
     966            1 :         ASSERT(buf[0] == '\0', "fmt_thou(0): empty string");
     967              : 
     968              :         /* n=1: single digit */
     969            1 :         fmt_thou(buf, sizeof(buf), 1);
     970            1 :         ASSERT(strcmp(buf, "1") == 0, "fmt_thou(1): \"1\"");
     971              : 
     972              :         /* n=999: three digits, no separator */
     973            1 :         fmt_thou(buf, sizeof(buf), 999);
     974            1 :         ASSERT(strcmp(buf, "999") == 0, "fmt_thou(999): \"999\"");
     975              : 
     976              :         /* n=1000: four digits → "1 000" */
     977            1 :         fmt_thou(buf, sizeof(buf), 1000);
     978            1 :         ASSERT(strcmp(buf, "1 000") == 0, "fmt_thou(1000): \"1 000\"");
     979              : 
     980              :         /* n=1234567: → "1 234 567" */
     981            1 :         fmt_thou(buf, sizeof(buf), 1234567);
     982            1 :         ASSERT(strcmp(buf, "1 234 567") == 0, "fmt_thou(1234567): \"1 234 567\"");
     983              : 
     984              :         /* n=1000000: → "1 000 000" */
     985            1 :         fmt_thou(buf, sizeof(buf), 1000000);
     986            1 :         ASSERT(strcmp(buf, "1 000 000") == 0, "fmt_thou(1000000): \"1 000 000\"");
     987              :     }
     988              : 
     989              :     /* ── visible_line_cols ───────────────────────────────────────────── */
     990              :     {
     991              :         /* Pure ASCII: 5 chars = 5 cols */
     992            1 :         const char *s1 = "hello";
     993            1 :         ASSERT(visible_line_cols(s1, s1 + 5) == 5,
     994              :                "visible_line_cols: ASCII 5 chars → 5 cols");
     995              : 
     996              :         /* Empty span: 0 cols */
     997            1 :         const char *s2 = "abc";
     998            1 :         ASSERT(visible_line_cols(s2, s2) == 0,
     999              :                "visible_line_cols: empty span → 0");
    1000              : 
    1001              :         /* ANSI CSI escape must not count toward cols */
    1002            1 :         const char *s3 = "\033[1mABC\033[22m";
    1003            1 :         ASSERT(visible_line_cols(s3, s3 + strlen(s3)) == 3,
    1004              :                "visible_line_cols: ANSI bold wrappers → 3 visible cols");
    1005              : 
    1006              :         /* OSC sequence (hyperlink URL) must not count */
    1007              :         /* ESC ] 8 ; ; http://x BEL */
    1008            1 :         const char *s4 = "\033]8;;http://x\007hi\033]8;;\007";
    1009            1 :         ASSERT(visible_line_cols(s4, s4 + strlen(s4)) == 2,
    1010              :                "visible_line_cols: OSC hyperlink → 2 visible cols");
    1011              : 
    1012              :         /* 2-byte UTF-8: "é" (1 column wide) */
    1013            1 :         const char *s5 = "\xC3\xA9";
    1014            1 :         int vcols5 = visible_line_cols(s5, s5 + 2);
    1015            1 :         ASSERT(vcols5 == 1, "visible_line_cols: é → 1 col");
    1016              : 
    1017              :         /* 3-byte UTF-8: "中" (2 columns wide on CJK-capable terminal) */
    1018            1 :         const char *s6 = "\xE4\xB8\xAD";
    1019            1 :         int vcols6 = visible_line_cols(s6, s6 + 3);
    1020            1 :         ASSERT(vcols6 >= 1, "visible_line_cols: CJK char → ≥1 col");
    1021              : 
    1022              :         /* Invalid lead byte 0x80: treated as U+FFFD (width 0 or 1 depending on wcwidth) */
    1023            1 :         const char *s7 = "\x80";
    1024            1 :         int vcols7 = visible_line_cols(s7, s7 + 1);
    1025            1 :         ASSERT(vcols7 >= 0, "visible_line_cols: invalid lead byte → non-negative");
    1026              : 
    1027              :         /* OSC with ESC-backslash terminator */
    1028            1 :         const char *s8 = "\033]8;;\033\\X";
    1029            1 :         ASSERT(visible_line_cols(s8, s8 + strlen(s8)) == 1,
    1030              :                "visible_line_cols: OSC ESC-backslash term → 1 visible col");
    1031              :     }
    1032              : 
    1033              :     /* ── email_service_fetch_raw ─────────────────────────────────────── */
    1034              :     {
    1035            1 :         const char *fetch_folder = "INBOX";
    1036            1 :         const char *fetch_uid    = "0000000000008001";
    1037            1 :         const char *fetch_mime   =
    1038              :             "MIME-Version: 1.0\r\n"
    1039              :             "Content-Type: text/plain; charset=UTF-8\r\n"
    1040              :             "From: fetch@example.com\r\n"
    1041              :             "Subject: Fetch Raw Test\r\n"
    1042              :             "\r\n"
    1043              :             "Raw fetch test body.\r\n";
    1044              : 
    1045            1 :         int sr = local_msg_save(fetch_folder, fetch_uid, fetch_mime, strlen(fetch_mime));
    1046            1 :         ASSERT(sr == 0, "fetch_raw: local_msg_save ok");
    1047              : 
    1048            1 :         Config fr_cfg = {0};
    1049            1 :         fr_cfg.folder = (char *)fetch_folder;
    1050              : 
    1051            1 :         char *raw = email_service_fetch_raw(&fr_cfg, fetch_uid);
    1052            1 :         ASSERT(raw != NULL, "fetch_raw: returns non-NULL for cached message");
    1053            1 :         ASSERT(strstr(raw, "Raw fetch test body.") != NULL,
    1054              :                "fetch_raw: returned content matches saved message");
    1055            1 :         free(raw);
    1056              : 
    1057              :         /* Non-existent UID with no server: returns NULL */
    1058            1 :         char *raw2 = email_service_fetch_raw(&fr_cfg, "0000000000008999");
    1059            1 :         ASSERT(raw2 == NULL, "fetch_raw: returns NULL for non-existent UID");
    1060              :     }
    1061              : 
    1062              :     /* ── email_service_list_attachments ─────────────────────────────── */
    1063              :     {
    1064            1 :         const char *att_folder = "INBOX";
    1065            1 :         const char *att_uid    = "0000000000005555";
    1066            1 :         const char *mime_attach =
    1067              :             "MIME-Version: 1.0\r\n"
    1068              :             "Content-Type: multipart/mixed; boundary=\"B001\"\r\n"
    1069              :             "From: test@example.com\r\n"
    1070              :             "Subject: Attach Test\r\n"
    1071              :             "\r\n"
    1072              :             "--B001\r\n"
    1073              :             "Content-Type: text/plain\r\n"
    1074              :             "\r\n"
    1075              :             "Body text\r\n"
    1076              :             "--B001\r\n"
    1077              :             "Content-Type: text/plain; name=\"notes.txt\"\r\n"
    1078              :             "Content-Disposition: attachment; filename=\"notes.txt\"\r\n"
    1079              :             "Content-Transfer-Encoding: base64\r\n"
    1080              :             "\r\n"
    1081              :             "SGVsbG8gV29ybGQ=\r\n"
    1082              :             "--B001--\r\n";
    1083              : 
    1084            1 :         int sa = local_msg_save(att_folder, att_uid, mime_attach, strlen(mime_attach));
    1085            1 :         ASSERT(sa == 0, "list_attachments: local_msg_save ok");
    1086              : 
    1087            1 :         Config att_cfg = {0};
    1088            1 :         att_cfg.folder = (char *)att_folder;
    1089              : 
    1090              :         /* Capture stdout */
    1091              :         int pipefd[2];
    1092            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "list_attachments: pipe failed"); goto skip_list_att; }
    1093            1 :         fflush(stdout);
    1094            1 :         int saved_la = dup(STDOUT_FILENO);
    1095            1 :         dup2(pipefd[1], STDOUT_FILENO); close(pipefd[1]);
    1096              : 
    1097            1 :         int rc_la = email_service_list_attachments(&att_cfg, att_uid);
    1098              : 
    1099            1 :         fflush(stdout);
    1100            1 :         dup2(saved_la, STDOUT_FILENO); close(saved_la);
    1101            1 :         char buf_la[512] = {0};
    1102            1 :         ssize_t n_la = read(pipefd[0], buf_la, sizeof(buf_la) - 1);
    1103            1 :         close(pipefd[0]);
    1104            1 :         buf_la[n_la > 0 ? n_la : 0] = '\0';
    1105              : 
    1106            1 :         ASSERT(rc_la == 0, "list_attachments: returns 0");
    1107            1 :         ASSERT(strstr(buf_la, "notes.txt") != NULL,
    1108              :                "list_attachments: attachment name in output");
    1109            1 :         skip_list_att:;
    1110              :     }
    1111              : 
    1112              :     /* list_attachments on message with no attachments → "No attachments." */
    1113              :     {
    1114            1 :         const char *na_folder = "INBOX";
    1115            1 :         const char *na_uid    = "0000000000005556";
    1116            1 :         const char *mime_plain =
    1117              :             "MIME-Version: 1.0\r\n"
    1118              :             "Content-Type: text/plain\r\n"
    1119              :             "From: noatt@example.com\r\n"
    1120              :             "Subject: No Attach\r\n"
    1121              :             "\r\n"
    1122              :             "Just text.\r\n";
    1123              : 
    1124            1 :         local_msg_save(na_folder, na_uid, mime_plain, strlen(mime_plain));
    1125              : 
    1126            1 :         Config na_cfg = {0};
    1127            1 :         na_cfg.folder = (char *)na_folder;
    1128              : 
    1129              :         int pipefd[2];
    1130            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "list_att_none: pipe failed"); goto skip_list_att_none; }
    1131            1 :         fflush(stdout);
    1132            1 :         int saved_na = dup(STDOUT_FILENO);
    1133            1 :         dup2(pipefd[1], STDOUT_FILENO); close(pipefd[1]);
    1134              : 
    1135            1 :         int rc_na = email_service_list_attachments(&na_cfg, na_uid);
    1136              : 
    1137            1 :         fflush(stdout);
    1138            1 :         dup2(saved_na, STDOUT_FILENO); close(saved_na);
    1139            1 :         char buf_na[128] = {0};
    1140            1 :         ssize_t n_na = read(pipefd[0], buf_na, sizeof(buf_na) - 1);
    1141            1 :         close(pipefd[0]);
    1142            1 :         buf_na[n_na > 0 ? n_na : 0] = '\0';
    1143              : 
    1144            1 :         ASSERT(rc_na == 0, "list_att_none: returns 0");
    1145            1 :         ASSERT(strstr(buf_na, "No attachments") != NULL,
    1146              :                "list_att_none: output says 'No attachments'");
    1147            1 :         skip_list_att_none:;
    1148              :     }
    1149              : 
    1150              :     /* list_attachments on non-existent UID → returns -1 */
    1151              :     {
    1152            1 :         Config ne_cfg = {0};
    1153            1 :         ne_cfg.folder = "INBOX";
    1154              : 
    1155              :         /* Redirect stderr to suppress error message */
    1156            1 :         fflush(stderr);
    1157            1 :         int saved_ne_err = dup(STDERR_FILENO);
    1158            1 :         int null_ne = open("/dev/null", O_WRONLY);
    1159            1 :         if (null_ne >= 0) { dup2(null_ne, STDERR_FILENO); close(null_ne); }
    1160              : 
    1161            1 :         int rc_ne = email_service_list_attachments(&ne_cfg, "0000000000009999");
    1162              : 
    1163            1 :         fflush(stderr);
    1164            1 :         dup2(saved_ne_err, STDERR_FILENO); close(saved_ne_err);
    1165              : 
    1166            1 :         ASSERT(rc_ne == -1, "list_att_missing: non-existent UID → -1");
    1167              :     }
    1168              : 
    1169              :     /* ── email_service_save_attachment ──────────────────────────────── */
    1170              :     {
    1171            1 :         const char *sv_folder = "INBOX";
    1172            1 :         const char *sv_uid    = "0000000000005557";
    1173            1 :         const char *mime_sv =
    1174              :             "MIME-Version: 1.0\r\n"
    1175              :             "Content-Type: multipart/mixed; boundary=\"B002\"\r\n"
    1176              :             "From: sv@example.com\r\n"
    1177              :             "Subject: Save Attach Test\r\n"
    1178              :             "\r\n"
    1179              :             "--B002\r\n"
    1180              :             "Content-Type: text/plain\r\n"
    1181              :             "\r\n"
    1182              :             "Body\r\n"
    1183              :             "--B002\r\n"
    1184              :             "Content-Type: text/plain; name=\"save_me.txt\"\r\n"
    1185              :             "Content-Disposition: attachment; filename=\"save_me.txt\"\r\n"
    1186              :             "Content-Transfer-Encoding: base64\r\n"
    1187              :             "\r\n"
    1188              :             "SGVsbG8gV29ybGQ=\r\n"
    1189              :             "--B002--\r\n";
    1190              : 
    1191            1 :         local_msg_save(sv_folder, sv_uid, mime_sv, strlen(mime_sv));
    1192              : 
    1193            1 :         Config sv_cfg = {0};
    1194            1 :         sv_cfg.folder = (char *)sv_folder;
    1195              : 
    1196              :         /* Use /tmp as output dir to avoid touching $HOME */
    1197              :         char sv_dest[256];
    1198            1 :         snprintf(sv_dest, sizeof(sv_dest), "/tmp/email_cli_test_%d", (int)getpid());
    1199            1 :         mkdir(sv_dest, 0700);
    1200              : 
    1201              :         /* Capture stdout (prints "Saved: ...") */
    1202              :         int pipefd[2];
    1203            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "save_attachment: pipe failed"); goto skip_save_att; }
    1204            1 :         fflush(stdout);
    1205            1 :         int saved_sv = dup(STDOUT_FILENO);
    1206            1 :         dup2(pipefd[1], STDOUT_FILENO); close(pipefd[1]);
    1207              : 
    1208            1 :         int rc_sv = email_service_save_attachment(&sv_cfg, sv_uid, "save_me.txt", sv_dest);
    1209              : 
    1210            1 :         fflush(stdout);
    1211            1 :         dup2(saved_sv, STDOUT_FILENO); close(saved_sv);
    1212            1 :         char buf_sv[256] = {0};
    1213            1 :         ssize_t n_sv = read(pipefd[0], buf_sv, sizeof(buf_sv) - 1);
    1214            1 :         close(pipefd[0]);
    1215            1 :         buf_sv[n_sv > 0 ? n_sv : 0] = '\0';
    1216              : 
    1217            1 :         ASSERT(rc_sv == 0, "save_attachment: returns 0");
    1218            1 :         ASSERT(strstr(buf_sv, "Saved:") != NULL,
    1219              :                "save_attachment: output contains 'Saved:'");
    1220              : 
    1221              :         /* Verify file exists */
    1222              :         char expected_path[512];
    1223            1 :         snprintf(expected_path, sizeof(expected_path), "%s/save_me.txt", sv_dest);
    1224              :         struct stat st_sv;
    1225            1 :         ASSERT(stat(expected_path, &st_sv) == 0,
    1226              :                "save_attachment: saved file exists on disk");
    1227              : 
    1228              :         /* Cleanup */
    1229            1 :         unlink(expected_path);
    1230            1 :         rmdir(sv_dest);
    1231            1 :         skip_save_att:;
    1232              :     }
    1233              : 
    1234              :     /* save_attachment: attachment name not found → -1 */
    1235              :     {
    1236            1 :         const char *sf2_uid = "0000000000005557";  /* already saved above */
    1237            1 :         Config sf2_cfg = {0};
    1238            1 :         sf2_cfg.folder = "INBOX";
    1239              : 
    1240            1 :         fflush(stderr);
    1241            1 :         int saved_sf2_err = dup(STDERR_FILENO);
    1242            1 :         int null_sf2 = open("/dev/null", O_WRONLY);
    1243            1 :         if (null_sf2 >= 0) { dup2(null_sf2, STDERR_FILENO); close(null_sf2); }
    1244              : 
    1245            1 :         int rc_sf2 = email_service_save_attachment(&sf2_cfg, sf2_uid,
    1246              :                                                     "nonexistent.txt", "/tmp");
    1247              : 
    1248            1 :         fflush(stderr);
    1249            1 :         dup2(saved_sf2_err, STDERR_FILENO); close(saved_sf2_err);
    1250              : 
    1251            1 :         ASSERT(rc_sf2 == -1, "save_attachment: wrong name → -1");
    1252              :     }
    1253              : 
    1254              :     /* save_attachment: non-existent UID → -1 */
    1255              :     {
    1256            1 :         Config sf3_cfg = {0};
    1257            1 :         sf3_cfg.folder = "INBOX";
    1258              : 
    1259            1 :         fflush(stderr);
    1260            1 :         int saved_sf3_err = dup(STDERR_FILENO);
    1261            1 :         int null_sf3 = open("/dev/null", O_WRONLY);
    1262            1 :         if (null_sf3 >= 0) { dup2(null_sf3, STDERR_FILENO); close(null_sf3); }
    1263              : 
    1264            1 :         int rc_sf3 = email_service_save_attachment(&sf3_cfg, "0000000000009998",
    1265              :                                                     "any.txt", "/tmp");
    1266              : 
    1267            1 :         fflush(stderr);
    1268            1 :         dup2(saved_sf3_err, STDERR_FILENO); close(saved_sf3_err);
    1269              : 
    1270            1 :         ASSERT(rc_sf3 == -1, "save_attachment: missing UID → -1");
    1271              :     }
    1272              : 
    1273              :     /* ── email_service_list_labels ───────────────────────────────────── */
    1274              :     /* Without a real connection, make_mail returns NULL → function returns -1.
    1275              :      * This covers the early-exit error path of email_service_list_labels. */
    1276              :     {
    1277            1 :         Config lbl_cfg = {0};
    1278            1 :         lbl_cfg.folder     = "INBOX";
    1279            1 :         lbl_cfg.gmail_mode = 0;
    1280              : 
    1281              :         /* Suppress stderr "Error: Could not connect." */
    1282            1 :         fflush(stderr);
    1283            1 :         int saved_lbl_err = dup(STDERR_FILENO);
    1284            1 :         int null_lbl = open("/dev/null", O_WRONLY);
    1285            1 :         if (null_lbl >= 0) { dup2(null_lbl, STDERR_FILENO); close(null_lbl); }
    1286              : 
    1287            1 :         int rc_lbl = email_service_list_labels(&lbl_cfg);
    1288              : 
    1289            1 :         fflush(stderr);
    1290            1 :         dup2(saved_lbl_err, STDERR_FILENO); close(saved_lbl_err);
    1291              : 
    1292            1 :         ASSERT(rc_lbl == -1, "list_labels: no connection → -1");
    1293              :     }
    1294              : 
    1295              :     /* ── email_service_list (cron/cache mode, offline) ───────────────── */
    1296              :     {
    1297              :         /* Use a unique folder to avoid collisions with other tests */
    1298            1 :         const char *lf = "test_list_cron_folder";
    1299              : 
    1300              :         /* Create a manifest with 2 messages */
    1301            1 :         Manifest *m = calloc(1, sizeof(Manifest));
    1302            1 :         manifest_upsert(m, "0000000000008001", strdup("alice@example.com"),
    1303              :                         strdup("Hello World"), strdup("2026-01-15 10:00"), MSG_FLAG_UNSEEN);
    1304            1 :         manifest_upsert(m, "0000000000008002", strdup("bob@example.com"),
    1305              :                         strdup("Meeting notes"), strdup("2026-01-14 09:00"), 0);
    1306            1 :         manifest_save(lf, m);
    1307            1 :         manifest_free(m);
    1308              : 
    1309            1 :         Config lcfg = {0};
    1310            1 :         lcfg.host         = "imaps://test.example.com";
    1311            1 :         lcfg.user         = "testuser";
    1312            1 :         lcfg.folder       = (char *)lf;
    1313            1 :         lcfg.sync_interval = 1;  /* cron mode: read from manifest cache */
    1314              : 
    1315            1 :         EmailListOpts opts = {0};
    1316            1 :         opts.folder = lf;
    1317            1 :         opts.pager  = 1;   /* TUI mode */
    1318              : 
    1319              :         /* Inject ESC (27) to exit the TUI */
    1320            1 :         int lp[2]; int _pr = pipe(lp); (void)_pr;
    1321            1 :         unsigned char esc = 27;
    1322            1 :         ssize_t _wr = write(lp[1], &esc, 1); (void)_wr;
    1323            1 :         close(lp[1]);
    1324            1 :         int saved_stdin = dup(STDIN_FILENO);
    1325            1 :         dup2(lp[0], STDIN_FILENO); close(lp[0]);
    1326              : 
    1327              :         /* Suppress TUI output */
    1328            1 :         fflush(stdout); fflush(stderr);
    1329            1 :         int lnull = open("/dev/null", O_WRONLY);
    1330            1 :         int lsout = dup(STDOUT_FILENO), lserr = dup(STDERR_FILENO);
    1331            1 :         if (lnull >= 0) { dup2(lnull, STDOUT_FILENO); dup2(lnull, STDERR_FILENO); close(lnull); }
    1332              : 
    1333            1 :         int lr = email_service_list(&lcfg, &opts);
    1334              : 
    1335            1 :         fflush(stdout); fflush(stderr);
    1336            1 :         dup2(lsout, STDOUT_FILENO); close(lsout);
    1337            1 :         dup2(lserr, STDERR_FILENO); close(lserr);
    1338            1 :         dup2(saved_stdin, STDIN_FILENO); close(saved_stdin);
    1339              : 
    1340              :         /* ESC returns 0 from the TUI */
    1341            1 :         ASSERT(lr == 0 || lr == 1, "list cron: ESC exits cleanly");
    1342              :     }
    1343              : 
    1344              :     /* ── email_service_list (cron/cache mode, batch output) ─────────── */
    1345              :     {
    1346            1 :         const char *bf = "test_list_batch_folder";
    1347              : 
    1348              :         /* Create manifest */
    1349            1 :         Manifest *m = calloc(1, sizeof(Manifest));
    1350            1 :         manifest_upsert(m, "0000000000008010", strdup("sender@example.com"),
    1351              :                         strdup("Batch Test Subject"), strdup("2026-02-01 08:00"), MSG_FLAG_UNSEEN);
    1352            1 :         manifest_save(bf, m);
    1353            1 :         manifest_free(m);
    1354              : 
    1355            1 :         Config bcfg = {0};
    1356            1 :         bcfg.host         = "imaps://test.example.com";
    1357            1 :         bcfg.user         = "testuser";
    1358            1 :         bcfg.folder       = (char *)bf;
    1359            1 :         bcfg.sync_interval = 1;  /* cron mode */
    1360              : 
    1361            1 :         EmailListOpts opts = {0};
    1362            1 :         opts.folder = bf;
    1363            1 :         opts.pager  = 0;  /* batch mode: prints to stdout, no TUI */
    1364              : 
    1365              :         /* Capture stdout */
    1366            1 :         int bp[2]; int _pr = pipe(bp); (void)_pr;
    1367            1 :         fflush(stdout);
    1368            1 :         int bsout = dup(STDOUT_FILENO);
    1369            1 :         dup2(bp[1], STDOUT_FILENO); close(bp[1]);
    1370              : 
    1371              :         /* Suppress stderr */
    1372            1 :         int bnull = open("/dev/null", O_WRONLY);
    1373            1 :         int bserr = dup(STDERR_FILENO);
    1374            1 :         if (bnull >= 0) { dup2(bnull, STDERR_FILENO); close(bnull); }
    1375              : 
    1376            1 :         int br = email_service_list(&bcfg, &opts);
    1377              : 
    1378            1 :         fflush(stdout);
    1379            1 :         dup2(bsout, STDOUT_FILENO); close(bsout);
    1380            1 :         dup2(bserr, STDERR_FILENO); close(bserr);
    1381              : 
    1382            1 :         char bbuf[2048] = {0};
    1383            1 :         ssize_t bn = read(bp[0], bbuf, sizeof(bbuf)-1);
    1384            1 :         close(bp[0]);
    1385            1 :         bbuf[bn > 0 ? bn : 0] = '\0';
    1386              : 
    1387            1 :         ASSERT(br == 0, "list cron batch: returns 0");
    1388            1 :         ASSERT(strstr(bbuf, "Batch Test Subject") != NULL || strstr(bbuf, "message") != NULL,
    1389              :                "list cron batch: content present in output");
    1390              :     }
    1391              : 
    1392              :     /* ── email_service_list (cron mode, empty manifest, batch) ──────── */
    1393              :     {
    1394            1 :         Config ecfg = {0};
    1395            1 :         ecfg.host         = "imaps://test.example.com";
    1396            1 :         ecfg.user         = "testuser";
    1397            1 :         ecfg.folder       = "test_empty_cron_folder";
    1398            1 :         ecfg.sync_interval = 1;  /* cron mode */
    1399              : 
    1400            1 :         EmailListOpts opts = {0};
    1401            1 :         opts.folder = "test_empty_cron_folder";
    1402            1 :         opts.pager  = 0;  /* batch mode: prints "No cached data" message */
    1403              : 
    1404            1 :         int ep[2]; int _pr = pipe(ep); (void)_pr;
    1405            1 :         fflush(stdout);
    1406            1 :         int esout = dup(STDOUT_FILENO);
    1407            1 :         dup2(ep[1], STDOUT_FILENO); close(ep[1]);
    1408            1 :         int enull = open("/dev/null", O_WRONLY);
    1409            1 :         int eserr = dup(STDERR_FILENO);
    1410            1 :         if (enull >= 0) { dup2(enull, STDERR_FILENO); close(enull); }
    1411              : 
    1412            1 :         int er = email_service_list(&ecfg, &opts);
    1413              : 
    1414            1 :         fflush(stdout);
    1415            1 :         dup2(esout, STDOUT_FILENO); close(esout);
    1416            1 :         dup2(eserr, STDERR_FILENO); close(eserr);
    1417              : 
    1418            1 :         char ebuf[512] = {0};
    1419            1 :         ssize_t en = read(ep[0], ebuf, sizeof(ebuf)-1);
    1420            1 :         close(ep[0]);
    1421              : 
    1422            1 :         ASSERT(er == 0, "list empty cron: returns 0");
    1423              :         (void)en; /* may print to stdout */
    1424              :     }
    1425              : 
    1426              :     /* ── email_service_read (batch mode, cached message) ─────────────── */
    1427              :     {
    1428              :         /* Message already saved by earlier test; use a fresh one */
    1429            1 :         const char *ruid = "0000000000008888";
    1430            1 :         const char *rmsg =
    1431              :             "From: reader@example.com\r\n"
    1432              :             "Subject: Read Test\r\n"
    1433              :             "MIME-Version: 1.0\r\n"
    1434              :             "Content-Type: text/plain; charset=UTF-8\r\n"
    1435              :             "\r\n"
    1436              :             "This is the message body for reader test.\r\n";
    1437            1 :         local_msg_save("INBOX", ruid, rmsg, strlen(rmsg));
    1438              : 
    1439            1 :         Config rcfg = {0};
    1440            1 :         rcfg.host   = "imaps://test.example.com";
    1441            1 :         rcfg.user   = "testuser";
    1442            1 :         rcfg.folder = "INBOX";
    1443              : 
    1444              :         /* Capture stdout */
    1445            1 :         int rp[2]; int _pr = pipe(rp); (void)_pr;
    1446            1 :         fflush(stdout);
    1447            1 :         int rsout = dup(STDOUT_FILENO);
    1448            1 :         dup2(rp[1], STDOUT_FILENO); close(rp[1]);
    1449            1 :         int rnull = open("/dev/null", O_WRONLY);
    1450            1 :         int rserr = dup(STDERR_FILENO);
    1451            1 :         if (rnull >= 0) { dup2(rnull, STDERR_FILENO); close(rnull); }
    1452              : 
    1453              :         /* email_service_read(cfg, uid, pager=0, page_size=25) for batch output */
    1454            1 :         int rr = email_service_read(&rcfg, ruid, 0, 25);
    1455              : 
    1456            1 :         fflush(stdout);
    1457            1 :         dup2(rsout, STDOUT_FILENO); close(rsout);
    1458            1 :         dup2(rserr, STDERR_FILENO); close(rserr);
    1459              : 
    1460            1 :         char rbuf[4096] = {0};
    1461            1 :         ssize_t rn = read(rp[0], rbuf, sizeof(rbuf)-1);
    1462            1 :         close(rp[0]);
    1463            1 :         rbuf[rn > 0 ? rn : 0] = '\0';
    1464              : 
    1465            1 :         ASSERT(rr == 0, "read batch: returns 0");
    1466            1 :         ASSERT(strstr(rbuf, "reader@example.com") != NULL || strstr(rbuf, "Read Test") != NULL,
    1467              :                "read batch: header content in output");
    1468              :     }
    1469              : 
    1470              :     /* ── get_account_totals (IMAP mode, offline) ─────────────────────── */
    1471              :     {
    1472              :         /* Create a manifest with 3 messages: 2 unseen, 1 flagged */
    1473            1 :         const char *gfold = "test_totals_folder";
    1474            1 :         Manifest *tm = calloc(1, sizeof(Manifest));
    1475            1 :         manifest_upsert(tm, "0000000000009010", strdup("a@ex.com"), strdup("s1"),
    1476              :                         strdup("2026-01-01 00:00"), MSG_FLAG_UNSEEN);
    1477            1 :         manifest_upsert(tm, "0000000000009011", strdup("b@ex.com"), strdup("s2"),
    1478              :                         strdup("2026-01-02 00:00"), MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED);
    1479            1 :         manifest_upsert(tm, "0000000000009012", strdup("c@ex.com"), strdup("s3"),
    1480              :                         strdup("2026-01-03 00:00"), 0);
    1481            1 :         manifest_save(gfold, tm);
    1482            1 :         manifest_free(tm);
    1483              : 
    1484            1 :         Config gcfg = {0};
    1485            1 :         gcfg.host   = "imaps://test.example.com";
    1486            1 :         gcfg.user   = "testuser";
    1487            1 :         gcfg.folder = (char *)gfold;
    1488            1 :         gcfg.gmail_mode = 0;
    1489              : 
    1490            1 :         int unseen = 0, flagged = 0;
    1491            1 :         get_account_totals(&gcfg, &unseen, &flagged);
    1492            1 :         ASSERT(unseen >= 0, "get_account_totals IMAP: unseen >= 0");
    1493            1 :         ASSERT(flagged >= 0, "get_account_totals IMAP: flagged >= 0");
    1494              : 
    1495              :         /* Restore local store for subsequent tests */
    1496            1 :         local_store_init("imaps://test.example.com", "testuser");
    1497              :     }
    1498              : 
    1499              :     /* ── email_service_list (Gmail offline mode) ─────────────────────── */
    1500              :     {
    1501            1 :         local_store_init(NULL, "gmailtest@gmail.com");
    1502              : 
    1503              :         /* Add some entries to a Gmail label index */
    1504            1 :         label_idx_add("INBOX", "abcdef1234567");
    1505            1 :         label_idx_add("INBOX", "abcdef1234568");
    1506              : 
    1507            1 :         Config gcfg2 = {0};
    1508            1 :         gcfg2.host       = NULL;
    1509            1 :         gcfg2.user       = "gmailtest@gmail.com";
    1510            1 :         gcfg2.folder     = "INBOX";
    1511            1 :         gcfg2.gmail_mode = 1;
    1512            1 :         gcfg2.sync_interval = 1;  /* cron/cache mode */
    1513              : 
    1514            1 :         EmailListOpts gopts = {0};
    1515            1 :         gopts.folder = "INBOX";
    1516            1 :         gopts.pager  = 0;  /* batch */
    1517              : 
    1518            1 :         int gp[2]; int _pr = pipe(gp); (void)_pr;
    1519            1 :         fflush(stdout);
    1520            1 :         int gsout = dup(STDOUT_FILENO);
    1521            1 :         dup2(gp[1], STDOUT_FILENO); close(gp[1]);
    1522            1 :         int gnull = open("/dev/null", O_WRONLY);
    1523            1 :         int gserr = dup(STDERR_FILENO);
    1524            1 :         if (gnull >= 0) { dup2(gnull, STDERR_FILENO); close(gnull); }
    1525              : 
    1526            1 :         int gret = email_service_list(&gcfg2, &gopts);
    1527              : 
    1528            1 :         fflush(stdout);
    1529            1 :         dup2(gsout, STDOUT_FILENO); close(gsout);
    1530            1 :         dup2(gserr, STDERR_FILENO); close(gserr);
    1531              : 
    1532            1 :         char gbuf[1024] = {0};
    1533            1 :         ssize_t gn = read(gp[0], gbuf, sizeof(gbuf)-1);
    1534            1 :         close(gp[0]);
    1535              : 
    1536            1 :         ASSERT(gret == 0 || gret == -1, "list gmail offline: returns 0 or -1");
    1537              :         (void)gn;
    1538              : 
    1539              :         /* Restore normal local store */
    1540            1 :         local_store_init("imaps://test.example.com", "testuser");
    1541              :     }
    1542              : 
    1543              :     /* ── stdin/stdout injection helpers (local) ──────────────────────── */
    1544              : #define INJECT_STDIN(keys, klen, saved)  do { \
    1545              :         int _pfd[2]; int _pr = pipe(_pfd); (void)_pr; \
    1546              :         ssize_t _wr = write(_pfd[1], (keys), (klen)); (void)_wr; \
    1547              :         close(_pfd[1]); \
    1548              :         (saved) = dup(STDIN_FILENO); \
    1549              :         dup2(_pfd[0], STDIN_FILENO); close(_pfd[0]); } while(0)
    1550              : #define RESTORE_STDIN(saved)  do { dup2((saved), STDIN_FILENO); close(saved); } while(0)
    1551              : #define SUPPRESS_OUT(so, se)  do { \
    1552              :         fflush(stdout); fflush(stderr); \
    1553              :         int _nfd = open("/dev/null", O_WRONLY); \
    1554              :         (so) = dup(STDOUT_FILENO); (se) = dup(STDERR_FILENO); \
    1555              :         if (_nfd >= 0) { dup2(_nfd,STDOUT_FILENO); dup2(_nfd,STDERR_FILENO); close(_nfd); } \
    1556              :     } while(0)
    1557              : #define RESTORE_OUT(so, se)   do { \
    1558              :         fflush(stdout); fflush(stderr); \
    1559              :         dup2((so),STDOUT_FILENO); close(so); \
    1560              :         dup2((se),STDERR_FILENO); close(se); } while(0)
    1561              : 
    1562              :     /* ── fmt_size ────────────────────────────────────────────────────── */
    1563              :     {
    1564              :         char buf[64];
    1565            1 :         fmt_size(buf, sizeof(buf), 512);
    1566            1 :         ASSERT(strstr(buf, "KB") || strstr(buf, "0 KB"),
    1567              :                "fmt_size: <1MB shows KB");
    1568              : 
    1569            1 :         fmt_size(buf, sizeof(buf), 2 * 1024 * 1024);
    1570            1 :         ASSERT(strstr(buf, "MB") != NULL, "fmt_size: >=1MB shows MB");
    1571              : 
    1572            1 :         fmt_size(buf, sizeof(buf), 0);
    1573            1 :         ASSERT(strstr(buf, "KB") != NULL, "fmt_size: 0 bytes shows 0 KB");
    1574              : 
    1575            1 :         fmt_size(buf, sizeof(buf), 1024 * 1024);
    1576            1 :         ASSERT(strstr(buf, "MB") != NULL, "fmt_size: exactly 1MB shows MB");
    1577              :     }
    1578              : 
    1579              :     /* ── fmt_url_with_port ───────────────────────────────────────────── */
    1580              :     {
    1581              :         char out[256];
    1582              : 
    1583            1 :         fmt_url_with_port(NULL, 993, out, sizeof(out));
    1584            1 :         ASSERT(out[0] == '\0', "fmt_url_with_port: NULL → empty");
    1585              : 
    1586            1 :         fmt_url_with_port("", 993, out, sizeof(out));
    1587            1 :         ASSERT(out[0] == '\0', "fmt_url_with_port: empty → empty");
    1588              : 
    1589            1 :         fmt_url_with_port("imaps://imap.example.com", 993, out, sizeof(out));
    1590            1 :         ASSERT(strstr(out, ":993") != NULL, "fmt_url_with_port: appends :993");
    1591              : 
    1592            1 :         fmt_url_with_port("imaps://imap.example.com:143", 993, out, sizeof(out));
    1593            1 :         ASSERT(strstr(out, ":143") != NULL, "fmt_url_with_port: keeps existing port");
    1594              : 
    1595            1 :         fmt_url_with_port("imap.noscheme.com", 993, out, sizeof(out));
    1596            1 :         ASSERT(strstr(out, ":993") != NULL, "fmt_url_with_port: no scheme, appends port");
    1597              :     }
    1598              : 
    1599              :     /* ── is_system_or_special_label ─────────────────────────────────── */
    1600              :     {
    1601            1 :         ASSERT(is_system_or_special_label("INBOX")          == 1, "system label INBOX");
    1602            1 :         ASSERT(is_system_or_special_label("STARRED")        == 1, "system label STARRED");
    1603            1 :         ASSERT(is_system_or_special_label("UNREAD")         == 1, "system label UNREAD");
    1604            1 :         ASSERT(is_system_or_special_label("IMPORTANT")      == 1, "special label IMPORTANT");
    1605            1 :         ASSERT(is_system_or_special_label("CATEGORY_SOCIAL")== 1, "special label CATEGORY_*");
    1606            1 :         ASSERT(is_system_or_special_label("TRASH")          == 1, "special label TRASH");
    1607            1 :         ASSERT(is_system_or_special_label("SPAM")           == 1, "special label SPAM");
    1608            1 :         ASSERT(is_system_or_special_label("Work")           == 0, "user label Work");
    1609            1 :         ASSERT(is_system_or_special_label("Personal")       == 0, "user label Personal");
    1610              :     }
    1611              : 
    1612              :     /* ── folder_has_children ─────────────────────────────────────────── */
    1613              :     {
    1614            1 :         char *folders[] = { (char*)"INBOX", (char*)"INBOX.Sent",
    1615              :                             (char*)"INBOX.Archive", (char*)"Trash" };
    1616            1 :         int n = 4;
    1617            1 :         ASSERT(folder_has_children(folders, n, "INBOX", '.') == 1,
    1618              :                "folder_has_children: INBOX has children");
    1619            1 :         ASSERT(folder_has_children(folders, n, "Trash", '.') == 0,
    1620              :                "folder_has_children: Trash has no children");
    1621            1 :         ASSERT(folder_has_children(folders, n, "INBOX.Sent", '.') == 0,
    1622              :                "folder_has_children: INBOX.Sent leaf");
    1623            1 :         ASSERT(folder_has_children(NULL, 0, "x", '.') == 0,
    1624              :                "folder_has_children: empty list");
    1625              :     }
    1626              : 
    1627              :     /* ── sum_subtree ─────────────────────────────────────────────────── */
    1628              :     {
    1629            1 :         char *names[] = { (char*)"INBOX", (char*)"INBOX.Sent", (char*)"Trash" };
    1630            1 :         FolderStatus st[3] = { {5, 2, 1}, {3, 0, 0}, {1, 0, 0} };
    1631            1 :         int msgs = 0, unseen = 0, flagged = 0;
    1632              : 
    1633            1 :         sum_subtree(names, 3, '.', "INBOX", st, &msgs, &unseen, &flagged);
    1634            1 :         ASSERT(msgs == 8,    "sum_subtree: msgs INBOX+children = 8");
    1635            1 :         ASSERT(unseen == 2,  "sum_subtree: unseen = 2");
    1636            1 :         ASSERT(flagged == 1, "sum_subtree: flagged = 1");
    1637              : 
    1638              :         /* NULL statuses → all zeros */
    1639            1 :         sum_subtree(names, 3, '.', "INBOX", NULL, &msgs, &unseen, &flagged);
    1640            1 :         ASSERT(msgs == 0 && unseen == 0 && flagged == 0,
    1641              :                "sum_subtree: NULL statuses → zeros");
    1642              :     }
    1643              : 
    1644              :     /* ── build_flat_view ─────────────────────────────────────────────── */
    1645              :     {
    1646            1 :         char *names[] = { (char*)"A", (char*)"A.B", (char*)"A.B.C", (char*)"Z" };
    1647              :         int vis[8];
    1648              : 
    1649              :         /* Root level: only A and Z (no separator) */
    1650            1 :         int cnt = build_flat_view(names, 4, '.', "", vis);
    1651            1 :         ASSERT(cnt == 2, "build_flat_view: root level = 2");
    1652            1 :         ASSERT(vis[0] == 0 && vis[1] == 3, "build_flat_view: root indices 0,3");
    1653              : 
    1654              :         /* Children of A: only A.B (direct child, no second separator) */
    1655            1 :         cnt = build_flat_view(names, 4, '.', "A", vis);
    1656            1 :         ASSERT(cnt == 1, "build_flat_view: A children = 1 (A.B only)");
    1657            1 :         ASSERT(vis[0] == 1, "build_flat_view: A child is index 1");
    1658              : 
    1659              :         /* No prefix match → 0 */
    1660            1 :         cnt = build_flat_view(names, 4, '.', "X", vis);
    1661            1 :         ASSERT(cnt == 0, "build_flat_view: no match → 0");
    1662              :     }
    1663              : 
    1664              :     /* ── get_sync_bin_path ───────────────────────────────────────────── */
    1665              :     {
    1666              :         char buf[512];
    1667            1 :         get_sync_bin_path(buf, sizeof(buf));
    1668            1 :         ASSERT(buf[0] != '\0', "get_sync_bin_path: non-empty result");
    1669            1 :         ASSERT(strstr(buf, "email-sync") != NULL,
    1670              :                "get_sync_bin_path: contains email-sync");
    1671              :     }
    1672              : 
    1673              :     /* ── attachment_save_dir ─────────────────────────────────────────── */
    1674              :     {
    1675            1 :         char *dir = attachment_save_dir();
    1676            1 :         ASSERT(dir != NULL, "attachment_save_dir: non-NULL");
    1677            1 :         ASSERT(strlen(dir) > 0, "attachment_save_dir: non-empty");
    1678            1 :         free(dir);
    1679              :     }
    1680              : 
    1681              :     /* ── sync_progress_cb ────────────────────────────────────────────── */
    1682              :     {
    1683              :         SyncProgressCtx ctx;
    1684            1 :         ctx.loop_i     = 3;
    1685            1 :         ctx.loop_total = 10;
    1686            1 :         strncpy(ctx.uid, "0000000000000042", sizeof(ctx.uid) - 1);
    1687            1 :         ctx.uid[16] = '\0';
    1688              : 
    1689              :         int sout, serr;
    1690            1 :         SUPPRESS_OUT(sout, serr);
    1691            1 :         sync_progress_cb(512 * 1024, 1024 * 1024, &ctx);   /* <1MB */
    1692            1 :         sync_progress_cb(2 * 1024 * 1024, 4 * 1024 * 1024, &ctx); /* >=1MB */
    1693            1 :         RESTORE_OUT(sout, serr);
    1694            1 :         ASSERT(1, "sync_progress_cb: no crash");
    1695              :     }
    1696              : 
    1697              :     /* ── build_label_display / free_label_display ────────────────────── */
    1698              :     {
    1699            1 :         char *user[] = { (char*)"Work", (char*)"Personal" };
    1700            1 :         char **ids = NULL, **nms = NULL;
    1701            1 :         int *seps = NULL, *hdrs = NULL;
    1702            1 :         char *cats[] = { (char*)"CATEGORY_SOCIAL" };
    1703            1 :         int cnt = build_label_display(&ids, &nms, &seps, &hdrs, user, 2, cats, 1);
    1704            1 :         ASSERT(cnt > 2, "build_label_display: count > user count (system labels added)");
    1705            1 :         ASSERT(ids   != NULL, "build_label_display: ids non-NULL");
    1706            1 :         ASSERT(nms   != NULL, "build_label_display: names non-NULL");
    1707            1 :         ASSERT(seps  != NULL, "build_label_display: seps non-NULL");
    1708            1 :         ASSERT(hdrs  != NULL, "build_label_display: hdrs non-NULL");
    1709              :         /* First row is "Tags / Flags" section header */
    1710            1 :         ASSERT(hdrs[0] == 1, "build_label_display: first row is header");
    1711            1 :         ASSERT(ids[0] != NULL, "build_label_display: first id non-NULL");
    1712            1 :         free_label_display(ids, nms, seps, hdrs, cnt);
    1713            1 :         ASSERT(1, "free_label_display: no crash after free");
    1714              : 
    1715              :         /* Empty user labels */
    1716            1 :         cnt = build_label_display(&ids, &nms, &seps, &hdrs, NULL, 0, NULL, 0);
    1717            1 :         ASSERT(cnt > 0, "build_label_display: no user labels still returns system labels");
    1718            1 :         free_label_display(ids, nms, seps, hdrs, cnt);
    1719              :     }
    1720              : 
    1721              :     /* ── fetch_uid_headers_cached ────────────────────────────────────── */
    1722              :     {
    1723              :         /* Save a fake header to cache, then load via fetch_uid_headers_cached */
    1724            1 :         const char *hfold = "test_hdr_cache_folder";
    1725            1 :         const char *huid  = "0000000000007777";
    1726            1 :         const char *hdr   = "From: hdr@test.com\r\nSubject: Cached\r\n\r\n";
    1727            1 :         local_store_init("imaps://test.example.com", "testuser");
    1728            1 :         local_hdr_save(hfold, huid, hdr, strlen(hdr));
    1729              : 
    1730            1 :         Config hcfg = {0};
    1731            1 :         hcfg.host   = "imaps://test.example.com";
    1732            1 :         hcfg.user   = "testuser";
    1733            1 :         hcfg.folder = (char *)hfold;
    1734              : 
    1735            1 :         char *loaded = fetch_uid_headers_cached(&hcfg, hfold, huid);
    1736            1 :         ASSERT(loaded != NULL, "fetch_uid_headers_cached: cached hdr returned");
    1737            1 :         if (loaded) {
    1738            1 :             ASSERT(strstr(loaded, "Cached") != NULL,
    1739              :                    "fetch_uid_headers_cached: correct content");
    1740            1 :             free(loaded);
    1741              :         }
    1742              :     }
    1743              : 
    1744              :     /* ── print_account_row ───────────────────────────────────────────── */
    1745              :     {
    1746            1 :         Config pcfg = {0};
    1747            1 :         pcfg.user   = "test@example.com";
    1748            1 :         pcfg.host   = "imaps://imap.example.com";
    1749            1 :         pcfg.gmail_mode = 0;
    1750              : 
    1751              :         int sout, serr;
    1752            1 :         SUPPRESS_OUT(sout, serr);
    1753            1 :         print_account_row(&pcfg, 0, 3, 1, 30, 20);  /* not selected */
    1754            1 :         print_account_row(&pcfg, 1, 3, 1, 30, 20);  /* selected (cursor=1) */
    1755              :         /* SMTP configured branch */
    1756            1 :         pcfg.smtp_host = (char *)"smtps://smtp.example.com";
    1757            1 :         pcfg.smtp_port = 465;
    1758            1 :         print_account_row(&pcfg, 0, 0, 0, 30, 20);
    1759              :         /* Gmail mode */
    1760            1 :         pcfg.gmail_mode = 1;
    1761            1 :         print_account_row(&pcfg, 0, 5, 2, 30, 20);
    1762            1 :         RESTORE_OUT(sout, serr);
    1763            1 :         ASSERT(1, "print_account_row: no crash");
    1764              :     }
    1765              : 
    1766              :     /* ── print_folder_item ───────────────────────────────────────────── */
    1767              :     {
    1768            1 :         char *names[] = { (char*)"INBOX", (char*)"INBOX.Sent", (char*)"Trash" };
    1769              :         int sout, serr;
    1770            1 :         SUPPRESS_OUT(sout, serr);
    1771              :         /* tree mode, not selected, has kids */
    1772            1 :         print_folder_item(names, 3, 0, '.', 1, 0, 1, 5, 2, 0, 20);
    1773              :         /* tree mode, selected */
    1774            1 :         print_folder_item(names, 3, 0, '.', 1, 1, 1, 5, 2, 0, 20);
    1775              :         /* flat mode */
    1776            1 :         print_folder_item(names, 3, 0, '.', 0, 0, 0, 0, 0, 0, 20);
    1777              :         /* empty folder (messages=0) */
    1778            1 :         print_folder_item(names, 3, 2, '.', 0, 0, 0, 0, 0, 0, 20);
    1779            1 :         RESTORE_OUT(sout, serr);
    1780            1 :         ASSERT(1, "print_folder_item: no crash");
    1781              :     }
    1782              : 
    1783              :     /* ── pager_prompt via stdin injection ───────────────────────────── */
    1784              :     {
    1785              :         int saved_stdin;
    1786              :         /* ESC → returns 0 */
    1787            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    1788              :         int sout, serr;
    1789            1 :         SUPPRESS_OUT(sout, serr);
    1790            1 :         int pr = pager_prompt(1, 3, 20, 24, 80);
    1791            1 :         RESTORE_OUT(sout, serr);
    1792            1 :         RESTORE_STDIN(saved_stdin);
    1793            1 :         ASSERT(pr == 0, "pager_prompt: ESC returns 0");
    1794              :     }
    1795              :     {
    1796              :         int saved_stdin;
    1797              :         /* Ctrl-C / 'q' → returns 0 */
    1798            1 :         INJECT_STDIN("\x03", 1, saved_stdin);
    1799              :         int sout, serr;
    1800            1 :         SUPPRESS_OUT(sout, serr);
    1801            1 :         int pr = pager_prompt(2, 5, 10, 24, 80);
    1802            1 :         RESTORE_OUT(sout, serr);
    1803            1 :         RESTORE_STDIN(saved_stdin);
    1804            1 :         ASSERT(pr == 0, "pager_prompt: Ctrl-C returns 0");
    1805              :     }
    1806              :     {
    1807              :         int saved_stdin;
    1808              :         /* PgDn → returns page_size */
    1809            1 :         INJECT_STDIN("\033[6~", 4, saved_stdin);
    1810              :         int sout, serr;
    1811            1 :         SUPPRESS_OUT(sout, serr);
    1812            1 :         int pr = pager_prompt(1, 3, 20, 24, 80);
    1813            1 :         RESTORE_OUT(sout, serr);
    1814            1 :         RESTORE_STDIN(saved_stdin);
    1815            1 :         ASSERT(pr == 20, "pager_prompt: PgDn returns page_size");
    1816              :     }
    1817              :     {
    1818              :         int saved_stdin;
    1819              :         /* PgUp → returns -page_size */
    1820            1 :         INJECT_STDIN("\033[5~", 4, saved_stdin);
    1821              :         int sout, serr;
    1822            1 :         SUPPRESS_OUT(sout, serr);
    1823            1 :         int pr = pager_prompt(2, 3, 20, 24, 80);
    1824            1 :         RESTORE_OUT(sout, serr);
    1825            1 :         RESTORE_STDIN(saved_stdin);
    1826            1 :         ASSERT(pr == -20, "pager_prompt: PgUp returns -page_size");
    1827              :     }
    1828              : 
    1829              :     /* ── show_help_popup via stdin injection ────────────────────────── */
    1830              :     {
    1831            1 :         const char *rows[][2] = {
    1832              :             { "Enter", "Open message" },
    1833              :             { "ESC",   "Quit"         },
    1834              :         };
    1835              :         int saved_stdin;
    1836            1 :         INJECT_STDIN("\r", 1, saved_stdin);
    1837              :         int sout, serr;
    1838            1 :         SUPPRESS_OUT(sout, serr);
    1839            1 :         show_help_popup("Help", rows, 2);
    1840            1 :         RESTORE_OUT(sout, serr);
    1841            1 :         RESTORE_STDIN(saved_stdin);
    1842            1 :         ASSERT(1, "show_help_popup: no crash");
    1843              :     }
    1844              : 
    1845              :     /* ── show_attachment_picker via stdin injection ──────────────────── */
    1846              :     {
    1847            1 :         MimeAttachment atts[2] = {
    1848              :             { .filename = (char*)"doc.pdf",  .content_type = (char*)"application/pdf",
    1849              :               .data = NULL, .size = 102400 },
    1850              :             { .filename = (char*)"img.png",  .content_type = (char*)"image/png",
    1851              :               .data = NULL, .size = 1500000 },
    1852              :         };
    1853              :         int saved_stdin;
    1854              :         /* ESC → returns -2 */
    1855            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    1856              :         int sout, serr;
    1857            1 :         SUPPRESS_OUT(sout, serr);
    1858            1 :         int picked = show_attachment_picker(atts, 2, 80, 24);
    1859            1 :         RESTORE_OUT(sout, serr);
    1860            1 :         RESTORE_STDIN(saved_stdin);
    1861            1 :         ASSERT(picked == -2, "show_attachment_picker: ESC returns -2");
    1862              :     }
    1863              :     {
    1864            1 :         MimeAttachment atts[1] = {
    1865              :             { .filename = (char*)"f.txt", .content_type = (char*)"text/plain",
    1866              :               .data = NULL, .size = 512 },
    1867              :         };
    1868              :         int saved_stdin;
    1869              :         /* Enter → returns 0 (cursor=0) */
    1870            1 :         INJECT_STDIN("\r", 1, saved_stdin);
    1871              :         int sout, serr;
    1872            1 :         SUPPRESS_OUT(sout, serr);
    1873            1 :         int picked = show_attachment_picker(atts, 1, 80, 24);
    1874            1 :         RESTORE_OUT(sout, serr);
    1875            1 :         RESTORE_STDIN(saved_stdin);
    1876            1 :         ASSERT(picked == 0, "show_attachment_picker: Enter returns cursor 0");
    1877              :     }
    1878              :     {
    1879            1 :         MimeAttachment atts[1] = {
    1880              :             { .filename = NULL, .content_type = NULL, .data = NULL, .size = 0 },
    1881              :         };
    1882              :         int saved_stdin;
    1883              :         /* Backspace → returns -1 */
    1884            1 :         INJECT_STDIN("\x7f", 1, saved_stdin);
    1885              :         int sout, serr;
    1886            1 :         SUPPRESS_OUT(sout, serr);
    1887            1 :         int picked = show_attachment_picker(atts, 1, 80, 24);
    1888            1 :         RESTORE_OUT(sout, serr);
    1889            1 :         RESTORE_STDIN(saved_stdin);
    1890            1 :         ASSERT(picked == -1, "show_attachment_picker: Backspace returns -1");
    1891              :     }
    1892              : 
    1893              :     /* ── email_service_cron_status / cron_remove ─────────────────────── */
    1894              :     {
    1895              :         int sout, serr;
    1896            1 :         SUPPRESS_OUT(sout, serr);
    1897            1 :         int cs = email_service_cron_status();
    1898            1 :         RESTORE_OUT(sout, serr);
    1899            1 :         ASSERT(cs == 0, "email_service_cron_status: returns 0");
    1900              :     }
    1901              :     {
    1902              :         int sout, serr;
    1903            1 :         SUPPRESS_OUT(sout, serr);
    1904            1 :         int cr = email_service_cron_remove();
    1905            1 :         RESTORE_OUT(sout, serr);
    1906            1 :         ASSERT(cr == 0, "email_service_cron_remove: no entry → returns 0");
    1907              :     }
    1908              : 
    1909              :     /* ── email_service_list_folders (batch, no server) ──────────────── */
    1910              :     {
    1911            1 :         Config fcfg = {0};
    1912            1 :         fcfg.host   = "imaps://no.such.host.invalid";
    1913            1 :         fcfg.user   = "nobody";
    1914            1 :         fcfg.folder = "INBOX";
    1915              : 
    1916              :         int sout, serr;
    1917            1 :         SUPPRESS_OUT(sout, serr);
    1918            1 :         int fr = email_service_list_folders(&fcfg, 0);  /* flat */
    1919            1 :         int tr = email_service_list_folders(&fcfg, 1);  /* tree */
    1920            1 :         RESTORE_OUT(sout, serr);
    1921              :         /* no local cache + no server → -1 or 0 */
    1922            1 :         ASSERT(fr == 0 || fr == -1, "email_service_list_folders flat: returns 0 or -1");
    1923            1 :         ASSERT(tr == 0 || tr == -1, "email_service_list_folders tree: returns 0 or -1");
    1924              :     }
    1925              : 
    1926              :     /* ── email_service_list_folders_interactive (no connection → NULL) ── */
    1927              :     {
    1928            1 :         Config ficfg = {0};
    1929            1 :         ficfg.host   = "imaps://no.such.host.invalid";
    1930            1 :         ficfg.user   = "nobody_folders";
    1931            1 :         ficfg.folder = "INBOX";
    1932            1 :         local_store_init(ficfg.host, ficfg.user);
    1933              : 
    1934            1 :         int go_up = 0;
    1935            1 :         char *sel = email_service_list_folders_interactive(&ficfg, "INBOX", &go_up);
    1936              :         /* No cached folders, no connection → returns NULL immediately */
    1937            1 :         ASSERT(sel == NULL, "email_service_list_folders_interactive: no folders → NULL");
    1938            1 :         free(sel);
    1939              : 
    1940            1 :         local_store_init("imaps://test.example.com", "testuser");
    1941              :     }
    1942              : 
    1943              :     /* ── email_service_list_labels_interactive (ESC exits) ──────────── */
    1944              :     {
    1945            1 :         local_store_init(NULL, "testlabels@gmail.com");
    1946            1 :         Config lcfg = {0};
    1947            1 :         lcfg.host       = NULL;
    1948            1 :         lcfg.user       = "testlabels@gmail.com";
    1949            1 :         lcfg.gmail_mode = 1;
    1950              : 
    1951            1 :         int go_up = 0;
    1952              :         int saved_stdin;
    1953            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    1954              :         int sout, serr;
    1955            1 :         SUPPRESS_OUT(sout, serr);
    1956            1 :         char *sel = email_service_list_labels_interactive(&lcfg, "INBOX", &go_up);
    1957            1 :         RESTORE_OUT(sout, serr);
    1958            1 :         RESTORE_STDIN(saved_stdin);
    1959            1 :         ASSERT(sel == NULL, "email_service_list_labels_interactive: ESC → NULL");
    1960            1 :         free(sel);
    1961              : 
    1962            1 :         local_store_init("imaps://test.example.com", "testuser");
    1963              :     }
    1964              : 
    1965              :     /* ── email_service_account_interactive (no accounts, ESC exits) ───── */
    1966              :     {
    1967            1 :         Config *acc_out = NULL;
    1968            1 :         int cursor = 0;
    1969              :         int saved_stdin;
    1970            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    1971              :         int sout, serr;
    1972            1 :         SUPPRESS_OUT(sout, serr);
    1973            1 :         int aret = email_service_account_interactive(&acc_out, &cursor, NULL);
    1974            1 :         RESTORE_OUT(sout, serr);
    1975            1 :         RESTORE_STDIN(saved_stdin);
    1976            1 :         ASSERT(aret == 0 || aret == -1 || aret == 1,
    1977              :                "email_service_account_interactive: exits cleanly");
    1978              :         (void)acc_out;
    1979              :     }
    1980              : 
    1981              :     /* ── email_service_cron_setup (exercises path, may fail w/o binary) ─ */
    1982              :     {
    1983            1 :         Config cscfg = {0};
    1984            1 :         cscfg.sync_interval = 30;
    1985              :         int sout, serr;
    1986            1 :         SUPPRESS_OUT(sout, serr);
    1987            1 :         int csr = email_service_cron_setup(&cscfg);
    1988            1 :         RESTORE_OUT(sout, serr);
    1989              :         /* May return 0 (already present or installed) or -1 (no binary path).
    1990              :          * Either is acceptable; we just exercise the code path. */
    1991            1 :         ASSERT(csr == 0 || csr == -1, "email_service_cron_setup: returns 0 or -1");
    1992              :         /* If installed, clean it up */
    1993            1 :         if (csr == 0) {
    1994            1 :             SUPPRESS_OUT(sout, serr);
    1995            1 :             email_service_cron_remove();
    1996            1 :             RESTORE_OUT(sout, serr);
    1997              :         }
    1998              :     }
    1999              : 
    2000              :     /* ── email_service_set_label (IMAP mode: no-op / Gmail fails) ────── */
    2001              :     {
    2002            1 :         Config slcfg = {0};
    2003            1 :         slcfg.host   = "imaps://no.such.host.invalid";
    2004            1 :         slcfg.user   = "nobody";
    2005            1 :         slcfg.folder = "INBOX";
    2006              :         int sout, serr;
    2007            1 :         SUPPRESS_OUT(sout, serr);
    2008            1 :         int slr = email_service_set_label(&slcfg, "uid123", "Work", 1);
    2009            1 :         RESTORE_OUT(sout, serr);
    2010              :         /* IMAP: mail_client_connect will fail → returns -1 */
    2011            1 :         ASSERT(slr == 0 || slr == -1, "email_service_set_label IMAP: returns -1 or 0");
    2012              :     }
    2013              : 
    2014              :     /* ── email_service_create_label / delete_label (fail paths) ─────── */
    2015              :     {
    2016            1 :         Config clcfg = {0};
    2017            1 :         clcfg.host   = "imaps://no.such.host.invalid";
    2018            1 :         clcfg.user   = "nobody";
    2019              :         int sout, serr;
    2020            1 :         SUPPRESS_OUT(sout, serr);
    2021            1 :         int clr = email_service_create_label(&clcfg, "NewLabel");
    2022            1 :         int dlr = email_service_delete_label(&clcfg, "NewLabel");
    2023            1 :         RESTORE_OUT(sout, serr);
    2024            1 :         ASSERT(clr == -1, "email_service_create_label: no connection → -1");
    2025            1 :         ASSERT(dlr == -1, "email_service_delete_label: no connection → -1");
    2026              :     }
    2027              : 
    2028              :     /* ── email_service_sync_all (no accounts) ───────────────────────── */
    2029              :     {
    2030              :         int sout, serr;
    2031            1 :         SUPPRESS_OUT(sout, serr);
    2032            1 :         int sar = email_service_sync_all(NULL, 0);
    2033            1 :         RESTORE_OUT(sout, serr);
    2034            1 :         ASSERT(sar == 0 || sar == -1, "email_service_sync_all NULL: returns 0 or -1");
    2035              :     }
    2036              : 
    2037              :     /* ── email_service_save_sent (local save, no IMAP connection needed) ── */
    2038              :     {
    2039            1 :         Config sscfg = {0};
    2040            1 :         sscfg.host   = "imaps://no.such.host.invalid";
    2041            1 :         sscfg.user   = "nobody";
    2042            1 :         sscfg.folder = "INBOX";
    2043            1 :         const char *msg = "From: a@b.com\r\nTo: b@c.com\r\nSubject: Test\r\n\r\nHello";
    2044              :         int sout, serr;
    2045            1 :         SUPPRESS_OUT(sout, serr);
    2046            1 :         int ssr = email_service_save_sent(&sscfg, msg, strlen(msg));
    2047            1 :         RESTORE_OUT(sout, serr);
    2048            1 :         ASSERT(ssr == 0, "email_service_save_sent: saves locally without IMAP → 0");
    2049              :     }
    2050              :     /* ── email_service_save_sent (fail path: NULL msg) ─────────────────── */
    2051              :     {
    2052            1 :         Config sscfg = {0};
    2053            1 :         sscfg.host   = "imaps://no.such.host.invalid";
    2054            1 :         sscfg.user   = "nobody";
    2055            1 :         sscfg.folder = "INBOX";
    2056              :         int sout, serr;
    2057            1 :         SUPPRESS_OUT(sout, serr);
    2058            1 :         int ssr = email_service_save_sent(&sscfg, NULL, 0);
    2059            1 :         RESTORE_OUT(sout, serr);
    2060            1 :         ASSERT(ssr == -1, "email_service_save_sent: NULL msg → -1");
    2061              :     }
    2062              : 
    2063              :     /* ── show_label_picker via stdin injection ───────────────────────── */
    2064              :     {
    2065            1 :         local_store_init(NULL, "picker@gmail.com");
    2066              :         int saved_stdin;
    2067            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    2068              :         int sout, serr;
    2069            1 :         SUPPRESS_OUT(sout, serr);
    2070              :         /* mc=NULL is safe: the picker checks if(mc) before calling it */
    2071            1 :         show_label_picker(NULL, "0000000000001234", NULL, 0);
    2072            1 :         RESTORE_OUT(sout, serr);
    2073            1 :         RESTORE_STDIN(saved_stdin);
    2074            1 :         ASSERT(1, "show_label_picker: ESC exits cleanly");
    2075            1 :         local_store_init("imaps://test.example.com", "testuser");
    2076              :     }
    2077              : 
    2078              :     /* ── bg_sync_sigchld (direct call of signal handler) ─────────────── */
    2079              :     {
    2080              :         /* Simulate the handler being called with no active child */
    2081            1 :         bg_sync_pid = 99999999; /* set to unreachable PID */
    2082            1 :         bg_sync_sigchld(SIGCHLD);
    2083              :         /* Handler calls waitpid(-1, WNOHANG) which returns 0 (no child) */
    2084            1 :         ASSERT(1, "bg_sync_sigchld: no crash on direct call");
    2085            1 :         bg_sync_pid = -1; /* restore */
    2086              :     }
    2087              : 
    2088              :     /* ── sync_start_background (spawns child that fails exec) ────────── */
    2089              :     {
    2090              :         /* bg_sync_pid must be -1 so sync_is_running() returns false */
    2091            1 :         bg_sync_pid = -1;
    2092            1 :         bg_sync_done = 0;
    2093            1 :         int sbr = sync_start_background();
    2094              :         /* May return 1 (forked) or -1 (fork error). Either is acceptable. */
    2095            1 :         ASSERT(sbr == 1 || sbr == -1 || sbr == 0,
    2096              :                "sync_start_background: returns expected value");
    2097              :         /* Let the child finish (it will exec fail and exit almost immediately) */
    2098            1 :         if (sbr == 1) {
    2099            1 :             struct timespec ts = {0, 50000000}; /* 50ms */
    2100            1 :             nanosleep(&ts, NULL);
    2101              :         }
    2102              :     }
    2103              : 
    2104              :     /* ── flag_push_background (spawns child that fails connect) ─────── */
    2105              :     {
    2106            1 :         Config fpbcfg = {0};
    2107            1 :         fpbcfg.host   = "imaps://127.0.0.1"; /* will fail connection */
    2108            1 :         fpbcfg.user   = "nobody";
    2109            1 :         fpbcfg.folder = "INBOX";
    2110              :         /* fork child that will try to connect and exit immediately */
    2111            1 :         flag_push_background(&fpbcfg, "0000000000001111", "\\Seen", 1);
    2112            1 :         ASSERT(1, "flag_push_background: no crash");
    2113              :         /* Short wait for child reaping */
    2114            1 :         struct timespec ts2 = {0, 50000000};
    2115            1 :         nanosleep(&ts2, NULL);
    2116              :     }
    2117              : 
    2118              :     /* ── pager_prompt: Down arrow → returns 1 ────────────────────────── */
    2119              :     {
    2120              :         int saved_stdin;
    2121            1 :         INJECT_STDIN("\033[B", 3, saved_stdin);
    2122              :         int sout, serr;
    2123            1 :         SUPPRESS_OUT(sout, serr);
    2124            1 :         int pr = pager_prompt(1, 3, 20, 24, 80);
    2125            1 :         RESTORE_OUT(sout, serr);
    2126            1 :         RESTORE_STDIN(saved_stdin);
    2127            1 :         ASSERT(pr == 1, "pager_prompt: Down returns 1");
    2128              :     }
    2129              : 
    2130              :     /* ── pager_prompt: Up arrow → returns -1 ─────────────────────────── */
    2131              :     {
    2132              :         int saved_stdin;
    2133            1 :         INJECT_STDIN("\033[A", 3, saved_stdin);
    2134              :         int sout, serr;
    2135            1 :         SUPPRESS_OUT(sout, serr);
    2136            1 :         int pr = pager_prompt(2, 3, 20, 24, 80);
    2137            1 :         RESTORE_OUT(sout, serr);
    2138            1 :         RESTORE_STDIN(saved_stdin);
    2139            1 :         ASSERT(pr == -1, "pager_prompt: Up returns -1");
    2140              :     }
    2141              : 
    2142              :     /* ── pager_prompt: Enter continues loop, then ESC exits → 0 ──────── */
    2143              :     {
    2144              :         int saved_stdin;
    2145              :         /* Enter: continues loop (TERM_KEY_ENTER → continue)
    2146              :          * Then ESC: returns 0 */
    2147            1 :         INJECT_STDIN("\r\033x", 3, saved_stdin);
    2148              :         int sout, serr;
    2149            1 :         SUPPRESS_OUT(sout, serr);
    2150            1 :         int pr = pager_prompt(1, 3, 20, 24, 80);
    2151            1 :         RESTORE_OUT(sout, serr);
    2152            1 :         RESTORE_STDIN(saved_stdin);
    2153            1 :         ASSERT(pr == 0, "pager_prompt: Enter+ESC returns 0");
    2154              :     }
    2155              : 
    2156              :     /* ── show_attachment_picker: Down navigates, then ESC ─────────────── */
    2157              :     {
    2158            1 :         MimeAttachment atts[2] = {
    2159              :             { .filename = (char*)"a.pdf", .content_type = (char*)"application/pdf",
    2160              :               .data = NULL, .size = 1024 },
    2161              :             { .filename = (char*)"b.png", .content_type = (char*)"image/png",
    2162              :               .data = NULL, .size = 2048 },
    2163              :         };
    2164              :         int saved_stdin;
    2165              :         /* Down (cursor 0→1) then Up (1→0) then ESC */
    2166            1 :         INJECT_STDIN("\033[B\033[A\033x", 8, saved_stdin);
    2167              :         int sout, serr;
    2168            1 :         SUPPRESS_OUT(sout, serr);
    2169            1 :         int picked = show_attachment_picker(atts, 2, 80, 24);
    2170            1 :         RESTORE_OUT(sout, serr);
    2171            1 :         RESTORE_STDIN(saved_stdin);
    2172            1 :         ASSERT(picked == -2, "show_attachment_picker: Down+Up+ESC returns -2");
    2173              :     }
    2174              : 
    2175              :     /* ── email_service_list_folders with cached folders ─────────────── */
    2176              :     {
    2177              :         /* Populate folder cache for a dedicated test user.
    2178              :          * label_idx_add creates the account directory tree (mkdir_p) so that
    2179              :          * local_folder_list_save can write folders.cache there. */
    2180            1 :         local_store_init("imaps://test.example.com", "foldercache@example.com");
    2181            1 :         label_idx_add("_setup_", "0000000000000001");  /* ensures account dir exists */
    2182            1 :         const char *flist[] = { "INBOX", "INBOX.Sent", "INBOX.Archive", "Trash" };
    2183            1 :         local_folder_list_save(flist, 4, '.');
    2184              : 
    2185            1 :         Config fcfg2 = {0};
    2186            1 :         fcfg2.host   = "imaps://test.example.com";
    2187            1 :         fcfg2.user   = "foldercache@example.com";
    2188            1 :         fcfg2.folder = "INBOX";
    2189              : 
    2190              :         int sout, serr;
    2191            1 :         SUPPRESS_OUT(sout, serr);
    2192            1 :         int fr2 = email_service_list_folders(&fcfg2, 0);  /* flat */
    2193            1 :         int tr2 = email_service_list_folders(&fcfg2, 1);  /* tree */
    2194            1 :         RESTORE_OUT(sout, serr);
    2195            1 :         ASSERT(fr2 == 0, "email_service_list_folders flat with cache: returns 0");
    2196            1 :         ASSERT(tr2 == 0, "email_service_list_folders tree with cache: returns 0");
    2197              : 
    2198              :         /* Restore */
    2199            1 :         local_store_init("imaps://test.example.com", "testuser");
    2200              :     }
    2201              : 
    2202              :     /* ── email_service_list_folders_interactive with cached folders + ESC ─ */
    2203              :     {
    2204            1 :         local_store_init("imaps://test.example.com", "foldercache@example.com");
    2205              :         /* Folders already cached from the previous test */
    2206              : 
    2207            1 :         Config ficfg2 = {0};
    2208            1 :         ficfg2.host   = "imaps://test.example.com";
    2209            1 :         ficfg2.user   = "foldercache@example.com";
    2210            1 :         ficfg2.folder = "INBOX";
    2211              : 
    2212            1 :         int go_up = 0;
    2213              :         int saved_stdin;
    2214            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    2215              :         int sout, serr;
    2216            1 :         SUPPRESS_OUT(sout, serr);
    2217            1 :         char *sel = email_service_list_folders_interactive(&ficfg2, "INBOX", &go_up);
    2218            1 :         RESTORE_OUT(sout, serr);
    2219            1 :         RESTORE_STDIN(saved_stdin);
    2220            1 :         ASSERT(sel == NULL, "email_service_list_folders_interactive cached+ESC: NULL");
    2221            1 :         ASSERT(go_up == 0,  "email_service_list_folders_interactive cached+ESC: go_up=0");
    2222            1 :         free(sel);
    2223              : 
    2224            1 :         local_store_init("imaps://test.example.com", "testuser");
    2225              :     }
    2226              : 
    2227              :     /* ── email_service_list_folders_interactive: Down+Enter selects ──── */
    2228              :     {
    2229            1 :         local_store_init("imaps://test.example.com", "foldercache@example.com");
    2230              : 
    2231            1 :         Config ficfg3 = {0};
    2232            1 :         ficfg3.host   = "imaps://test.example.com";
    2233            1 :         ficfg3.user   = "foldercache@example.com";
    2234            1 :         ficfg3.folder = "INBOX";
    2235              : 
    2236              :         /* Ensure tree_mode=1 by resetting pref */
    2237            1 :         ui_pref_set_int("folder_view_mode", 1);
    2238              : 
    2239            1 :         int go_up2 = 0;
    2240              :         int saved_stdin;
    2241              :         /* Down moves cursor to item 1 ("INBOX.Sent"), Enter selects it */
    2242            1 :         INJECT_STDIN("\033[B\r", 4, saved_stdin);
    2243              :         int sout, serr;
    2244            1 :         SUPPRESS_OUT(sout, serr);
    2245            1 :         char *sel2 = email_service_list_folders_interactive(&ficfg3, "INBOX", &go_up2);
    2246            1 :         RESTORE_OUT(sout, serr);
    2247            1 :         RESTORE_STDIN(saved_stdin);
    2248            1 :         ASSERT(sel2 != NULL, "email_service_list_folders_interactive: Down+Enter returns non-NULL");
    2249            1 :         free(sel2);
    2250              : 
    2251            1 :         local_store_init("imaps://test.example.com", "testuser");
    2252              :     }
    2253              : 
    2254              :     /* ── email_service_list_folders_interactive: Backspace sets go_up ── */
    2255              :     {
    2256            1 :         local_store_init("imaps://test.example.com", "foldercache@example.com");
    2257              : 
    2258            1 :         Config ficfg4 = {0};
    2259            1 :         ficfg4.host   = "imaps://test.example.com";
    2260            1 :         ficfg4.user   = "foldercache@example.com";
    2261            1 :         ficfg4.folder = "INBOX";
    2262              : 
    2263            1 :         ui_pref_set_int("folder_view_mode", 1);  /* tree mode */
    2264              : 
    2265            1 :         int go_up3 = 0;
    2266              :         int saved_stdin;
    2267            1 :         INJECT_STDIN("\x7f", 1, saved_stdin);
    2268              :         int sout, serr;
    2269            1 :         SUPPRESS_OUT(sout, serr);
    2270            1 :         char *sel3 = email_service_list_folders_interactive(&ficfg4, "INBOX", &go_up3);
    2271            1 :         RESTORE_OUT(sout, serr);
    2272            1 :         RESTORE_STDIN(saved_stdin);
    2273            1 :         ASSERT(sel3 == NULL, "email_service_list_folders_interactive: Backspace→NULL");
    2274            1 :         ASSERT(go_up3 == 1,  "email_service_list_folders_interactive: Backspace→go_up=1");
    2275            1 :         free(sel3);
    2276              : 
    2277            1 :         local_store_init("imaps://test.example.com", "testuser");
    2278              :     }
    2279              : 
    2280              :     /* ── email_service_list_folders_interactive: PgDn+PgUp+Quit ─────── */
    2281              :     {
    2282            1 :         local_store_init("imaps://test.example.com", "foldercache@example.com");
    2283              : 
    2284            1 :         Config ficfg5 = {0};
    2285            1 :         ficfg5.host   = "imaps://test.example.com";
    2286            1 :         ficfg5.user   = "foldercache@example.com";
    2287            1 :         ficfg5.folder = "INBOX";
    2288              : 
    2289            1 :         ui_pref_set_int("folder_view_mode", 1);  /* tree mode */
    2290              : 
    2291            1 :         int go_up4 = 0;
    2292              :         int saved_stdin;
    2293              :         /* PgDn, PgUp, then Ctrl-C (quit) */
    2294            1 :         INJECT_STDIN("\033[6~\033[5~\x03", 9, saved_stdin);
    2295              :         int sout, serr;
    2296            1 :         SUPPRESS_OUT(sout, serr);
    2297            1 :         char *sel4 = email_service_list_folders_interactive(&ficfg5, "INBOX", &go_up4);
    2298            1 :         RESTORE_OUT(sout, serr);
    2299            1 :         RESTORE_STDIN(saved_stdin);
    2300            1 :         ASSERT(sel4 == NULL, "email_service_list_folders_interactive: PgDn+PgUp+Quit→NULL");
    2301            1 :         free(sel4);
    2302              : 
    2303            1 :         local_store_init("imaps://test.example.com", "testuser");
    2304              :     }
    2305              : 
    2306              :     /* ── email_service_list (cron pager, empty manifest) ─────────────── */
    2307              :     {
    2308            1 :         Config cpcfg = {0};
    2309            1 :         cpcfg.host          = "imaps://test.example.com";
    2310            1 :         cpcfg.user          = "testuser";
    2311            1 :         cpcfg.folder        = "test_cron_pager_only";
    2312            1 :         cpcfg.sync_interval = 1;
    2313              : 
    2314            1 :         EmailListOpts cpopts = {0};
    2315            1 :         cpopts.folder = "test_cron_pager_only";
    2316            1 :         cpopts.pager  = 1;  /* TUI mode: enters the empty-pager loop */
    2317              : 
    2318              :         /* ESC exits the loop */
    2319              :         int saved_stdin;
    2320            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    2321              :         int sout, serr;
    2322            1 :         SUPPRESS_OUT(sout, serr);
    2323            1 :         int cpr = email_service_list(&cpcfg, &cpopts);
    2324            1 :         RESTORE_OUT(sout, serr);
    2325            1 :         RESTORE_STDIN(saved_stdin);
    2326            1 :         ASSERT(cpr == 0, "email_service_list cron pager empty+ESC: returns 0");
    2327              :     }
    2328              : 
    2329              :     /* ── email_service_list (cron pager empty, Backspace → 1) ─────────── */
    2330              :     {
    2331            1 :         Config cpbcfg = {0};
    2332            1 :         cpbcfg.host          = "imaps://test.example.com";
    2333            1 :         cpbcfg.user          = "testuser";
    2334            1 :         cpbcfg.folder        = "test_cron_pager_bs";
    2335            1 :         cpbcfg.sync_interval = 1;
    2336              : 
    2337            1 :         EmailListOpts cpbopts = {0};
    2338            1 :         cpbopts.folder = "test_cron_pager_bs";
    2339            1 :         cpbopts.pager  = 1;
    2340              : 
    2341              :         int saved_stdin;
    2342            1 :         INJECT_STDIN("\x7f", 1, saved_stdin);
    2343              :         int sout, serr;
    2344            1 :         SUPPRESS_OUT(sout, serr);
    2345            1 :         int cpbr = email_service_list(&cpbcfg, &cpbopts);
    2346            1 :         RESTORE_OUT(sout, serr);
    2347            1 :         RESTORE_STDIN(saved_stdin);
    2348            1 :         ASSERT(cpbr == 1, "email_service_list cron pager empty+Backspace: returns 1");
    2349              :     }
    2350              : 
    2351              :     /* ── email_service_list (cron pager, non-special key then ESC) ────── */
    2352              :     {
    2353              :         /* Exercises lines after the BACK/QUIT/ESC checks (ch path) */
    2354            1 :         Config cpxcfg = {0};
    2355            1 :         cpxcfg.host          = "imaps://test.example.com";
    2356            1 :         cpxcfg.user          = "testuser";
    2357            1 :         cpxcfg.folder        = "test_cron_pager_x";
    2358            1 :         cpxcfg.sync_interval = 1;
    2359              : 
    2360            1 :         EmailListOpts cpxopts = {0};
    2361            1 :         cpxopts.folder = "test_cron_pager_x";
    2362            1 :         cpxopts.pager  = 1;
    2363              : 
    2364              :         int saved_stdin;
    2365              :         /* 'x' → TERM_KEY_IGNORE, ch='x' → neither 's' nor 'R' → loop
    2366              :          * Then ESC → returns 0 */
    2367            1 :         INJECT_STDIN("x\033x", 3, saved_stdin);
    2368              :         int sout, serr;
    2369            1 :         SUPPRESS_OUT(sout, serr);
    2370            1 :         int cpxr = email_service_list(&cpxcfg, &cpxopts);
    2371            1 :         RESTORE_OUT(sout, serr);
    2372            1 :         RESTORE_STDIN(saved_stdin);
    2373            1 :         ASSERT(cpxr == 0, "email_service_list cron pager+x+ESC: returns 0");
    2374              :     }
    2375              : 
    2376              :     /* ── email_service_list (Gmail offline, sync_interval=0) ──────────── */
    2377              :     {
    2378            1 :         local_store_init(NULL, "gmailoffline@gmail.com");
    2379              : 
    2380              :         /* Add label index entries */
    2381            1 :         label_idx_add("INBOX", "0000000000abcd01");
    2382            1 :         label_idx_add("INBOX", "0000000000abcd02");
    2383              : 
    2384              :         /* Add .hdr files: from\tsubject\tdate\tlabels\tflags */
    2385            1 :         const char *hdr1 = "sender@test.com\tHello World\t2026-01-15 10:00\tINBOX\t0";
    2386            1 :         local_hdr_save("", "0000000000abcd01", hdr1, strlen(hdr1));
    2387              : 
    2388            1 :         Config gmcfg = {0};
    2389            1 :         gmcfg.host          = NULL;
    2390            1 :         gmcfg.user          = "gmailoffline@gmail.com";
    2391            1 :         gmcfg.folder        = "INBOX";
    2392            1 :         gmcfg.gmail_mode    = 1;
    2393            1 :         gmcfg.sync_interval = 0;  /* Gmail offline mode (not cron) */
    2394              : 
    2395            1 :         EmailListOpts gmopts = {0};
    2396            1 :         gmopts.folder = "INBOX";
    2397            1 :         gmopts.pager  = 0;  /* batch: renders without key input */
    2398              : 
    2399              :         int sout, serr;
    2400            1 :         SUPPRESS_OUT(sout, serr);
    2401            1 :         int gmr = email_service_list(&gmcfg, &gmopts);
    2402            1 :         RESTORE_OUT(sout, serr);
    2403            1 :         ASSERT(gmr == 0 || gmr == -1, "email_service_list Gmail offline batch: returns 0 or -1");
    2404              : 
    2405            1 :         local_store_init("imaps://test.example.com", "testuser");
    2406              :     }
    2407              : 
    2408              :     /* ── email_service_list_labels_interactive: Down+ESC ─────────────── */
    2409              :     {
    2410            1 :         local_store_init(NULL, "testlabels@gmail.com");
    2411            1 :         Config lcfg2 = {0};
    2412            1 :         lcfg2.host       = NULL;
    2413            1 :         lcfg2.user       = "testlabels@gmail.com";
    2414            1 :         lcfg2.gmail_mode = 1;
    2415              : 
    2416            1 :         int go_up5 = 0;
    2417              :         int saved_stdin;
    2418            1 :         INJECT_STDIN("\033[B\033x", 5, saved_stdin);
    2419              :         int sout, serr;
    2420            1 :         SUPPRESS_OUT(sout, serr);
    2421            1 :         char *lsel = email_service_list_labels_interactive(&lcfg2, "INBOX", &go_up5);
    2422            1 :         RESTORE_OUT(sout, serr);
    2423            1 :         RESTORE_STDIN(saved_stdin);
    2424            1 :         ASSERT(lsel == NULL, "labels_interactive: Down+ESC → NULL");
    2425            1 :         free(lsel);
    2426              : 
    2427            1 :         local_store_init("imaps://test.example.com", "testuser");
    2428              :     }
    2429              : 
    2430              :     /* ── email_service_list_labels_interactive: Up+ESC ───────────────── */
    2431              :     {
    2432            1 :         local_store_init(NULL, "testlabels@gmail.com");
    2433            1 :         Config lcfg3 = {0};
    2434            1 :         lcfg3.host       = NULL;
    2435            1 :         lcfg3.user       = "testlabels@gmail.com";
    2436            1 :         lcfg3.gmail_mode = 1;
    2437              : 
    2438            1 :         int go_up6 = 0;
    2439              :         int saved_stdin;
    2440            1 :         INJECT_STDIN("\033[A\033x", 5, saved_stdin);
    2441              :         int sout, serr;
    2442            1 :         SUPPRESS_OUT(sout, serr);
    2443            1 :         char *lsel2 = email_service_list_labels_interactive(&lcfg3, "INBOX", &go_up6);
    2444            1 :         RESTORE_OUT(sout, serr);
    2445            1 :         RESTORE_STDIN(saved_stdin);
    2446            1 :         ASSERT(lsel2 == NULL, "labels_interactive: Up+ESC → NULL");
    2447            1 :         free(lsel2);
    2448              : 
    2449            1 :         local_store_init("imaps://test.example.com", "testuser");
    2450              :     }
    2451              : 
    2452              :     /* ── email_service_list_labels_interactive: PgDn+ESC ─────────────── */
    2453              :     {
    2454            1 :         local_store_init(NULL, "testlabels@gmail.com");
    2455            1 :         Config lcfg4 = {0};
    2456            1 :         lcfg4.host       = NULL;
    2457            1 :         lcfg4.user       = "testlabels@gmail.com";
    2458            1 :         lcfg4.gmail_mode = 1;
    2459              : 
    2460            1 :         int go_up7 = 0;
    2461              :         int saved_stdin;
    2462            1 :         INJECT_STDIN("\033[6~\033x", 6, saved_stdin);
    2463              :         int sout, serr;
    2464            1 :         SUPPRESS_OUT(sout, serr);
    2465            1 :         char *lsel3 = email_service_list_labels_interactive(&lcfg4, "INBOX", &go_up7);
    2466            1 :         RESTORE_OUT(sout, serr);
    2467            1 :         RESTORE_STDIN(saved_stdin);
    2468            1 :         ASSERT(lsel3 == NULL, "labels_interactive: PgDn+ESC → NULL");
    2469            1 :         free(lsel3);
    2470              : 
    2471            1 :         local_store_init("imaps://test.example.com", "testuser");
    2472              :     }
    2473              : 
    2474              :     /* ── email_service_list_labels_interactive: PgUp+ESC ─────────────── */
    2475              :     {
    2476            1 :         local_store_init(NULL, "testlabels@gmail.com");
    2477            1 :         Config lcfg5 = {0};
    2478            1 :         lcfg5.host       = NULL;
    2479            1 :         lcfg5.user       = "testlabels@gmail.com";
    2480            1 :         lcfg5.gmail_mode = 1;
    2481              : 
    2482            1 :         int go_up8 = 0;
    2483              :         int saved_stdin;
    2484            1 :         INJECT_STDIN("\033[5~\033x", 6, saved_stdin);
    2485              :         int sout, serr;
    2486            1 :         SUPPRESS_OUT(sout, serr);
    2487            1 :         char *lsel4 = email_service_list_labels_interactive(&lcfg5, "INBOX", &go_up8);
    2488            1 :         RESTORE_OUT(sout, serr);
    2489            1 :         RESTORE_STDIN(saved_stdin);
    2490            1 :         ASSERT(lsel4 == NULL, "labels_interactive: PgUp+ESC → NULL");
    2491            1 :         free(lsel4);
    2492              : 
    2493            1 :         local_store_init("imaps://test.example.com", "testuser");
    2494              :     }
    2495              : 
    2496              :     /* ── email_service_list_labels_interactive: Enter returns selection ─ */
    2497              :     {
    2498            1 :         local_store_init(NULL, "testlabels@gmail.com");
    2499            1 :         Config lcfg6 = {0};
    2500            1 :         lcfg6.host       = NULL;
    2501            1 :         lcfg6.user       = "testlabels@gmail.com";
    2502            1 :         lcfg6.gmail_mode = 1;
    2503              : 
    2504            1 :         int go_up9 = 0;
    2505              :         int saved_stdin;
    2506            1 :         INJECT_STDIN("\r", 1, saved_stdin);
    2507              :         int sout, serr;
    2508            1 :         SUPPRESS_OUT(sout, serr);
    2509            1 :         char *lsel5 = email_service_list_labels_interactive(&lcfg6, "INBOX", &go_up9);
    2510            1 :         RESTORE_OUT(sout, serr);
    2511            1 :         RESTORE_STDIN(saved_stdin);
    2512            1 :         ASSERT(lsel5 != NULL, "labels_interactive: Enter returns non-NULL label");
    2513            1 :         free(lsel5);
    2514              : 
    2515            1 :         local_store_init("imaps://test.example.com", "testuser");
    2516              :     }
    2517              : 
    2518              :     /* ── email_service_list_labels_interactive: Backspace → go_up=1 ──── */
    2519              :     {
    2520            1 :         local_store_init(NULL, "testlabels@gmail.com");
    2521            1 :         Config lcfg7 = {0};
    2522            1 :         lcfg7.host       = NULL;
    2523            1 :         lcfg7.user       = "testlabels@gmail.com";
    2524            1 :         lcfg7.gmail_mode = 1;
    2525              : 
    2526            1 :         int go_upA = 0;
    2527              :         int saved_stdin;
    2528            1 :         INJECT_STDIN("\x7f", 1, saved_stdin);
    2529              :         int sout, serr;
    2530            1 :         SUPPRESS_OUT(sout, serr);
    2531            1 :         char *lsel6 = email_service_list_labels_interactive(&lcfg7, "INBOX", &go_upA);
    2532            1 :         RESTORE_OUT(sout, serr);
    2533            1 :         RESTORE_STDIN(saved_stdin);
    2534            1 :         ASSERT(lsel6 == NULL, "labels_interactive: Backspace → NULL");
    2535            1 :         ASSERT(go_upA == 1,   "labels_interactive: Backspace → go_up=1");
    2536            1 :         free(lsel6);
    2537              : 
    2538            1 :         local_store_init("imaps://test.example.com", "testuser");
    2539              :     }
    2540              : 
    2541              :     /* ── email_service_list (Gmail pager, full interactive key coverage) ─ */
    2542              :     /*
    2543              :      * Covers show_uid_interactive key handlers (lines ~893-939) and
    2544              :      * email_service_list Gmail handlers (lines ~1970-2228) and
    2545              :      * show_label_picker user-label section (lines ~2626-2643).
    2546              :      *
    2547              :      * Key sequence (36 bytes):
    2548              :      *   \r          Enter: open message in show_uid_interactive
    2549              :      *   \033[6~     PgDn in reader
    2550              :      *   \033[5~     PgUp in reader
    2551              :      *   \033[B      Down in reader
    2552              :      *   \033[A      Up in reader
    2553              :      *   \r          Enter in reader (break/stay)
    2554              :      *   h\r         help popup + dismiss (lines 926-939)
    2555              :      *   q           quit reader → back to list
    2556              :      *   h\r         Gmail help popup in list + dismiss (lines 2011-2053)
    2557              :      *   a           archive (lines 2055-2088)
    2558              :      *   D           trash (lines 2090-2112)
    2559              :      *   u           untrash (lines 2114-2147)
    2560              :      *   t           label picker (lines 2149-2151; user-label lines 2626-2643)
    2561              :      *   \033[B\r    Down + toggle in picker
    2562              :      *   \033x       ESC exit picker
    2563              :      *   d           remove label (lines 2153-2183)
    2564              :      *   n           toggle unread (lines 2185-2228, Gmail path)
    2565              :      *   f           toggle starred
    2566              :      *   \033x       ESC exit list
    2567              :      */
    2568              :     {
    2569            1 :         const char *gluid = "0000000000ee0001";
    2570            1 :         local_store_init(NULL, "listpager@gmail.com");
    2571              : 
    2572              :         /* Populate INBOX index and a user label for show_label_picker coverage */
    2573            1 :         label_idx_add("INBOX", gluid);
    2574            1 :         label_idx_add("UserLbl", gluid);
    2575              : 
    2576              :         /* .hdr: from\tsubject\tdate\tlabels\tflags */
    2577            1 :         const char *gl_hdr = "from@t.com\tHello Test\t2026-01-15 10:00\tINBOX,UserLbl\t0";
    2578            1 :         local_hdr_save("", gluid, gl_hdr, strlen(gl_hdr));
    2579              : 
    2580              :         /* Body required so show_uid_interactive can load it (not return -1) */
    2581            1 :         const char *gl_body =
    2582              :             "From: from@t.com\r\n"
    2583              :             "Subject: Hello Test\r\n"
    2584              :             "Date: Thu, 15 Jan 2026 10:00:00 +0000\r\n"
    2585              :             "\r\n"
    2586              :             "Hello, world!\r\n";
    2587            1 :         local_msg_save("INBOX", gluid, gl_body, strlen(gl_body));
    2588              : 
    2589            1 :         Config glcfg = {0};
    2590            1 :         glcfg.host          = NULL;
    2591            1 :         glcfg.user          = "listpager@gmail.com";
    2592            1 :         glcfg.folder        = "INBOX";
    2593            1 :         glcfg.gmail_mode    = 1;
    2594            1 :         glcfg.sync_interval = 0;
    2595              : 
    2596            1 :         EmailListOpts glopts = {0};
    2597            1 :         glopts.folder = "INBOX";
    2598            1 :         glopts.pager  = 1;
    2599              : 
    2600              :         int saved_stdin;
    2601            1 :         INJECT_STDIN(
    2602              :             "\r"           /* Enter → show_uid_interactive */
    2603              :             "\033[6~"      /* PgDn */
    2604              :             "\033[5~"      /* PgUp */
    2605              :             "\033[B"       /* Down */
    2606              :             "\033[A"       /* Up */
    2607              :             "\r"           /* Enter (stay) */
    2608              :             "h\r"          /* help popup + dismiss */
    2609              :             "q"            /* quit reader */
    2610              :             "h\r"          /* Gmail help popup + dismiss */
    2611              :             "aDut"         /* archive, trash, untrash, label-picker */
    2612              :             "\033[B\r"     /* Down+toggle in picker */
    2613              :             "\033x"        /* ESC exit picker */
    2614              :             "dnf"          /* remove-label, toggle-unread, toggle-starred */
    2615              :             "\033x",       /* ESC exit list */
    2616              :             36, saved_stdin);
    2617              : 
    2618              :         int sout, serr;
    2619            1 :         SUPPRESS_OUT(sout, serr);
    2620            1 :         int glr = email_service_list(&glcfg, &glopts);
    2621            1 :         RESTORE_OUT(sout, serr);
    2622            1 :         RESTORE_STDIN(saved_stdin);
    2623            1 :         ASSERT(glr == 0 || glr == 1,
    2624              :                "email_service_list Gmail pager full coverage: returns 0 or 1");
    2625              : 
    2626            1 :         local_store_init("imaps://test.example.com", "testuser");
    2627              :     }
    2628              : 
    2629              :     /* ── email_service_list_labels_interactive: user labels + qsort ──── */
    2630              :     {
    2631              :         /* Two user labels → covers filtering loop (2831-2832) and qsort (2840) */
    2632            1 :         local_store_init(NULL, "lblsort@gmail.com");
    2633            1 :         label_idx_add("WorkLabel",     "0000000000f10001");
    2634            1 :         label_idx_add("PersonalLabel", "0000000000f10002");
    2635              : 
    2636            1 :         Config lscfg = {0};
    2637            1 :         lscfg.host       = NULL;
    2638            1 :         lscfg.user       = "lblsort@gmail.com";
    2639            1 :         lscfg.gmail_mode = 1;
    2640              : 
    2641            1 :         int go_upB = 0;
    2642              :         int saved_stdin;
    2643            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    2644              :         int sout, serr;
    2645            1 :         SUPPRESS_OUT(sout, serr);
    2646            1 :         char *lsret = email_service_list_labels_interactive(&lscfg, "INBOX", &go_upB);
    2647            1 :         RESTORE_OUT(sout, serr);
    2648            1 :         RESTORE_STDIN(saved_stdin);
    2649            1 :         free(lsret);
    2650            1 :         ASSERT(1, "labels_interactive: user labels qsort covered");
    2651              : 
    2652            1 :         local_store_init("imaps://test.example.com", "testuser");
    2653              :     }
    2654              : 
    2655              :     /* ── email_service_account_interactive: with one Gmail account ─────── */
    2656              :     {
    2657              :         /* Save a Gmail account (has user+refresh_token → passes validation in
    2658              :          * load_config_from_path, so config_list_accounts returns count=1).
    2659              :          * This covers the render loop (lines 3182-3221, get_account_totals
    2660              :          * Gmail path 3068-3072, print_account_row 3107-3151) and navigation
    2661              :          * key handlers (3242-3290). */
    2662            1 :         Config acc_cfg = {0};
    2663            1 :         acc_cfg.user                = "acctest-tui@gmail.com";
    2664            1 :         acc_cfg.gmail_mode          = 1;
    2665            1 :         acc_cfg.gmail_refresh_token = "fake_token_for_test";
    2666            1 :         acc_cfg.folder              = "INBOX";
    2667            1 :         config_delete_account("acctest-tui@gmail.com"); /* pre-clean */
    2668            1 :         config_save_account(&acc_cfg);
    2669              : 
    2670            1 :         Config *acc_out2 = NULL;
    2671            1 :         int cursor2 = 0;
    2672              :         int saved_stdin;
    2673              :         /* Down (cursor++ clamped to 0), Up (no-op), Backspace (continue),
    2674              :          * h (help popup), Enter (dismiss popup), n (return 3 = add account) */
    2675            1 :         INJECT_STDIN("\033[B\033[A\x7fh\rn", 10, saved_stdin);
    2676              :         int sout, serr;
    2677            1 :         SUPPRESS_OUT(sout, serr);
    2678            1 :         int aret2 = email_service_account_interactive(&acc_out2, &cursor2, NULL);
    2679            1 :         RESTORE_OUT(sout, serr);
    2680            1 :         RESTORE_STDIN(saved_stdin);
    2681            1 :         config_free(acc_out2);
    2682            1 :         ASSERT(aret2 == 3, "email_service_account_interactive with account: n → 3");
    2683              : 
    2684            1 :         config_delete_account("acctest-tui@gmail.com");
    2685            1 :         local_store_init("imaps://test.example.com", "testuser");
    2686              :     }
    2687              : 
    2688              :     /* ── email_service_read: pager mode, short message fits one page ─── */
    2689              :     {
    2690              :         /* Message "0000000000008888" was saved by the batch-mode read test */
    2691            1 :         Config rpager_cfg = {0};
    2692            1 :         rpager_cfg.host   = "imaps://test.example.com";
    2693            1 :         rpager_cfg.user   = "testuser";
    2694            1 :         rpager_cfg.folder = "INBOX";
    2695              : 
    2696              :         int sout, serr;
    2697            1 :         SUPPRESS_OUT(sout, serr);
    2698            1 :         int rpager_r = email_service_read(&rpager_cfg, "0000000000008888", 1, 25);
    2699            1 :         RESTORE_OUT(sout, serr);
    2700            1 :         ASSERT(rpager_r == 0, "email_service_read pager: short msg fits → 0");
    2701              :     }
    2702              : 
    2703              :     /* ── email_service_read: cron mode + message not cached → -1 ─────── */
    2704              :     {
    2705            1 :         Config rcron_cfg = {0};
    2706            1 :         rcron_cfg.host          = "imaps://test.example.com";
    2707            1 :         rcron_cfg.user          = "testuser";
    2708            1 :         rcron_cfg.folder        = "INBOX";
    2709            1 :         rcron_cfg.sync_interval = 1; /* cron: do not connect */
    2710              : 
    2711              :         int sout, serr;
    2712            1 :         SUPPRESS_OUT(sout, serr);
    2713            1 :         int rcron_r = email_service_read(&rcron_cfg, "nonexistent_uid_xyz", 0, 0);
    2714            1 :         RESTORE_OUT(sout, serr);
    2715            1 :         ASSERT(rcron_r == -1, "email_service_read: cron + no cache → -1");
    2716              :     }
    2717              : 
    2718              :     /* ── email_service_set_flag: all flag types + Gmail label paths ───── */
    2719              :     {
    2720            1 :         local_store_init("imaps://test.example.com", "testuser");
    2721            1 :         Config sffcfg = {0};
    2722            1 :         sffcfg.host   = "imaps://no.such.host.invalid";
    2723            1 :         sffcfg.user   = "testuser";
    2724            1 :         sffcfg.folder = "INBOX";
    2725              : 
    2726              :         int sout, serr;
    2727              : 
    2728              :         /* Unknown flag bit → -1 immediately */
    2729            1 :         SUPPRESS_OUT(sout, serr);
    2730            1 :         int sfr1 = email_service_set_flag(&sffcfg, "0000000000008888", NULL, 0xFF, 1);
    2731            1 :         RESTORE_OUT(sout, serr);
    2732            1 :         ASSERT(sfr1 == -1, "set_flag: unknown bit → -1");
    2733              : 
    2734              :         /* FLAGGED bit, IMAP, no connection → 0 (queued) */
    2735            1 :         SUPPRESS_OUT(sout, serr);
    2736            1 :         int sfr2 = email_service_set_flag(&sffcfg, "0000000000008888", NULL,
    2737              :                                           MSG_FLAG_FLAGGED, 1);
    2738            1 :         RESTORE_OUT(sout, serr);
    2739            1 :         ASSERT(sfr2 == 0, "set_flag: FLAGGED IMAP no-conn → 0");
    2740              : 
    2741              :         /* DONE bit, IMAP, no connection → 0 (queued) */
    2742            1 :         SUPPRESS_OUT(sout, serr);
    2743            1 :         int sfr3 = email_service_set_flag(&sffcfg, "0000000000008888", NULL,
    2744              :                                           MSG_FLAG_DONE, 1);
    2745            1 :         RESTORE_OUT(sout, serr);
    2746            1 :         ASSERT(sfr3 == 0, "set_flag: DONE IMAP no-conn → 0");
    2747              : 
    2748              :         /* UNSEEN bit, Gmail mode → updates UNREAD label index → 0 */
    2749            1 :         local_store_init(NULL, "listpager@gmail.com");
    2750            1 :         Config sfgcfg = {0};
    2751            1 :         sfgcfg.user       = "listpager@gmail.com";
    2752            1 :         sfgcfg.gmail_mode = 1;
    2753            1 :         sfgcfg.folder     = "INBOX";
    2754              : 
    2755            1 :         SUPPRESS_OUT(sout, serr);
    2756            1 :         int sfr4 = email_service_set_flag(&sfgcfg, "0000000000ee0001", NULL,
    2757              :                                           MSG_FLAG_UNSEEN, 1);
    2758            1 :         RESTORE_OUT(sout, serr);
    2759            1 :         ASSERT(sfr4 == 0, "set_flag: UNSEEN Gmail add → 0");
    2760              : 
    2761              :         /* FLAGGED/remove, Gmail mode → updates STARRED label index */
    2762            1 :         SUPPRESS_OUT(sout, serr);
    2763            1 :         int sfr5 = email_service_set_flag(&sfgcfg, "0000000000ee0001", NULL,
    2764              :                                           MSG_FLAG_FLAGGED, 0);
    2765            1 :         RESTORE_OUT(sout, serr);
    2766            1 :         ASSERT(sfr5 == 0, "set_flag: FLAGGED Gmail remove → 0");
    2767              : 
    2768            1 :         local_store_init("imaps://test.example.com", "testuser");
    2769              :     }
    2770              : 
    2771              :     /* ── email_service_cron_status ─────────────────────────────────────── */
    2772              :     {
    2773              :         int sout, serr;
    2774            1 :         SUPPRESS_OUT(sout, serr);
    2775            1 :         int cst = email_service_cron_status();
    2776            1 :         RESTORE_OUT(sout, serr);
    2777            1 :         ASSERT(cst == 0, "email_service_cron_status: returns 0");
    2778              :     }
    2779              : 
    2780              :     /* ── email_service_list_attachments + email_service_fetch_raw ─────── */
    2781              :     {
    2782              :         /* "0000000000008888" is a plain-text message (no attachments) */
    2783            1 :         Config attcfg = {0};
    2784            1 :         attcfg.host   = "imaps://test.example.com";
    2785            1 :         attcfg.user   = "testuser";
    2786            1 :         attcfg.folder = "INBOX";
    2787              : 
    2788              :         int sout, serr;
    2789            1 :         SUPPRESS_OUT(sout, serr);
    2790            1 :         int attr = email_service_list_attachments(&attcfg, "0000000000008888");
    2791            1 :         RESTORE_OUT(sout, serr);
    2792            1 :         ASSERT(attr == 0, "list_attachments: plain-text msg → 0");
    2793              : 
    2794              :         /* fetch_raw returns the raw message string */
    2795            1 :         char *rawmsg = email_service_fetch_raw(&attcfg, "0000000000008888");
    2796            1 :         ASSERT(rawmsg != NULL, "email_service_fetch_raw: cached msg → not NULL");
    2797            1 :         free(rawmsg);
    2798              :     }
    2799              : 
    2800              :     /* ── email_service_list Gmail pager: navigation keys ─────────────── */
    2801              :     {
    2802              :         /* Set up an account with 2 messages, both with .hdr files so the
    2803              :          * render loop has no missing entries and no slow-fetch poll. */
    2804            1 :         local_store_init(NULL, "navtest@gmail.com");
    2805            1 :         label_idx_add("INBOX", "0000000000cc0001");
    2806            1 :         label_idx_add("INBOX", "0000000000cc0002");
    2807            1 :         const char *nhdr1 = "s@t.com\tMsg One\t2026-01-15 10:00\tINBOX\t0";
    2808            1 :         local_hdr_save("", "0000000000cc0001", nhdr1, strlen(nhdr1));
    2809            1 :         const char *nhdr2 = "s@t.com\tMsg Two\t2026-01-16 10:00\tINBOX\t0";
    2810            1 :         local_hdr_save("", "0000000000cc0002", nhdr2, strlen(nhdr2));
    2811              : 
    2812            1 :         Config navcfg = {0};
    2813            1 :         navcfg.user          = "navtest@gmail.com";
    2814            1 :         navcfg.gmail_mode    = 1;
    2815            1 :         navcfg.sync_interval = 0;
    2816            1 :         navcfg.folder        = "INBOX";
    2817            1 :         EmailListOpts navopts = {0};
    2818            1 :         navopts.folder = "INBOX";
    2819            1 :         navopts.pager  = 1;
    2820              : 
    2821              :         /* Down, Up, PgDn, PgUp, ESC — covers TERM_KEY_NEXT_LINE/PREV_LINE/
    2822              :          * NEXT_PAGE/PREV_PAGE cases in email_service_list (lines 2230-2243). */
    2823              :         int saved_stdin;
    2824            1 :         INJECT_STDIN("\033[B\033[A\033[6~\033[5~\033x", 16, saved_stdin);
    2825              :         int sout, serr;
    2826            1 :         SUPPRESS_OUT(sout, serr);
    2827            1 :         int navr = email_service_list(&navcfg, &navopts);
    2828            1 :         RESTORE_OUT(sout, serr);
    2829            1 :         RESTORE_STDIN(saved_stdin);
    2830            1 :         ASSERT(navr == 0 || navr == 1,
    2831              :                "email_service_list Gmail navigation: returns 0 or 1");
    2832              : 
    2833            1 :         local_store_init("imaps://test.example.com", "testuser");
    2834              :     }
    2835              : 
    2836              :     /* ── email_service_account_interactive: 'd' deletes account ─────── */
    2837              :     {
    2838              :         /* Save a Gmail account, press 'd' to delete it.  After deletion the
    2839              :          * loop re-renders with count=0, then reads ESC and returns 0.
    2840              :          * Covers the 'd' key handler (lines 3292-3318) and the non-empty
    2841              :          * text path in print_infoline (lines 378-381). */
    2842            1 :         Config acc_del = {0};
    2843            1 :         acc_del.user                = "acctest-del@gmail.com";
    2844            1 :         acc_del.gmail_mode          = 1;
    2845            1 :         acc_del.gmail_refresh_token = "fake_token_for_delete";
    2846            1 :         acc_del.folder              = "INBOX";
    2847            1 :         config_delete_account("acctest-del@gmail.com");
    2848            1 :         config_save_account(&acc_del);
    2849              : 
    2850            1 :         Config *acc_d = NULL;
    2851            1 :         int cursorD = 0;
    2852              :         int saved_stdin;
    2853              :         /* 'd' deletes account → loop re-renders with count=0 → ESC exits */
    2854            1 :         INJECT_STDIN("d\033x", 3, saved_stdin);
    2855              :         int sout, serr;
    2856            1 :         SUPPRESS_OUT(sout, serr);
    2857            1 :         int aretD = email_service_account_interactive(&acc_d, &cursorD, NULL);
    2858            1 :         RESTORE_OUT(sout, serr);
    2859            1 :         RESTORE_STDIN(saved_stdin);
    2860            1 :         ASSERT(aretD == 0, "email_service_account_interactive: d+ESC → 0");
    2861              : 
    2862            1 :         local_store_init("imaps://test.example.com", "testuser");
    2863              :     }
    2864              : 
    2865              :     /* ── email_service_account_interactive: Enter opens account ─────── */
    2866              :     {
    2867            1 :         Config acc_en = {0};
    2868            1 :         acc_en.user                = "acctest-enter@gmail.com";
    2869            1 :         acc_en.gmail_mode          = 1;
    2870            1 :         acc_en.gmail_refresh_token = "fake_token_enter";
    2871            1 :         acc_en.folder              = "INBOX";
    2872            1 :         config_delete_account("acctest-enter@gmail.com");
    2873            1 :         config_save_account(&acc_en);
    2874              : 
    2875            1 :         Config *acc_out_en = NULL;
    2876            1 :         int cursor_en = 0;
    2877              :         int saved_stdin;
    2878            1 :         INJECT_STDIN("\r", 1, saved_stdin);   /* Enter → return 1 */
    2879              :         int sout, serr;
    2880            1 :         SUPPRESS_OUT(sout, serr);
    2881            1 :         int aret_en = email_service_account_interactive(&acc_out_en, &cursor_en, NULL);
    2882            1 :         RESTORE_OUT(sout, serr);
    2883            1 :         RESTORE_STDIN(saved_stdin);
    2884            1 :         config_free(acc_out_en);
    2885            1 :         ASSERT(aret_en == 1, "email_service_account_interactive: Enter → 1");
    2886            1 :         config_delete_account("acctest-enter@gmail.com");
    2887            1 :         local_store_init("imaps://test.example.com", "testuser");
    2888              :     }
    2889              : 
    2890              :     /* ── email_service_account_interactive: 'i' = edit IMAP ─────────── */
    2891              :     {
    2892            1 :         Config acc_iv = {0};
    2893            1 :         acc_iv.user                = "acctest-imapedit@gmail.com";
    2894            1 :         acc_iv.gmail_mode          = 1;
    2895            1 :         acc_iv.gmail_refresh_token = "fake_token_imapedit";
    2896            1 :         acc_iv.folder              = "INBOX";
    2897            1 :         config_delete_account("acctest-imapedit@gmail.com");
    2898            1 :         config_save_account(&acc_iv);
    2899              : 
    2900            1 :         Config *acc_out_iv = NULL;
    2901            1 :         int cursor_iv = 0;
    2902              :         int saved_stdin;
    2903            1 :         INJECT_STDIN("i", 1, saved_stdin);   /* 'i' → return 4 */
    2904              :         int sout, serr;
    2905            1 :         SUPPRESS_OUT(sout, serr);
    2906            1 :         int aret_iv = email_service_account_interactive(&acc_out_iv, &cursor_iv, NULL);
    2907            1 :         RESTORE_OUT(sout, serr);
    2908            1 :         RESTORE_STDIN(saved_stdin);
    2909            1 :         config_free(acc_out_iv);
    2910            1 :         ASSERT(aret_iv == 4, "email_service_account_interactive: i → 4");
    2911            1 :         config_delete_account("acctest-imapedit@gmail.com");
    2912            1 :         local_store_init("imaps://test.example.com", "testuser");
    2913              :     }
    2914              : 
    2915              :     /* ── email_service_account_interactive: 'e' = edit SMTP ─────────── */
    2916              :     {
    2917            1 :         Config acc_ev = {0};
    2918            1 :         acc_ev.user                = "acctest-smtpedit@gmail.com";
    2919            1 :         acc_ev.gmail_mode          = 1;
    2920            1 :         acc_ev.gmail_refresh_token = "fake_token_smtpedit";
    2921            1 :         acc_ev.folder              = "INBOX";
    2922            1 :         config_delete_account("acctest-smtpedit@gmail.com");
    2923            1 :         config_save_account(&acc_ev);
    2924              : 
    2925            1 :         Config *acc_out_ev = NULL;
    2926            1 :         int cursor_ev = 0;
    2927              :         int saved_stdin;
    2928            1 :         INJECT_STDIN("e", 1, saved_stdin);   /* 'e' → return 2 */
    2929              :         int sout, serr;
    2930            1 :         SUPPRESS_OUT(sout, serr);
    2931            1 :         int aret_ev = email_service_account_interactive(&acc_out_ev, &cursor_ev, NULL);
    2932            1 :         RESTORE_OUT(sout, serr);
    2933            1 :         RESTORE_STDIN(saved_stdin);
    2934            1 :         config_free(acc_out_ev);
    2935            1 :         ASSERT(aret_ev == 2, "email_service_account_interactive: e → 2");
    2936            1 :         config_delete_account("acctest-smtpedit@gmail.com");
    2937            1 :         local_store_init("imaps://test.example.com", "testuser");
    2938              :     }
    2939              : 
    2940              :     /* ── email_service_account_interactive: IMAP account render ─────── */
    2941              :     {
    2942              :         /* Pre-populate the IMAP account's local store with a folder list so
    2943              :          * get_account_totals covers the iteration loop (lines 3078-3085).
    2944              :          * print_account_row then exercises IMAP-specific branches (3116,
    2945              :          * 3126, 3140-3141) and fmt_url_with_port (3094-3101). */
    2946            1 :         local_store_init("imaps://imap.example.com", "imap-user@example.com");
    2947            1 :         const char *imap_fldrs[] = { "INBOX" };
    2948            1 :         local_folder_list_save(imap_fldrs, 1, '/');
    2949            1 :         local_store_init("imaps://test.example.com", "testuser");
    2950              : 
    2951            1 :         Config imap_acc = {0};
    2952            1 :         imap_acc.host   = "imaps://imap.example.com";
    2953            1 :         imap_acc.user   = "imap-user@example.com";
    2954            1 :         imap_acc.pass   = "testpass";
    2955            1 :         imap_acc.folder = "INBOX";
    2956            1 :         config_delete_account("imap-user@example.com");
    2957            1 :         config_save_account(&imap_acc);
    2958              : 
    2959            1 :         Config *acc_imap = NULL;
    2960            1 :         int cursor_imap = 0;
    2961              :         int saved_stdin;
    2962            1 :         INJECT_STDIN("\033x", 2, saved_stdin);   /* ESC → return 0 */
    2963              :         int sout, serr;
    2964            1 :         SUPPRESS_OUT(sout, serr);
    2965            1 :         int aret_imap = email_service_account_interactive(&acc_imap, &cursor_imap, NULL);
    2966            1 :         RESTORE_OUT(sout, serr);
    2967            1 :         RESTORE_STDIN(saved_stdin);
    2968            1 :         ASSERT(aret_imap == 0, "email_service_account_interactive: IMAP acct ESC → 0");
    2969            1 :         config_delete_account("imap-user@example.com");
    2970            1 :         local_store_init("imaps://test.example.com", "testuser");
    2971              :     }
    2972              : 
    2973              :     /* ── fmt_url_with_port: NULL url and url-with-port branches ─────── */
    2974              :     {
    2975              :         char fubuf[256];
    2976              :         /* NULL URL → early return, out[0]='\0' (line 3094 true branch) */
    2977            1 :         fmt_url_with_port(NULL, 993, fubuf, sizeof(fubuf));
    2978            1 :         ASSERT(fubuf[0] == '\0', "fmt_url_with_port: NULL url → empty string");
    2979              :         /* URL with explicit port → copies as-is (line 3099) */
    2980            1 :         fmt_url_with_port("imaps://imap.example.com:993", 993, fubuf, sizeof(fubuf));
    2981            1 :         ASSERT(strcmp(fubuf, "imaps://imap.example.com:993") == 0,
    2982              :                "fmt_url_with_port: port present → unchanged");
    2983              :     }
    2984              : 
    2985              :     /* ── email_service_set_flag: remaining Gmail label paths ─────────── */
    2986              :     {
    2987            1 :         local_store_init(NULL, "listpager@gmail.com");
    2988            1 :         Config sfg2 = {0};
    2989            1 :         sfg2.user       = "listpager@gmail.com";
    2990            1 :         sfg2.gmail_mode = 1;
    2991            1 :         sfg2.folder     = "INBOX";
    2992              : 
    2993              :         int sout, serr;
    2994              :         /* UNSEEN/add=0 → label_idx_remove("UNREAD", uid) — line 3986 */
    2995            1 :         SUPPRESS_OUT(sout, serr);
    2996            1 :         int sfr6 = email_service_set_flag(&sfg2, "0000000000ee0001", NULL,
    2997              :                                           MSG_FLAG_UNSEEN, 0);
    2998            1 :         RESTORE_OUT(sout, serr);
    2999            1 :         ASSERT(sfr6 == 0, "set_flag: UNSEEN Gmail remove → 0");
    3000              : 
    3001              :         /* FLAGGED/add=1 → label_idx_add("STARRED", uid) — line 3989 */
    3002            1 :         SUPPRESS_OUT(sout, serr);
    3003            1 :         int sfr7 = email_service_set_flag(&sfg2, "0000000000ee0001", NULL,
    3004              :                                           MSG_FLAG_FLAGGED, 1);
    3005            1 :         RESTORE_OUT(sout, serr);
    3006            1 :         ASSERT(sfr7 == 0, "set_flag: FLAGGED Gmail add → 0");
    3007              : 
    3008            1 :         local_store_init("imaps://test.example.com", "testuser");
    3009              :     }
    3010              : 
    3011              :     /* ── visible_line_cols: 4-byte UTF-8 and invalid byte ───────────── */
    3012              :     {
    3013              :         /* F0 9F 98 80 = U+1F600: triggers 'c < 0xF8' branch (line 277) */
    3014            1 :         const char *emoji4 = "\xF0\x9F\x98\x80";
    3015            1 :         int vc1 = visible_line_cols(emoji4, emoji4 + 4);
    3016            1 :         ASSERT(vc1 >= 0, "visible_line_cols: 4-byte UTF-8 emoji → non-negative");
    3017              : 
    3018              :         /* FF = invalid start byte (c >= 0xF8): triggers else branch (line 278) */
    3019            1 :         const char *inv_ff = "\xFF";
    3020            1 :         int vc2 = visible_line_cols(inv_ff, inv_ff + 1);
    3021            1 :         ASSERT(vc2 >= 0, "visible_line_cols: 0xFF invalid byte → non-negative");
    3022              :     }
    3023              : 
    3024              :     /* ── text_end_at_cols: ANSI CSI escape sequence ──────────────────── */
    3025              :     {
    3026              :         /* "\033[31mHi" — ESC '[' '3' '1' 'm' = red.
    3027              :          * '3' and '1' are < 0x40, so the while-loop body (line 334)
    3028              :          * executes.  Covers the CSI-skip branch (lines 331-337). */
    3029            1 :         const char *ansi_txt = "\033[31mHi";
    3030            1 :         const char *ep = text_end_at_cols(ansi_txt, 80);
    3031            1 :         ASSERT(ep > ansi_txt,
    3032              :                "text_end_at_cols: ANSI escape → advances past sequence");
    3033              :     }
    3034              : 
    3035              :     /* ── print_clean: multi-byte UTF-8 branches ──────────────────────── */
    3036              :     {
    3037              :         /* Covers lines 677-681:
    3038              :          * \x80  = continuation byte (0x80 < 0xC2) → line 677
    3039              :          * \xC3\xA9 = 'é' (2-byte, 0xC3 < 0xE0) → line 678
    3040              :          * \xE4\xB8\xAD = '中' (3-byte, 0xE4 < 0xF0) → line 679
    3041              :          * \xF0\x9F\x98\x80 = '😀' (4-byte, 0xF0 < 0xF8) → line 680
    3042              :          * \xFF = invalid (0xFF >= 0xF8) → line 681 */
    3043              :         int sout, serr;
    3044            1 :         SUPPRESS_OUT(sout, serr);
    3045            1 :         print_clean("\x80\xC3\xA9\xE4\xB8\xAD\xF0\x9F\x98\x80\xFF", NULL, 80);
    3046            1 :         RESTORE_OUT(sout, serr);
    3047            1 :         ASSERT(1, "print_clean: multi-byte UTF-8 all branches covered");
    3048              :     }
    3049              : 
    3050              :     /* ── email_service_read: multi-page pager ────────────────────────── */
    3051              :     {
    3052              :         /* Save a plain-text message with 6 body lines.
    3053              :          * With page_size=8, rows_avail = 8 - SHOW_HDR_LINES(5) = 3.
    3054              :          * body_vrows(6) > 3 → pager calls pager_prompt on first iteration.
    3055              :          * Inject NEXT_LINE (Down) → second iteration executes lines 3390-3391,
    3056              :          * then ESC → delta=0 → break.  Covers lines 3390-3391 and 3400-3405. */
    3057            1 :         const char *puid = "0000000000009998";
    3058            1 :         const char *pmsg =
    3059              :             "From: pager@example.com\r\n"
    3060              :             "Subject: Multi-Page Test\r\n"
    3061              :             "MIME-Version: 1.0\r\n"
    3062              :             "Content-Type: text/plain; charset=UTF-8\r\n"
    3063              :             "\r\n"
    3064              :             "Line 1\r\nLine 2\r\nLine 3\r\nLine 4\r\nLine 5\r\nLine 6\r\n";
    3065            1 :         local_msg_save("INBOX", puid, pmsg, strlen(pmsg));
    3066              : 
    3067            1 :         Config pcfg = {0};
    3068            1 :         pcfg.host   = "imaps://test.example.com";
    3069            1 :         pcfg.user   = "testuser";
    3070            1 :         pcfg.folder = "INBOX";
    3071              : 
    3072              :         int saved_stdin;
    3073              :         /* \033[B = NEXT_LINE → advances to second page (triggers 3390-3391)
    3074              :          * \033x  = ESC       → exits pager */
    3075            1 :         INJECT_STDIN("\033[B\033x", 5, saved_stdin);
    3076              :         int sout, serr;
    3077            1 :         SUPPRESS_OUT(sout, serr);
    3078            1 :         int pr = email_service_read(&pcfg, puid, 1, 8);
    3079            1 :         RESTORE_OUT(sout, serr);
    3080            1 :         RESTORE_STDIN(saved_stdin);
    3081            1 :         ASSERT(pr == 0, "email_service_read: multi-page pager → 0");
    3082              :     }
    3083              : 
    3084              :     /* ── find_match_line ────────────────────────────────────────────────── */
    3085              :     {
    3086            1 :         const char *body = "Hello World\nFoo bar\nBaz qux\nHello again\n";
    3087              : 
    3088              :         /* Forward search: find "hello" from line -1 (before first) */
    3089            1 :         int ml = find_match_line(body, "hello", -1, 1);
    3090            1 :         ASSERT(ml == 0, "find_match_line: forward from -1 finds line 0");
    3091              : 
    3092              :         /* Forward search: find "hello" from line 0 (wraps to line 3) */
    3093            1 :         ml = find_match_line(body, "hello", 0, 1);
    3094            1 :         ASSERT(ml == 3, "find_match_line: forward from 0 finds line 3");
    3095              : 
    3096              :         /* Forward wrap: find "hello" from line 3 (wraps to line 0) */
    3097            1 :         ml = find_match_line(body, "hello", 3, 1);
    3098            1 :         ASSERT(ml == 0, "find_match_line: forward wrap returns line 0");
    3099              : 
    3100              :         /* Backward search: find "hello" from line 4 → line 3 */
    3101            1 :         ml = find_match_line(body, "hello", 4, -1);
    3102            1 :         ASSERT(ml == 3, "find_match_line: backward from 4 finds line 3");
    3103              : 
    3104              :         /* Backward wrap: find "hello" from line 0 → wraps to line 3 */
    3105            1 :         ml = find_match_line(body, "hello", 0, -1);
    3106            1 :         ASSERT(ml == 3, "find_match_line: backward wrap returns line 3");
    3107              : 
    3108              :         /* No match: returns -1 */
    3109            1 :         ml = find_match_line(body, "xyzzy", 0, 1);
    3110            1 :         ASSERT(ml == -1, "find_match_line: no match → -1");
    3111              : 
    3112              :         /* NULL term: returns -1 */
    3113            1 :         ml = find_match_line(body, NULL, 0, 1);
    3114            1 :         ASSERT(ml == -1, "find_match_line: NULL term → -1");
    3115              : 
    3116              :         /* NULL body: returns -1 */
    3117            1 :         ml = find_match_line(NULL, "hello", 0, 1);
    3118            1 :         ASSERT(ml == -1, "find_match_line: NULL body → -1");
    3119              :     }
    3120              : 
    3121              :     /* ── csv_update_labels ──────────────────────────────────────────────── */
    3122              :     {
    3123              :         /* Add labels to empty existing: no remove */
    3124              :         {
    3125            1 :             char *add[] = { (char*)"Work", (char*)"Personal" };
    3126            1 :             char *rm[]  = { NULL };
    3127            1 :             char *r = csv_update_labels(NULL, add, 2, rm, 0);
    3128            1 :             ASSERT(r != NULL, "csv_update_labels: NULL existing + add → non-NULL");
    3129            1 :             if (r) {
    3130            1 :                 ASSERT(strstr(r, "Work") != NULL,
    3131              :                        "csv_update_labels: Work present");
    3132            1 :                 ASSERT(strstr(r, "Personal") != NULL,
    3133              :                        "csv_update_labels: Personal present");
    3134            1 :                 free(r);
    3135              :             }
    3136              :         }
    3137              :         /* Keep existing, remove one */
    3138              :         {
    3139            1 :             char *add[] = { NULL };
    3140            1 :             char *rm[]  = { (char*)"INBOX" };
    3141            1 :             char *r = csv_update_labels("INBOX,Work,Personal", add, 0, rm, 1);
    3142            1 :             ASSERT(r != NULL, "csv_update_labels: remove one → non-NULL");
    3143            1 :             if (r) {
    3144            1 :                 ASSERT(strstr(r, "INBOX") == NULL,
    3145              :                        "csv_update_labels: INBOX removed");
    3146            1 :                 ASSERT(strstr(r, "Work") != NULL,
    3147              :                        "csv_update_labels: Work kept");
    3148            1 :                 free(r);
    3149              :             }
    3150              :         }
    3151              :         /* Skip duplicate add */
    3152              :         {
    3153            1 :             char *add[] = { (char*)"Work" };
    3154            1 :             char *rm[]  = { NULL };
    3155            1 :             char *r = csv_update_labels("Work,INBOX", add, 1, rm, 0);
    3156            1 :             ASSERT(r != NULL, "csv_update_labels: skip dup → non-NULL");
    3157            1 :             if (r) {
    3158              :                 /* Work appears only once */
    3159            1 :                 const char *p = r;
    3160            1 :                 int cnt = 0;
    3161            2 :                 while ((p = strstr(p, "Work")) != NULL) { cnt++; p++; }
    3162            1 :                 ASSERT(cnt == 1, "csv_update_labels: Work appears once");
    3163            1 :                 free(r);
    3164              :             }
    3165              :         }
    3166              :         /* Empty existing, empty add, empty rm → empty string */
    3167              :         {
    3168            1 :             char *r = csv_update_labels("", NULL, 0, NULL, 0);
    3169            1 :             ASSERT(r != NULL, "csv_update_labels: all-empty → non-NULL");
    3170            1 :             free(r);
    3171              :         }
    3172              :     }
    3173              : 
    3174              :     /* ── list_filter_rebuild ────────────────────────────────────────────── */
    3175              :     {
    3176              :         /* Set up a manifest and entries for filter testing */
    3177            1 :         const char *lfr_folder = "test_lfr_folder";
    3178            1 :         Manifest *lfr_m = calloc(1, sizeof(Manifest));
    3179            1 :         manifest_upsert(lfr_m, "0000000000lfr001",
    3180              :                         strdup("alice@example.com"), strdup("Hello World"),
    3181              :                         strdup("2026-01-01 00:00"), MSG_FLAG_UNSEEN);
    3182            1 :         manifest_upsert(lfr_m, "0000000000lfr002",
    3183              :                         strdup("bob@example.com"), strdup("Goodbye Cruel World"),
    3184              :                         strdup("2026-01-02 00:00"), 0);
    3185            1 :         manifest_save(lfr_folder, lfr_m);
    3186              : 
    3187              :         /* Build MsgEntry array from manifest */
    3188              :         MsgEntry lfr_entries[2];
    3189            1 :         memset(lfr_entries, 0, sizeof(lfr_entries));
    3190            1 :         memcpy(lfr_entries[0].uid, "0000000000lfr001", 16); lfr_entries[0].uid[16] = '\0';
    3191            1 :         lfr_entries[0].flags = MSG_FLAG_UNSEEN;
    3192            1 :         memcpy(lfr_entries[1].uid, "0000000000lfr002", 16); lfr_entries[1].uid[16] = '\0';
    3193              : 
    3194            1 :         int fentries[4] = {0};
    3195            1 :         int fcount = 0;
    3196              : 
    3197            1 :         Config lfr_cfg = {0};
    3198            1 :         lfr_cfg.host   = "imaps://test.example.com";
    3199            1 :         lfr_cfg.user   = "testuser";
    3200            1 :         lfr_cfg.folder = (char *)lfr_folder;
    3201              : 
    3202              :         /* Empty filter: identity pass-through */
    3203            1 :         list_filter_rebuild(lfr_entries, 2, lfr_m, &lfr_cfg, lfr_folder,
    3204              :                             "", 0, fentries, &fcount);
    3205            1 :         ASSERT(fcount == 2, "list_filter_rebuild: empty filter → all 2");
    3206              : 
    3207              :         /* fscope=0 (subject) filter "Hello" → 1 match */
    3208            1 :         list_filter_rebuild(lfr_entries, 2, lfr_m, &lfr_cfg, lfr_folder,
    3209              :                             "Hello", 0, fentries, &fcount);
    3210            1 :         ASSERT(fcount == 1, "list_filter_rebuild: subject 'Hello' → 1");
    3211            1 :         ASSERT(fentries[0] == 0, "list_filter_rebuild: matched entry 0");
    3212              : 
    3213              :         /* fscope=1 (from) filter "bob" → 1 match */
    3214            1 :         list_filter_rebuild(lfr_entries, 2, lfr_m, &lfr_cfg, lfr_folder,
    3215              :                             "bob", 1, fentries, &fcount);
    3216            1 :         ASSERT(fcount == 1, "list_filter_rebuild: from 'bob' → 1");
    3217            1 :         ASSERT(fentries[0] == 1, "list_filter_rebuild: matched entry 1");
    3218              : 
    3219              :         /* fscope=0 filter with no match */
    3220            1 :         list_filter_rebuild(lfr_entries, 2, lfr_m, &lfr_cfg, lfr_folder,
    3221              :                             "XYZZY_NOMATCH", 0, fentries, &fcount);
    3222            1 :         ASSERT(fcount == 0, "list_filter_rebuild: no match → 0");
    3223              : 
    3224              :         /* fscope=3 (body) — body not cached, match returns 0 */
    3225            1 :         list_filter_rebuild(lfr_entries, 2, lfr_m, &lfr_cfg, lfr_folder,
    3226              :                             "Hello", 3, fentries, &fcount);
    3227            1 :         ASSERT(fcount >= 0, "list_filter_rebuild: body scope → non-negative");
    3228              : 
    3229            1 :         manifest_free(lfr_m);
    3230              :     }
    3231              : 
    3232              :     /* ── email_service_save_draft ───────────────────────────────────────── */
    3233              :     {
    3234            1 :         Config sd_cfg = {0};
    3235            1 :         sd_cfg.host   = "imaps://test.example.com";
    3236            1 :         sd_cfg.user   = "testuser";
    3237            1 :         sd_cfg.folder = "INBOX";
    3238              : 
    3239            1 :         const char *draft_msg =
    3240              :             "From: test@example.com\r\nSubject: Draft\r\n\r\nDraft body\r\n";
    3241              : 
    3242              :         int sout, serr;
    3243            1 :         SUPPRESS_OUT(sout, serr);
    3244            1 :         int dr = email_service_save_draft(&sd_cfg, draft_msg, strlen(draft_msg));
    3245            1 :         RESTORE_OUT(sout, serr);
    3246            1 :         ASSERT(dr == 0, "email_service_save_draft: saves locally → 0");
    3247              : 
    3248              :         /* NULL msg → -1 */
    3249            1 :         SUPPRESS_OUT(sout, serr);
    3250            1 :         int dr2 = email_service_save_draft(&sd_cfg, NULL, 0);
    3251            1 :         RESTORE_OUT(sout, serr);
    3252            1 :         ASSERT(dr2 == -1, "email_service_save_draft: NULL msg → -1");
    3253              :     }
    3254              : 
    3255              :     /* ── email_service_apply_rules: account not found ────────────────────── */
    3256              :     {
    3257              :         int sout, serr;
    3258            1 :         SUPPRESS_OUT(sout, serr);
    3259            1 :         int ar = email_service_apply_rules("nonexistent_account_xyz_ZZZZZ",
    3260              :                                             0, 0);
    3261            1 :         RESTORE_OUT(sout, serr);
    3262            1 :         ASSERT(ar == -1, "apply_rules: nonexistent account → -1");
    3263              :     }
    3264              : 
    3265              :     /* ── email_service_apply_rules: account with no rules ─────────────────── */
    3266              :     {
    3267              :         /* Save an IMAP account that has NO rules file */
    3268            1 :         Config norules_cfg = {0};
    3269            1 :         norules_cfg.host   = "imaps://no.such.host.invalid";
    3270            1 :         norules_cfg.user   = "norules-unit@example.com";
    3271            1 :         norules_cfg.pass   = "x";
    3272            1 :         norules_cfg.folder = "INBOX";
    3273            1 :         config_delete_account("norules-unit@example.com");
    3274            1 :         config_save_account(&norules_cfg);
    3275              : 
    3276              :         int sout, serr;
    3277            1 :         SUPPRESS_OUT(sout, serr);
    3278            1 :         int ar = email_service_apply_rules("norules-unit@example.com", 0, 0);
    3279            1 :         RESTORE_OUT(sout, serr);
    3280              :         /* No rules file → total_fired=0, done=1 → returns 0 */
    3281            1 :         ASSERT(ar >= 0, "apply_rules: no-rules account → 0 (or total_fired)");
    3282              : 
    3283            1 :         config_delete_account("norules-unit@example.com");
    3284              :     }
    3285              : 
    3286              :     /* ── email_service_apply_rules: Gmail account with rules (dry-run) ──── */
    3287              :     {
    3288              :         /* Create Gmail account with a rule that matches INBOX messages */
    3289            1 :         Config gar_cfg = {0};
    3290            1 :         gar_cfg.host                = NULL;
    3291            1 :         gar_cfg.user                = "applyrules-gmail@example.com";
    3292            1 :         gar_cfg.gmail_mode          = 1;
    3293            1 :         gar_cfg.gmail_refresh_token = "fake_token_applyrules";
    3294            1 :         gar_cfg.folder              = "INBOX";
    3295            1 :         config_delete_account("applyrules-gmail@example.com");
    3296            1 :         config_save_account(&gar_cfg);
    3297              : 
    3298              :         /* Populate local store for this account */
    3299            1 :         local_store_init(NULL, "applyrules-gmail@example.com");
    3300            1 :         const char *gar_uid = "0000000000ar0001";
    3301            1 :         label_idx_add("INBOX", gar_uid);
    3302              :         /* .hdr: from\tsubject\tdate\tlabels\tflags */
    3303            1 :         const char *gar_hdr = "github@github.com\tPR review\t2026-01-15 10:00\tINBOX\t0";
    3304            1 :         local_hdr_save("", gar_uid, gar_hdr, strlen(gar_hdr));
    3305              : 
    3306              :         /* Write a rules.ini for this account via mail_rules_save() */
    3307              :         {
    3308              :             MailRules gar_rules;
    3309            1 :             memset(&gar_rules, 0, sizeof(gar_rules));
    3310              :             MailRule gar_rule;
    3311            1 :             memset(&gar_rule, 0, sizeof(gar_rule));
    3312              :             /* Use when= expression so mail_rules_save writes it correctly */
    3313            1 :             gar_rule.when              = (char *)"from:*@github.com";
    3314            1 :             gar_rule.then_add_label[0] = (char *)"GitHub";
    3315            1 :             gar_rule.then_add_count    = 1;
    3316            1 :             gar_rule.then_rm_label[0]  = (char *)"INBOX";
    3317            1 :             gar_rule.then_rm_count     = 1;
    3318            1 :             gar_rules.rules = &gar_rule;
    3319            1 :             gar_rules.count = 1;
    3320            1 :             gar_rules.cap   = 1;
    3321            1 :             mail_rules_save("applyrules-gmail@example.com", &gar_rules);
    3322              :         }
    3323              : 
    3324              :         int sout, serr;
    3325              : 
    3326              :         /* dry_run=1, verbose=1: covers print_rule_matches path */
    3327            1 :         SUPPRESS_OUT(sout, serr);
    3328            1 :         int ar_dry = email_service_apply_rules("applyrules-gmail@example.com",
    3329              :                                                 1, 1);
    3330            1 :         RESTORE_OUT(sout, serr);
    3331            1 :         ASSERT(ar_dry >= 0, "apply_rules Gmail dry-run: returns >=0");
    3332              : 
    3333              :         /* dry_run=0: actually applies changes */
    3334            1 :         local_store_init(NULL, "applyrules-gmail@example.com");
    3335            1 :         local_hdr_save("", gar_uid, gar_hdr, strlen(gar_hdr));  /* reset hdr */
    3336            1 :         label_idx_add("INBOX", gar_uid);
    3337              : 
    3338            1 :         SUPPRESS_OUT(sout, serr);
    3339            1 :         int ar_live = email_service_apply_rules("applyrules-gmail@example.com",
    3340              :                                                  0, 0);
    3341            1 :         RESTORE_OUT(sout, serr);
    3342            1 :         ASSERT(ar_live >= 0, "apply_rules Gmail live: returns >=0");
    3343              : 
    3344            1 :         config_delete_account("applyrules-gmail@example.com");
    3345            1 :         local_store_init("imaps://test.example.com", "testuser");
    3346              :     }
    3347              : 
    3348              :     /* ── email_service_apply_rules: IMAP account with rules (dry-run) ─────── */
    3349              :     {
    3350            1 :         const char *imap_ar_user = "applyrules-imap@example.com";
    3351            1 :         Config iap_cfg = {0};
    3352            1 :         iap_cfg.host   = "imaps://no.such.host.invalid";
    3353            1 :         iap_cfg.user   = (char *)imap_ar_user;
    3354            1 :         iap_cfg.pass   = "x";
    3355            1 :         iap_cfg.folder = "INBOX";
    3356            1 :         config_delete_account(imap_ar_user);
    3357            1 :         config_save_account(&iap_cfg);
    3358              : 
    3359              :         /* Populate manifest with a message that will match the rule */
    3360            1 :         local_store_init(iap_cfg.host, iap_cfg.user);
    3361            1 :         Manifest *iap_m = calloc(1, sizeof(Manifest));
    3362            1 :         manifest_upsert(iap_m, "0000000000ap0001",
    3363              :                         strdup("boss@company.com"), strdup("Urgent task"),
    3364              :                         strdup("2026-01-15 10:00"), MSG_FLAG_UNSEEN);
    3365            1 :         manifest_save("INBOX", iap_m);
    3366            1 :         manifest_free(iap_m);
    3367              : 
    3368              :         /* Write a rules.ini via mail_rules_save() */
    3369              :         {
    3370              :             MailRules iap_rules;
    3371            1 :             memset(&iap_rules, 0, sizeof(iap_rules));
    3372              :             MailRule iap_rule;
    3373            1 :             memset(&iap_rule, 0, sizeof(iap_rule));
    3374              :             /* Use when= expression; add _flagged → lmap path; remove UNREAD → fmap path */
    3375            1 :             iap_rule.when              = (char *)"from:*@company.com";
    3376            1 :             iap_rule.then_add_label[0] = (char *)"_flagged";
    3377            1 :             iap_rule.then_add_count    = 1;
    3378            1 :             iap_rule.then_rm_label[0]  = (char *)"UNREAD";
    3379            1 :             iap_rule.then_rm_count     = 1;
    3380            1 :             iap_rules.rules = &iap_rule;
    3381            1 :             iap_rules.count = 1;
    3382            1 :             iap_rules.cap   = 1;
    3383            1 :             mail_rules_save(imap_ar_user, &iap_rules);
    3384              :         }
    3385              : 
    3386              :         int sout, serr;
    3387              : 
    3388              :         /* dry_run=1, verbose=1: covers print_rule_matches IMAP path */
    3389            1 :         SUPPRESS_OUT(sout, serr);
    3390            1 :         int iap_dry = email_service_apply_rules(imap_ar_user, 1, 1);
    3391            1 :         RESTORE_OUT(sout, serr);
    3392            1 :         ASSERT(iap_dry >= 0, "apply_rules IMAP dry-run: returns >=0");
    3393              : 
    3394              :         /* dry_run=0: applies changes, covers lines 5742-5774 */
    3395            1 :         local_store_init(iap_cfg.host, iap_cfg.user);
    3396            1 :         Manifest *iap_m2 = calloc(1, sizeof(Manifest));
    3397            1 :         manifest_upsert(iap_m2, "0000000000ap0001",
    3398              :                         strdup("boss@company.com"), strdup("Urgent task"),
    3399              :                         strdup("2026-01-15 10:00"), MSG_FLAG_UNSEEN);
    3400            1 :         manifest_save("INBOX", iap_m2);
    3401            1 :         manifest_free(iap_m2);
    3402              : 
    3403            1 :         SUPPRESS_OUT(sout, serr);
    3404            1 :         int iap_live = email_service_apply_rules(imap_ar_user, 0, 0);
    3405            1 :         RESTORE_OUT(sout, serr);
    3406            1 :         ASSERT(iap_live >= 0, "apply_rules IMAP live: returns >=0");
    3407              : 
    3408            1 :         config_delete_account(imap_ar_user);
    3409            1 :         local_store_init("imaps://test.example.com", "testuser");
    3410              :     }
    3411              : 
    3412              :     /* ── email_service_apply_rules: IMAP with move_folder rule ──────────── */
    3413              :     {
    3414            1 :         const char *iap_mv_user = "applyrules-move@example.com";
    3415            1 :         Config iap_mv_cfg = {0};
    3416            1 :         iap_mv_cfg.host   = "imaps://no.such.host.invalid";
    3417            1 :         iap_mv_cfg.user   = (char *)iap_mv_user;
    3418            1 :         iap_mv_cfg.pass   = "x";
    3419            1 :         iap_mv_cfg.folder = "INBOX";
    3420            1 :         config_delete_account(iap_mv_user);
    3421            1 :         config_save_account(&iap_mv_cfg);
    3422              : 
    3423            1 :         local_store_init(iap_mv_cfg.host, iap_mv_cfg.user);
    3424            1 :         Manifest *mv_m = calloc(1, sizeof(Manifest));
    3425            1 :         manifest_upsert(mv_m, "0000000000mv0001",
    3426              :                         strdup("newsletter@promo.com"), strdup("Promo"),
    3427              :                         strdup("2026-01-15 10:00"), MSG_FLAG_UNSEEN);
    3428            1 :         manifest_save("INBOX", mv_m);
    3429            1 :         manifest_free(mv_m);
    3430              : 
    3431              :         {
    3432              :             MailRules mv_rules;
    3433            1 :             memset(&mv_rules, 0, sizeof(mv_rules));
    3434              :             MailRule mv_rule;
    3435            1 :             memset(&mv_rule, 0, sizeof(mv_rule));
    3436            1 :             mv_rule.when             = (char *)"from:*@promo.com";
    3437            1 :             mv_rule.then_move_folder = (char *)"Promotions";
    3438            1 :             mv_rules.rules = &mv_rule;
    3439            1 :             mv_rules.count = 1;
    3440            1 :             mv_rules.cap   = 1;
    3441            1 :             mail_rules_save(iap_mv_user, &mv_rules);
    3442              :         }
    3443              : 
    3444              :         int sout, serr;
    3445            1 :         SUPPRESS_OUT(sout, serr);
    3446            1 :         int mv_r = email_service_apply_rules(iap_mv_user, 0, 0);
    3447            1 :         RESTORE_OUT(sout, serr);
    3448            1 :         ASSERT(mv_r >= 0, "apply_rules IMAP move: returns >=0");
    3449              : 
    3450            1 :         config_delete_account(iap_mv_user);
    3451            1 :         local_store_init("imaps://test.example.com", "testuser");
    3452              :     }
    3453              : 
    3454              :     /* ── email_service_rebuild_indexes: no accounts ─────────────────────── */
    3455              :     /* Call with a definitely-nonexistent account while real accounts exist:
    3456              :      * done==0 → returns -1 (covers 5395-5397) */
    3457              :     {
    3458              :         int sout, serr;
    3459            1 :         SUPPRESS_OUT(sout, serr);
    3460            1 :         int ri = email_service_rebuild_indexes("nonexistent_rebuild_acct_XYZ");
    3461            1 :         RESTORE_OUT(sout, serr);
    3462            1 :         ASSERT(ri == -1, "rebuild_indexes: nonexistent account → -1");
    3463              :     }
    3464              : 
    3465              :     /* ── email_service_rebuild_indexes: IMAP account → skip ─────────────── */
    3466              :     {
    3467              :         /* imap account is not Gmail → covers 5381-5384 */
    3468            1 :         const char *ri_user = "rebuild-imap-unit@example.com";
    3469            1 :         Config ri_cfg = {0};
    3470            1 :         ri_cfg.host      = "imaps://no.such.host.invalid";
    3471            1 :         ri_cfg.user      = (char *)ri_user;
    3472            1 :         ri_cfg.pass      = "x";
    3473            1 :         ri_cfg.folder    = "INBOX";
    3474            1 :         ri_cfg.gmail_mode = 0;
    3475            1 :         config_delete_account(ri_user);
    3476            1 :         config_save_account(&ri_cfg);
    3477              : 
    3478              :         int sout, serr;
    3479            1 :         SUPPRESS_OUT(sout, serr);
    3480            1 :         int ri2 = email_service_rebuild_indexes(ri_user);
    3481            1 :         RESTORE_OUT(sout, serr);
    3482              :         /* IMAP accounts are skipped → done=1, errors=0 → returns 0 */
    3483            1 :         ASSERT(ri2 == 0, "rebuild_indexes: IMAP account → 0 (skipped)");
    3484              : 
    3485            1 :         config_delete_account(ri_user);
    3486              :     }
    3487              : 
    3488              :     /* ── email_service_rebuild_contacts: account not found ──────────────── */
    3489              :     {
    3490              :         int sout, serr;
    3491            1 :         SUPPRESS_OUT(sout, serr);
    3492            1 :         int rc = email_service_rebuild_contacts("nonexistent_contacts_XYZ");
    3493            1 :         RESTORE_OUT(sout, serr);
    3494            1 :         ASSERT(rc == -1, "rebuild_contacts: nonexistent account → -1");
    3495              :     }
    3496              : 
    3497              :     /* ── email_service_rebuild_contacts: only_account filter ────────────── */
    3498              :     {
    3499              :         /* Save an account; call rebuild with a different only_account filter.
    3500              :          * done==0 → returns -1 (covers line 5820-5821 and 5830-5832). */
    3501            1 :         const char *rcc_user = "rebuild-contacts-unit@example.com";
    3502            1 :         Config rcc_cfg = {0};
    3503            1 :         rcc_cfg.host   = "imaps://no.such.host.invalid";
    3504            1 :         rcc_cfg.user   = (char *)rcc_user;
    3505            1 :         rcc_cfg.pass   = "x";
    3506            1 :         rcc_cfg.folder = "INBOX";
    3507            1 :         config_delete_account(rcc_user);
    3508            1 :         config_save_account(&rcc_cfg);
    3509              : 
    3510              :         int sout, serr;
    3511            1 :         SUPPRESS_OUT(sout, serr);
    3512              :         /* Filter is a different name → skip all → done=0 → -1 */
    3513            1 :         int rc2 = email_service_rebuild_contacts("different_account_ZZZZ");
    3514            1 :         RESTORE_OUT(sout, serr);
    3515            1 :         ASSERT(rc2 == -1, "rebuild_contacts: filter skips all → -1");
    3516              : 
    3517              :         /* Call with matching account → done=1 → 0 */
    3518            1 :         SUPPRESS_OUT(sout, serr);
    3519            1 :         int rc3 = email_service_rebuild_contacts(rcc_user);
    3520            1 :         RESTORE_OUT(sout, serr);
    3521            1 :         ASSERT(rc3 == 0, "rebuild_contacts: matching account → 0");
    3522              : 
    3523            1 :         config_delete_account(rcc_user);
    3524            1 :         local_store_init("imaps://test.example.com", "testuser");
    3525              :     }
    3526              : 
    3527              :     /* ── email_service_sync_all: only_account not found ─────────────────── */
    3528              :     {
    3529              :         int sout, serr;
    3530            1 :         SUPPRESS_OUT(sout, serr);
    3531            1 :         int sa2 = email_service_sync_all("nonexistent_sync_acct_XYZZY", 0);
    3532            1 :         RESTORE_OUT(sout, serr);
    3533            1 :         ASSERT(sa2 == -1, "email_service_sync_all: not-found account → -1");
    3534              :     }
    3535              : 
    3536              :     /* ── email_service_list_labels_interactive: HOME / END keys ─────────── */
    3537              :     {
    3538            1 :         local_store_init(NULL, "testlabels@gmail.com");
    3539            1 :         Config lhe_cfg = {0};
    3540            1 :         lhe_cfg.host       = NULL;
    3541            1 :         lhe_cfg.user       = "testlabels@gmail.com";
    3542            1 :         lhe_cfg.gmail_mode = 1;
    3543              : 
    3544            1 :         int go_upHE = 0;
    3545              :         int saved_stdin;
    3546              :         /* HOME → cursor goes to first, END → cursor goes to last, then ESC */
    3547            1 :         const char home_end[] = { '\033','[','H',  /* TERM_KEY_HOME */
    3548              :                                    '\033','[','F',  /* TERM_KEY_END  */
    3549              :                                    '\033', 'x'      /* ESC exit     */};
    3550            1 :         INJECT_STDIN(home_end, 8, saved_stdin);
    3551              :         int sout, serr;
    3552            1 :         SUPPRESS_OUT(sout, serr);
    3553            1 :         char *he_sel = email_service_list_labels_interactive(
    3554              :                            &lhe_cfg, "INBOX", &go_upHE);
    3555            1 :         RESTORE_OUT(sout, serr);
    3556            1 :         RESTORE_STDIN(saved_stdin);
    3557            1 :         free(he_sel);
    3558            1 :         ASSERT(1, "labels_interactive: HOME+END+ESC no crash");
    3559              : 
    3560            1 :         local_store_init("imaps://test.example.com", "testuser");
    3561              :     }
    3562              : 
    3563              :     /* ── email_service_list_labels_interactive: no labels synced yet ────── */
    3564              :     {
    3565              :         /* Create a Gmail account whose local_store has NO label index at all.
    3566              :          * list_labels_interactive will show "No labels synced yet." message and
    3567              :          * wait for a key. Covers lines 4136-4151. */
    3568            1 :         local_store_init(NULL, "nolabels-unit@gmail.com");
    3569              :         /* Do NOT call label_idx_add — leave label index empty */
    3570              : 
    3571            1 :         Config nl_cfg = {0};
    3572            1 :         nl_cfg.host       = NULL;
    3573            1 :         nl_cfg.user       = "nolabels-unit@gmail.com";
    3574            1 :         nl_cfg.gmail_mode = 1;
    3575              : 
    3576            1 :         int go_up_nl = 0;
    3577              :         int saved_stdin;
    3578              :         /* ESC exits the "no labels" loop */
    3579            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    3580              :         int sout, serr;
    3581            1 :         SUPPRESS_OUT(sout, serr);
    3582            1 :         char *nl_sel = email_service_list_labels_interactive(
    3583              :                            &nl_cfg, "INBOX", &go_up_nl);
    3584            1 :         RESTORE_OUT(sout, serr);
    3585            1 :         RESTORE_STDIN(saved_stdin);
    3586            1 :         free(nl_sel);
    3587            1 :         ASSERT(1, "labels_interactive: no-labels path no crash");
    3588              : 
    3589            1 :         local_store_init("imaps://test.example.com", "testuser");
    3590              :     }
    3591              : 
    3592              :     /* ── email_service_list_folders_interactive: flat mode + Enter ──────── */
    3593              :     /* Covers the flat-mode code path (lines 3617-3627): when tree_mode=0 and
    3594              :      * the user presses Enter on a leaf folder (not a parent), it returns the
    3595              :      * selected folder. */
    3596              :     {
    3597            1 :         local_store_init("imaps://test.example.com", "foldercache@example.com");
    3598              :         /* The folder cache was populated in an earlier test (INBOX, INBOX.Sent,
    3599              :          * INBOX.Archive, Trash). */
    3600              : 
    3601            1 :         Config fif_cfg = {0};
    3602            1 :         fif_cfg.host   = "imaps://test.example.com";
    3603            1 :         fif_cfg.user   = "foldercache@example.com";
    3604            1 :         fif_cfg.folder = "INBOX";
    3605              : 
    3606              :         /* Force flat mode */
    3607            1 :         ui_pref_set_int("folder_view_mode", 0);
    3608              : 
    3609            1 :         int go_up_fif = 0;
    3610              :         int saved_stdin;
    3611              :         /* Enter selects first visible item (root "INBOX" = has children → drills in),
    3612              :          * then Down moves to "INBOX.Sent" (leaf), then Enter selects it. */
    3613              :         /* In flat mode root view, pressing Enter on INBOX (which has children)
    3614              :          * updates current_prefix and loops; subsequent Enter on leaf selects it.
    3615              :          * Use: Enter (INBOX→drill), Enter (INBOX.Sent→select) */
    3616            1 :         INJECT_STDIN("\r\r", 2, saved_stdin);
    3617              :         int sout, serr;
    3618            1 :         SUPPRESS_OUT(sout, serr);
    3619            1 :         char *fif_sel = email_service_list_folders_interactive(
    3620              :                             &fif_cfg, "INBOX", &go_up_fif);
    3621            1 :         RESTORE_OUT(sout, serr);
    3622            1 :         RESTORE_STDIN(saved_stdin);
    3623              :         /* May select a folder or ESC-timeout → either is fine */
    3624            1 :         ASSERT(fif_sel == NULL || strlen(fif_sel) > 0,
    3625              :                "folders_interactive flat: Enter returns NULL or folder");
    3626            1 :         free(fif_sel);
    3627              : 
    3628              :         /* Restore */
    3629            1 :         ui_pref_set_int("folder_view_mode", 1);
    3630            1 :         local_store_init("imaps://test.example.com", "testuser");
    3631              :     }
    3632              : 
    3633              :     /* ── show_uid_interactive: search ('/') and 'n' navigation ─────────── */
    3634              :     /* Covers lines 1069-1097 (inline search prompt) and 1098-1104 (next match). */
    3635              :     {
    3636            1 :         const char *srch_uid = "0000000000sr0001";
    3637            1 :         const char *srch_msg =
    3638              :             "From: srch@example.com\r\n"
    3639              :             "Subject: Search Test\r\n"
    3640              :             "MIME-Version: 1.0\r\n"
    3641              :             "Content-Type: text/plain; charset=UTF-8\r\n"
    3642              :             "\r\n"
    3643              :             "Line one: the quick brown fox\r\n"
    3644              :             "Line two: jumps over the lazy dog\r\n"
    3645              :             "Line three: quick again\r\n";
    3646            1 :         local_store_init("imaps://test.example.com", "testuser");
    3647            1 :         local_msg_save("INBOX", srch_uid, srch_msg, strlen(srch_msg));
    3648              : 
    3649            1 :         Config srch_cfg = {0};
    3650            1 :         srch_cfg.host   = "imaps://test.example.com";
    3651            1 :         srch_cfg.user   = "testuser";
    3652            1 :         srch_cfg.folder = "INBOX";
    3653              : 
    3654              :         int saved_stdin;
    3655              :         /* '/' opens search; type "quick"; Enter confirms; 'n' finds next; ESC exits */
    3656            1 :         const char srch_keys[] =
    3657              :             "/"        /* open search */
    3658              :             "quick"    /* type search term */
    3659              :             "\r"       /* confirm search */
    3660              :             "n"        /* find next match */
    3661              :             "\033x";   /* ESC exit */
    3662            1 :         INJECT_STDIN(srch_keys, (int)strlen(srch_keys), saved_stdin);
    3663              :         int sout, serr;
    3664            1 :         SUPPRESS_OUT(sout, serr);
    3665            1 :         int srch_r = show_uid_interactive(&srch_cfg, NULL, "INBOX",
    3666              :                                           srch_uid, 25, 0, NULL);
    3667            1 :         RESTORE_OUT(sout, serr);
    3668            1 :         RESTORE_STDIN(saved_stdin);
    3669            1 :         ASSERT(srch_r == 0 || srch_r == 1,
    3670              :                "show_uid_interactive: search '/' + 'n' no crash");
    3671              :     }
    3672              : 
    3673              :     /* ── show_uid_interactive: HOME / END keys ──────────────────────────── */
    3674              :     /* Covers lines 1046-1051 */
    3675              :     {
    3676            1 :         Config he_cfg = {0};
    3677            1 :         he_cfg.host   = "imaps://test.example.com";
    3678            1 :         he_cfg.user   = "testuser";
    3679            1 :         he_cfg.folder = "INBOX";
    3680              : 
    3681              :         int saved_stdin;
    3682              :         /* HOME, END, then ESC */
    3683            1 :         const char he_keys[] = { '\033','[','H',  /* HOME */
    3684              :                                    '\033','[','F',  /* END  */
    3685              :                                    '\033', 'x'      /* ESC  */ };
    3686            1 :         INJECT_STDIN(he_keys, 8, saved_stdin);
    3687              :         int sout, serr;
    3688            1 :         SUPPRESS_OUT(sout, serr);
    3689            1 :         int he_r = show_uid_interactive(&he_cfg, NULL, "INBOX",
    3690              :                                          "0000000000sr0001", 25, 0, NULL);
    3691            1 :         RESTORE_OUT(sout, serr);
    3692            1 :         RESTORE_STDIN(saved_stdin);
    3693            1 :         ASSERT(he_r == 0 || he_r == 1,
    3694              :                "show_uid_interactive: HOME+END+ESC no crash");
    3695              :     }
    3696              : 
    3697              :     /* ── show_uid_interactive: Gmail 'a' archive key (lines 1184-1220) ── */
    3698              :     /* Also covers 't' label-picker (1221-1225), 'f' star (1228-1237),
    3699              :      * 'n' unread toggle (1253-1262), 'D' trash (1164-1176),
    3700              :      * 'r' remove-label (1153-1163). */
    3701              :     {
    3702            1 :         local_store_init(NULL, "gmailreader@test.com");
    3703            1 :         const char *gr_uid = "0000000000gr0001";
    3704              : 
    3705              :         /* Store .hdr with labels including INBOX and UNREAD */
    3706            1 :         const char *gr_hdr = "from@test.com\tGmail Reader Test\t2026-01-15 10:00\tINBOX,UNREAD\t3";
    3707            1 :         local_hdr_save("", gr_uid, gr_hdr, strlen(gr_hdr));
    3708              : 
    3709              :         /* Store label indexes */
    3710            1 :         label_idx_add("INBOX",  gr_uid);
    3711            1 :         label_idx_add("UNREAD", gr_uid);
    3712              : 
    3713            1 :         const char *gr_msg =
    3714              :             "From: from@test.com\r\n"
    3715              :             "Subject: Gmail Reader Test\r\n"
    3716              :             "Date: Thu, 15 Jan 2026 10:00:00 +0000\r\n"
    3717              :             "\r\n"
    3718              :             "Line 1\r\nLine 2\r\nLine 3\r\nLine 4\r\n"
    3719              :             "Line 5\r\nLine 6\r\nLine 7\r\nLine 8\r\n";
    3720            1 :         local_msg_save("INBOX", gr_uid, gr_msg, strlen(gr_msg));
    3721              : 
    3722            1 :         Config gr_cfg = {0};
    3723            1 :         gr_cfg.host       = NULL;
    3724            1 :         gr_cfg.user       = "gmailreader@test.com";
    3725            1 :         gr_cfg.folder     = "INBOX";
    3726            1 :         gr_cfg.gmail_mode = 1;
    3727              : 
    3728              :         int saved_stdin;
    3729              :         int sout, serr;
    3730              : 
    3731              :         /* 'r' = remove current label (1153-1163), then ESC */
    3732            1 :         INJECT_STDIN("r\033x", 4, saved_stdin);
    3733            1 :         SUPPRESS_OUT(sout, serr);
    3734            1 :         show_uid_interactive(&gr_cfg, NULL, "INBOX", gr_uid, 25, 3, NULL);
    3735            1 :         RESTORE_OUT(sout, serr);
    3736            1 :         RESTORE_STDIN(saved_stdin);
    3737              : 
    3738              :         /* Restore hdr/labels for next sub-test */
    3739            1 :         local_store_init(NULL, "gmailreader@test.com");
    3740            1 :         local_hdr_save("", gr_uid, gr_hdr, strlen(gr_hdr));
    3741            1 :         label_idx_add("INBOX",  gr_uid);
    3742            1 :         label_idx_add("UNREAD", gr_uid);
    3743            1 :         local_msg_save("INBOX", gr_uid, gr_msg, strlen(gr_msg));
    3744              : 
    3745              :         /* 'D' = trash (1164-1176), then ESC */
    3746            1 :         INJECT_STDIN("D\033x", 4, saved_stdin);
    3747            1 :         SUPPRESS_OUT(sout, serr);
    3748            1 :         show_uid_interactive(&gr_cfg, NULL, "INBOX", gr_uid, 25, 3, NULL);
    3749            1 :         RESTORE_OUT(sout, serr);
    3750            1 :         RESTORE_STDIN(saved_stdin);
    3751              : 
    3752              :         /* Restore for archive test */
    3753            1 :         local_store_init(NULL, "gmailreader@test.com");
    3754            1 :         local_hdr_save("", gr_uid, gr_hdr, strlen(gr_hdr));
    3755            1 :         label_idx_add("INBOX",  gr_uid);
    3756            1 :         label_idx_add("UNREAD", gr_uid);
    3757            1 :         local_msg_save("INBOX", gr_uid, gr_msg, strlen(gr_msg));
    3758              : 
    3759              :         /* 'a' = archive (1177-1220), then ESC */
    3760            1 :         INJECT_STDIN("a\033x", 4, saved_stdin);
    3761            1 :         SUPPRESS_OUT(sout, serr);
    3762            1 :         int gr_r = show_uid_interactive(&gr_cfg, NULL, "INBOX", gr_uid, 25, 3, NULL);
    3763            1 :         RESTORE_OUT(sout, serr);
    3764            1 :         RESTORE_STDIN(saved_stdin);
    3765            1 :         ASSERT(gr_r == 0 || gr_r == 1,
    3766              :                "show_uid_interactive Gmail 'a' archive: no crash");
    3767              : 
    3768              :         /* Restore for 't' + 'f' + 'n' tests */
    3769            1 :         local_store_init(NULL, "gmailreader@test.com");
    3770            1 :         local_hdr_save("", gr_uid, gr_hdr, strlen(gr_hdr));
    3771            1 :         label_idx_add("INBOX",  gr_uid);
    3772            1 :         label_idx_add("UNREAD", gr_uid);
    3773            1 :         local_msg_save("INBOX", gr_uid, gr_msg, strlen(gr_msg));
    3774              : 
    3775              :         /* 't' = label-picker (1221-1225): picker opens, ESC cancels it, then 'q' */
    3776              :         /* picker reads '\033'+'X' = ESC (X consumed as c2 by ESC handler), then 'q' quits reader */
    3777            1 :         INJECT_STDIN("t\033Xq", 4, saved_stdin);
    3778            1 :         SUPPRESS_OUT(sout, serr);
    3779            1 :         show_uid_interactive(&gr_cfg, NULL, "INBOX", gr_uid, 25, 3, NULL);
    3780            1 :         RESTORE_OUT(sout, serr);
    3781            1 :         RESTORE_STDIN(saved_stdin);
    3782              : 
    3783              :         /* Restore for 'f' (star) test */
    3784            1 :         local_store_init(NULL, "gmailreader@test.com");
    3785            1 :         local_hdr_save("", gr_uid, gr_hdr, strlen(gr_hdr));
    3786            1 :         label_idx_add("INBOX",  gr_uid);
    3787            1 :         label_idx_add("UNREAD", gr_uid);
    3788            1 :         local_msg_save("INBOX", gr_uid, gr_msg, strlen(gr_msg));
    3789              : 
    3790              :         /* 'f' = toggle starred (1226-1250): message not starred → add STARRED label */
    3791              :         /* 'n' = toggle unread (1251-1275): message is UNREAD → mark as read */
    3792            1 :         INJECT_STDIN("fn\033x", 5, saved_stdin);
    3793            1 :         SUPPRESS_OUT(sout, serr);
    3794            1 :         show_uid_interactive(&gr_cfg, NULL, "INBOX", gr_uid, 25, 3, NULL);
    3795            1 :         RESTORE_OUT(sout, serr);
    3796            1 :         RESTORE_STDIN(saved_stdin);
    3797            1 :         ASSERT(1, "show_uid_interactive Gmail 'f'+'n' toggle: no crash");
    3798              : 
    3799            1 :         local_store_init("imaps://test.example.com", "testuser");
    3800              :     }
    3801              : 
    3802              :     /* ── email_service_list: empty Gmail _trash pager (lines 2295-2298) ─── */
    3803              :     /* Covers the show_count==0 + pager + _trash statusbar variant */
    3804              :     {
    3805            1 :         local_store_init(NULL, "emptytrash@gmail.com");
    3806              :         /* No messages: label index empty → show_count = 0 */
    3807              : 
    3808            1 :         Config et_cfg = {0};
    3809            1 :         et_cfg.host       = NULL;
    3810            1 :         et_cfg.user       = "emptytrash@gmail.com";
    3811            1 :         et_cfg.folder     = "_trash";
    3812            1 :         et_cfg.gmail_mode = 1;
    3813              : 
    3814            1 :         EmailListOpts et_opts = {0};
    3815            1 :         et_opts.folder = "_trash";
    3816            1 :         et_opts.pager  = 1;
    3817              : 
    3818              :         int saved_stdin;
    3819            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    3820              :         int sout, serr;
    3821            1 :         SUPPRESS_OUT(sout, serr);
    3822            1 :         int et_r = email_service_list(&et_cfg, &et_opts);
    3823            1 :         RESTORE_OUT(sout, serr);
    3824            1 :         RESTORE_STDIN(saved_stdin);
    3825            1 :         ASSERT(et_r == 0 || et_r == 1,
    3826              :                "email_service_list empty Gmail _trash pager: returns 0 or 1");
    3827              : 
    3828            1 :         local_store_init("imaps://test.example.com", "testuser");
    3829              :     }
    3830              : 
    3831              :     /* ── email_service_list: empty Gmail non-trash pager (lines 2304-2308) ── */
    3832              :     /* Covers show_count==0 + pager + Gmail non-trash statusbar */
    3833              :     {
    3834            1 :         local_store_init(NULL, "emptyinbox@gmail.com");
    3835              : 
    3836            1 :         Config ei_cfg = {0};
    3837            1 :         ei_cfg.host       = NULL;
    3838            1 :         ei_cfg.user       = "emptyinbox@gmail.com";
    3839            1 :         ei_cfg.folder     = "INBOX";
    3840            1 :         ei_cfg.gmail_mode = 1;
    3841              : 
    3842            1 :         EmailListOpts ei_opts = {0};
    3843            1 :         ei_opts.folder = "INBOX";
    3844            1 :         ei_opts.pager  = 1;
    3845              : 
    3846              :         int saved_stdin;
    3847            1 :         INJECT_STDIN("\033x", 2, saved_stdin);
    3848              :         int sout, serr;
    3849            1 :         SUPPRESS_OUT(sout, serr);
    3850            1 :         int ei_r = email_service_list(&ei_cfg, &ei_opts);
    3851            1 :         RESTORE_OUT(sout, serr);
    3852            1 :         RESTORE_STDIN(saved_stdin);
    3853            1 :         ASSERT(ei_r == 0 || ei_r == 1,
    3854              :                "email_service_list empty Gmail INBOX pager: returns 0 or 1");
    3855              : 
    3856            1 :         local_store_init("imaps://test.example.com", "testuser");
    3857              :     }
    3858              : 
    3859              :     /* ── email_service_list: Gmail 'd' remove-label when only label left ─── */
    3860              :     /* Covers lines 3128-3147 (has_real check, _nolabel fallback) */
    3861              :     {
    3862            1 :         local_store_init(NULL, "lblremove@gmail.com");
    3863            1 :         const char *lr_uid = "0000000000lr0001";
    3864              : 
    3865              :         /* Message has INBOX + UNREAD labels.
    3866              :          * After removing INBOX the loop in email_service.c lines 3133-3143 runs:
    3867              :          * UNREAD remains but is excluded from "real" labels → has_real stays 0
    3868              :          * → label_idx_add("_nolabel", uid) fires (line 3147). */
    3869            1 :         const char *lr_hdr = "sender@test.com\tLabel Remove\t2026-01-15 10:00\tINBOX,UNREAD\t1";
    3870            1 :         local_hdr_save("", lr_uid, lr_hdr, strlen(lr_hdr));
    3871            1 :         label_idx_add("INBOX", lr_uid);
    3872            1 :         label_idx_add("UNREAD", lr_uid);
    3873              : 
    3874              :         /* Also need a raw message so the manifest can be populated */
    3875            1 :         const char *lr_msg =
    3876              :             "From: sender@test.com\r\n"
    3877              :             "Subject: Label Remove\r\n"
    3878              :             "\r\n"
    3879              :             "Body text\r\n";
    3880            1 :         local_msg_save("INBOX", lr_uid, lr_msg, strlen(lr_msg));
    3881              : 
    3882            1 :         Config lr_cfg = {0};
    3883            1 :         lr_cfg.host       = NULL;
    3884            1 :         lr_cfg.user       = "lblremove@gmail.com";
    3885            1 :         lr_cfg.folder     = "INBOX";
    3886            1 :         lr_cfg.gmail_mode = 1;
    3887              : 
    3888            1 :         EmailListOpts lr_opts = {0};
    3889            1 :         lr_opts.folder = "INBOX";
    3890            1 :         lr_opts.pager  = 1;
    3891              : 
    3892              :         int saved_stdin;
    3893              :         /* 'd' removes INBOX label from message → no real labels remain → _nolabel
    3894              :          * 'd' again on same entry → undo (restore) path
    3895              :          * then ESC to exit */
    3896            1 :         INJECT_STDIN("dd\033x", 5, saved_stdin);
    3897              :         int sout, serr;
    3898            1 :         SUPPRESS_OUT(sout, serr);
    3899            1 :         int lr_r = email_service_list(&lr_cfg, &lr_opts);
    3900            1 :         RESTORE_OUT(sout, serr);
    3901            1 :         RESTORE_STDIN(saved_stdin);
    3902            1 :         ASSERT(lr_r == 0 || lr_r == 1,
    3903              :                "email_service_list Gmail 'd' remove-label no-real-labels: no crash");
    3904              : 
    3905            1 :         local_store_init("imaps://test.example.com", "testuser");
    3906              :     }
    3907              : 
    3908              :     /* ── email_service_list_folders_interactive: HOME+END + '/' search ── */
    3909              :     /* Covers lines 3654-3660 (HOME/END), 3670-3684 ('/'), 3695-3702 (BACK+UTF8),
    3910              :      * 3707-3715 ('t' toggle, 'c' compose) */
    3911              :     {
    3912            1 :         local_store_init("imaps://test.example.com", "foldercache@example.com");
    3913              : 
    3914            1 :         Config fhe_cfg = {0};
    3915            1 :         fhe_cfg.host   = "imaps://test.example.com";
    3916            1 :         fhe_cfg.user   = "foldercache@example.com";
    3917            1 :         fhe_cfg.folder = "INBOX";
    3918              : 
    3919            1 :         ui_pref_set_int("folder_view_mode", 1);
    3920              : 
    3921            1 :         int go_up_fhe = 0;
    3922              :         int saved_stdin;
    3923              :         int sout, serr;
    3924              : 
    3925              :         /* HOME key → cursor=0 */
    3926            1 :         const char fhe_home[] = { '\033','[','H',  /* HOME */ '\033','x' /* ESC */ };
    3927            1 :         INJECT_STDIN(fhe_home, 5, saved_stdin);
    3928            1 :         SUPPRESS_OUT(sout, serr);
    3929            1 :         char *fhe_r1 = email_service_list_folders_interactive(
    3930              :                            &fhe_cfg, "INBOX", &go_up_fhe);
    3931            1 :         RESTORE_OUT(sout, serr);
    3932            1 :         RESTORE_STDIN(saved_stdin);
    3933            1 :         free(fhe_r1);
    3934            1 :         ASSERT(1, "folders_interactive: HOME key covered");
    3935              : 
    3936              :         /* END key */
    3937            1 :         const char fhe_end[] = { '\033','[','F',  /* END */ '\033','x' /* ESC */ };
    3938            1 :         INJECT_STDIN(fhe_end, 5, saved_stdin);
    3939            1 :         SUPPRESS_OUT(sout, serr);
    3940            1 :         char *fhe_r2 = email_service_list_folders_interactive(
    3941              :                            &fhe_cfg, "INBOX", &go_up_fhe);
    3942            1 :         RESTORE_OUT(sout, serr);
    3943            1 :         RESTORE_STDIN(saved_stdin);
    3944            1 :         free(fhe_r2);
    3945            1 :         ASSERT(1, "folders_interactive: END key covered");
    3946              : 
    3947              :         /* '/' search: type 'x', then TAB (toggle scope), then BACK (delete),
    3948              :          * then 'a', then ESC+Z to cancel search (Z is c2 consumed by ESC handler),
    3949              :          * then ESC+X to exit folder picker */
    3950            1 :         const char fhe_slash[] = {
    3951              :             '/', 'x', '\t', '\x7f', 'a', '\033', 'Z',  /* search loop: type+tab+back+type+ESC+c2 */
    3952              :             '\033', 'X'                                  /* exit picker (X is c2 consumed) */
    3953              :         };
    3954            1 :         INJECT_STDIN(fhe_slash, 9, saved_stdin);
    3955            1 :         SUPPRESS_OUT(sout, serr);
    3956            1 :         char *fhe_r3 = email_service_list_folders_interactive(
    3957              :                            &fhe_cfg, "INBOX", &go_up_fhe);
    3958            1 :         RESTORE_OUT(sout, serr);
    3959            1 :         RESTORE_STDIN(saved_stdin);
    3960            1 :         free(fhe_r3);
    3961            1 :         ASSERT(1, "folders_interactive: '/' search with TAB+BACK+UTF8 covered");
    3962              : 
    3963              :         /* 't' = toggle tree/flat mode */
    3964            1 :         INJECT_STDIN("t\033x", 4, saved_stdin);
    3965            1 :         SUPPRESS_OUT(sout, serr);
    3966            1 :         char *fhe_r4 = email_service_list_folders_interactive(
    3967              :                            &fhe_cfg, "INBOX", &go_up_fhe);
    3968            1 :         RESTORE_OUT(sout, serr);
    3969            1 :         RESTORE_STDIN(saved_stdin);
    3970            1 :         free(fhe_r4);
    3971            1 :         ASSERT(1, "folders_interactive: 't' tree toggle covered");
    3972              : 
    3973              :         /* 'c' = compose → returns "__compose__" */
    3974            1 :         INJECT_STDIN("c", 1, saved_stdin);
    3975            1 :         SUPPRESS_OUT(sout, serr);
    3976            1 :         char *fhe_r5 = email_service_list_folders_interactive(
    3977              :                            &fhe_cfg, "INBOX", &go_up_fhe);
    3978            1 :         RESTORE_OUT(sout, serr);
    3979            1 :         RESTORE_STDIN(saved_stdin);
    3980            1 :         ASSERT(fhe_r5 != NULL && strcmp(fhe_r5, "__compose__") == 0,
    3981              :                "folders_interactive: 'c' returns __compose__");
    3982            1 :         free(fhe_r5);
    3983              : 
    3984            1 :         ui_pref_set_int("folder_view_mode", 1);
    3985            1 :         local_store_init("imaps://test.example.com", "testuser");
    3986              :     }
    3987              : 
    3988              :     /* ── email_service_list_labels_interactive: '/' search (4334-4344) ── */
    3989              :     /* Also covers 'd' delete label rebuild (4422-4449) */
    3990              :     {
    3991            1 :         local_store_init(NULL, "lblsearch@gmail.com");
    3992            1 :         label_idx_add("MyLabel", "0000000000ls0001");
    3993              : 
    3994            1 :         Config lbs_cfg = {0};
    3995            1 :         lbs_cfg.host       = NULL;
    3996            1 :         lbs_cfg.user       = "lblsearch@gmail.com";
    3997            1 :         lbs_cfg.gmail_mode = 1;
    3998              : 
    3999            1 :         int go_up_lbs = 0;
    4000              :         int saved_stdin;
    4001              :         int sout, serr;
    4002              : 
    4003              :         /* '/' opens search; type 'x', then TAB (cycle scope), then BACK (delete x),
    4004              :          * then 'a' (type 'a'), then ESC cancel search, then 'd' delete current label,
    4005              :          * then ESC exit.
    4006              :          * Note: ESC handler reads the NEXT byte as c2 (VMIN=0 on pipe fails silently,
    4007              :          * but read() still reads from the pipe buffer). So use \033+Z to ESC-cancel
    4008              :          * search (Z consumed as c2), leaving 'd' available for the outer loop. */
    4009            1 :         const char lbs_keys[] = {
    4010              :             '/', 'x', '\t', '\x7f', 'a', '\033', 'Z',  /* search: type+tab+back+type+ESC+consume */
    4011              :             'd',                                         /* delete selected label */
    4012              :             '\033', 'X'                                  /* ESC exit (X consumed as c2) */
    4013              :         };
    4014            1 :         INJECT_STDIN(lbs_keys, 10, saved_stdin);
    4015            1 :         SUPPRESS_OUT(sout, serr);
    4016            1 :         char *lbs_ret = email_service_list_labels_interactive(
    4017              :                             &lbs_cfg, "INBOX", &go_up_lbs);
    4018            1 :         RESTORE_OUT(sout, serr);
    4019            1 :         RESTORE_STDIN(saved_stdin);
    4020            1 :         free(lbs_ret);
    4021            1 :         ASSERT(1, "labels_interactive: '/' search TAB+BACK + 'd' delete covered");
    4022              : 
    4023            1 :         local_store_init("imaps://test.example.com", "testuser");
    4024              :     }
    4025              : }
        

Generated by: LCOV version 2.0-1