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