Line data Source code
1 : #include "local_store.h"
2 : #include "fs_util.h"
3 : #include "mime_util.h"
4 : #include "platform/path.h"
5 : #include "raii.h"
6 : #include "logger.h"
7 : #include <ctype.h>
8 : #include <inttypes.h>
9 : #include <stdio.h>
10 : #include <stdlib.h>
11 : #include <string.h>
12 : #include <dirent.h>
13 : #include <unistd.h>
14 : #include <time.h>
15 :
16 : /* ── Account base path (set by local_store_init) ─────────────────────── */
17 :
18 : static char g_account_base[8192];
19 : static char g_account_name[520];
20 :
21 1997 : int local_store_init(const char *host_url, const char *username) {
22 1997 : const char *data_base = platform_data_dir();
23 1997 : if (!data_base) return -1;
24 1997 : if (!host_url && (!username || !username[0])) return -1;
25 :
26 : /* The email address (username) uniquely identifies an account.
27 : * Use it directly as the directory key so two accounts on the same
28 : * server get separate local stores without a double-@ suffix.
29 : * Falls back to hostname-only for legacy single-account setups. */
30 1997 : if (username && username[0]) {
31 1970 : snprintf(g_account_base, sizeof(g_account_base),
32 : "%s/email-cli/accounts/%s", data_base, username);
33 1970 : snprintf(g_account_name, sizeof(g_account_name), "%s", username);
34 : } else {
35 : /* Extract hostname from URL: imaps://host:port → host */
36 27 : const char *p = strstr(host_url, "://");
37 27 : p = p ? p + 3 : host_url;
38 : char hostname[512];
39 27 : int i = 0;
40 825 : while (*p && *p != ':' && *p != '/' && i < (int)sizeof(hostname) - 1)
41 798 : hostname[i++] = *p++;
42 27 : hostname[i] = '\0';
43 825 : for (char *c = hostname; *c; c++) *c = (char)tolower((unsigned char)*c);
44 27 : snprintf(g_account_base, sizeof(g_account_base),
45 : "%s/email-cli/accounts/imap.%s", data_base, hostname);
46 27 : snprintf(g_account_name, sizeof(g_account_name), "imap.%s", hostname);
47 : }
48 :
49 1997 : logger_log(LOG_DEBUG, "local_store: account base = %s", g_account_base);
50 : /* Ensure the account base directory exists so callers can write files
51 : * (e.g. pending_fetch.tsv) before any message has been downloaded. */
52 1997 : fs_mkdir_p(g_account_base, 0700);
53 1997 : return 0;
54 : }
55 :
56 102 : const char *local_store_account_name(void) { return g_account_name; }
57 :
58 : /* ── Reverse digit bucketing helpers ─────────────────────────────────── */
59 :
60 16954 : static char digit1(const char *uid) {
61 16954 : size_t len = strlen(uid);
62 16954 : return len > 0 ? uid[len - 1] : '0';
63 : }
64 16954 : static char digit2(const char *uid) {
65 16954 : size_t len = strlen(uid);
66 16954 : return len > 1 ? uid[len - 2] : '0';
67 : }
68 :
69 : /* ── Shared file I/O ─────────────────────────────────────────────────── */
70 :
71 12029 : static char *load_file(const char *path) {
72 24058 : RAII_FILE FILE *fp = fopen(path, "r");
73 12029 : if (!fp) return NULL;
74 9811 : if (fseek(fp, 0, SEEK_END) != 0) return NULL;
75 9811 : long size = ftell(fp);
76 9811 : if (size <= 0) return NULL;
77 9807 : rewind(fp);
78 9807 : char *buf = malloc((size_t)size + 1);
79 9807 : if (!buf) return NULL;
80 9807 : if ((long)fread(buf, 1, (size_t)size, fp) != size) { free(buf); return NULL; }
81 9807 : buf[size] = '\0';
82 9807 : return buf;
83 : }
84 :
85 2915 : static int write_file(const char *path, const char *content, size_t len) {
86 5830 : RAII_FILE FILE *fp = fopen(path, "w");
87 2915 : if (!fp) return -1;
88 2915 : if (fwrite(content, 1, len, fp) != len) return -1;
89 2915 : return 0;
90 : }
91 :
92 : /** @brief Ensures the parent directory of a bucketed path exists. */
93 2797 : static int ensure_bucket_dir(const char *area, const char *folder, const char *uid) {
94 2795 : RAII_STRING char *dir = NULL;
95 2797 : if (asprintf(&dir, "%s/%s/%s/%c/%c",
96 2797 : g_account_base, area, folder, digit1(uid), digit2(uid)) == -1)
97 0 : return -1;
98 2797 : return fs_mkdir_p(dir, 0700);
99 : }
100 :
101 : /* ── Message store ───────────────────────────────────────────────────── */
102 :
103 4648 : static char *msg_path(const char *folder, const char *uid) {
104 4648 : if (!g_account_base[0]) return NULL;
105 4648 : char *path = NULL;
106 4648 : if (asprintf(&path, "%s/store/%s/%c/%c/%s.eml",
107 4648 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
108 0 : return NULL;
109 4648 : return path;
110 : }
111 :
112 65 : char *local_msg_path(const char *folder, const char *uid) {
113 65 : return msg_path(folder, uid);
114 : }
115 :
116 3378 : int local_msg_exists(const char *folder, const char *uid) {
117 6756 : RAII_STRING char *path = msg_path(folder, uid);
118 3378 : if (!path) return 0;
119 3378 : RAII_FILE FILE *fp = fopen(path, "r");
120 3378 : return fp != NULL;
121 : }
122 :
123 1084 : int local_msg_save(const char *folder, const char *uid, const char *content, size_t len) {
124 1084 : if (!g_account_base[0]) return -1;
125 1084 : if (ensure_bucket_dir("store", folder, uid) != 0) {
126 0 : logger_log(LOG_ERROR, "Failed to create store bucket for %s/%s", folder, uid);
127 0 : return -1;
128 : }
129 2164 : RAII_STRING char *path = msg_path(folder, uid);
130 1082 : if (!path) return -1;
131 1082 : if (write_file(path, content, len) != 0) {
132 0 : logger_log(LOG_ERROR, "Failed to write store file: %s", path);
133 0 : return -1;
134 : }
135 1082 : logger_log(LOG_DEBUG, "Stored %s/%s at %s", folder, uid, path);
136 1082 : return 0;
137 : }
138 :
139 118 : char *local_msg_load(const char *folder, const char *uid) {
140 236 : RAII_STRING char *path = msg_path(folder, uid);
141 118 : if (!path) return NULL;
142 118 : return load_file(path);
143 : }
144 :
145 : /* ── Header store ────────────────────────────────────────────────────── */
146 :
147 9509 : static char *hdr_path(const char *folder, const char *uid) {
148 9509 : if (!g_account_base[0]) return NULL;
149 9509 : char *path = NULL;
150 9509 : if (asprintf(&path, "%s/headers/%s/%c/%c/%s.hdr",
151 9509 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
152 0 : return NULL;
153 9509 : return path;
154 : }
155 :
156 2218 : int local_hdr_exists(const char *folder, const char *uid) {
157 4436 : RAII_STRING char *path = hdr_path(folder, uid);
158 2218 : if (!path) return 0;
159 2218 : RAII_FILE FILE *fp = fopen(path, "r");
160 2218 : return fp != NULL;
161 : }
162 :
163 1713 : int local_hdr_save(const char *folder, const char *uid, const char *content, size_t len) {
164 1713 : if (!g_account_base[0]) return -1;
165 1713 : if (ensure_bucket_dir("headers", folder, uid) != 0) {
166 0 : logger_log(LOG_ERROR, "Failed to create header bucket for %s/%s", folder, uid);
167 0 : return -1;
168 : }
169 3426 : RAII_STRING char *path = hdr_path(folder, uid);
170 1713 : if (!path) return -1;
171 1713 : if (write_file(path, content, len) != 0) return -1;
172 1713 : logger_log(LOG_DEBUG, "Stored header %s/%s", folder, uid);
173 1713 : return 0;
174 : }
175 :
176 5573 : char *local_hdr_load(const char *folder, const char *uid) {
177 11146 : RAII_STRING char *path = hdr_path(folder, uid);
178 5573 : if (!path) return NULL;
179 5573 : return load_file(path);
180 : }
181 :
182 94 : int local_hdr_update_flags(const char *folder, const char *uid, int new_flags) {
183 94 : char *hdr = local_hdr_load(folder, uid);
184 94 : if (!hdr) return -1;
185 :
186 : /* Find the last tab → flags field starts after it */
187 90 : char *last_tab = strrchr(hdr, '\t');
188 90 : if (!last_tab) { free(hdr); return -1; }
189 :
190 : /* Rebuild: keep everything up to and including last tab, replace flags */
191 34 : *last_tab = '\0';
192 34 : char *updated = NULL;
193 34 : if (asprintf(&updated, "%s\t%d", hdr, new_flags) == -1) {
194 0 : free(hdr);
195 0 : return -1;
196 : }
197 34 : free(hdr);
198 :
199 34 : int rc = local_hdr_save(folder, uid, updated, strlen(updated));
200 34 : free(updated);
201 34 : return rc;
202 : }
203 :
204 74 : int local_hdr_update_labels(const char *folder, const char *uid,
205 : const char **add_ids, int add_count,
206 : const char **rm_ids, int rm_count) {
207 74 : char *hdr = local_hdr_load(folder, uid);
208 74 : if (!hdr) return -1;
209 :
210 : /* .hdr format: from\tsubject\tdate\tlabels\tflags
211 : * Locate the labels field (4th tab-separated token). */
212 74 : char *t1 = strchr(hdr, '\t');
213 74 : if (!t1) { free(hdr); return -1; }
214 74 : char *t2 = strchr(t1 + 1, '\t');
215 74 : if (!t2) { free(hdr); return -1; }
216 74 : char *t3 = strchr(t2 + 1, '\t');
217 74 : if (!t3) { free(hdr); return -1; }
218 74 : char *t4 = strchr(t3 + 1, '\t'); /* may be NULL if flags field absent */
219 :
220 : /* labels field: [t3+1 .. t4) (or end of string if no t4) */
221 74 : *t3 = '\0'; /* NUL-terminate prefix (from\tsubject\tdate) */
222 74 : const char *prefix = hdr;
223 74 : const char *lbl_str = t3 + 1;
224 74 : const char *suffix = t4 ? t4 + 1 : ""; /* flags value */
225 74 : if (t4) *t4 = '\0';
226 :
227 : /* Build new label set: start from existing labels */
228 74 : int cap = 64, cnt = 0;
229 74 : char **labels = malloc((size_t)cap * sizeof(char *));
230 74 : if (!labels) { free(hdr); return -1; }
231 :
232 74 : char *lbl_copy = strdup(lbl_str);
233 74 : if (!lbl_copy) { free(labels); free(hdr); return -1; }
234 74 : char *saveptr = NULL;
235 74 : for (char *tok = strtok_r(lbl_copy, ",", &saveptr);
236 199 : tok; tok = strtok_r(NULL, ",", &saveptr)) {
237 125 : if (!tok[0]) continue;
238 : /* skip labels in rm_ids */
239 125 : int rm = 0;
240 179 : for (int i = 0; i < rm_count; i++)
241 103 : if (rm_ids && rm_ids[i] && strcmp(tok, rm_ids[i]) == 0) { rm = 1; break; }
242 125 : if (rm) continue;
243 76 : if (cnt == cap) {
244 0 : cap *= 2;
245 0 : char **tmp = realloc(labels, (size_t)cap * sizeof(char *));
246 0 : if (!tmp) { free(lbl_copy); free(labels); free(hdr); return -1; }
247 0 : labels = tmp;
248 : }
249 76 : labels[cnt++] = tok; /* points into lbl_copy */
250 : }
251 :
252 : /* append add_ids (skip duplicates) */
253 107 : for (int i = 0; i < add_count; i++) {
254 33 : if (!add_ids || !add_ids[i] || !add_ids[i][0]) continue;
255 33 : int dup = 0;
256 58 : for (int j = 0; j < cnt; j++)
257 35 : if (strcmp(labels[j], add_ids[i]) == 0) { dup = 1; break; }
258 33 : if (!dup) {
259 23 : if (cnt == cap) {
260 0 : cap *= 2;
261 0 : char **tmp = realloc(labels, (size_t)cap * sizeof(char *));
262 0 : if (!tmp) { free(lbl_copy); free(labels); free(hdr); return -1; }
263 0 : labels = tmp;
264 : }
265 23 : labels[cnt++] = (char *)add_ids[i]; /* borrows caller's pointer */
266 : }
267 : }
268 :
269 : /* Rebuild labels CSV */
270 74 : size_t lbl_len = 0;
271 173 : for (int i = 0; i < cnt; i++) lbl_len += strlen(labels[i]) + 1;
272 74 : char *new_lbl = malloc(lbl_len + 1);
273 74 : if (!new_lbl) { free(lbl_copy); free(labels); free(hdr); return -1; }
274 74 : new_lbl[0] = '\0';
275 173 : for (int i = 0; i < cnt; i++) {
276 99 : if (i) strcat(new_lbl, ",");
277 99 : strcat(new_lbl, labels[i]);
278 : }
279 :
280 : /* Recompute flags integer from the updated label set.
281 : * Label-derived bits: UNREAD→MSG_FLAG_UNSEEN(1), STARRED→MSG_FLAG_FLAGGED(2).
282 : * Non-label bits (MSG_FLAG_DONE=4, MSG_FLAG_ATTACH=8) are preserved. */
283 74 : int old_flags = (suffix && suffix[0]) ? atoi(suffix) : 0;
284 74 : int new_flags = old_flags & ~(MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED);
285 173 : for (int i = 0; i < cnt; i++) {
286 99 : if (strcmp(labels[i], "UNREAD") == 0) new_flags |= MSG_FLAG_UNSEEN;
287 99 : if (strcmp(labels[i], "STARRED") == 0) new_flags |= MSG_FLAG_FLAGGED;
288 : }
289 : char flags_str[16];
290 74 : snprintf(flags_str, sizeof(flags_str), "%d", new_flags);
291 :
292 74 : free(lbl_copy);
293 74 : free(labels);
294 :
295 : /* Reassemble: prefix already NUL-terminated at t3 */
296 74 : char *updated = NULL;
297 74 : int rc = asprintf(&updated, "%s\t%s\t%s", prefix, new_lbl, flags_str);
298 74 : free(new_lbl);
299 74 : free(hdr);
300 74 : if (rc == -1) return -1;
301 :
302 74 : rc = local_hdr_save(folder, uid, updated, strlen(updated));
303 74 : free(updated);
304 74 : return rc;
305 : }
306 :
307 8896 : static int cmp_uid_evict(const void *a, const void *b) {
308 8896 : return memcmp(a, b, 16);
309 : }
310 :
311 271 : void local_hdr_evict_stale(const char *folder,
312 : const char (*keep_uids)[17], int keep_count) {
313 271 : if (!g_account_base[0]) return;
314 :
315 271 : char (*sorted)[17] = malloc((size_t)keep_count * sizeof(char[17]));
316 271 : if (!sorted) return;
317 271 : memcpy(sorted, keep_uids, (size_t)keep_count * sizeof(char[17]));
318 271 : qsort(sorted, (size_t)keep_count, sizeof(char[17]), cmp_uid_evict);
319 :
320 : /* Walk all 100 buckets (10 × 10) */
321 2981 : for (int d1 = 0; d1 <= 9; d1++) {
322 29810 : for (int d2 = 0; d2 <= 9; d2++) {
323 27100 : RAII_STRING char *dir = NULL;
324 27100 : if (asprintf(&dir, "%s/headers/%s/%d/%d",
325 : g_account_base, folder, d1, d2) == -1)
326 0 : continue;
327 :
328 54200 : RAII_DIR DIR *d = opendir(dir);
329 27100 : if (!d) continue;
330 :
331 : struct dirent *ent;
332 2971 : while ((ent = readdir(d)) != NULL) {
333 2277 : const char *name = ent->d_name;
334 2277 : const char *dot = strrchr(name, '.');
335 2277 : if (!dot || strcmp(dot, ".hdr") != 0) continue;
336 889 : size_t stem_len = (size_t)(dot - name);
337 889 : if (stem_len == 0 || stem_len > 16) continue;
338 889 : char key[17] = {0};
339 889 : memcpy(key, name, stem_len);
340 889 : if (!bsearch(key, sorted, (size_t)keep_count,
341 : sizeof(char[17]), cmp_uid_evict)) {
342 8 : RAII_STRING char *path = NULL;
343 8 : if (asprintf(&path, "%s/%s", dir, name) != -1) {
344 8 : remove(path);
345 8 : logger_log(LOG_DEBUG,
346 : "Evicted stale header: UID %s in %s", key, folder);
347 : }
348 : }
349 : }
350 : }
351 : }
352 271 : free(sorted);
353 : }
354 :
355 125 : int local_hdr_list_all_uids(const char *folder,
356 : char (**uids_out)[17], int *count_out) {
357 125 : *uids_out = NULL;
358 125 : *count_out = 0;
359 :
360 125 : int cap = 256;
361 125 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
362 125 : if (!arr) return -1;
363 125 : int count = 0;
364 :
365 : /* Walk all 256 buckets (d1, d2 ∈ 0-9 + a-f).
366 : * IMAP UIDs use decimal digits only; Gmail UIDs use full hex.
367 : * Hex directories a-f will simply not exist for IMAP accounts. */
368 : static const char hex[] = "0123456789abcdef";
369 2125 : for (int i1 = 0; i1 < 16; i1++) {
370 34000 : for (int i2 = 0; i2 < 16; i2++) {
371 32000 : char d1 = hex[i1], d2 = hex[i2];
372 32000 : RAII_STRING char *dir = NULL;
373 32000 : if (asprintf(&dir, "%s/headers/%s/%c/%c",
374 : g_account_base, folder, d1, d2) == -1)
375 0 : continue;
376 64000 : RAII_DIR DIR *dp = opendir(dir);
377 32000 : if (!dp) continue;
378 :
379 : struct dirent *ent;
380 15542 : while ((ent = readdir(dp)) != NULL) {
381 11662 : const char *name = ent->d_name;
382 11662 : const char *dot = strrchr(name, '.');
383 11662 : if (!dot || strcmp(dot, ".hdr") != 0) continue;
384 3902 : size_t stem_len = (size_t)(dot - name);
385 3902 : if (stem_len == 0 || stem_len > 16) continue;
386 :
387 3902 : if (count >= cap) {
388 0 : cap *= 2;
389 0 : char (*tmp)[17] = realloc(arr, (size_t)cap * sizeof(char[17]));
390 0 : if (!tmp) { free(arr); return -1; }
391 0 : arr = tmp;
392 : }
393 3902 : memset(arr[count], 0, 17);
394 3902 : memcpy(arr[count], name, stem_len);
395 3902 : count++;
396 : }
397 : }
398 : }
399 :
400 125 : *uids_out = arr;
401 125 : *count_out = count;
402 125 : return 0;
403 : }
404 :
405 : /* ── Index helpers ───────────────────────────────────────────────────── */
406 :
407 : /** @brief Checks if a reference line already exists in an index file. */
408 447 : static int index_has_ref(const char *path, const char *ref) {
409 447 : char *content = load_file(path);
410 447 : if (!content) return 0;
411 342 : size_t ref_len = strlen(ref);
412 342 : const char *p = content;
413 1534 : while (*p) {
414 1194 : if (strncmp(p, ref, ref_len) == 0 &&
415 2 : (p[ref_len] == '\n' || p[ref_len] == '\0')) {
416 2 : free(content);
417 2 : return 1;
418 : }
419 1192 : const char *nl = strchr(p, '\n');
420 1192 : if (!nl) break;
421 1192 : p = nl + 1;
422 : }
423 340 : free(content);
424 340 : return 0;
425 : }
426 :
427 : /** @brief Appends a reference to an index file (skips duplicates). */
428 447 : static int index_append(const char *dir_path, const char *file_name,
429 : const char *ref) {
430 447 : if (fs_mkdir_p(dir_path, 0700) != 0) return -1;
431 :
432 447 : RAII_STRING char *path = NULL;
433 447 : if (asprintf(&path, "%s/%s", dir_path, file_name) == -1) return -1;
434 :
435 447 : if (index_has_ref(path, ref)) return 0; /* already indexed */
436 :
437 890 : RAII_FILE FILE *fp = fopen(path, "a");
438 445 : if (!fp) return -1;
439 445 : fprintf(fp, "%s\n", ref);
440 445 : return 0;
441 : }
442 :
443 : /** @brief Removes a reference from an index file. */
444 : __attribute__((unused))
445 : /** @brief Extracts email address parts from a From header value. */
446 224 : static void extract_email_parts(const char *from,
447 : char *domain, size_t dlen,
448 : char *local_part, size_t llen) {
449 224 : domain[0] = '\0';
450 224 : local_part[0] = '\0';
451 :
452 : /* Try "Name <user@domain>" format first */
453 224 : const char *lt = strchr(from, '<');
454 224 : const char *gt = lt ? strchr(lt, '>') : NULL;
455 : const char *email;
456 : size_t elen;
457 224 : if (lt && gt && gt > lt + 1) {
458 220 : email = lt + 1;
459 220 : elen = (size_t)(gt - email);
460 : } else {
461 : /* Bare address: skip leading whitespace */
462 4 : email = from;
463 4 : while (*email == ' ' || *email == '\t') email++;
464 4 : elen = strlen(email);
465 : /* Trim trailing whitespace */
466 4 : while (elen > 0 && (email[elen - 1] == ' ' || email[elen - 1] == '\n'
467 4 : || email[elen - 1] == '\r'))
468 0 : elen--;
469 : }
470 :
471 224 : const char *at = memchr(email, '@', elen);
472 224 : if (!at) return;
473 :
474 223 : size_t ll = (size_t)(at - email);
475 223 : size_t dl = elen - ll - 1;
476 223 : if (ll >= llen) ll = llen - 1;
477 223 : if (dl >= dlen) dl = dlen - 1;
478 223 : memcpy(local_part, email, ll);
479 223 : local_part[ll] = '\0';
480 223 : memcpy(domain, at + 1, dl);
481 223 : domain[dl] = '\0';
482 :
483 : /* Lowercase domain */
484 2685 : for (char *c = domain; *c; c++)
485 2462 : *c = (char)tolower((unsigned char)*c);
486 : /* Lowercase local part */
487 1417 : for (char *c = local_part; *c; c++)
488 1194 : *c = (char)tolower((unsigned char)*c);
489 : }
490 :
491 224 : int local_index_update(const char *folder, const char *uid, const char *raw_msg) {
492 224 : if (!g_account_base[0] || !raw_msg) return -1;
493 :
494 : char ref[512];
495 224 : snprintf(ref, sizeof(ref), "%s/%s", folder, uid);
496 :
497 : /* 1. From index: index/from/<domain>/<localpart> */
498 448 : RAII_STRING char *from_raw = mime_get_header(raw_msg, "From");
499 224 : if (from_raw) {
500 : char domain[256], local_part[256];
501 224 : extract_email_parts(from_raw, domain, sizeof(domain),
502 : local_part, sizeof(local_part));
503 224 : if (domain[0] && local_part[0]) {
504 223 : RAII_STRING char *idx_dir = NULL;
505 223 : if (asprintf(&idx_dir, "%s/index/from/%s",
506 : g_account_base, domain) != -1)
507 223 : index_append(idx_dir, local_part, ref);
508 : }
509 : }
510 :
511 : /* 2. Date index: index/date/<year>/<month>/<day> */
512 224 : RAII_STRING char *date_raw = mime_get_header(raw_msg, "Date");
513 224 : if (date_raw) {
514 448 : RAII_STRING char *formatted = mime_format_date(date_raw);
515 224 : if (formatted && strlen(formatted) >= 10) {
516 : int year, month, day;
517 224 : if (sscanf(formatted, "%d-%d-%d", &year, &month, &day) == 3) {
518 224 : RAII_STRING char *idx_dir = NULL;
519 : char day_str[4];
520 224 : snprintf(day_str, sizeof(day_str), "%02d", day);
521 224 : if (asprintf(&idx_dir, "%s/index/date/%04d/%02d",
522 : g_account_base, year, month) != -1)
523 224 : index_append(idx_dir, day_str, ref);
524 : }
525 : }
526 : }
527 :
528 224 : return 0;
529 : }
530 :
531 5 : int local_msg_delete(const char *folder, const char *uid) {
532 5 : if (!g_account_base[0]) return -1;
533 :
534 : char ref[512];
535 5 : snprintf(ref, sizeof(ref), "%s/%s", folder, uid);
536 :
537 : /* 1. Remove .eml file */
538 10 : RAII_STRING char *mpath = msg_path(folder, uid);
539 5 : if (mpath) remove(mpath);
540 :
541 : /* 2. Remove .hdr file */
542 5 : RAII_STRING char *hpath = hdr_path(folder, uid);
543 5 : if (hpath) remove(hpath);
544 :
545 : /* 3. Remove from indexes — best effort scan of from/ and date/ */
546 : /* For from/: we'd need to know which file has this ref.
547 : * Since we don't track that, just load the message (if still cached)
548 : * or accept the stale entry. A full re-index can clean up. */
549 5 : logger_log(LOG_DEBUG, "Deleted %s/%s", folder, uid);
550 5 : return 0;
551 : }
552 :
553 : /* ── UI preferences ──────────────────────────────────────────────────── */
554 :
555 1134 : static char *ui_pref_path(void) {
556 1134 : const char *data_base = platform_data_dir();
557 1134 : if (!data_base) return NULL;
558 1134 : char *path = NULL;
559 1134 : if (asprintf(&path, "%s/email-cli/ui.ini", data_base) == -1)
560 0 : return NULL;
561 1134 : return path;
562 : }
563 :
564 213 : int ui_pref_get_int(const char *key, int default_val) {
565 426 : RAII_STRING char *path = ui_pref_path();
566 213 : if (!path) return default_val;
567 426 : RAII_FILE FILE *fp = fopen(path, "r");
568 213 : if (!fp) return default_val;
569 : char line[256];
570 210 : size_t klen = strlen(key);
571 558 : while (fgets(line, sizeof(line), fp))
572 383 : if (strncmp(line, key, klen) == 0 && line[klen] == '=')
573 35 : return atoi(line + klen + 1);
574 175 : return default_val;
575 : }
576 :
577 15 : int ui_pref_set_int(const char *key, int value) {
578 15 : const char *data_base = platform_data_dir();
579 15 : if (!data_base) return -1;
580 15 : RAII_STRING char *dir = NULL;
581 15 : if (asprintf(&dir, "%s/email-cli", data_base) == -1) return -1;
582 15 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
583 30 : RAII_STRING char *path = ui_pref_path();
584 15 : if (!path) return -1;
585 :
586 15 : char *existing = load_file(path);
587 :
588 30 : RAII_FILE FILE *fp = fopen(path, "w");
589 15 : if (!fp) { free(existing); return -1; }
590 :
591 15 : size_t klen = strlen(key);
592 15 : if (existing) {
593 13 : char *line = existing;
594 33 : while (*line) {
595 20 : char *nl = strchr(line, '\n');
596 20 : size_t llen = nl ? (size_t)(nl - line + 1) : strlen(line);
597 20 : if (!(strncmp(line, key, klen) == 0 && line[klen] == '='))
598 9 : fwrite(line, 1, llen, fp);
599 20 : line += llen;
600 : }
601 13 : free(existing);
602 : }
603 15 : fprintf(fp, "%s=%d\n", key, value);
604 15 : logger_log(LOG_DEBUG, "UI pref %s=%d saved", key, value);
605 15 : return 0;
606 : }
607 :
608 461 : char *ui_pref_get_str(const char *key) {
609 922 : RAII_STRING char *path = ui_pref_path();
610 461 : if (!path) return NULL;
611 922 : RAII_FILE FILE *fp = fopen(path, "r");
612 461 : if (!fp) return NULL;
613 : char line[1024];
614 455 : size_t klen = strlen(key);
615 535 : while (fgets(line, sizeof(line), fp)) {
616 494 : if (strncmp(line, key, klen) == 0 && line[klen] == '=') {
617 414 : char *val = line + klen + 1;
618 414 : size_t vlen = strlen(val);
619 828 : while (vlen > 0 && (val[vlen-1] == '\n' || val[vlen-1] == '\r'))
620 414 : val[--vlen] = '\0';
621 414 : return strdup(val);
622 : }
623 : }
624 41 : return NULL;
625 : }
626 :
627 445 : int ui_pref_set_str(const char *key, const char *value) {
628 445 : const char *data_base = platform_data_dir();
629 445 : if (!data_base) return -1;
630 445 : RAII_STRING char *dir = NULL;
631 445 : if (asprintf(&dir, "%s/email-cli", data_base) == -1) return -1;
632 445 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
633 890 : RAII_STRING char *path = ui_pref_path();
634 445 : if (!path) return -1;
635 :
636 445 : char *existing = load_file(path);
637 :
638 890 : RAII_FILE FILE *fp = fopen(path, "w");
639 445 : if (!fp) { free(existing); return -1; }
640 :
641 445 : size_t klen = strlen(key);
642 445 : if (existing) {
643 439 : char *line = existing;
644 1306 : while (*line) {
645 867 : char *nl = strchr(line, '\n');
646 867 : size_t llen = nl ? (size_t)(nl - line + 1) : strlen(line);
647 867 : if (!(strncmp(line, key, klen) == 0 && line[klen] == '='))
648 468 : fwrite(line, 1, llen, fp);
649 867 : line += llen;
650 : }
651 439 : free(existing);
652 : }
653 445 : fprintf(fp, "%s=%s\n", key, value);
654 445 : logger_log(LOG_DEBUG, "UI pref %s=%s saved", key, value);
655 445 : return 0;
656 : }
657 :
658 : /* ── Folder manifest ─────────────────────────────────────────────────── */
659 :
660 5495 : static char *manifest_path(const char *folder) {
661 5495 : if (!g_account_base[0]) return NULL;
662 5495 : char *path = NULL;
663 5495 : if (asprintf(&path, "%s/manifests/%s.tsv", g_account_base, folder) == -1)
664 0 : return NULL;
665 5495 : return path;
666 : }
667 :
668 : /** @brief Duplicates a string, replacing tabs with spaces. */
669 2919 : static char *sanitise(const char *s) {
670 2919 : if (!s) return strdup("");
671 2919 : char *d = strdup(s);
672 66389 : if (d) for (char *p = d; *p; p++) if (*p == '\t') *p = ' ';
673 2919 : return d;
674 : }
675 :
676 5132 : Manifest *manifest_load(const char *folder) {
677 10264 : RAII_STRING char *path = manifest_path(folder);
678 5132 : logger_log(LOG_DEBUG, "manifest_load: folder=%s account_base=%s path=%s",
679 5132 : folder, g_account_base, path ? path : "(null)");
680 5132 : if (!path) return NULL;
681 :
682 5132 : char *data = load_file(path);
683 5132 : if (!data) return NULL;
684 :
685 3271 : Manifest *m = calloc(1, sizeof(*m));
686 3271 : if (!m) { free(data); return NULL; }
687 3271 : m->capacity = 64;
688 3271 : m->entries = malloc((size_t)m->capacity * sizeof(ManifestEntry));
689 3271 : if (!m->entries) { free(m); free(data); return NULL; }
690 :
691 3271 : char *line = data;
692 8857 : while (*line) {
693 5586 : char *nl = strchr(line, '\n');
694 5586 : if (nl) *nl = '\0';
695 :
696 : /* Parse: uid\tfrom\tsubject\tdate */
697 5586 : char *t1 = strchr(line, '\t');
698 5586 : if (!t1 || t1 == line) {
699 0 : line = nl ? nl + 1 : line + strlen(line);
700 0 : continue;
701 : }
702 5586 : *t1 = '\0';
703 5586 : char *uid_field = line;
704 5586 : char *from_start = t1 + 1;
705 5586 : char *t2 = strchr(from_start, '\t');
706 5586 : if (!t2) { line = nl ? nl + 1 : line + strlen(line); continue; }
707 5586 : *t2 = '\0';
708 5586 : char *subj_start = t2 + 1;
709 5586 : char *t3 = strchr(subj_start, '\t');
710 5586 : if (!t3) { line = nl ? nl + 1 : line + strlen(line); continue; }
711 5586 : *t3 = '\0';
712 5586 : char *date_start = t3 + 1;
713 : /* Optional 5th field: unseen flag */
714 5586 : int unseen_val = 0;
715 5586 : char *t4 = strchr(date_start, '\t');
716 5586 : if (t4) {
717 5586 : *t4 = '\0';
718 5586 : unseen_val = atoi(t4 + 1);
719 : }
720 :
721 5586 : if (m->count == m->capacity) {
722 20 : m->capacity *= 2;
723 20 : ManifestEntry *tmp = realloc(m->entries,
724 20 : (size_t)m->capacity * sizeof(ManifestEntry));
725 20 : if (!tmp) break;
726 20 : m->entries = tmp;
727 : }
728 5586 : ManifestEntry *e = &m->entries[m->count++];
729 5586 : snprintf(e->uid, sizeof(e->uid), "%s", uid_field);
730 5586 : e->from = strdup(from_start);
731 5586 : e->subject = strdup(subj_start);
732 5586 : e->date = strdup(date_start);
733 5586 : e->flags = unseen_val;
734 :
735 5586 : line = nl ? nl + 1 : line + strlen(line);
736 : }
737 3271 : free(data);
738 3271 : return m;
739 : }
740 :
741 368 : int manifest_save(const char *folder, const Manifest *m) {
742 368 : if (!g_account_base[0] || !m) return -1;
743 :
744 357 : RAII_STRING char *dir = NULL;
745 368 : if (asprintf(&dir, "%s/manifests", g_account_base) == -1) return -1;
746 368 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
747 :
748 : /* For nested folders like "munka/ai" we need the parent dir */
749 720 : RAII_STRING char *path = manifest_path(folder);
750 363 : if (!path) return -1;
751 :
752 : /* Ensure parent directory exists (folder path may have slashes) */
753 363 : char *last_slash = strrchr(path, '/');
754 363 : if (last_slash) {
755 363 : char saved = *last_slash;
756 363 : *last_slash = '\0';
757 363 : fs_mkdir_p(path, 0700);
758 360 : *last_slash = saved;
759 : }
760 :
761 718 : RAII_FILE FILE *fp = fopen(path, "w");
762 358 : if (!fp) return -1;
763 :
764 1331 : for (int i = 0; i < m->count; i++) {
765 973 : ManifestEntry *e = &m->entries[i];
766 1946 : RAII_STRING char *f = sanitise(e->from);
767 1946 : RAII_STRING char *s = sanitise(e->subject);
768 1946 : RAII_STRING char *d = sanitise(e->date);
769 973 : fprintf(fp, "%s\t%s\t%s\t%s\t%d\n", e->uid, f ? f : "", s ? s : "", d ? d : "", e->flags);
770 : }
771 358 : logger_log(LOG_DEBUG, "Manifest saved: %s (%d entries)", folder, m->count);
772 358 : return 0;
773 : }
774 :
775 3659 : void manifest_free(Manifest *m) {
776 3659 : if (!m) return;
777 11181 : for (int i = 0; i < m->count; i++) {
778 7522 : free(m->entries[i].from);
779 7522 : free(m->entries[i].subject);
780 7522 : free(m->entries[i].date);
781 : }
782 3659 : free(m->entries);
783 3659 : free(m);
784 : }
785 :
786 14591 : ManifestEntry *manifest_find(const Manifest *m, const char *uid) {
787 14591 : if (!m) return NULL;
788 956651 : for (int i = 0; i < m->count; i++)
789 953210 : if (strcmp(m->entries[i].uid, uid) == 0) return &m->entries[i];
790 3441 : return NULL;
791 : }
792 :
793 2857 : void manifest_upsert(Manifest *m, const char *uid,
794 : char *from, char *subject, char *date, int flags) {
795 2857 : if (!m) return;
796 2857 : ManifestEntry *existing = manifest_find(m, uid);
797 2857 : if (existing) {
798 674 : free(existing->from); existing->from = from;
799 674 : free(existing->subject); existing->subject = subject;
800 674 : free(existing->date); existing->date = date;
801 674 : existing->flags = flags;
802 674 : return;
803 : }
804 2183 : if (m->count == m->capacity) {
805 474 : int new_cap = m->capacity ? m->capacity * 2 : 64;
806 474 : ManifestEntry *tmp = realloc(m->entries,
807 474 : (size_t)new_cap * sizeof(ManifestEntry));
808 474 : if (!tmp) { free(from); free(subject); free(date); return; }
809 474 : m->entries = tmp;
810 474 : m->capacity = new_cap;
811 : }
812 2183 : ManifestEntry *e = &m->entries[m->count++];
813 2183 : snprintf(e->uid, sizeof(e->uid), "%s", uid);
814 2183 : e->from = from; e->subject = subject; e->date = date;
815 2183 : e->flags = flags;
816 : }
817 :
818 431 : void manifest_retain(Manifest *m, const char (*keep_uids)[17], int keep_count) {
819 431 : if (!m) return;
820 431 : int dst = 0;
821 1357 : for (int i = 0; i < m->count; i++) {
822 926 : int found = 0;
823 71885 : for (int j = 0; j < keep_count; j++) {
824 71882 : if (strcmp(keep_uids[j], m->entries[i].uid) == 0) { found = 1; break; }
825 : }
826 926 : if (found) {
827 923 : if (dst != i) m->entries[dst] = m->entries[i];
828 923 : dst++;
829 : } else {
830 3 : free(m->entries[i].from);
831 3 : free(m->entries[i].subject);
832 3 : free(m->entries[i].date);
833 : }
834 : }
835 431 : m->count = dst;
836 : }
837 :
838 2 : void manifest_remove(Manifest *m, const char *uid) {
839 2 : if (!m || !uid) return;
840 3 : for (int i = 0; i < m->count; i++) {
841 3 : if (strcmp(m->entries[i].uid, uid) == 0) {
842 2 : free(m->entries[i].from);
843 2 : free(m->entries[i].subject);
844 2 : free(m->entries[i].date);
845 : /* Shift remaining entries down */
846 2 : for (int j = i + 1; j < m->count; j++)
847 0 : m->entries[j - 1] = m->entries[j];
848 2 : m->count--;
849 2 : return;
850 : }
851 : }
852 : }
853 :
854 : /* ── Folder list cache ───────────────────────────────────────────────── */
855 :
856 35 : int local_folder_list_save(const char **folders, int count, char sep) {
857 35 : if (!g_account_base[0]) return -1;
858 35 : RAII_STRING char *path = NULL;
859 35 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return -1;
860 70 : RAII_FILE FILE *fp = fopen(path, "w");
861 35 : if (!fp) return -1;
862 35 : fprintf(fp, "sep=%c\n", sep);
863 297 : for (int i = 0; i < count; i++)
864 262 : fprintf(fp, "%s\n", folders[i] ? folders[i] : "");
865 35 : logger_log(LOG_DEBUG, "Folder list cache saved: %d folders", count);
866 35 : return 0;
867 : }
868 :
869 838 : char **local_folder_list_load(int *count_out, char *sep_out) {
870 838 : *count_out = 0;
871 838 : if (!g_account_base[0]) return NULL;
872 838 : RAII_STRING char *path = NULL;
873 838 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return NULL;
874 1676 : RAII_FILE FILE *fp = fopen(path, "r");
875 838 : if (!fp) return NULL;
876 :
877 : char line[1024];
878 654 : char sep = '.';
879 : /* First line: sep=<char> */
880 654 : if (!fgets(line, sizeof(line), fp)) return NULL;
881 654 : if (strncmp(line, "sep=", 4) == 0 && line[4] != '\n')
882 654 : sep = line[4];
883 :
884 654 : int cap = 32, cnt = 0;
885 654 : char **folders = malloc((size_t)cap * sizeof(char *));
886 654 : if (!folders) return NULL;
887 5824 : while (fgets(line, sizeof(line), fp)) {
888 : /* strip trailing newline */
889 5170 : size_t len = strlen(line);
890 10340 : while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
891 5170 : line[--len] = '\0';
892 5170 : if (len == 0) continue;
893 5170 : if (cnt == cap) {
894 0 : cap *= 2;
895 0 : char **tmp = realloc(folders, (size_t)cap * sizeof(char *));
896 0 : if (!tmp) { for (int i = 0; i < cnt; i++) free(folders[i]); free(folders); return NULL; }
897 0 : folders = tmp;
898 : }
899 5170 : folders[cnt] = strdup(line);
900 5170 : if (!folders[cnt]) { for (int i = 0; i < cnt; i++) free(folders[i]); free(folders); return NULL; }
901 5170 : cnt++;
902 : }
903 654 : *count_out = cnt;
904 654 : if (sep_out) *sep_out = sep;
905 654 : logger_log(LOG_DEBUG, "Folder list cache loaded: %d folders", cnt);
906 654 : return folders;
907 : }
908 :
909 3297 : void manifest_count_folder(const char *folder, int *total_out,
910 : int *unseen_out, int *flagged_out) {
911 3297 : *total_out = 0; *unseen_out = 0; *flagged_out = 0;
912 3297 : Manifest *m = manifest_load(folder);
913 3297 : if (!m) return;
914 1717 : *total_out = m->count;
915 3650 : for (int i = 0; i < m->count; i++) {
916 1933 : if (m->entries[i].flags & MSG_FLAG_UNSEEN) (*unseen_out)++;
917 1933 : if (m->entries[i].flags & MSG_FLAG_FLAGGED) (*flagged_out)++;
918 : }
919 1717 : manifest_free(m);
920 : }
921 :
922 229 : Manifest *manifest_load_all_with_flag(int flag_mask) {
923 229 : Manifest *result = calloc(1, sizeof(Manifest));
924 229 : if (!result) return NULL;
925 229 : if (!g_account_base[0]) return result;
926 : char dir_path[8300];
927 229 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
928 458 : RAII_DIR DIR *dp = opendir(dir_path);
929 229 : if (!dp) return result;
930 : struct dirent *ent;
931 1624 : while ((ent = readdir(dp)) != NULL) {
932 1406 : const char *name = ent->d_name;
933 1406 : size_t nlen = strlen(name);
934 1407 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
935 : /* Skip virtual flag manifests (__unread__, __flagged__, etc.) —
936 : * they are legacy artifacts; real data lives in per-folder manifests. */
937 970 : if (name[0] == '_' && name[1] == '_') continue;
938 970 : RAII_STRING char *folder = strndup(name, nlen - 4);
939 970 : if (!folder) continue;
940 970 : Manifest *m = manifest_load(folder);
941 970 : if (!m) continue;
942 1968 : for (int i = 0; i < m->count; i++) {
943 999 : if (flag_mask == 0 || (m->entries[i].flags & flag_mask))
944 899 : manifest_upsert(result, m->entries[i].uid,
945 899 : strdup(m->entries[i].from ? m->entries[i].from : ""),
946 899 : strdup(m->entries[i].subject ? m->entries[i].subject : ""),
947 899 : strdup(m->entries[i].date ? m->entries[i].date : ""),
948 899 : m->entries[i].flags);
949 : }
950 969 : manifest_free(m);
951 : }
952 218 : return result;
953 : }
954 :
955 210 : void manifest_count_all_flags(int *unread_out, int *flagged_out,
956 : int *junk_out, int *phishing_out,
957 : int *answered_out, int *forwarded_out) {
958 210 : if (unread_out) *unread_out = 0;
959 210 : if (flagged_out) *flagged_out = 0;
960 210 : if (junk_out) *junk_out = 0;
961 210 : if (phishing_out) *phishing_out = 0;
962 210 : if (answered_out) *answered_out = 0;
963 210 : if (forwarded_out)*forwarded_out= 0;
964 210 : if (!g_account_base[0]) return;
965 : /* Use the deduplicated aggregate manifest so that Gmail messages appearing
966 : * in multiple label manifests are counted only once. */
967 210 : int combined = MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED | MSG_FLAG_JUNK |
968 : MSG_FLAG_PHISHING | MSG_FLAG_ANSWERED | MSG_FLAG_FORWARDED;
969 210 : Manifest *m = manifest_load_all_with_flag(combined);
970 210 : if (!m) return;
971 451 : for (int i = 0; i < m->count; i++) {
972 241 : int f = m->entries[i].flags;
973 241 : if (unread_out && (f & MSG_FLAG_UNSEEN)) (*unread_out)++;
974 241 : if (flagged_out && (f & MSG_FLAG_FLAGGED)) (*flagged_out)++;
975 241 : if (junk_out && (f & MSG_FLAG_JUNK)) (*junk_out)++;
976 241 : if (phishing_out && (f & MSG_FLAG_PHISHING)) (*phishing_out)++;
977 241 : if (answered_out && (f & MSG_FLAG_ANSWERED)) (*answered_out)++;
978 241 : if (forwarded_out&& (f & MSG_FLAG_FORWARDED)) (*forwarded_out)++;
979 : }
980 210 : manifest_free(m);
981 : }
982 :
983 : /* ── Cross-folder flag search ────────────────────────────────────────── */
984 :
985 24 : int local_flag_search(int flag_mask,
986 : SearchResult **results_out, int *count_out)
987 : {
988 24 : *results_out = NULL;
989 24 : *count_out = 0;
990 24 : if (!g_account_base[0]) return 0;
991 :
992 24 : int cap = 64, cnt = 0;
993 24 : SearchResult *res = malloc((size_t)cap * sizeof(SearchResult));
994 24 : if (!res) return -1;
995 :
996 : char dir_path[8300];
997 24 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
998 48 : RAII_DIR DIR *dp = opendir(dir_path);
999 24 : if (!dp) { free(res); return 0; }
1000 :
1001 : struct dirent *ent;
1002 200 : while ((ent = readdir(dp)) != NULL) {
1003 176 : const char *name = ent->d_name;
1004 176 : size_t nlen = strlen(name);
1005 176 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
1006 : /* Skip virtual flag manifests (__unread__, __flagged__, etc.) —
1007 : * they are legacy artifacts; real data lives in per-folder manifests. */
1008 128 : if (name[0] == '_' && name[1] == '_') continue;
1009 128 : RAII_STRING char *folder = strndup(name, nlen - 4);
1010 128 : if (!folder) continue;
1011 128 : Manifest *m = manifest_load(folder);
1012 128 : if (!m) continue;
1013 277 : for (int i = 0; i < m->count; i++) {
1014 149 : if (flag_mask != 0 && !(m->entries[i].flags & flag_mask)) continue;
1015 : /* Deduplicate by UID. For Gmail a message appears in multiple label
1016 : * manifests; prefer the empty-folder entry (where .eml is stored).
1017 : * If the UID is already present with a non-empty folder, replace it
1018 : * with the current empty-folder entry. Otherwise skip duplicates. */
1019 65 : int dup_idx = -1;
1020 94 : for (int j = 0; j < cnt; j++) {
1021 55 : if (strcmp(res[j].uid, m->entries[i].uid) == 0) {
1022 26 : dup_idx = j; break;
1023 : }
1024 : }
1025 65 : if (dup_idx >= 0) {
1026 26 : if (folder[0] == '\0' && res[dup_idx].folder[0] != '\0') {
1027 : /* Upgrade to empty-folder entry (better for file operations) */
1028 0 : res[dup_idx].folder[0] = '\0';
1029 0 : res[dup_idx].flags = m->entries[i].flags;
1030 : }
1031 26 : continue; /* skip duplicate regardless */
1032 : }
1033 39 : if (cnt == cap) {
1034 0 : int nc = cap * 2;
1035 0 : SearchResult *tmp = realloc(res, (size_t)nc * sizeof(SearchResult));
1036 0 : if (!tmp) { manifest_free(m); free(res); return -1; }
1037 0 : res = tmp; cap = nc;
1038 : }
1039 39 : SearchResult *r = &res[cnt++];
1040 39 : snprintf(r->uid, sizeof(r->uid), "%s", m->entries[i].uid);
1041 39 : snprintf(r->folder, sizeof(r->folder), "%s", folder);
1042 39 : r->flags = m->entries[i].flags;
1043 39 : r->from = strdup(m->entries[i].from ? m->entries[i].from : "");
1044 39 : r->subject = strdup(m->entries[i].subject ? m->entries[i].subject : "");
1045 39 : r->date = strdup(m->entries[i].date ? m->entries[i].date : "");
1046 : }
1047 128 : manifest_free(m);
1048 : }
1049 24 : *results_out = res;
1050 24 : *count_out = cnt;
1051 24 : return 0;
1052 : }
1053 :
1054 : /* ── Cross-folder text search ─────────────────────────────────────────── */
1055 :
1056 16 : int local_search(const char *query, int scope,
1057 : SearchResult **results_out, int *count_out)
1058 : {
1059 16 : *results_out = NULL;
1060 16 : *count_out = 0;
1061 16 : if (!query || !query[0] || !g_account_base[0]) return 0;
1062 :
1063 : char dir_path[8300];
1064 14 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
1065 28 : RAII_DIR DIR *dp = opendir(dir_path);
1066 14 : if (!dp) return 0; /* no manifests — not an error */
1067 :
1068 14 : int cap = 64;
1069 14 : SearchResult *results = malloc((size_t)cap * sizeof(SearchResult));
1070 14 : if (!results) return -1;
1071 14 : int count = 0;
1072 :
1073 : struct dirent *ent;
1074 94 : while ((ent = readdir(dp)) != NULL) {
1075 80 : const char *name = ent->d_name;
1076 80 : size_t nlen = strlen(name);
1077 80 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
1078 :
1079 52 : RAII_STRING char *fold = strndup(name, nlen - 4);
1080 52 : if (!fold) continue;
1081 :
1082 52 : Manifest *m = manifest_load(fold);
1083 52 : if (!m) continue;
1084 :
1085 112 : for (int i = 0; i < m->count; i++) {
1086 60 : ManifestEntry *me = &m->entries[i];
1087 60 : int match = 0;
1088 60 : if (scope == 0) {
1089 26 : const char *s = (me->subject && me->subject[0]) ? me->subject : "";
1090 26 : match = strcasestr(s, query) != NULL;
1091 34 : } else if (scope == 1) {
1092 10 : const char *s = (me->from && me->from[0]) ? me->from : "";
1093 10 : match = strcasestr(s, query) != NULL;
1094 24 : } else if (scope == 2) {
1095 8 : char *hdr = local_hdr_load(fold, me->uid);
1096 8 : if (hdr) {
1097 8 : char *to_raw = mime_get_header(hdr, "To");
1098 8 : if (to_raw) { match = strcasestr(to_raw, query) != NULL; free(to_raw); }
1099 8 : free(hdr);
1100 : }
1101 : } else {
1102 : /* Body scope: search the *decoded* text, not the stored file.
1103 : * Matching the raw .eml only ever finds ASCII inside
1104 : * unencoded parts: a base64 body is opaque, and in a
1105 : * non-UTF-8 message the accented words are stored in their
1106 : * original charset, so a UTF-8 query can never match them.
1107 : * mime_get_text_body() applies transfer-decoding, charset
1108 : * conversion and HTML rendering — the same text `show`
1109 : * displays, which is what the user is searching for. */
1110 16 : char *raw = local_msg_load(fold, me->uid);
1111 16 : if (raw) {
1112 15 : char *text = mime_get_text_body(raw);
1113 15 : match = strcasestr(text ? text : raw, query) != NULL;
1114 15 : free(text);
1115 15 : free(raw);
1116 : }
1117 : }
1118 60 : if (!match) continue;
1119 :
1120 17 : if (count >= cap) {
1121 0 : cap *= 2;
1122 0 : SearchResult *tmp = realloc(results, (size_t)cap * sizeof(SearchResult));
1123 0 : if (!tmp) { manifest_free(m); free(results); return -1; }
1124 0 : results = tmp;
1125 : }
1126 17 : SearchResult *r = &results[count++];
1127 17 : memcpy(r->uid, me->uid, 17);
1128 17 : snprintf(r->folder, sizeof(r->folder), "%s", fold);
1129 17 : r->flags = me->flags;
1130 17 : r->from = me->from ? strdup(me->from) : strdup("");
1131 17 : r->subject = me->subject ? strdup(me->subject) : strdup("");
1132 17 : r->date = me->date ? strdup(me->date) : strdup("");
1133 : }
1134 52 : manifest_free(m);
1135 : }
1136 :
1137 14 : *results_out = results;
1138 14 : *count_out = count;
1139 14 : return 0;
1140 : }
1141 :
1142 21 : void local_search_free(SearchResult *results, int count)
1143 : {
1144 21 : if (!results) return;
1145 53 : for (int i = 0; i < count; i++) {
1146 32 : free(results[i].from);
1147 32 : free(results[i].subject);
1148 32 : free(results[i].date);
1149 : }
1150 21 : free(results);
1151 : }
1152 :
1153 : /* ── Pending flag changes ─────────────────────────────────────────────── */
1154 :
1155 210 : static char *pending_flag_path(const char *folder) {
1156 210 : if (!g_account_base[0]) return NULL;
1157 210 : char *path = NULL;
1158 210 : if (asprintf(&path, "%s/pending_flags/%s.tsv", g_account_base, folder) == -1)
1159 0 : return NULL;
1160 210 : return path;
1161 : }
1162 :
1163 47 : int local_pending_flag_add(const char *folder, const char *uid,
1164 : const char *flag_name, int add) {
1165 94 : RAII_STRING char *path = pending_flag_path(folder);
1166 47 : if (!path) return -1;
1167 :
1168 : /* Ensure parent directory exists (folder path may have slashes) */
1169 47 : char *dir_end = strrchr(path, '/');
1170 47 : if (dir_end) {
1171 47 : char saved = *dir_end;
1172 47 : *dir_end = '\0';
1173 47 : fs_mkdir_p(path, 0700);
1174 47 : *dir_end = saved;
1175 : }
1176 :
1177 94 : RAII_FILE FILE *fp = fopen(path, "a");
1178 47 : if (!fp) return -1;
1179 47 : fprintf(fp, "%s\t%s\t%d\n", uid, flag_name, add);
1180 47 : return 0;
1181 : }
1182 :
1183 161 : PendingFlag *local_pending_flag_load(const char *folder, int *count_out) {
1184 161 : *count_out = 0;
1185 322 : RAII_STRING char *path = pending_flag_path(folder);
1186 161 : if (!path) return NULL;
1187 :
1188 322 : RAII_FILE FILE *fp = fopen(path, "r");
1189 161 : if (!fp) return NULL;
1190 :
1191 2 : int cap = 16, count = 0;
1192 2 : PendingFlag *arr = malloc((size_t)cap * sizeof(PendingFlag));
1193 2 : if (!arr) return NULL;
1194 :
1195 : char line[256];
1196 7 : while (fgets(line, sizeof(line), fp)) {
1197 : int add_val;
1198 : char uid_str[17], flag[64];
1199 5 : if (sscanf(line, "%16[^\t]\t%63[^\t]\t%d", uid_str, flag, &add_val) != 3)
1200 0 : continue;
1201 5 : if (count == cap) {
1202 0 : cap *= 2;
1203 0 : PendingFlag *tmp = realloc(arr, (size_t)cap * sizeof(PendingFlag));
1204 0 : if (!tmp) break;
1205 0 : arr = tmp;
1206 : }
1207 5 : snprintf(arr[count].uid, sizeof(arr[count].uid), "%s", uid_str);
1208 5 : arr[count].add = add_val;
1209 5 : strncpy(arr[count].flag_name, flag, sizeof(arr[count].flag_name) - 1);
1210 5 : arr[count].flag_name[sizeof(arr[count].flag_name) - 1] = '\0';
1211 5 : count++;
1212 : }
1213 2 : *count_out = count;
1214 2 : return arr;
1215 : }
1216 :
1217 2 : void local_pending_flag_clear(const char *folder) {
1218 4 : RAII_STRING char *path = pending_flag_path(folder);
1219 2 : if (path) remove(path);
1220 2 : }
1221 :
1222 : /* ── Pending folder moves ─────────────────────────────────────────────── */
1223 :
1224 162 : static char *pending_move_path(const char *folder) {
1225 162 : if (!g_account_base[0]) return NULL;
1226 162 : char *path = NULL;
1227 162 : if (asprintf(&path, "%s/pending_moves/%s.tsv", g_account_base, folder) == -1)
1228 0 : return NULL;
1229 162 : return path;
1230 : }
1231 :
1232 1 : int local_pending_move_add(const char *folder, const char *uid,
1233 : const char *target_folder) {
1234 2 : RAII_STRING char *path = pending_move_path(folder);
1235 1 : if (!path) return -1;
1236 1 : char *dir_end = strrchr(path, '/');
1237 1 : if (dir_end) {
1238 1 : char saved = *dir_end; *dir_end = '\0';
1239 1 : fs_mkdir_p(path, 0700);
1240 1 : *dir_end = saved;
1241 : }
1242 2 : RAII_FILE FILE *fp = fopen(path, "a");
1243 1 : if (!fp) return -1;
1244 1 : fprintf(fp, "%s\t%s\n", uid, target_folder);
1245 1 : return 0;
1246 : }
1247 :
1248 161 : PendingMove *local_pending_move_load(const char *folder, int *count_out) {
1249 161 : *count_out = 0;
1250 322 : RAII_STRING char *path = pending_move_path(folder);
1251 161 : if (!path) return NULL;
1252 322 : RAII_FILE FILE *fp = fopen(path, "r");
1253 161 : if (!fp) return NULL;
1254 0 : int cap = 16, count = 0;
1255 0 : PendingMove *arr = malloc((size_t)cap * sizeof(PendingMove));
1256 0 : if (!arr) return NULL;
1257 : char line[512];
1258 0 : while (fgets(line, sizeof(line), fp)) {
1259 : char uid_str[17], tgt[256];
1260 0 : if (sscanf(line, "%16[^\t]\t%255[^\n]", uid_str, tgt) != 2)
1261 0 : continue;
1262 0 : if (count == cap) {
1263 0 : cap *= 2;
1264 0 : PendingMove *tmp = realloc(arr, (size_t)cap * sizeof(PendingMove));
1265 0 : if (!tmp) break;
1266 0 : arr = tmp;
1267 : }
1268 0 : snprintf(arr[count].uid, sizeof(arr[count].uid), "%s", uid_str);
1269 0 : snprintf(arr[count].target_folder, sizeof(arr[count].target_folder), "%s", tgt);
1270 0 : count++;
1271 : }
1272 0 : *count_out = count;
1273 0 : return arr;
1274 : }
1275 :
1276 0 : void local_pending_move_clear(const char *folder) {
1277 0 : RAII_STRING char *path = pending_move_path(folder);
1278 0 : if (path) remove(path);
1279 0 : }
1280 :
1281 : /* ── Gmail label index files (.idx) ──────────────────────────────────── */
1282 :
1283 : #define IDX_RECORD_SIZE 17 /* 16 char UID + '\n' */
1284 :
1285 : /** @brief Returns heap-allocated path to labels/<label>.idx. */
1286 6627 : static char *label_idx_path(const char *label) {
1287 6627 : if (!g_account_base[0] || !label) return NULL;
1288 6627 : char *path = NULL;
1289 6627 : if (asprintf(&path, "%s/labels/%s.idx", g_account_base, label) == -1)
1290 0 : return NULL;
1291 6627 : return path;
1292 : }
1293 :
1294 : /** @brief Ensures the labels/ directory (and any parent for nested labels) exists. */
1295 1911 : static int ensure_label_dir(const char *label) {
1296 3822 : RAII_STRING char *path = label_idx_path(label);
1297 1911 : if (!path) return -1;
1298 : /* Find last slash and mkdir_p up to it */
1299 1911 : char *last_slash = strrchr(path, '/');
1300 1911 : if (!last_slash) return -1;
1301 1911 : *last_slash = '\0';
1302 1911 : int rc = fs_mkdir_p(path, 0700);
1303 1911 : return rc;
1304 : }
1305 :
1306 120 : int label_idx_contains(const char *label, const char *uid) {
1307 120 : char (*arr)[17] = NULL;
1308 120 : int n = 0;
1309 120 : if (label_idx_load(label, &arr, &n) != 0 || n == 0) {
1310 64 : free(arr);
1311 64 : return 0;
1312 : }
1313 :
1314 : /* In-memory binary search (file is kept sorted) */
1315 56 : int lo = 0, hi = n - 1, found = 0;
1316 105 : while (lo <= hi) {
1317 95 : int mid = lo + (hi - lo) / 2;
1318 95 : int cmp = strcmp(arr[mid], uid);
1319 95 : if (cmp == 0) { found = 1; break; }
1320 49 : if (cmp < 0) lo = mid + 1;
1321 8 : else hi = mid - 1;
1322 : }
1323 56 : free(arr);
1324 56 : return found;
1325 : }
1326 :
1327 897 : int label_idx_count(const char *label) {
1328 897 : char (*arr)[17] = NULL;
1329 897 : int n = 0;
1330 897 : label_idx_load(label, &arr, &n);
1331 897 : free(arr);
1332 897 : return n;
1333 : }
1334 :
1335 0 : int label_idx_intersect_count(const char *label_a,
1336 : const char (*b_uids)[17], int b_count) {
1337 0 : if (!label_a || b_count <= 0 || !b_uids) return 0;
1338 0 : char (*a_uids)[17] = NULL;
1339 0 : int a_count = 0;
1340 0 : if (label_idx_load(label_a, &a_uids, &a_count) != 0 || a_count == 0) {
1341 0 : free(a_uids);
1342 0 : return 0;
1343 : }
1344 : /* Merge-join on two sorted arrays — O(N+M). */
1345 0 : int i = 0, j = 0, matches = 0;
1346 0 : while (i < a_count && j < b_count) {
1347 0 : int cmp = strcmp(a_uids[i], b_uids[j]);
1348 0 : if (cmp == 0) { matches++; i++; j++; }
1349 0 : else if (cmp < 0) { i++; }
1350 0 : else { j++; }
1351 : }
1352 0 : free(a_uids);
1353 0 : return matches;
1354 : }
1355 :
1356 2805 : int label_idx_load(const char *label, char (**uids_out)[17], int *count_out) {
1357 2805 : *uids_out = NULL;
1358 2805 : *count_out = 0;
1359 :
1360 5610 : RAII_STRING char *path = label_idx_path(label);
1361 2805 : if (!path) return -1;
1362 :
1363 5610 : RAII_FILE FILE *fp = fopen(path, "r");
1364 2805 : if (!fp) return 0; /* Empty / nonexistent label → 0 entries, not error */
1365 :
1366 : /* Use fgets-based reading to handle both old variable-length format
1367 : * (where short Gmail IDs < 16 chars were stored without NUL padding)
1368 : * and new fixed-width format (16 NUL-padded bytes + '\n'). */
1369 1883 : int cap = 256;
1370 1883 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
1371 1883 : if (!arr) return -1;
1372 :
1373 1883 : int count = 0;
1374 : char line[64];
1375 64222 : while (fgets(line, sizeof(line), fp)) {
1376 : /* fgets stops at '\n'; strip trailing whitespace/newline */
1377 62339 : size_t len = strlen(line);
1378 124638 : while (len > 0 && ((unsigned char)line[len-1] <= ' '))
1379 62299 : line[--len] = '\0';
1380 62339 : if (len == 0 || len > 16) continue;
1381 :
1382 62339 : if (count >= cap) {
1383 0 : cap *= 2;
1384 0 : char (*tmp)[17] = realloc(arr, (size_t)cap * sizeof(char[17]));
1385 0 : if (!tmp) { free(arr); return -1; }
1386 0 : arr = tmp;
1387 : }
1388 62339 : memset(arr[count], 0, sizeof(arr[count]));
1389 62339 : memcpy(arr[count], line, len);
1390 62339 : count++;
1391 : }
1392 :
1393 1883 : *uids_out = arr;
1394 1883 : *count_out = count;
1395 1883 : return 0;
1396 : }
1397 :
1398 1911 : int label_idx_write(const char *label, const char (*uids)[17], int count) {
1399 1911 : if (ensure_label_dir(label) != 0) return -1;
1400 :
1401 3822 : RAII_STRING char *path = label_idx_path(label);
1402 1911 : if (!path) return -1;
1403 :
1404 3822 : RAII_FILE FILE *fp = fopen(path, "w");
1405 1911 : if (!fp) return -1;
1406 :
1407 : /* Write fixed-width records: exactly 16 NUL-padded bytes + '\n' = 17 bytes.
1408 : * Short Gmail IDs (< 16 chars) are padded with NUL so the record size is
1409 : * always 17 bytes, preventing embedded newlines on read-back. */
1410 69325 : for (int i = 0; i < count; i++) {
1411 : char padded[17];
1412 67414 : size_t uid_len = strlen(uids[i]);
1413 67414 : if (uid_len > 16) uid_len = 16;
1414 67414 : memset(padded, 0, 16);
1415 67414 : memcpy(padded, uids[i], uid_len);
1416 67414 : padded[16] = '\n';
1417 67414 : if (fwrite(padded, 1, 17, fp) != 17) return -1;
1418 : }
1419 :
1420 1911 : logger_log(LOG_DEBUG, "label_idx_write: %s → %d entries", label, count);
1421 1911 : return 0;
1422 : }
1423 :
1424 46 : char *local_hdr_get_labels(const char *folder, const char *uid) {
1425 46 : char *hdr = local_hdr_load(folder, uid);
1426 46 : if (!hdr) return NULL;
1427 :
1428 : /* Parse 4th tab-separated field: from\tsubject\tdate\tLABELS\tflags */
1429 44 : const char *p = hdr;
1430 175 : for (int t = 0; t < 3; t++) {
1431 132 : p = strchr(p, '\t');
1432 132 : if (!p) { free(hdr); return NULL; }
1433 131 : p++;
1434 : }
1435 : /* p now points to the start of the labels field */
1436 43 : const char *end = strchr(p, '\t');
1437 43 : size_t len = end ? (size_t)(end - p) : strlen(p);
1438 43 : char *result = strndup(p, len);
1439 43 : free(hdr);
1440 43 : return result;
1441 : }
1442 :
1443 81 : int label_idx_list(char ***labels_out, int *count_out) {
1444 81 : *labels_out = NULL;
1445 81 : *count_out = 0;
1446 :
1447 : char dir_path[8300];
1448 81 : snprintf(dir_path, sizeof(dir_path), "%s/labels", g_account_base);
1449 :
1450 162 : RAII_DIR DIR *dp = opendir(dir_path);
1451 81 : if (!dp) return 0; /* No labels directory → 0 labels */
1452 :
1453 71 : char **list = NULL;
1454 71 : int count = 0, cap = 0;
1455 :
1456 : struct dirent *ent;
1457 373 : while ((ent = readdir(dp)) != NULL) {
1458 302 : const char *name = ent->d_name;
1459 302 : size_t nlen = strlen(name);
1460 302 : if (nlen <= 4) continue;
1461 160 : if (strcmp(name + nlen - 4, ".idx") != 0) continue;
1462 :
1463 : /* Extract label name (strip .idx) */
1464 160 : char *label = strndup(name, nlen - 4);
1465 160 : if (!label) continue;
1466 :
1467 160 : if (count == cap) {
1468 71 : int newcap = cap ? cap * 2 : 16;
1469 71 : char **tmp = realloc(list, (size_t)newcap * sizeof(char *));
1470 71 : if (!tmp) { free(label); break; }
1471 71 : list = tmp;
1472 71 : cap = newcap;
1473 : }
1474 160 : list[count++] = label;
1475 : }
1476 :
1477 71 : *labels_out = list;
1478 71 : *count_out = count;
1479 71 : return 0;
1480 : }
1481 :
1482 1565 : int label_idx_add(const char *label, const char *uid) {
1483 1565 : if (!uid || strlen(uid) < 1) return -1;
1484 :
1485 : /* Load existing entries */
1486 1565 : char (*existing)[17] = NULL;
1487 1565 : int ecount = 0;
1488 1565 : label_idx_load(label, &existing, &ecount);
1489 :
1490 : /* Check if already present (binary search) */
1491 1565 : int lo = 0, hi = ecount - 1, insert_pos = ecount;
1492 7624 : while (lo <= hi) {
1493 6069 : int mid = lo + (hi - lo) / 2;
1494 6069 : int cmp = strcmp(existing[mid], uid);
1495 6069 : if (cmp == 0) { free(existing); return 0; } /* Already present */
1496 6059 : if (cmp < 0) lo = mid + 1;
1497 48 : else { insert_pos = mid; hi = mid - 1; }
1498 : }
1499 1555 : if (lo < ecount && insert_pos == ecount) insert_pos = lo;
1500 :
1501 : /* Build new array with uid inserted at insert_pos */
1502 1555 : int newcount = ecount + 1;
1503 1555 : char (*arr)[17] = malloc((size_t)newcount * sizeof(char[17]));
1504 1555 : if (!arr) { free(existing); return -1; }
1505 :
1506 1555 : if (insert_pos > 0 && existing)
1507 1322 : memcpy(arr, existing, (size_t)insert_pos * sizeof(char[17]));
1508 1555 : snprintf(arr[insert_pos], 17, "%.16s", uid);
1509 1555 : if (insert_pos < ecount && existing)
1510 16 : memcpy(arr + insert_pos + 1, existing + insert_pos,
1511 16 : (size_t)(ecount - insert_pos) * sizeof(char[17]));
1512 1555 : free(existing);
1513 :
1514 1555 : int rc = label_idx_write(label, (const char (*)[17])arr, newcount);
1515 1555 : free(arr);
1516 1555 : return rc;
1517 : }
1518 :
1519 129 : int label_idx_remove(const char *label, const char *uid) {
1520 129 : if (!uid) return -1;
1521 :
1522 129 : char (*existing)[17] = NULL;
1523 129 : int ecount = 0;
1524 129 : label_idx_load(label, &existing, &ecount);
1525 129 : if (!existing || ecount == 0) { free(existing); return 0; }
1526 :
1527 : /* Find uid with binary search */
1528 116 : int lo = 0, hi = ecount - 1, found = -1;
1529 281 : while (lo <= hi) {
1530 268 : int mid = lo + (hi - lo) / 2;
1531 268 : int cmp = strcmp(existing[mid], uid);
1532 268 : if (cmp == 0) { found = mid; break; }
1533 165 : if (cmp < 0) lo = mid + 1;
1534 34 : else hi = mid - 1;
1535 : }
1536 :
1537 116 : if (found < 0) { free(existing); return 0; } /* Not present */
1538 :
1539 : /* Shift down */
1540 103 : if (found < ecount - 1)
1541 13 : memmove(existing + found, existing + found + 1,
1542 13 : (size_t)(ecount - found - 1) * sizeof(char[17]));
1543 :
1544 103 : int rc = label_idx_write(label, (const char (*)[17])existing, ecount - 1);
1545 103 : free(existing);
1546 103 : return rc;
1547 : }
1548 :
1549 : /* ── Gmail history ID ─────────────────────────────────────────────── */
1550 :
1551 : /* ── Trash label backup (for untrash restore) ────────────────────── */
1552 :
1553 15 : static char *trash_labels_path(const char *uid) {
1554 15 : if (!g_account_base[0] || !uid) return NULL;
1555 15 : char *path = NULL;
1556 15 : if (asprintf(&path, "%s/trash_labels/%s.lbl", g_account_base, uid) == -1)
1557 0 : return NULL;
1558 15 : return path;
1559 : }
1560 :
1561 3 : int local_trash_labels_save(const char *uid, const char *labels) {
1562 3 : if (!uid || !labels) return -1;
1563 : /* Ensure directory exists */
1564 : char dir[8300];
1565 3 : snprintf(dir, sizeof(dir), "%s/trash_labels", g_account_base);
1566 3 : fs_mkdir_p(dir, 0700);
1567 :
1568 6 : RAII_STRING char *path = trash_labels_path(uid);
1569 3 : if (!path) return -1;
1570 6 : RAII_FILE FILE *fp = fopen(path, "w");
1571 3 : if (!fp) return -1;
1572 3 : fprintf(fp, "%s\n", labels);
1573 3 : return 0;
1574 : }
1575 :
1576 8 : char *local_trash_labels_load(const char *uid) {
1577 16 : RAII_STRING char *path = trash_labels_path(uid);
1578 8 : if (!path) return NULL;
1579 16 : RAII_FILE FILE *fp = fopen(path, "r");
1580 8 : if (!fp) return NULL;
1581 : char buf[4096];
1582 3 : if (!fgets(buf, (int)sizeof(buf), fp)) return NULL;
1583 3 : buf[strcspn(buf, "\r\n")] = '\0';
1584 3 : return strdup(buf);
1585 : }
1586 :
1587 4 : void local_trash_labels_remove(const char *uid) {
1588 8 : RAII_STRING char *path = trash_labels_path(uid);
1589 4 : if (path) unlink(path);
1590 4 : }
1591 :
1592 67 : int local_gmail_label_names_save(char **ids, char **names, int count) {
1593 67 : if (!g_account_base[0]) return -1;
1594 67 : if (fs_mkdir_p(g_account_base, 0700) != 0) return -1;
1595 67 : RAII_STRING char *path = NULL;
1596 67 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return -1;
1597 134 : RAII_FILE FILE *fp = fopen(path, "w");
1598 67 : if (!fp) return -1;
1599 635 : for (int i = 0; i < count; i++)
1600 568 : fprintf(fp, "%s\t%s\n", ids[i], names[i]);
1601 67 : return 0;
1602 : }
1603 :
1604 77 : char *local_gmail_label_name_lookup(const char *id) {
1605 77 : if (!g_account_base[0] || !id) return NULL;
1606 77 : RAII_STRING char *path = NULL;
1607 77 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return NULL;
1608 154 : RAII_FILE FILE *fp = fopen(path, "r");
1609 77 : if (!fp) return NULL;
1610 : char buf[1024];
1611 176 : while (fgets(buf, (int)sizeof(buf), fp)) {
1612 163 : buf[strcspn(buf, "\r\n")] = '\0';
1613 163 : char *tab = strchr(buf, '\t');
1614 163 : if (!tab) continue;
1615 163 : *tab = '\0';
1616 163 : if (strcmp(buf, id) == 0)
1617 46 : return strdup(tab + 1);
1618 : }
1619 13 : return NULL;
1620 : }
1621 :
1622 66 : char *local_gmail_label_id_lookup(const char *name) {
1623 66 : if (!g_account_base[0] || !name) return NULL;
1624 66 : RAII_STRING char *path = NULL;
1625 66 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return NULL;
1626 132 : RAII_FILE FILE *fp = fopen(path, "r");
1627 66 : if (!fp) return NULL;
1628 : char buf[1024];
1629 176 : while (fgets(buf, (int)sizeof(buf), fp)) {
1630 163 : buf[strcspn(buf, "\r\n")] = '\0';
1631 163 : char *tab = strchr(buf, '\t');
1632 163 : if (!tab) continue;
1633 163 : *tab = '\0';
1634 163 : if (strcasecmp(tab + 1, name) == 0)
1635 46 : return strdup(buf); /* return the ID */
1636 : }
1637 13 : return NULL;
1638 : }
1639 :
1640 81 : int local_gmail_history_save(const char *history_id) {
1641 81 : if (!g_account_base[0] || !history_id) return -1;
1642 80 : if (fs_mkdir_p(g_account_base, 0700) != 0) return -1;
1643 80 : RAII_STRING char *path = NULL;
1644 80 : if (asprintf(&path, "%s/gmail_history_id", g_account_base) == -1) return -1;
1645 80 : return write_file(path, history_id, strlen(history_id));
1646 : }
1647 :
1648 91 : char *local_gmail_history_load(void) {
1649 91 : if (!g_account_base[0]) return NULL;
1650 91 : RAII_STRING char *path = NULL;
1651 91 : if (asprintf(&path, "%s/gmail_history_id", g_account_base) == -1) return NULL;
1652 91 : char *data = load_file(path);
1653 91 : if (!data) return NULL;
1654 : /* Trim trailing whitespace */
1655 26 : size_t len = strlen(data);
1656 26 : while (len > 0 && (data[len-1] == '\n' || data[len-1] == '\r' || data[len-1] == ' '))
1657 0 : data[--len] = '\0';
1658 26 : return data;
1659 : }
1660 :
1661 : /* ── Contact suggestion cache ────────────────────────────────────────── */
1662 :
1663 : /** Extract all "addr" tokens from a comma/semicolon-separated RFC 2822
1664 : * address list like "Alice B <alice@x.com>, bob@y.com" .
1665 : * Calls cb(addr, display_name, userdata) for each address found.
1666 : * Addresses longer than 255 bytes are silently skipped. */
1667 672 : static void parse_addr_list(const char *hdr,
1668 : void (*cb)(const char *, const char *, void *),
1669 : void *ud) {
1670 672 : if (!hdr || !hdr[0]) return;
1671 : /* Walk comma-separated tokens */
1672 : char buf[512];
1673 225 : const char *p = hdr;
1674 452 : while (*p) {
1675 : /* skip leading whitespace / commas / semicolons */
1676 456 : while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' ||
1677 458 : *p == ',' || *p == ';') p++;
1678 227 : if (!*p) break;
1679 :
1680 : /* Copy until the next top-level comma (respecting quoted strings
1681 : * and angle-bracket groups). */
1682 227 : int depth = 0; int in_q = 0; const char *start = p;
1683 227 : size_t i = 0;
1684 8079 : while (*p) {
1685 7854 : if (*p == '"') { in_q = !in_q; }
1686 7854 : else if (!in_q && *p == '<') { depth++; }
1687 7645 : else if (!in_q && *p == '>') { depth--; }
1688 7436 : else if (!in_q && depth == 0 && (*p == ',' || *p == ';')) break;
1689 7852 : if (i < sizeof(buf) - 1) buf[i++] = *p;
1690 7852 : p++;
1691 : }
1692 227 : buf[i] = '\0';
1693 227 : if (buf[0] == '\0') continue;
1694 :
1695 : /* Extract: "Display Name <addr>" or bare "addr" */
1696 227 : char addr[256] = ""; char name[256] = "";
1697 227 : char *lt = strchr(buf, '<');
1698 227 : char *gt = lt ? strchr(lt, '>') : NULL;
1699 436 : if (lt && gt) {
1700 209 : size_t alen = (size_t)(gt - lt - 1);
1701 209 : if (alen < sizeof(addr)) {
1702 209 : memcpy(addr, lt + 1, alen); addr[alen] = '\0';
1703 : }
1704 : /* display name: everything before '<', trimmed, dequoted */
1705 209 : size_t nlen = (size_t)(lt - buf);
1706 209 : if (nlen > 0 && nlen < sizeof(name)) {
1707 209 : memcpy(name, buf, nlen); name[nlen] = '\0';
1708 : /* trim whitespace */
1709 209 : char *ns = name;
1710 209 : while (*ns == ' ' || *ns == '\t') ns++;
1711 209 : char *ne = ns + strlen(ns);
1712 418 : while (ne > ns && (*(ne-1) == ' ' || *(ne-1) == '\t' ||
1713 418 : *(ne-1) == '"')) ne--;
1714 209 : if (*ns == '"') ns++;
1715 209 : *ne = '\0';
1716 209 : memmove(name, ns, strlen(ns) + 1);
1717 : }
1718 : } else {
1719 : /* bare address */
1720 18 : char *ns = buf;
1721 18 : while (*ns == ' ' || *ns == '\t') ns++;
1722 18 : char *ne = ns + strlen(ns);
1723 18 : while (ne > ns && (*(ne-1) == ' ' || *(ne-1) == '\t')) ne--;
1724 18 : size_t alen = (size_t)(ne - ns);
1725 18 : if (alen < sizeof(addr)) { memcpy(addr, ns, alen); addr[alen] = '\0'; }
1726 : }
1727 227 : if (addr[0]) cb(addr, name, ud);
1728 : (void)start;
1729 : }
1730 : }
1731 :
1732 : /* Locate which cached folder holds a UID.
1733 : *
1734 : * IMAP UIDs are unique only within a mailbox, so callers normally pass the
1735 : * folder explicitly. A search result, however, spans folders, and the table
1736 : * output carries no folder column — without this lookup the user has to guess
1737 : * which folder to hand to `show`. Returns a heap-allocated folder name, or
1738 : * NULL when the UID is not in the local store. */
1739 55 : static int cmp_folder_names(const void *a, const void *b) {
1740 55 : return strcmp(*(const char *const *)a, *(const char *const *)b);
1741 : }
1742 :
1743 40 : int local_msg_find_folders(const char *uid, char ***folders_out, int *count_out) {
1744 40 : if (folders_out) *folders_out = NULL;
1745 40 : if (count_out) *count_out = 0;
1746 40 : if (!uid || !uid[0] || !g_account_base[0]) return 0;
1747 :
1748 : char dir_path[8300];
1749 38 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
1750 76 : RAII_DIR DIR *dp = opendir(dir_path);
1751 38 : if (!dp) return 0;
1752 :
1753 27 : int cap = 8, n = 0;
1754 27 : char **list = malloc((size_t)cap * sizeof(char *));
1755 27 : if (!list) return -1;
1756 :
1757 : struct dirent *ent;
1758 197 : while ((ent = readdir(dp)) != NULL) {
1759 170 : const char *name = ent->d_name;
1760 170 : size_t nlen = strlen(name);
1761 230 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
1762 116 : if (name[0] == '_' && name[1] == '_') continue;
1763 :
1764 116 : RAII_STRING char *folder = strndup(name, nlen - 4);
1765 116 : if (!folder) continue;
1766 :
1767 : /* RFC 3501: INBOX is the one mailbox name that is case-insensitive,
1768 : * so a store holding both "Inbox" and "INBOX" holds one mailbox, not
1769 : * two. Reporting both would make every inbox message look ambiguous. */
1770 116 : if (strcasecmp(folder, "INBOX") == 0) {
1771 36 : int dup = 0;
1772 44 : for (int i = 0; i < n; i++) {
1773 15 : if (strcasecmp(list[i], "INBOX") != 0) continue;
1774 : /* Keep the spelling closest to the canonical "INBOX" so the
1775 : * reported name does not depend on readdir order. */
1776 7 : if (strcmp(folder, list[i]) < 0) {
1777 7 : char *swap = strdup(folder);
1778 7 : if (swap) { free(list[i]); list[i] = swap; }
1779 : }
1780 7 : dup = 1;
1781 7 : break;
1782 : }
1783 36 : if (dup) continue;
1784 : }
1785 :
1786 109 : int here = local_msg_exists(folder, uid);
1787 109 : if (!here) {
1788 63 : Manifest *m = manifest_load(folder);
1789 63 : if (m) {
1790 63 : here = manifest_find(m, uid) != NULL;
1791 63 : manifest_free(m);
1792 : }
1793 : }
1794 109 : if (!here) continue;
1795 :
1796 56 : if (n == cap) {
1797 0 : cap *= 2;
1798 0 : char **tmp = realloc(list, (size_t)cap * sizeof(char *));
1799 0 : if (!tmp) break;
1800 0 : list = tmp;
1801 : }
1802 56 : list[n] = strdup(folder);
1803 56 : if (list[n]) n++;
1804 : }
1805 :
1806 27 : if (n == 0) { free(list); return 0; }
1807 : /* readdir order is filesystem-defined; sort so callers, error messages and
1808 : * tests all see the same sequence. */
1809 25 : qsort(list, (size_t)n, sizeof(char *), cmp_folder_names);
1810 25 : if (folders_out) *folders_out = list; else { for (int i = 0; i < n; i++) free(list[i]); free(list); }
1811 25 : if (count_out) *count_out = n;
1812 25 : return n;
1813 : }
1814 :
1815 38 : void local_folder_list_free(char **folders, int count) {
1816 38 : if (!folders) return;
1817 81 : for (int i = 0; i < count; i++) free(folders[i]);
1818 25 : free(folders);
1819 : }
1820 :
1821 0 : char *local_msg_find_folder(const char *uid) {
1822 : /* One scan, one set of rules: sharing local_msg_find_folders() keeps the
1823 : * INBOX case-folding and the ordering from drifting between the two. */
1824 0 : char **folders = NULL;
1825 0 : int n = 0;
1826 0 : local_msg_find_folders(uid, &folders, &n);
1827 0 : if (n <= 0) return NULL;
1828 :
1829 0 : char *first = folders[0];
1830 0 : folders[0] = NULL;
1831 0 : local_folder_list_free(folders, n);
1832 0 : return first;
1833 : }
1834 :
1835 : /* ---- contacts.tsv upsert ---- */
1836 :
1837 : #define CONTACTS_MAX 4096
1838 :
1839 : typedef struct {
1840 : char addr[256];
1841 : char name[128];
1842 : int freq;
1843 : } ContactEntry;
1844 :
1845 784 : static int contact_cmp_freq(const void *a, const void *b) {
1846 784 : return ((const ContactEntry *)b)->freq - ((const ContactEntry *)a)->freq;
1847 : }
1848 :
1849 : typedef struct { ContactEntry *arr; int count; int cap; } ContactBuf;
1850 :
1851 227 : static void contact_add_cb(const char *addr, const char *name, void *ud) {
1852 227 : ContactBuf *cb = (ContactBuf *)ud;
1853 227 : if (!addr || !addr[0]) return;
1854 : /* case-insensitive dedup on address */
1855 499 : for (int i = 0; i < cb->count; i++) {
1856 450 : if (strcasecmp(cb->arr[i].addr, addr) == 0) {
1857 178 : cb->arr[i].freq++;
1858 : /* update name if we now have one and didn't before */
1859 178 : if (name && name[0] && !cb->arr[i].name[0]) {
1860 1 : size_t _n = strlen(name);
1861 1 : if (_n >= sizeof(cb->arr[i].name)) _n = sizeof(cb->arr[i].name) - 1;
1862 1 : memcpy(cb->arr[i].name, name, _n); cb->arr[i].name[_n] = '\0';
1863 : }
1864 178 : return;
1865 : }
1866 : }
1867 49 : if (cb->count >= cb->cap) return; /* full */
1868 49 : { size_t _a = strlen(addr); if (_a >= sizeof(cb->arr[cb->count].addr)) _a = sizeof(cb->arr[cb->count].addr) - 1;
1869 49 : memcpy(cb->arr[cb->count].addr, addr, _a); cb->arr[cb->count].addr[_a] = '\0'; }
1870 49 : { const char *_nm = name ? name : "";
1871 49 : size_t _n = strlen(_nm); if (_n >= sizeof(cb->arr[cb->count].name)) _n = sizeof(cb->arr[cb->count].name) - 1;
1872 49 : memcpy(cb->arr[cb->count].name, _nm, _n); cb->arr[cb->count].name[_n] = '\0'; }
1873 49 : cb->arr[cb->count].freq = 1;
1874 49 : cb->count++;
1875 : }
1876 :
1877 8 : void local_contacts_rebuild(void) {
1878 8 : const char *data_base = platform_data_dir();
1879 8 : if (!data_base || !g_account_name[0]) return;
1880 :
1881 8 : ContactEntry *arr = calloc(CONTACTS_MAX, sizeof(ContactEntry));
1882 8 : if (!arr) return;
1883 8 : ContactBuf cb = { arr, 0, CONTACTS_MAX };
1884 :
1885 8 : int fcount = 0;
1886 8 : char **folders = local_folder_list_load(&fcount, NULL);
1887 :
1888 8 : if (fcount > 0 && folders) {
1889 : /* IMAP account: .hdr files contain raw RFC 2822 headers */
1890 47 : for (int fi = 0; fi < fcount && cb.count < CONTACTS_MAX; fi++) {
1891 41 : char (*uids)[17] = NULL;
1892 41 : int uid_count = 0;
1893 41 : local_hdr_list_all_uids(folders[fi], &uids, &uid_count);
1894 53 : for (int u = 0; u < uid_count && cb.count < CONTACTS_MAX; u++) {
1895 12 : char *raw = local_hdr_load(folders[fi], uids[u]);
1896 12 : if (!raw) continue;
1897 12 : char *from_h = mime_get_header(raw, "From");
1898 12 : char *to_h = mime_get_header(raw, "To");
1899 12 : char *cc_h = mime_get_header(raw, "Cc");
1900 12 : parse_addr_list(from_h, contact_add_cb, &cb);
1901 12 : parse_addr_list(to_h, contact_add_cb, &cb);
1902 12 : parse_addr_list(cc_h, contact_add_cb, &cb);
1903 12 : free(from_h); free(to_h); free(cc_h);
1904 12 : free(raw);
1905 : }
1906 41 : free(uids);
1907 : }
1908 47 : for (int i = 0; i < fcount; i++) free(folders[i]);
1909 6 : free(folders);
1910 : } else {
1911 : /* Gmail account (or no folder cache): .hdr files are tab-separated;
1912 : * load full .eml files to extract From/To/Cc. */
1913 2 : if (folders) {
1914 0 : for (int i = 0; i < fcount; i++) free(folders[i]);
1915 0 : free(folders);
1916 : }
1917 2 : char (*uids)[17] = NULL;
1918 2 : int uid_count = 0;
1919 2 : local_hdr_list_all_uids("", &uids, &uid_count);
1920 2 : for (int u = 0; u < uid_count && cb.count < CONTACTS_MAX; u++) {
1921 0 : char *raw = local_msg_load("", uids[u]);
1922 0 : if (!raw) continue;
1923 0 : char *from_h = mime_get_header(raw, "From");
1924 0 : char *to_h = mime_get_header(raw, "To");
1925 0 : char *cc_h = mime_get_header(raw, "Cc");
1926 0 : parse_addr_list(from_h, contact_add_cb, &cb);
1927 0 : parse_addr_list(to_h, contact_add_cb, &cb);
1928 0 : parse_addr_list(cc_h, contact_add_cb, &cb);
1929 0 : free(from_h); free(to_h); free(cc_h);
1930 0 : free(raw);
1931 : }
1932 2 : free(uids);
1933 : }
1934 :
1935 8 : qsort(arr, (size_t)cb.count, sizeof(ContactEntry), contact_cmp_freq);
1936 :
1937 : char path[8192];
1938 8 : snprintf(path, sizeof(path), "%s/email-cli/accounts/%s/contacts.tsv",
1939 : data_base, g_account_name);
1940 8 : FILE *f = fopen(path, "w");
1941 8 : if (f) {
1942 15 : for (int i = 0; i < cb.count; i++)
1943 7 : fprintf(f, "%s\t%s\t%d\n", arr[i].addr, arr[i].name, arr[i].freq);
1944 8 : fclose(f);
1945 : }
1946 : /* Infrastructure must not print to the user's screen — the TUI calls this
1947 : * while a full-screen dialog is on display. Callers that want a message
1948 : * (e.g. email-sync --rebuild-contacts) print their own. */
1949 8 : logger_log(LOG_INFO, "contacts rebuilt: %d entries written to %s", cb.count, path);
1950 8 : free(arr);
1951 : }
1952 :
1953 212 : void local_contacts_update(const char *from_hdr,
1954 : const char *to_hdr,
1955 : const char *cc_hdr) {
1956 212 : const char *data_base = platform_data_dir();
1957 212 : if (!data_base || !g_account_name[0]) return;
1958 :
1959 : char path[8192];
1960 212 : snprintf(path, sizeof(path), "%s/email-cli/accounts/%s/contacts.tsv",
1961 : data_base, g_account_name);
1962 :
1963 : /* Load existing entries */
1964 212 : ContactEntry *arr = calloc(CONTACTS_MAX, sizeof(ContactEntry));
1965 212 : if (!arr) return;
1966 212 : ContactBuf cb = { arr, 0, CONTACTS_MAX };
1967 :
1968 212 : FILE *f = fopen(path, "r");
1969 212 : if (f) {
1970 : char line[512];
1971 852 : while (cb.count < CONTACTS_MAX && fgets(line, sizeof(line), f)) {
1972 : /* format: addr\tname\tfreq\n */
1973 668 : char *t1 = strchr(line, '\t');
1974 668 : if (!t1) continue;
1975 668 : *t1 = '\0';
1976 668 : char *t2 = strchr(t1 + 1, '\t');
1977 668 : char *name = t1 + 1;
1978 668 : int freq = 1;
1979 668 : if (t2) { *t2 = '\0'; freq = atoi(t2 + 1); if (freq < 1) freq = 1; }
1980 668 : char *nl = strchr(name, '\n'); if (nl) *nl = '\0';
1981 668 : size_t _al = strlen(line); if (_al >= sizeof(arr[cb.count].addr)) _al = sizeof(arr[cb.count].addr) - 1;
1982 668 : memcpy(arr[cb.count].addr, line, _al); arr[cb.count].addr[_al] = '\0';
1983 668 : size_t _nl = strlen(name); if (_nl >= sizeof(arr[cb.count].name)) _nl = sizeof(arr[cb.count].name) - 1;
1984 668 : memcpy(arr[cb.count].name, name, _nl); arr[cb.count].name[_nl] = '\0';
1985 668 : arr[cb.count].freq = freq;
1986 668 : cb.count++;
1987 : }
1988 184 : fclose(f);
1989 : }
1990 :
1991 : /* Add new addresses from headers */
1992 212 : parse_addr_list(from_hdr, contact_add_cb, &cb);
1993 212 : parse_addr_list(to_hdr, contact_add_cb, &cb);
1994 212 : parse_addr_list(cc_hdr, contact_add_cb, &cb);
1995 :
1996 : /* Sort by frequency descending */
1997 212 : qsort(arr, (size_t)cb.count, sizeof(ContactEntry), contact_cmp_freq);
1998 :
1999 : /* Write back */
2000 212 : f = fopen(path, "w");
2001 212 : if (f) {
2002 922 : for (int i = 0; i < cb.count; i++)
2003 710 : fprintf(f, "%s\t%s\t%d\n", arr[i].addr, arr[i].name, arr[i].freq);
2004 212 : fclose(f);
2005 : }
2006 212 : free(arr);
2007 : }
2008 :
2009 : /* ── Pending APPEND queue ────────────────────────────────────────────── */
2010 :
2011 49 : static char *pending_append_path(void) {
2012 49 : if (!g_account_base[0]) return NULL;
2013 49 : char *path = NULL;
2014 49 : if (asprintf(&path, "%s/pending_appends.tsv", g_account_base) == -1)
2015 0 : return NULL;
2016 49 : return path;
2017 : }
2018 :
2019 17 : int local_pending_append_add(const char *folder, const char *uid) {
2020 34 : RAII_STRING char *path = pending_append_path();
2021 17 : if (!path) return -1;
2022 34 : RAII_FILE FILE *fp = fopen(path, "a");
2023 17 : if (!fp) return -1;
2024 17 : fprintf(fp, "%s\t%s\n", folder, uid);
2025 17 : return 0;
2026 : }
2027 :
2028 29 : PendingAppend *local_pending_append_load(int *count_out) {
2029 29 : *count_out = 0;
2030 58 : RAII_STRING char *path = pending_append_path();
2031 29 : if (!path) return NULL;
2032 58 : RAII_FILE FILE *fp = fopen(path, "r");
2033 29 : if (!fp) return NULL;
2034 :
2035 4 : int cap = 8, count = 0;
2036 4 : PendingAppend *arr = malloc((size_t)cap * sizeof(PendingAppend));
2037 4 : if (!arr) return NULL;
2038 :
2039 : char line[512];
2040 9 : while (fgets(line, sizeof(line), fp)) {
2041 5 : char *tab = strchr(line, '\t');
2042 5 : if (!tab) continue;
2043 5 : *tab = '\0';
2044 5 : char *nl = strchr(tab + 1, '\n'); if (nl) *nl = '\0';
2045 5 : if (count == cap) {
2046 0 : cap *= 2;
2047 0 : PendingAppend *tmp = realloc(arr, (size_t)cap * sizeof(PendingAppend));
2048 0 : if (!tmp) break;
2049 0 : arr = tmp;
2050 : }
2051 5 : strncpy(arr[count].folder, line, sizeof(arr[count].folder) - 1);
2052 5 : arr[count].folder[sizeof(arr[count].folder) - 1] = '\0';
2053 5 : strncpy(arr[count].uid, tab + 1, sizeof(arr[count].uid) - 1);
2054 5 : arr[count].uid[sizeof(arr[count].uid) - 1] = '\0';
2055 5 : count++;
2056 : }
2057 4 : *count_out = count;
2058 4 : return arr;
2059 : }
2060 :
2061 3 : void local_pending_append_remove(const char *folder, const char *uid) {
2062 6 : RAII_STRING char *path = pending_append_path();
2063 3 : if (!path) return;
2064 :
2065 : /* Copy every line except the matching one to a sibling temp file, then
2066 : * rename it over the original. Streaming keeps the queue length
2067 : * unbounded and the rename makes the update atomic — an earlier version
2068 : * buffered the whole file in a 2 MB stack array and silently dropped
2069 : * everything past 4096 entries. */
2070 6 : RAII_FILE FILE *rfp = fopen(path, "r");
2071 3 : if (!rfp) return;
2072 :
2073 3 : RAII_STRING char *tmp_path = NULL;
2074 3 : if (asprintf(&tmp_path, "%s.tmp", path) == -1) return;
2075 :
2076 6 : RAII_FILE FILE *wfp = fopen(tmp_path, "w");
2077 3 : if (!wfp) return;
2078 :
2079 : char line[512];
2080 7 : while (fgets(line, sizeof(line), rfp)) {
2081 : char tmp[512];
2082 4 : snprintf(tmp, sizeof(tmp), "%s", line);
2083 4 : char *tab = strchr(tmp, '\t');
2084 4 : if (tab) {
2085 4 : *tab = '\0';
2086 4 : char *nl = strchr(tab + 1, '\n');
2087 4 : if (nl) *nl = '\0';
2088 4 : if (strcmp(tmp, folder) == 0 && strcmp(tab + 1, uid) == 0)
2089 2 : continue; /* this is the entry being removed */
2090 : }
2091 2 : fputs(line, wfp);
2092 : }
2093 :
2094 : /* Close both handles before renaming: the write must be flushed, and on
2095 : * Windows a rename over an open file fails. */
2096 3 : fclose(wfp); wfp = NULL;
2097 3 : fclose(rfp); rfp = NULL;
2098 :
2099 3 : if (rename(tmp_path, path) != 0)
2100 0 : remove(tmp_path);
2101 : }
2102 :
2103 : /* ── Pending Gmail fetch queue ───────────────────────────────────────── */
2104 :
2105 1901 : static char *pending_fetch_path(void) {
2106 1901 : if (!g_account_base[0]) return NULL;
2107 1901 : char *path = NULL;
2108 1901 : if (asprintf(&path, "%s/pending_fetch.tsv", g_account_base) == -1)
2109 0 : return NULL;
2110 1901 : return path;
2111 : }
2112 :
2113 839 : int local_pending_fetch_add(const char *uid) {
2114 1678 : RAII_STRING char *path = pending_fetch_path();
2115 839 : if (!path || !uid) return -1;
2116 1676 : RAII_FILE FILE *fp = fopen(path, "a");
2117 838 : if (!fp) return -1;
2118 838 : fprintf(fp, "%s\n", uid);
2119 838 : return 0;
2120 : }
2121 :
2122 67 : char (*local_pending_fetch_load(int *count_out))[17] {
2123 67 : *count_out = 0;
2124 134 : RAII_STRING char *path = pending_fetch_path();
2125 67 : if (!path) return NULL;
2126 134 : RAII_FILE FILE *fp = fopen(path, "r");
2127 67 : if (!fp) return NULL;
2128 :
2129 65 : int cap = 64, count = 0;
2130 65 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
2131 65 : if (!arr) return NULL;
2132 :
2133 : char line[32];
2134 900 : while (fgets(line, sizeof(line), fp)) {
2135 835 : char *nl = strchr(line, '\n'); if (nl) *nl = '\0';
2136 835 : char *cr = strchr(line, '\r'); if (cr) *cr = '\0';
2137 835 : if (line[0] == '\0') continue;
2138 835 : if (count == cap) {
2139 6 : cap *= 2;
2140 6 : char (*tmp)[17] = realloc(arr, (size_t)cap * sizeof(char[17]));
2141 6 : if (!tmp) break;
2142 6 : arr = tmp;
2143 : }
2144 835 : memcpy(arr[count], line, 16);
2145 835 : arr[count][16] = '\0';
2146 835 : count++;
2147 : }
2148 65 : *count_out = count;
2149 65 : return arr;
2150 : }
2151 :
2152 825 : void local_pending_fetch_remove(const char *uid) {
2153 1650 : RAII_STRING char *path = pending_fetch_path();
2154 825 : if (!path || !uid) return;
2155 :
2156 825 : FILE *rfp = fopen(path, "r");
2157 825 : if (!rfp) return;
2158 :
2159 : /* Read all lines, skip the matching UID */
2160 825 : int cap = 64, count = 0;
2161 825 : char (*lines)[32] = malloc((size_t)cap * sizeof(char[32]));
2162 825 : if (!lines) { fclose(rfp); return; }
2163 :
2164 : char line[32];
2165 53332 : while (fgets(line, sizeof(line), rfp)) {
2166 : char tmp[32];
2167 52507 : strncpy(tmp, line, 31); tmp[31] = '\0';
2168 52507 : char *nl = strchr(tmp, '\n'); if (nl) *nl = '\0';
2169 52507 : char *cr = strchr(tmp, '\r'); if (cr) *cr = '\0';
2170 52507 : if (strcmp(tmp, uid) == 0) continue;
2171 51682 : if (count == cap) {
2172 518 : cap *= 2;
2173 518 : char (*newlines)[32] = realloc(lines, (size_t)cap * sizeof(char[32]));
2174 518 : if (!newlines) break;
2175 518 : lines = newlines;
2176 : }
2177 51682 : memcpy(lines[count++], line, 31);
2178 51682 : lines[count - 1][31] = '\0';
2179 : }
2180 825 : fclose(rfp);
2181 :
2182 825 : FILE *wfp = fopen(path, "w");
2183 825 : if (wfp) {
2184 52507 : for (int i = 0; i < count; i++)
2185 51682 : fputs(lines[i], wfp);
2186 825 : fclose(wfp);
2187 : }
2188 825 : free(lines);
2189 : }
2190 :
2191 85 : int local_pending_fetch_count(void) {
2192 170 : RAII_STRING char *path = pending_fetch_path();
2193 85 : if (!path) return 0;
2194 170 : RAII_FILE FILE *fp = fopen(path, "r");
2195 85 : if (!fp) return 0;
2196 22 : int count = 0;
2197 : char line[32];
2198 43 : while (fgets(line, sizeof(line), fp)) {
2199 21 : if (line[0] != '\n' && line[0] != '\r' && line[0] != '\0')
2200 21 : count++;
2201 : }
2202 22 : return count;
2203 : }
2204 :
2205 85 : void local_pending_fetch_clear(void) {
2206 170 : RAII_STRING char *path = pending_fetch_path();
2207 85 : if (path) remove(path);
2208 85 : }
2209 :
2210 : /* ── Local outgoing message save ─────────────────────────────────────── */
2211 :
2212 20 : int local_save_outgoing(const char *folder, const char *msg, size_t msg_len) {
2213 20 : if (!g_account_base[0] || !folder || !msg) return -1;
2214 :
2215 : /* Generate temporary UID: t<milliseconds_since_epoch> */
2216 : char uid[17];
2217 : {
2218 : struct timespec ts;
2219 17 : clock_gettime(CLOCK_REALTIME, &ts);
2220 17 : long long ms = (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
2221 17 : snprintf(uid, sizeof(uid), "t%lld", ms);
2222 : }
2223 :
2224 : /* Save full message */
2225 17 : if (local_msg_save(folder, uid, msg, msg_len) != 0) return -1;
2226 :
2227 : /* Extract raw header block (everything up to the first blank line) */
2228 15 : const char *blank = strstr(msg, "\r\n\r\n");
2229 15 : if (!blank) blank = strstr(msg, "\n\n");
2230 15 : size_t hdr_len = blank ? (size_t)(blank - msg) : msg_len;
2231 15 : local_hdr_save(folder, uid, msg, hdr_len);
2232 :
2233 : /* Decode fields for the manifest */
2234 15 : char *from_raw = mime_get_header(msg, "From");
2235 15 : char *subj_raw = mime_get_header(msg, "Subject");
2236 15 : char *date_raw = mime_get_header(msg, "Date");
2237 15 : char *from_dec = from_raw ? mime_decode_words(from_raw) : strdup("");
2238 15 : char *subj_dec = subj_raw ? mime_decode_words(subj_raw) : strdup("");
2239 15 : char *date_dec = date_raw ? mime_format_date(date_raw) : strdup("");
2240 15 : free(from_raw); free(subj_raw); free(date_raw);
2241 :
2242 : /* Update manifest (MSG_FLAG_SEEN: sent messages are already read) */
2243 15 : Manifest *mf = manifest_load(folder);
2244 15 : if (!mf) mf = calloc(1, sizeof(Manifest));
2245 15 : if (mf) {
2246 : /* flags=0: no UNSEEN bit → sent message is already read */
2247 15 : manifest_upsert(mf, uid, from_dec, subj_dec, date_dec, 0);
2248 15 : manifest_save(folder, mf);
2249 15 : manifest_free(mf);
2250 : } else {
2251 0 : free(from_dec); free(subj_dec); free(date_dec);
2252 : }
2253 :
2254 : /* Queue for IMAP APPEND on next sync */
2255 15 : local_pending_append_add(folder, uid);
2256 :
2257 15 : logger_log(LOG_INFO, "local_save_outgoing: saved %s/%s, queued for APPEND",
2258 : folder, uid);
2259 15 : return 0;
2260 : }
2261 :
2262 : /* ── CONDSTORE folder sync state ─────────────────────────────────────────── */
2263 :
2264 256 : static char *sync_state_path(const char *folder) {
2265 256 : if (!g_account_base[0]) return NULL;
2266 256 : char *path = NULL;
2267 256 : if (asprintf(&path, "%s/sync_state/%s.tsv", g_account_base, folder) == -1)
2268 0 : return NULL;
2269 256 : return path;
2270 : }
2271 :
2272 40 : int local_sync_state_save(const char *folder, const FolderSyncState *state) {
2273 40 : if (!folder || !state) return -1;
2274 80 : RAII_STRING char *path = sync_state_path(folder);
2275 40 : if (!path) return -1;
2276 40 : char *last_slash = strrchr(path, '/');
2277 40 : if (last_slash) {
2278 40 : char saved = *last_slash; *last_slash = '\0';
2279 40 : fs_mkdir_p(path, 0700);
2280 40 : *last_slash = saved;
2281 : }
2282 : char buf[64];
2283 40 : int n = snprintf(buf, sizeof(buf), "%" PRIu32 "\t%" PRIu64 "\n",
2284 40 : state->uidvalidity, state->highestmodseq);
2285 40 : return write_file(path, buf, (size_t)n);
2286 : }
2287 :
2288 208 : int local_sync_state_load(const char *folder, FolderSyncState *state) {
2289 208 : state->uidvalidity = 0;
2290 208 : state->highestmodseq = 0;
2291 416 : RAII_STRING char *path = sync_state_path(folder);
2292 208 : if (!path) return -1;
2293 208 : char *data = load_file(path);
2294 208 : if (!data) return -1;
2295 40 : int rc = sscanf(data, "%" SCNu32 "\t%" SCNu64,
2296 : &state->uidvalidity, &state->highestmodseq);
2297 40 : free(data);
2298 40 : return (rc == 2) ? 0 : -1;
2299 : }
2300 :
2301 8 : void local_sync_state_clear(const char *folder) {
2302 16 : RAII_STRING char *path = sync_state_path(folder);
2303 8 : if (path) unlink(path);
2304 8 : }
|