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