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 : }
|