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