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 1778 : int local_store_init(const char *host_url, const char *username) {
22 1778 : const char *data_base = platform_data_dir();
23 1778 : if (!data_base) return -1;
24 1778 : 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 1778 : if (username && username[0]) {
31 1778 : snprintf(g_account_base, sizeof(g_account_base),
32 : "%s/email-cli/accounts/%s", data_base, username);
33 1778 : snprintf(g_account_name, sizeof(g_account_name), "%s", username);
34 : } else {
35 : /* Extract hostname from URL: imaps://host:port → host */
36 0 : const char *p = strstr(host_url, "://");
37 0 : p = p ? p + 3 : host_url;
38 : char hostname[512];
39 0 : int i = 0;
40 0 : while (*p && *p != ':' && *p != '/' && i < (int)sizeof(hostname) - 1)
41 0 : hostname[i++] = *p++;
42 0 : hostname[i] = '\0';
43 0 : for (char *c = hostname; *c; c++) *c = (char)tolower((unsigned char)*c);
44 0 : snprintf(g_account_base, sizeof(g_account_base),
45 : "%s/email-cli/accounts/imap.%s", data_base, hostname);
46 0 : snprintf(g_account_name, sizeof(g_account_name), "imap.%s", hostname);
47 : }
48 :
49 1778 : 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 1778 : fs_mkdir_p(g_account_base, 0700);
53 1778 : return 0;
54 : }
55 :
56 95 : const char *local_store_account_name(void) { return g_account_name; }
57 :
58 : /* ── Reverse digit bucketing helpers ─────────────────────────────────── */
59 :
60 16291 : static char digit1(const char *uid) {
61 16291 : size_t len = strlen(uid);
62 16291 : return len > 0 ? uid[len - 1] : '0';
63 : }
64 16291 : static char digit2(const char *uid) {
65 16291 : size_t len = strlen(uid);
66 16291 : return len > 1 ? uid[len - 2] : '0';
67 : }
68 :
69 : /* ── Shared file I/O ─────────────────────────────────────────────────── */
70 :
71 11611 : static char *load_file(const char *path) {
72 23222 : RAII_FILE FILE *fp = fopen(path, "r");
73 11611 : if (!fp) return NULL;
74 9489 : if (fseek(fp, 0, SEEK_END) != 0) return NULL;
75 9489 : long size = ftell(fp);
76 9489 : if (size <= 0) return NULL;
77 9485 : rewind(fp);
78 9485 : char *buf = malloc((size_t)size + 1);
79 9485 : if (!buf) return NULL;
80 9485 : if ((long)fread(buf, 1, (size_t)size, fp) != size) { free(buf); return NULL; }
81 9485 : buf[size] = '\0';
82 9485 : return buf;
83 : }
84 :
85 2756 : static int write_file(const char *path, const char *content, size_t len) {
86 5512 : RAII_FILE FILE *fp = fopen(path, "w");
87 2756 : if (!fp) return -1;
88 2756 : if (fwrite(content, 1, len, fp) != len) return -1;
89 2756 : return 0;
90 : }
91 :
92 : /** @brief Ensures the parent directory of a bucketed path exists. */
93 2649 : static int ensure_bucket_dir(const char *area, const char *folder, const char *uid) {
94 2647 : RAII_STRING char *dir = NULL;
95 2649 : if (asprintf(&dir, "%s/%s/%s/%c/%c",
96 2649 : g_account_base, area, folder, digit1(uid), digit2(uid)) == -1)
97 0 : return -1;
98 2649 : return fs_mkdir_p(dir, 0700);
99 : }
100 :
101 : /* ── Message store ───────────────────────────────────────────────────── */
102 :
103 4466 : static char *msg_path(const char *folder, const char *uid) {
104 4466 : if (!g_account_base[0]) return NULL;
105 4466 : char *path = NULL;
106 4466 : if (asprintf(&path, "%s/store/%s/%c/%c/%s.eml",
107 4466 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
108 0 : return NULL;
109 4466 : return path;
110 : }
111 :
112 53 : char *local_msg_path(const char *folder, const char *uid) {
113 53 : return msg_path(folder, uid);
114 : }
115 :
116 3275 : int local_msg_exists(const char *folder, const char *uid) {
117 6550 : RAII_STRING char *path = msg_path(folder, uid);
118 3275 : if (!path) return 0;
119 3275 : RAII_FILE FILE *fp = fopen(path, "r");
120 3275 : return fp != NULL;
121 : }
122 :
123 1048 : int local_msg_save(const char *folder, const char *uid, const char *content, size_t len) {
124 1048 : if (!g_account_base[0]) return -1;
125 1048 : 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 2092 : RAII_STRING char *path = msg_path(folder, uid);
130 1046 : if (!path) return -1;
131 1046 : 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 1046 : logger_log(LOG_DEBUG, "Stored %s/%s at %s", folder, uid, path);
136 1046 : return 0;
137 : }
138 :
139 91 : char *local_msg_load(const char *folder, const char *uid) {
140 182 : RAII_STRING char *path = msg_path(folder, uid);
141 91 : if (!path) return NULL;
142 91 : return load_file(path);
143 : }
144 :
145 : /* ── Header store ────────────────────────────────────────────────────── */
146 :
147 9176 : static char *hdr_path(const char *folder, const char *uid) {
148 9176 : if (!g_account_base[0]) return NULL;
149 9176 : char *path = NULL;
150 9176 : if (asprintf(&path, "%s/headers/%s/%c/%c/%s.hdr",
151 9176 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
152 0 : return NULL;
153 9176 : return path;
154 : }
155 :
156 2205 : int local_hdr_exists(const char *folder, const char *uid) {
157 4410 : RAII_STRING char *path = hdr_path(folder, uid);
158 2205 : if (!path) return 0;
159 2205 : RAII_FILE FILE *fp = fopen(path, "r");
160 2205 : return fp != NULL;
161 : }
162 :
163 1601 : int local_hdr_save(const char *folder, const char *uid, const char *content, size_t len) {
164 1601 : if (!g_account_base[0]) return -1;
165 1601 : 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 3202 : RAII_STRING char *path = hdr_path(folder, uid);
170 1601 : if (!path) return -1;
171 1601 : if (write_file(path, content, len) != 0) return -1;
172 1601 : logger_log(LOG_DEBUG, "Stored header %s/%s", folder, uid);
173 1601 : return 0;
174 : }
175 :
176 5369 : char *local_hdr_load(const char *folder, const char *uid) {
177 10738 : RAII_STRING char *path = hdr_path(folder, uid);
178 5369 : if (!path) return NULL;
179 5369 : return load_file(path);
180 : }
181 :
182 65 : int local_hdr_update_flags(const char *folder, const char *uid, int new_flags) {
183 65 : char *hdr = local_hdr_load(folder, uid);
184 65 : if (!hdr) return -1;
185 :
186 : /* Find the last tab → flags field starts after it */
187 65 : char *last_tab = strrchr(hdr, '\t');
188 65 : if (!last_tab) { free(hdr); return -1; }
189 :
190 : /* Rebuild: keep everything up to and including last tab, replace flags */
191 9 : *last_tab = '\0';
192 9 : char *updated = NULL;
193 9 : if (asprintf(&updated, "%s\t%d", hdr, new_flags) == -1) {
194 0 : free(hdr);
195 0 : return -1;
196 : }
197 9 : free(hdr);
198 :
199 9 : int rc = local_hdr_save(folder, uid, updated, strlen(updated));
200 9 : free(updated);
201 9 : return rc;
202 : }
203 :
204 54 : 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 54 : char *hdr = local_hdr_load(folder, uid);
208 54 : if (!hdr) return -1;
209 :
210 : /* .hdr format: from\tsubject\tdate\tlabels\tflags
211 : * Locate the labels field (4th tab-separated token). */
212 54 : char *t1 = strchr(hdr, '\t');
213 54 : if (!t1) { free(hdr); return -1; }
214 54 : char *t2 = strchr(t1 + 1, '\t');
215 54 : if (!t2) { free(hdr); return -1; }
216 54 : char *t3 = strchr(t2 + 1, '\t');
217 54 : if (!t3) { free(hdr); return -1; }
218 54 : 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 54 : *t3 = '\0'; /* NUL-terminate prefix (from\tsubject\tdate) */
222 54 : const char *prefix = hdr;
223 54 : const char *lbl_str = t3 + 1;
224 54 : const char *suffix = t4 ? t4 + 1 : ""; /* flags value */
225 54 : if (t4) *t4 = '\0';
226 :
227 : /* Build new label set: start from existing labels */
228 54 : int cap = 64, cnt = 0;
229 54 : char **labels = malloc((size_t)cap * sizeof(char *));
230 54 : if (!labels) { free(hdr); return -1; }
231 :
232 54 : char *lbl_copy = strdup(lbl_str);
233 54 : if (!lbl_copy) { free(labels); free(hdr); return -1; }
234 54 : char *saveptr = NULL;
235 54 : for (char *tok = strtok_r(lbl_copy, ",", &saveptr);
236 146 : tok; tok = strtok_r(NULL, ",", &saveptr)) {
237 92 : if (!tok[0]) continue;
238 : /* skip labels in rm_ids */
239 92 : int rm = 0;
240 134 : for (int i = 0; i < rm_count; i++)
241 80 : if (rm_ids && rm_ids[i] && strcmp(tok, rm_ids[i]) == 0) { rm = 1; break; }
242 92 : if (rm) continue;
243 54 : 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 54 : labels[cnt++] = tok; /* points into lbl_copy */
250 : }
251 :
252 : /* append add_ids (skip duplicates) */
253 77 : for (int i = 0; i < add_count; i++) {
254 23 : if (!add_ids || !add_ids[i] || !add_ids[i][0]) continue;
255 23 : int dup = 0;
256 39 : for (int j = 0; j < cnt; j++)
257 24 : if (strcmp(labels[j], add_ids[i]) == 0) { dup = 1; break; }
258 23 : if (!dup) {
259 15 : 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 15 : labels[cnt++] = (char *)add_ids[i]; /* borrows caller's pointer */
266 : }
267 : }
268 :
269 : /* Rebuild labels CSV */
270 54 : size_t lbl_len = 0;
271 123 : for (int i = 0; i < cnt; i++) lbl_len += strlen(labels[i]) + 1;
272 54 : char *new_lbl = malloc(lbl_len + 1);
273 54 : if (!new_lbl) { free(lbl_copy); free(labels); free(hdr); return -1; }
274 54 : new_lbl[0] = '\0';
275 123 : for (int i = 0; i < cnt; i++) {
276 69 : if (i) strcat(new_lbl, ",");
277 69 : 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 54 : int old_flags = (suffix && suffix[0]) ? atoi(suffix) : 0;
284 54 : int new_flags = old_flags & ~(MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED);
285 123 : for (int i = 0; i < cnt; i++) {
286 69 : if (strcmp(labels[i], "UNREAD") == 0) new_flags |= MSG_FLAG_UNSEEN;
287 69 : if (strcmp(labels[i], "STARRED") == 0) new_flags |= MSG_FLAG_FLAGGED;
288 : }
289 : char flags_str[16];
290 54 : snprintf(flags_str, sizeof(flags_str), "%d", new_flags);
291 :
292 54 : free(lbl_copy);
293 54 : free(labels);
294 :
295 : /* Reassemble: prefix already NUL-terminated at t3 */
296 54 : char *updated = NULL;
297 54 : int rc = asprintf(&updated, "%s\t%s\t%s", prefix, new_lbl, flags_str);
298 54 : free(new_lbl);
299 54 : free(hdr);
300 54 : if (rc == -1) return -1;
301 :
302 54 : rc = local_hdr_save(folder, uid, updated, strlen(updated));
303 54 : free(updated);
304 54 : return rc;
305 : }
306 :
307 8894 : static int cmp_uid_evict(const void *a, const void *b) {
308 8894 : return memcmp(a, b, 16);
309 : }
310 :
311 269 : void local_hdr_evict_stale(const char *folder,
312 : const char (*keep_uids)[17], int keep_count) {
313 269 : if (!g_account_base[0]) return;
314 :
315 269 : char (*sorted)[17] = malloc((size_t)keep_count * sizeof(char[17]));
316 269 : if (!sorted) return;
317 269 : memcpy(sorted, keep_uids, (size_t)keep_count * sizeof(char[17]));
318 269 : qsort(sorted, (size_t)keep_count, sizeof(char[17]), cmp_uid_evict);
319 :
320 : /* Walk all 100 buckets (10 × 10) */
321 2959 : for (int d1 = 0; d1 <= 9; d1++) {
322 29590 : for (int d2 = 0; d2 <= 9; d2++) {
323 26900 : RAII_STRING char *dir = NULL;
324 26900 : if (asprintf(&dir, "%s/headers/%s/%d/%d",
325 : g_account_base, folder, d1, d2) == -1)
326 0 : continue;
327 :
328 53800 : RAII_DIR DIR *d = opendir(dir);
329 26900 : if (!d) continue;
330 :
331 : struct dirent *ent;
332 2956 : while ((ent = readdir(d)) != NULL) {
333 2266 : const char *name = ent->d_name;
334 2266 : const char *dot = strrchr(name, '.');
335 2266 : if (!dot || strcmp(dot, ".hdr") != 0) continue;
336 886 : size_t stem_len = (size_t)(dot - name);
337 886 : if (stem_len == 0 || stem_len > 16) continue;
338 886 : char key[17] = {0};
339 886 : memcpy(key, name, stem_len);
340 886 : if (!bsearch(key, sorted, (size_t)keep_count,
341 : sizeof(char[17]), cmp_uid_evict)) {
342 6 : RAII_STRING char *path = NULL;
343 6 : if (asprintf(&path, "%s/%s", dir, name) != -1) {
344 6 : remove(path);
345 6 : logger_log(LOG_DEBUG,
346 : "Evicted stale header: UID %s in %s", key, folder);
347 : }
348 : }
349 : }
350 : }
351 : }
352 269 : free(sorted);
353 : }
354 :
355 112 : int local_hdr_list_all_uids(const char *folder,
356 : char (**uids_out)[17], int *count_out) {
357 112 : *uids_out = NULL;
358 112 : *count_out = 0;
359 :
360 112 : int cap = 256;
361 112 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
362 112 : if (!arr) return -1;
363 112 : 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 1904 : for (int i1 = 0; i1 < 16; i1++) {
370 30464 : for (int i2 = 0; i2 < 16; i2++) {
371 28672 : char d1 = hex[i1], d2 = hex[i2];
372 28672 : RAII_STRING char *dir = NULL;
373 28672 : if (asprintf(&dir, "%s/headers/%s/%c/%c",
374 : g_account_base, folder, d1, d2) == -1)
375 0 : continue;
376 57344 : RAII_DIR DIR *dp = opendir(dir);
377 28672 : if (!dp) continue;
378 :
379 : struct dirent *ent;
380 15199 : while ((ent = readdir(dp)) != NULL) {
381 11399 : const char *name = ent->d_name;
382 11399 : const char *dot = strrchr(name, '.');
383 11399 : if (!dot || strcmp(dot, ".hdr") != 0) continue;
384 3799 : size_t stem_len = (size_t)(dot - name);
385 3799 : if (stem_len == 0 || stem_len > 16) continue;
386 :
387 3799 : 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 3799 : memset(arr[count], 0, 17);
394 3799 : memcpy(arr[count], name, stem_len);
395 3799 : count++;
396 : }
397 : }
398 : }
399 :
400 112 : *uids_out = arr;
401 112 : *count_out = count;
402 112 : return 0;
403 : }
404 :
405 : /* ── Index helpers ───────────────────────────────────────────────────── */
406 :
407 : /** @brief Checks if a reference line already exists in an index file. */
408 436 : static int index_has_ref(const char *path, const char *ref) {
409 436 : char *content = load_file(path);
410 436 : if (!content) return 0;
411 340 : size_t ref_len = strlen(ref);
412 340 : const char *p = content;
413 1532 : while (*p) {
414 1192 : if (strncmp(p, ref, ref_len) == 0 &&
415 0 : (p[ref_len] == '\n' || p[ref_len] == '\0')) {
416 0 : free(content);
417 0 : 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 436 : static int index_append(const char *dir_path, const char *file_name,
429 : const char *ref) {
430 436 : if (fs_mkdir_p(dir_path, 0700) != 0) return -1;
431 :
432 436 : RAII_STRING char *path = NULL;
433 436 : if (asprintf(&path, "%s/%s", dir_path, file_name) == -1) return -1;
434 :
435 436 : if (index_has_ref(path, ref)) return 0; /* already indexed */
436 :
437 872 : RAII_FILE FILE *fp = fopen(path, "a");
438 436 : if (!fp) return -1;
439 436 : fprintf(fp, "%s\n", ref);
440 436 : 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 218 : static void extract_email_parts(const char *from,
447 : char *domain, size_t dlen,
448 : char *local_part, size_t llen) {
449 218 : domain[0] = '\0';
450 218 : local_part[0] = '\0';
451 :
452 : /* Try "Name <user@domain>" format first */
453 218 : const char *lt = strchr(from, '<');
454 218 : const char *gt = lt ? strchr(lt, '>') : NULL;
455 : const char *email;
456 : size_t elen;
457 218 : if (lt && gt && gt > lt + 1) {
458 218 : email = lt + 1;
459 218 : elen = (size_t)(gt - email);
460 : } else {
461 : /* Bare address: skip leading whitespace */
462 0 : email = from;
463 0 : while (*email == ' ' || *email == '\t') email++;
464 0 : elen = strlen(email);
465 : /* Trim trailing whitespace */
466 0 : while (elen > 0 && (email[elen - 1] == ' ' || email[elen - 1] == '\n'
467 0 : || email[elen - 1] == '\r'))
468 0 : elen--;
469 : }
470 :
471 218 : const char *at = memchr(email, '@', elen);
472 218 : if (!at) return;
473 :
474 218 : size_t ll = (size_t)(at - email);
475 218 : size_t dl = elen - ll - 1;
476 218 : if (ll >= llen) ll = llen - 1;
477 218 : if (dl >= dlen) dl = dlen - 1;
478 218 : memcpy(local_part, email, ll);
479 218 : local_part[ll] = '\0';
480 218 : memcpy(domain, at + 1, dl);
481 218 : domain[dl] = '\0';
482 :
483 : /* Lowercase domain */
484 2631 : for (char *c = domain; *c; c++)
485 2413 : *c = (char)tolower((unsigned char)*c);
486 : /* Lowercase local part */
487 1385 : for (char *c = local_part; *c; c++)
488 1167 : *c = (char)tolower((unsigned char)*c);
489 : }
490 :
491 218 : int local_index_update(const char *folder, const char *uid, const char *raw_msg) {
492 218 : if (!g_account_base[0] || !raw_msg) return -1;
493 :
494 : char ref[512];
495 218 : snprintf(ref, sizeof(ref), "%s/%s", folder, uid);
496 :
497 : /* 1. From index: index/from/<domain>/<localpart> */
498 436 : RAII_STRING char *from_raw = mime_get_header(raw_msg, "From");
499 218 : if (from_raw) {
500 : char domain[256], local_part[256];
501 218 : extract_email_parts(from_raw, domain, sizeof(domain),
502 : local_part, sizeof(local_part));
503 218 : if (domain[0] && local_part[0]) {
504 218 : RAII_STRING char *idx_dir = NULL;
505 218 : if (asprintf(&idx_dir, "%s/index/from/%s",
506 : g_account_base, domain) != -1)
507 218 : index_append(idx_dir, local_part, ref);
508 : }
509 : }
510 :
511 : /* 2. Date index: index/date/<year>/<month>/<day> */
512 218 : RAII_STRING char *date_raw = mime_get_header(raw_msg, "Date");
513 218 : if (date_raw) {
514 436 : RAII_STRING char *formatted = mime_format_date(date_raw);
515 218 : if (formatted && strlen(formatted) >= 10) {
516 : int year, month, day;
517 218 : if (sscanf(formatted, "%d-%d-%d", &year, &month, &day) == 3) {
518 218 : RAII_STRING char *idx_dir = NULL;
519 : char day_str[4];
520 218 : snprintf(day_str, sizeof(day_str), "%02d", day);
521 218 : if (asprintf(&idx_dir, "%s/index/date/%04d/%02d",
522 : g_account_base, year, month) != -1)
523 218 : index_append(idx_dir, day_str, ref);
524 : }
525 : }
526 : }
527 :
528 218 : return 0;
529 : }
530 :
531 1 : int local_msg_delete(const char *folder, const char *uid) {
532 1 : if (!g_account_base[0]) return -1;
533 :
534 : char ref[512];
535 1 : snprintf(ref, sizeof(ref), "%s/%s", folder, uid);
536 :
537 : /* 1. Remove .eml file */
538 2 : RAII_STRING char *mpath = msg_path(folder, uid);
539 1 : if (mpath) remove(mpath);
540 :
541 : /* 2. Remove .hdr file */
542 1 : RAII_STRING char *hpath = hdr_path(folder, uid);
543 1 : 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 1 : logger_log(LOG_DEBUG, "Deleted %s/%s", folder, uid);
550 1 : return 0;
551 : }
552 :
553 : /* ── UI preferences ──────────────────────────────────────────────────── */
554 :
555 1097 : static char *ui_pref_path(void) {
556 1097 : const char *data_base = platform_data_dir();
557 1097 : if (!data_base) return NULL;
558 1097 : char *path = NULL;
559 1097 : if (asprintf(&path, "%s/email-cli/ui.ini", data_base) == -1)
560 0 : return NULL;
561 1097 : return path;
562 : }
563 :
564 196 : int ui_pref_get_int(const char *key, int default_val) {
565 392 : RAII_STRING char *path = ui_pref_path();
566 196 : if (!path) return default_val;
567 392 : RAII_FILE FILE *fp = fopen(path, "r");
568 196 : if (!fp) return default_val;
569 : char line[256];
570 196 : size_t klen = strlen(key);
571 541 : while (fgets(line, sizeof(line), fp))
572 367 : if (strncmp(line, key, klen) == 0 && line[klen] == '=')
573 22 : return atoi(line + klen + 1);
574 174 : return default_val;
575 : }
576 :
577 4 : int ui_pref_set_int(const char *key, int value) {
578 4 : const char *data_base = platform_data_dir();
579 4 : if (!data_base) return -1;
580 4 : RAII_STRING char *dir = NULL;
581 4 : if (asprintf(&dir, "%s/email-cli", data_base) == -1) return -1;
582 4 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
583 8 : RAII_STRING char *path = ui_pref_path();
584 4 : if (!path) return -1;
585 :
586 4 : char *existing = load_file(path);
587 :
588 8 : RAII_FILE FILE *fp = fopen(path, "w");
589 4 : if (!fp) { free(existing); return -1; }
590 :
591 4 : size_t klen = strlen(key);
592 4 : if (existing) {
593 4 : char *line = existing;
594 15 : while (*line) {
595 11 : char *nl = strchr(line, '\n');
596 11 : size_t llen = nl ? (size_t)(nl - line + 1) : strlen(line);
597 11 : if (!(strncmp(line, key, klen) == 0 && line[klen] == '='))
598 8 : fwrite(line, 1, llen, fp);
599 11 : line += llen;
600 : }
601 4 : free(existing);
602 : }
603 4 : fprintf(fp, "%s=%d\n", key, value);
604 4 : logger_log(LOG_DEBUG, "UI pref %s=%d saved", key, value);
605 4 : return 0;
606 : }
607 :
608 455 : char *ui_pref_get_str(const char *key) {
609 910 : RAII_STRING char *path = ui_pref_path();
610 455 : if (!path) return NULL;
611 910 : RAII_FILE FILE *fp = fopen(path, "r");
612 455 : if (!fp) return NULL;
613 : char line[1024];
614 450 : size_t klen = strlen(key);
615 527 : while (fgets(line, sizeof(line), fp)) {
616 487 : if (strncmp(line, key, klen) == 0 && line[klen] == '=') {
617 410 : char *val = line + klen + 1;
618 410 : size_t vlen = strlen(val);
619 820 : while (vlen > 0 && (val[vlen-1] == '\n' || val[vlen-1] == '\r'))
620 410 : val[--vlen] = '\0';
621 410 : return strdup(val);
622 : }
623 : }
624 40 : return NULL;
625 : }
626 :
627 442 : int ui_pref_set_str(const char *key, const char *value) {
628 442 : const char *data_base = platform_data_dir();
629 442 : if (!data_base) return -1;
630 442 : RAII_STRING char *dir = NULL;
631 442 : if (asprintf(&dir, "%s/email-cli", data_base) == -1) return -1;
632 442 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
633 884 : RAII_STRING char *path = ui_pref_path();
634 442 : if (!path) return -1;
635 :
636 442 : char *existing = load_file(path);
637 :
638 884 : RAII_FILE FILE *fp = fopen(path, "w");
639 442 : if (!fp) { free(existing); return -1; }
640 :
641 442 : size_t klen = strlen(key);
642 442 : if (existing) {
643 437 : char *line = existing;
644 1302 : while (*line) {
645 865 : char *nl = strchr(line, '\n');
646 865 : size_t llen = nl ? (size_t)(nl - line + 1) : strlen(line);
647 865 : if (!(strncmp(line, key, klen) == 0 && line[klen] == '='))
648 467 : fwrite(line, 1, llen, fp);
649 865 : line += llen;
650 : }
651 437 : free(existing);
652 : }
653 442 : fprintf(fp, "%s=%s\n", key, value);
654 442 : logger_log(LOG_DEBUG, "UI pref %s=%s saved", key, value);
655 442 : return 0;
656 : }
657 :
658 : /* ── Folder manifest ─────────────────────────────────────────────────── */
659 :
660 5305 : static char *manifest_path(const char *folder) {
661 5305 : if (!g_account_base[0]) return NULL;
662 5305 : char *path = NULL;
663 5305 : if (asprintf(&path, "%s/manifests/%s.tsv", g_account_base, folder) == -1)
664 0 : return NULL;
665 5305 : return path;
666 : }
667 :
668 : /** @brief Duplicates a string, replacing tabs with spaces. */
669 2727 : static char *sanitise(const char *s) {
670 2727 : if (!s) return strdup("");
671 2727 : char *d = strdup(s);
672 64064 : if (d) for (char *p = d; *p; p++) if (*p == '\t') *p = ' ';
673 2727 : return d;
674 : }
675 :
676 4984 : Manifest *manifest_load(const char *folder) {
677 9968 : RAII_STRING char *path = manifest_path(folder);
678 4984 : logger_log(LOG_DEBUG, "manifest_load: folder=%s account_base=%s path=%s",
679 4984 : folder, g_account_base, path ? path : "(null)");
680 4984 : if (!path) return NULL;
681 :
682 4984 : char *data = load_file(path);
683 4984 : if (!data) return NULL;
684 :
685 3187 : Manifest *m = calloc(1, sizeof(*m));
686 3187 : if (!m) { free(data); return NULL; }
687 3187 : m->capacity = 64;
688 3187 : m->entries = malloc((size_t)m->capacity * sizeof(ManifestEntry));
689 3187 : if (!m->entries) { free(m); free(data); return NULL; }
690 :
691 3187 : char *line = data;
692 8645 : while (*line) {
693 5458 : char *nl = strchr(line, '\n');
694 5458 : if (nl) *nl = '\0';
695 :
696 : /* Parse: uid\tfrom\tsubject\tdate */
697 5458 : char *t1 = strchr(line, '\t');
698 5458 : if (!t1 || t1 == line) {
699 0 : line = nl ? nl + 1 : line + strlen(line);
700 0 : continue;
701 : }
702 5458 : *t1 = '\0';
703 5458 : char *uid_field = line;
704 5458 : char *from_start = t1 + 1;
705 5458 : char *t2 = strchr(from_start, '\t');
706 5458 : if (!t2) { line = nl ? nl + 1 : line + strlen(line); continue; }
707 5458 : *t2 = '\0';
708 5458 : char *subj_start = t2 + 1;
709 5458 : char *t3 = strchr(subj_start, '\t');
710 5458 : if (!t3) { line = nl ? nl + 1 : line + strlen(line); continue; }
711 5458 : *t3 = '\0';
712 5458 : char *date_start = t3 + 1;
713 : /* Optional 5th field: unseen flag */
714 5458 : int unseen_val = 0;
715 5458 : char *t4 = strchr(date_start, '\t');
716 5458 : if (t4) {
717 5458 : *t4 = '\0';
718 5458 : unseen_val = atoi(t4 + 1);
719 : }
720 :
721 5458 : 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 5458 : ManifestEntry *e = &m->entries[m->count++];
729 5458 : snprintf(e->uid, sizeof(e->uid), "%s", uid_field);
730 5458 : e->from = strdup(from_start);
731 5458 : e->subject = strdup(subj_start);
732 5458 : e->date = strdup(date_start);
733 5458 : e->flags = unseen_val;
734 :
735 5458 : line = nl ? nl + 1 : line + strlen(line);
736 : }
737 3187 : free(data);
738 3187 : return m;
739 : }
740 :
741 326 : int manifest_save(const char *folder, const Manifest *m) {
742 326 : if (!g_account_base[0] || !m) return -1;
743 :
744 315 : RAII_STRING char *dir = NULL;
745 326 : if (asprintf(&dir, "%s/manifests", g_account_base) == -1) return -1;
746 326 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
747 :
748 : /* For nested folders like "munka/ai" we need the parent dir */
749 636 : RAII_STRING char *path = manifest_path(folder);
750 321 : if (!path) return -1;
751 :
752 : /* Ensure parent directory exists (folder path may have slashes) */
753 321 : char *last_slash = strrchr(path, '/');
754 321 : if (last_slash) {
755 321 : char saved = *last_slash;
756 321 : *last_slash = '\0';
757 321 : fs_mkdir_p(path, 0700);
758 318 : *last_slash = saved;
759 : }
760 :
761 634 : RAII_FILE FILE *fp = fopen(path, "w");
762 316 : if (!fp) return -1;
763 :
764 1225 : for (int i = 0; i < m->count; i++) {
765 909 : ManifestEntry *e = &m->entries[i];
766 1818 : RAII_STRING char *f = sanitise(e->from);
767 1818 : RAII_STRING char *s = sanitise(e->subject);
768 1818 : RAII_STRING char *d = sanitise(e->date);
769 909 : fprintf(fp, "%s\t%s\t%s\t%s\t%d\n", e->uid, f ? f : "", s ? s : "", d ? d : "", e->flags);
770 : }
771 316 : logger_log(LOG_DEBUG, "Manifest saved: %s (%d entries)", folder, m->count);
772 316 : return 0;
773 : }
774 :
775 3527 : void manifest_free(Manifest *m) {
776 3527 : if (!m) return;
777 10872 : for (int i = 0; i < m->count; i++) {
778 7345 : free(m->entries[i].from);
779 7345 : free(m->entries[i].subject);
780 7345 : free(m->entries[i].date);
781 : }
782 3527 : free(m->entries);
783 3527 : free(m);
784 : }
785 :
786 14380 : ManifestEntry *manifest_find(const Manifest *m, const char *uid) {
787 14380 : if (!m) return NULL;
788 956322 : for (int i = 0; i < m->count; i++)
789 952971 : if (strcmp(m->entries[i].uid, uid) == 0) return &m->entries[i];
790 3351 : return NULL;
791 : }
792 :
793 2806 : void manifest_upsert(Manifest *m, const char *uid,
794 : char *from, char *subject, char *date, int flags) {
795 2806 : if (!m) return;
796 2806 : ManifestEntry *existing = manifest_find(m, uid);
797 2806 : if (existing) {
798 673 : free(existing->from); existing->from = from;
799 673 : free(existing->subject); existing->subject = subject;
800 673 : free(existing->date); existing->date = date;
801 673 : existing->flags = flags;
802 673 : return;
803 : }
804 2133 : if (m->count == m->capacity) {
805 444 : int new_cap = m->capacity ? m->capacity * 2 : 64;
806 444 : ManifestEntry *tmp = realloc(m->entries,
807 444 : (size_t)new_cap * sizeof(ManifestEntry));
808 444 : if (!tmp) { free(from); free(subject); free(date); return; }
809 444 : m->entries = tmp;
810 444 : m->capacity = new_cap;
811 : }
812 2133 : ManifestEntry *e = &m->entries[m->count++];
813 2133 : snprintf(e->uid, sizeof(e->uid), "%s", uid);
814 2133 : e->from = from; e->subject = subject; e->date = date;
815 2133 : e->flags = flags;
816 : }
817 :
818 430 : void manifest_retain(Manifest *m, const char (*keep_uids)[17], int keep_count) {
819 430 : if (!m) return;
820 430 : int dst = 0;
821 1354 : for (int i = 0; i < m->count; i++) {
822 924 : int found = 0;
823 71882 : for (int j = 0; j < keep_count; j++) {
824 71880 : if (strcmp(keep_uids[j], m->entries[i].uid) == 0) { found = 1; break; }
825 : }
826 924 : if (found) {
827 922 : if (dst != i) m->entries[dst] = m->entries[i];
828 922 : dst++;
829 : } else {
830 2 : free(m->entries[i].from);
831 2 : free(m->entries[i].subject);
832 2 : free(m->entries[i].date);
833 : }
834 : }
835 430 : 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 32 : int local_folder_list_save(const char **folders, int count, char sep) {
857 32 : if (!g_account_base[0]) return -1;
858 32 : RAII_STRING char *path = NULL;
859 32 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return -1;
860 64 : RAII_FILE FILE *fp = fopen(path, "w");
861 32 : if (!fp) return -1;
862 32 : fprintf(fp, "sep=%c\n", sep);
863 288 : for (int i = 0; i < count; i++)
864 256 : fprintf(fp, "%s\n", folders[i] ? folders[i] : "");
865 32 : logger_log(LOG_DEBUG, "Folder list cache saved: %d folders", count);
866 32 : return 0;
867 : }
868 :
869 806 : char **local_folder_list_load(int *count_out, char *sep_out) {
870 806 : *count_out = 0;
871 806 : if (!g_account_base[0]) return NULL;
872 806 : RAII_STRING char *path = NULL;
873 806 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return NULL;
874 1612 : RAII_FILE FILE *fp = fopen(path, "r");
875 806 : if (!fp) return NULL;
876 :
877 : char line[1024];
878 640 : char sep = '.';
879 : /* First line: sep=<char> */
880 640 : if (!fgets(line, sizeof(line), fp)) return NULL;
881 640 : if (strncmp(line, "sep=", 4) == 0 && line[4] != '\n')
882 640 : sep = line[4];
883 :
884 640 : int cap = 32, cnt = 0;
885 640 : char **folders = malloc((size_t)cap * sizeof(char *));
886 640 : if (!folders) return NULL;
887 5760 : while (fgets(line, sizeof(line), fp)) {
888 : /* strip trailing newline */
889 5120 : size_t len = strlen(line);
890 10240 : while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
891 5120 : line[--len] = '\0';
892 5120 : if (len == 0) continue;
893 5120 : 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 5120 : folders[cnt] = strdup(line);
900 5120 : if (!folders[cnt]) { for (int i = 0; i < cnt; i++) free(folders[i]); free(folders); return NULL; }
901 5120 : cnt++;
902 : }
903 640 : *count_out = cnt;
904 640 : if (sep_out) *sep_out = sep;
905 640 : logger_log(LOG_DEBUG, "Folder list cache loaded: %d folders", cnt);
906 640 : return folders;
907 : }
908 :
909 3248 : void manifest_count_folder(const char *folder, int *total_out,
910 : int *unseen_out, int *flagged_out) {
911 3248 : *total_out = 0; *unseen_out = 0; *flagged_out = 0;
912 3248 : Manifest *m = manifest_load(folder);
913 3248 : 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 213 : Manifest *manifest_load_all_with_flag(int flag_mask) {
923 213 : Manifest *result = calloc(1, sizeof(Manifest));
924 213 : if (!result) return NULL;
925 213 : if (!g_account_base[0]) return result;
926 : char dir_path[8300];
927 213 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
928 426 : RAII_DIR DIR *dp = opendir(dir_path);
929 213 : if (!dp) return result;
930 : struct dirent *ent;
931 1598 : while ((ent = readdir(dp)) != NULL) {
932 1386 : const char *name = ent->d_name;
933 1386 : size_t nlen = strlen(name);
934 1387 : 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 962 : if (name[0] == '_' && name[1] == '_') continue;
938 962 : RAII_STRING char *folder = strndup(name, nlen - 4);
939 962 : if (!folder) continue;
940 962 : Manifest *m = manifest_load(folder);
941 962 : if (!m) continue;
942 1940 : for (int i = 0; i < m->count; i++) {
943 979 : if (flag_mask == 0 || (m->entries[i].flags & flag_mask))
944 889 : manifest_upsert(result, m->entries[i].uid,
945 889 : strdup(m->entries[i].from ? m->entries[i].from : ""),
946 889 : strdup(m->entries[i].subject ? m->entries[i].subject : ""),
947 889 : strdup(m->entries[i].date ? m->entries[i].date : ""),
948 889 : m->entries[i].flags);
949 : }
950 961 : manifest_free(m);
951 : }
952 212 : return result;
953 : }
954 :
955 196 : 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 196 : if (unread_out) *unread_out = 0;
959 196 : if (flagged_out) *flagged_out = 0;
960 196 : if (junk_out) *junk_out = 0;
961 196 : if (phishing_out) *phishing_out = 0;
962 196 : if (answered_out) *answered_out = 0;
963 196 : if (forwarded_out)*forwarded_out= 0;
964 196 : 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 196 : int combined = MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED | MSG_FLAG_JUNK |
968 : MSG_FLAG_PHISHING | MSG_FLAG_ANSWERED | MSG_FLAG_FORWARDED;
969 196 : Manifest *m = manifest_load_all_with_flag(combined);
970 196 : if (!m) return;
971 433 : for (int i = 0; i < m->count; i++) {
972 237 : int f = m->entries[i].flags;
973 237 : if (unread_out && (f & MSG_FLAG_UNSEEN)) (*unread_out)++;
974 237 : if (flagged_out && (f & MSG_FLAG_FLAGGED)) (*flagged_out)++;
975 237 : if (junk_out && (f & MSG_FLAG_JUNK)) (*junk_out)++;
976 237 : if (phishing_out && (f & MSG_FLAG_PHISHING)) (*phishing_out)++;
977 237 : if (answered_out && (f & MSG_FLAG_ANSWERED)) (*answered_out)++;
978 237 : if (forwarded_out&& (f & MSG_FLAG_FORWARDED)) (*forwarded_out)++;
979 : }
980 196 : manifest_free(m);
981 : }
982 :
983 : /* ── Cross-folder flag search ────────────────────────────────────────── */
984 :
985 17 : int local_flag_search(int flag_mask,
986 : SearchResult **results_out, int *count_out)
987 : {
988 17 : *results_out = NULL;
989 17 : *count_out = 0;
990 17 : if (!g_account_base[0]) return 0;
991 :
992 17 : int cap = 64, cnt = 0;
993 17 : SearchResult *res = malloc((size_t)cap * sizeof(SearchResult));
994 17 : if (!res) return -1;
995 :
996 : char dir_path[8300];
997 17 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
998 34 : RAII_DIR DIR *dp = opendir(dir_path);
999 17 : if (!dp) { free(res); return 0; }
1000 :
1001 : struct dirent *ent;
1002 165 : while ((ent = readdir(dp)) != NULL) {
1003 148 : const char *name = ent->d_name;
1004 148 : size_t nlen = strlen(name);
1005 148 : 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 114 : if (name[0] == '_' && name[1] == '_') continue;
1009 114 : RAII_STRING char *folder = strndup(name, nlen - 4);
1010 114 : if (!folder) continue;
1011 114 : Manifest *m = manifest_load(folder);
1012 114 : if (!m) continue;
1013 237 : for (int i = 0; i < m->count; i++) {
1014 123 : 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 49 : int dup_idx = -1;
1020 66 : for (int j = 0; j < cnt; j++) {
1021 42 : if (strcmp(res[j].uid, m->entries[i].uid) == 0) {
1022 25 : dup_idx = j; break;
1023 : }
1024 : }
1025 49 : if (dup_idx >= 0) {
1026 25 : 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 25 : continue; /* skip duplicate regardless */
1032 : }
1033 24 : 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 24 : SearchResult *r = &res[cnt++];
1040 24 : snprintf(r->uid, sizeof(r->uid), "%s", m->entries[i].uid);
1041 24 : snprintf(r->folder, sizeof(r->folder), "%s", folder);
1042 24 : r->flags = m->entries[i].flags;
1043 24 : r->from = strdup(m->entries[i].from ? m->entries[i].from : "");
1044 24 : r->subject = strdup(m->entries[i].subject ? m->entries[i].subject : "");
1045 24 : r->date = strdup(m->entries[i].date ? m->entries[i].date : "");
1046 : }
1047 114 : manifest_free(m);
1048 : }
1049 17 : *results_out = res;
1050 17 : *count_out = cnt;
1051 17 : return 0;
1052 : }
1053 :
1054 : /* ── Cross-folder text search ─────────────────────────────────────────── */
1055 :
1056 10 : int local_search(const char *query, int scope,
1057 : SearchResult **results_out, int *count_out)
1058 : {
1059 10 : *results_out = NULL;
1060 10 : *count_out = 0;
1061 10 : if (!query || !query[0] || !g_account_base[0]) return 0;
1062 :
1063 : char dir_path[8300];
1064 10 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
1065 20 : RAII_DIR DIR *dp = opendir(dir_path);
1066 10 : if (!dp) return 0; /* no manifests — not an error */
1067 :
1068 10 : int cap = 64;
1069 10 : SearchResult *results = malloc((size_t)cap * sizeof(SearchResult));
1070 10 : if (!results) return -1;
1071 10 : int count = 0;
1072 :
1073 : struct dirent *ent;
1074 78 : while ((ent = readdir(dp)) != NULL) {
1075 68 : const char *name = ent->d_name;
1076 68 : size_t nlen = strlen(name);
1077 68 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
1078 :
1079 48 : RAII_STRING char *fold = strndup(name, nlen - 4);
1080 48 : if (!fold) continue;
1081 :
1082 48 : Manifest *m = manifest_load(fold);
1083 48 : if (!m) continue;
1084 :
1085 100 : for (int i = 0; i < m->count; i++) {
1086 52 : ManifestEntry *me = &m->entries[i];
1087 52 : int match = 0;
1088 52 : if (scope == 0) {
1089 20 : const char *s = (me->subject && me->subject[0]) ? me->subject : "";
1090 20 : match = strcasestr(s, query) != NULL;
1091 32 : } else if (scope == 1) {
1092 8 : const char *s = (me->from && me->from[0]) ? me->from : "";
1093 8 : 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 52 : if (!match) continue;
1119 :
1120 14 : 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 14 : SearchResult *r = &results[count++];
1127 14 : memcpy(r->uid, me->uid, 17);
1128 14 : snprintf(r->folder, sizeof(r->folder), "%s", fold);
1129 14 : r->flags = me->flags;
1130 14 : r->from = me->from ? strdup(me->from) : strdup("");
1131 14 : r->subject = me->subject ? strdup(me->subject) : strdup("");
1132 14 : r->date = me->date ? strdup(me->date) : strdup("");
1133 : }
1134 48 : manifest_free(m);
1135 : }
1136 :
1137 10 : *results_out = results;
1138 10 : *count_out = count;
1139 10 : return 0;
1140 : }
1141 :
1142 10 : void local_search_free(SearchResult *results, int count)
1143 : {
1144 10 : if (!results) return;
1145 24 : for (int i = 0; i < count; i++) {
1146 14 : free(results[i].from);
1147 14 : free(results[i].subject);
1148 14 : free(results[i].date);
1149 : }
1150 10 : free(results);
1151 : }
1152 :
1153 : /* ── Pending flag changes ─────────────────────────────────────────────── */
1154 :
1155 196 : static char *pending_flag_path(const char *folder) {
1156 196 : if (!g_account_base[0]) return NULL;
1157 196 : char *path = NULL;
1158 196 : if (asprintf(&path, "%s/pending_flags/%s.tsv", g_account_base, folder) == -1)
1159 0 : return NULL;
1160 196 : return path;
1161 : }
1162 :
1163 33 : int local_pending_flag_add(const char *folder, const char *uid,
1164 : const char *flag_name, int add) {
1165 66 : RAII_STRING char *path = pending_flag_path(folder);
1166 33 : if (!path) return -1;
1167 :
1168 : /* Ensure parent directory exists (folder path may have slashes) */
1169 33 : char *dir_end = strrchr(path, '/');
1170 33 : if (dir_end) {
1171 33 : char saved = *dir_end;
1172 33 : *dir_end = '\0';
1173 33 : fs_mkdir_p(path, 0700);
1174 33 : *dir_end = saved;
1175 : }
1176 :
1177 66 : RAII_FILE FILE *fp = fopen(path, "a");
1178 33 : if (!fp) return -1;
1179 33 : fprintf(fp, "%s\t%s\t%d\n", uid, flag_name, add);
1180 33 : 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 161 : static char *pending_move_path(const char *folder) {
1225 161 : if (!g_account_base[0]) return NULL;
1226 161 : char *path = NULL;
1227 161 : if (asprintf(&path, "%s/pending_moves/%s.tsv", g_account_base, folder) == -1)
1228 0 : return NULL;
1229 161 : return path;
1230 : }
1231 :
1232 0 : int local_pending_move_add(const char *folder, const char *uid,
1233 : const char *target_folder) {
1234 0 : RAII_STRING char *path = pending_move_path(folder);
1235 0 : if (!path) return -1;
1236 0 : char *dir_end = strrchr(path, '/');
1237 0 : if (dir_end) {
1238 0 : char saved = *dir_end; *dir_end = '\0';
1239 0 : fs_mkdir_p(path, 0700);
1240 0 : *dir_end = saved;
1241 : }
1242 0 : RAII_FILE FILE *fp = fopen(path, "a");
1243 0 : if (!fp) return -1;
1244 0 : fprintf(fp, "%s\t%s\n", uid, target_folder);
1245 0 : 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 5800 : static char *label_idx_path(const char *label) {
1287 5800 : if (!g_account_base[0] || !label) return NULL;
1288 5800 : char *path = NULL;
1289 5800 : if (asprintf(&path, "%s/labels/%s.idx", g_account_base, label) == -1)
1290 0 : return NULL;
1291 5800 : return path;
1292 : }
1293 :
1294 : /** @brief Ensures the labels/ directory (and any parent for nested labels) exists. */
1295 1719 : static int ensure_label_dir(const char *label) {
1296 3438 : RAII_STRING char *path = label_idx_path(label);
1297 1719 : if (!path) return -1;
1298 : /* Find last slash and mkdir_p up to it */
1299 1719 : char *last_slash = strrchr(path, '/');
1300 1719 : if (!last_slash) return -1;
1301 1719 : *last_slash = '\0';
1302 1719 : int rc = fs_mkdir_p(path, 0700);
1303 1719 : return rc;
1304 : }
1305 :
1306 54 : int label_idx_contains(const char *label, const char *uid) {
1307 54 : char (*arr)[17] = NULL;
1308 54 : int n = 0;
1309 54 : if (label_idx_load(label, &arr, &n) != 0 || n == 0) {
1310 35 : free(arr);
1311 35 : return 0;
1312 : }
1313 :
1314 : /* In-memory binary search (file is kept sorted) */
1315 19 : int lo = 0, hi = n - 1, found = 0;
1316 45 : while (lo <= hi) {
1317 39 : int mid = lo + (hi - lo) / 2;
1318 39 : int cmp = strcmp(arr[mid], uid);
1319 39 : if (cmp == 0) { found = 1; break; }
1320 26 : if (cmp < 0) lo = mid + 1;
1321 0 : else hi = mid - 1;
1322 : }
1323 19 : free(arr);
1324 19 : return found;
1325 : }
1326 :
1327 700 : int label_idx_count(const char *label) {
1328 700 : char (*arr)[17] = NULL;
1329 700 : int n = 0;
1330 700 : label_idx_load(label, &arr, &n);
1331 700 : free(arr);
1332 700 : 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 2362 : int label_idx_load(const char *label, char (**uids_out)[17], int *count_out) {
1357 2362 : *uids_out = NULL;
1358 2362 : *count_out = 0;
1359 :
1360 4724 : RAII_STRING char *path = label_idx_path(label);
1361 2362 : if (!path) return -1;
1362 :
1363 4724 : RAII_FILE FILE *fp = fopen(path, "r");
1364 2362 : 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 1701 : int cap = 256;
1370 1701 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
1371 1701 : if (!arr) return -1;
1372 :
1373 1701 : int count = 0;
1374 : char line[64];
1375 63757 : while (fgets(line, sizeof(line), fp)) {
1376 : /* fgets stops at '\n'; strip trailing whitespace/newline */
1377 62056 : size_t len = strlen(line);
1378 124112 : while (len > 0 && ((unsigned char)line[len-1] <= ' '))
1379 62056 : line[--len] = '\0';
1380 62056 : if (len == 0 || len > 16) continue;
1381 :
1382 62056 : 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 62056 : memset(arr[count], 0, sizeof(arr[count]));
1389 62056 : memcpy(arr[count], line, len);
1390 62056 : count++;
1391 : }
1392 :
1393 1701 : *uids_out = arr;
1394 1701 : *count_out = count;
1395 1701 : return 0;
1396 : }
1397 :
1398 1719 : int label_idx_write(const char *label, const char (*uids)[17], int count) {
1399 1719 : if (ensure_label_dir(label) != 0) return -1;
1400 :
1401 3438 : RAII_STRING char *path = label_idx_path(label);
1402 1719 : if (!path) return -1;
1403 :
1404 3438 : RAII_FILE FILE *fp = fopen(path, "w");
1405 1719 : 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 68609 : for (int i = 0; i < count; i++) {
1411 : char padded[17];
1412 66890 : size_t uid_len = strlen(uids[i]);
1413 66890 : if (uid_len > 16) uid_len = 16;
1414 66890 : memset(padded, 0, 16);
1415 66890 : memcpy(padded, uids[i], uid_len);
1416 66890 : padded[16] = '\n';
1417 66890 : if (fwrite(padded, 1, 17, fp) != 17) return -1;
1418 : }
1419 :
1420 1719 : logger_log(LOG_DEBUG, "label_idx_write: %s → %d entries", label, count);
1421 1719 : return 0;
1422 : }
1423 :
1424 24 : char *local_hdr_get_labels(const char *folder, const char *uid) {
1425 24 : char *hdr = local_hdr_load(folder, uid);
1426 24 : if (!hdr) return NULL;
1427 :
1428 : /* Parse 4th tab-separated field: from\tsubject\tdate\tLABELS\tflags */
1429 24 : const char *p = hdr;
1430 96 : for (int t = 0; t < 3; t++) {
1431 72 : p = strchr(p, '\t');
1432 72 : if (!p) { free(hdr); return NULL; }
1433 72 : p++;
1434 : }
1435 : /* p now points to the start of the labels field */
1436 24 : const char *end = strchr(p, '\t');
1437 24 : size_t len = end ? (size_t)(end - p) : strlen(p);
1438 24 : char *result = strndup(p, len);
1439 24 : free(hdr);
1440 24 : return result;
1441 : }
1442 :
1443 62 : int label_idx_list(char ***labels_out, int *count_out) {
1444 62 : *labels_out = NULL;
1445 62 : *count_out = 0;
1446 :
1447 : char dir_path[8300];
1448 62 : snprintf(dir_path, sizeof(dir_path), "%s/labels", g_account_base);
1449 :
1450 124 : RAII_DIR DIR *dp = opendir(dir_path);
1451 62 : if (!dp) return 0; /* No labels directory → 0 labels */
1452 :
1453 62 : char **list = NULL;
1454 62 : int count = 0, cap = 0;
1455 :
1456 : struct dirent *ent;
1457 322 : while ((ent = readdir(dp)) != NULL) {
1458 260 : const char *name = ent->d_name;
1459 260 : size_t nlen = strlen(name);
1460 260 : if (nlen <= 4) continue;
1461 136 : if (strcmp(name + nlen - 4, ".idx") != 0) continue;
1462 :
1463 : /* Extract label name (strip .idx) */
1464 136 : char *label = strndup(name, nlen - 4);
1465 136 : if (!label) continue;
1466 :
1467 136 : if (count == cap) {
1468 62 : int newcap = cap ? cap * 2 : 16;
1469 62 : char **tmp = realloc(list, (size_t)newcap * sizeof(char *));
1470 62 : if (!tmp) { free(label); break; }
1471 62 : list = tmp;
1472 62 : cap = newcap;
1473 : }
1474 136 : list[count++] = label;
1475 : }
1476 :
1477 62 : *labels_out = list;
1478 62 : *count_out = count;
1479 62 : return 0;
1480 : }
1481 :
1482 1454 : int label_idx_add(const char *label, const char *uid) {
1483 1454 : if (!uid || strlen(uid) < 1) return -1;
1484 :
1485 : /* Load existing entries */
1486 1454 : char (*existing)[17] = NULL;
1487 1454 : int ecount = 0;
1488 1454 : label_idx_load(label, &existing, &ecount);
1489 :
1490 : /* Check if already present (binary search) */
1491 1454 : int lo = 0, hi = ecount - 1, insert_pos = ecount;
1492 7472 : while (lo <= hi) {
1493 6020 : int mid = lo + (hi - lo) / 2;
1494 6020 : int cmp = strcmp(existing[mid], uid);
1495 6020 : if (cmp == 0) { free(existing); return 0; } /* Already present */
1496 6018 : if (cmp < 0) lo = mid + 1;
1497 38 : else { insert_pos = mid; hi = mid - 1; }
1498 : }
1499 1452 : if (lo < ecount && insert_pos == ecount) insert_pos = lo;
1500 :
1501 : /* Build new array with uid inserted at insert_pos */
1502 1452 : int newcount = ecount + 1;
1503 1452 : char (*arr)[17] = malloc((size_t)newcount * sizeof(char[17]));
1504 1452 : if (!arr) { free(existing); return -1; }
1505 :
1506 1452 : if (insert_pos > 0 && existing)
1507 1300 : memcpy(arr, existing, (size_t)insert_pos * sizeof(char[17]));
1508 1452 : snprintf(arr[insert_pos], 17, "%.16s", uid);
1509 1452 : if (insert_pos < ecount && existing)
1510 7 : memcpy(arr + insert_pos + 1, existing + insert_pos,
1511 7 : (size_t)(ecount - insert_pos) * sizeof(char[17]));
1512 1452 : free(existing);
1513 :
1514 1452 : int rc = label_idx_write(label, (const char (*)[17])arr, newcount);
1515 1452 : free(arr);
1516 1452 : return rc;
1517 : }
1518 :
1519 81 : int label_idx_remove(const char *label, const char *uid) {
1520 81 : if (!uid) return -1;
1521 :
1522 81 : char (*existing)[17] = NULL;
1523 81 : int ecount = 0;
1524 81 : label_idx_load(label, &existing, &ecount);
1525 81 : if (!existing || ecount == 0) { free(existing); return 0; }
1526 :
1527 : /* Find uid with binary search */
1528 81 : int lo = 0, hi = ecount - 1, found = -1;
1529 240 : while (lo <= hi) {
1530 229 : int mid = lo + (hi - lo) / 2;
1531 229 : int cmp = strcmp(existing[mid], uid);
1532 229 : if (cmp == 0) { found = mid; break; }
1533 159 : if (cmp < 0) lo = mid + 1;
1534 33 : else hi = mid - 1;
1535 : }
1536 :
1537 81 : if (found < 0) { free(existing); return 0; } /* Not present */
1538 :
1539 : /* Shift down */
1540 70 : if (found < ecount - 1)
1541 9 : memmove(existing + found, existing + found + 1,
1542 9 : (size_t)(ecount - found - 1) * sizeof(char[17]));
1543 :
1544 70 : int rc = label_idx_write(label, (const char (*)[17])existing, ecount - 1);
1545 70 : free(existing);
1546 70 : return rc;
1547 : }
1548 :
1549 : /* ── Gmail history ID ─────────────────────────────────────────────── */
1550 :
1551 : /* ── Trash label backup (for untrash restore) ────────────────────── */
1552 :
1553 0 : static char *trash_labels_path(const char *uid) {
1554 0 : if (!g_account_base[0] || !uid) return NULL;
1555 0 : char *path = NULL;
1556 0 : if (asprintf(&path, "%s/trash_labels/%s.lbl", g_account_base, uid) == -1)
1557 0 : return NULL;
1558 0 : return path;
1559 : }
1560 :
1561 0 : int local_trash_labels_save(const char *uid, const char *labels) {
1562 0 : if (!uid || !labels) return -1;
1563 : /* Ensure directory exists */
1564 : char dir[8300];
1565 0 : snprintf(dir, sizeof(dir), "%s/trash_labels", g_account_base);
1566 0 : fs_mkdir_p(dir, 0700);
1567 :
1568 0 : RAII_STRING char *path = trash_labels_path(uid);
1569 0 : if (!path) return -1;
1570 0 : RAII_FILE FILE *fp = fopen(path, "w");
1571 0 : if (!fp) return -1;
1572 0 : fprintf(fp, "%s\n", labels);
1573 0 : return 0;
1574 : }
1575 :
1576 0 : char *local_trash_labels_load(const char *uid) {
1577 0 : RAII_STRING char *path = trash_labels_path(uid);
1578 0 : if (!path) return NULL;
1579 0 : RAII_FILE FILE *fp = fopen(path, "r");
1580 0 : if (!fp) return NULL;
1581 : char buf[4096];
1582 0 : if (!fgets(buf, (int)sizeof(buf), fp)) return NULL;
1583 0 : buf[strcspn(buf, "\r\n")] = '\0';
1584 0 : return strdup(buf);
1585 : }
1586 :
1587 0 : void local_trash_labels_remove(const char *uid) {
1588 0 : RAII_STRING char *path = trash_labels_path(uid);
1589 0 : if (path) unlink(path);
1590 0 : }
1591 :
1592 62 : int local_gmail_label_names_save(char **ids, char **names, int count) {
1593 62 : if (!g_account_base[0]) return -1;
1594 62 : if (fs_mkdir_p(g_account_base, 0700) != 0) return -1;
1595 62 : RAII_STRING char *path = NULL;
1596 62 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return -1;
1597 124 : RAII_FILE FILE *fp = fopen(path, "w");
1598 62 : if (!fp) return -1;
1599 620 : for (int i = 0; i < count; i++)
1600 558 : fprintf(fp, "%s\t%s\n", ids[i], names[i]);
1601 62 : return 0;
1602 : }
1603 :
1604 59 : char *local_gmail_label_name_lookup(const char *id) {
1605 59 : if (!g_account_base[0] || !id) return NULL;
1606 59 : RAII_STRING char *path = NULL;
1607 59 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return NULL;
1608 118 : RAII_FILE FILE *fp = fopen(path, "r");
1609 59 : 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 59 : char *local_gmail_label_id_lookup(const char *name) {
1623 59 : if (!g_account_base[0] || !name) return NULL;
1624 59 : RAII_STRING char *path = NULL;
1625 59 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return NULL;
1626 118 : RAII_FILE FILE *fp = fopen(path, "r");
1627 59 : 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 69 : int local_gmail_history_save(const char *history_id) {
1641 69 : if (!g_account_base[0] || !history_id) return -1;
1642 69 : if (fs_mkdir_p(g_account_base, 0700) != 0) return -1;
1643 69 : RAII_STRING char *path = NULL;
1644 69 : if (asprintf(&path, "%s/gmail_history_id", g_account_base) == -1) return -1;
1645 69 : return write_file(path, history_id, strlen(history_id));
1646 : }
1647 :
1648 77 : char *local_gmail_history_load(void) {
1649 77 : if (!g_account_base[0]) return NULL;
1650 77 : RAII_STRING char *path = NULL;
1651 77 : if (asprintf(&path, "%s/gmail_history_id", g_account_base) == -1) return NULL;
1652 77 : char *data = load_file(path);
1653 77 : if (!data) return NULL;
1654 : /* Trim trailing whitespace */
1655 18 : size_t len = strlen(data);
1656 18 : while (len > 0 && (data[len-1] == '\n' || data[len-1] == '\r' || data[len-1] == ' '))
1657 0 : data[--len] = '\0';
1658 18 : 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 618 : static void parse_addr_list(const char *hdr,
1668 : void (*cb)(const char *, const char *, void *),
1669 : void *ud) {
1670 618 : if (!hdr || !hdr[0]) return;
1671 : /* Walk comma-separated tokens */
1672 : char buf[512];
1673 206 : const char *p = hdr;
1674 412 : while (*p) {
1675 : /* skip leading whitespace / commas / semicolons */
1676 412 : while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' ||
1677 412 : *p == ',' || *p == ';') p++;
1678 206 : if (!*p) break;
1679 :
1680 : /* Copy until the next top-level comma (respecting quoted strings
1681 : * and angle-bracket groups). */
1682 206 : int depth = 0; int in_q = 0; const char *start = p;
1683 206 : size_t i = 0;
1684 7676 : while (*p) {
1685 7470 : if (*p == '"') { in_q = !in_q; }
1686 7470 : else if (!in_q && *p == '<') { depth++; }
1687 7264 : else if (!in_q && *p == '>') { depth--; }
1688 7058 : else if (!in_q && depth == 0 && (*p == ',' || *p == ';')) break;
1689 7470 : if (i < sizeof(buf) - 1) buf[i++] = *p;
1690 7470 : p++;
1691 : }
1692 206 : buf[i] = '\0';
1693 206 : if (buf[0] == '\0') continue;
1694 :
1695 : /* Extract: "Display Name <addr>" or bare "addr" */
1696 206 : char addr[256] = ""; char name[256] = "";
1697 206 : char *lt = strchr(buf, '<');
1698 206 : char *gt = lt ? strchr(lt, '>') : NULL;
1699 412 : if (lt && gt) {
1700 206 : size_t alen = (size_t)(gt - lt - 1);
1701 206 : if (alen < sizeof(addr)) {
1702 206 : memcpy(addr, lt + 1, alen); addr[alen] = '\0';
1703 : }
1704 : /* display name: everything before '<', trimmed, dequoted */
1705 206 : size_t nlen = (size_t)(lt - buf);
1706 206 : if (nlen > 0 && nlen < sizeof(name)) {
1707 206 : memcpy(name, buf, nlen); name[nlen] = '\0';
1708 : /* trim whitespace */
1709 206 : char *ns = name;
1710 206 : while (*ns == ' ' || *ns == '\t') ns++;
1711 206 : char *ne = ns + strlen(ns);
1712 412 : while (ne > ns && (*(ne-1) == ' ' || *(ne-1) == '\t' ||
1713 412 : *(ne-1) == '"')) ne--;
1714 206 : if (*ns == '"') ns++;
1715 206 : *ne = '\0';
1716 206 : memmove(name, ns, strlen(ns) + 1);
1717 : }
1718 : } else {
1719 : /* bare address */
1720 0 : char *ns = buf;
1721 0 : while (*ns == ' ' || *ns == '\t') ns++;
1722 0 : char *ne = ns + strlen(ns);
1723 0 : while (ne > ns && (*(ne-1) == ' ' || *(ne-1) == '\t')) ne--;
1724 0 : size_t alen = (size_t)(ne - ns);
1725 0 : if (alen < sizeof(addr)) { memcpy(addr, ns, alen); addr[alen] = '\0'; }
1726 : }
1727 206 : 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 47 : static int cmp_folder_names(const void *a, const void *b) {
1740 47 : return strcmp(*(const char *const *)a, *(const char *const *)b);
1741 : }
1742 :
1743 30 : int local_msg_find_folders(const char *uid, char ***folders_out, int *count_out) {
1744 30 : if (folders_out) *folders_out = NULL;
1745 30 : if (count_out) *count_out = 0;
1746 30 : if (!uid || !uid[0] || !g_account_base[0]) return 0;
1747 :
1748 : char dir_path[8300];
1749 30 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
1750 60 : RAII_DIR DIR *dp = opendir(dir_path);
1751 30 : if (!dp) return 0;
1752 :
1753 20 : int cap = 8, n = 0;
1754 20 : char **list = malloc((size_t)cap * sizeof(char *));
1755 20 : if (!list) return -1;
1756 :
1757 : struct dirent *ent;
1758 128 : while ((ent = readdir(dp)) != NULL) {
1759 108 : const char *name = ent->d_name;
1760 108 : size_t nlen = strlen(name);
1761 131 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
1762 68 : if (name[0] == '_' && name[1] == '_') continue;
1763 :
1764 68 : RAII_STRING char *folder = strndup(name, nlen - 4);
1765 68 : 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 68 : if (strcasecmp(folder, "INBOX") == 0) {
1771 26 : int dup = 0;
1772 34 : for (int i = 0; i < n; i++) {
1773 14 : 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 6 : if (strcmp(folder, list[i]) < 0) {
1777 6 : char *swap = strdup(folder);
1778 6 : if (swap) { free(list[i]); list[i] = swap; }
1779 : }
1780 6 : dup = 1;
1781 6 : break;
1782 : }
1783 26 : if (dup) continue;
1784 : }
1785 :
1786 62 : int here = local_msg_exists(folder, uid);
1787 62 : if (!here) {
1788 27 : Manifest *m = manifest_load(folder);
1789 27 : if (m) {
1790 27 : here = manifest_find(m, uid) != NULL;
1791 27 : manifest_free(m);
1792 : }
1793 : }
1794 62 : if (!here) continue;
1795 :
1796 45 : 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 45 : list[n] = strdup(folder);
1803 45 : if (list[n]) n++;
1804 : }
1805 :
1806 20 : 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 19 : qsort(list, (size_t)n, sizeof(char *), cmp_folder_names);
1810 19 : if (folders_out) *folders_out = list; else { for (int i = 0; i < n; i++) free(list[i]); free(list); }
1811 19 : if (count_out) *count_out = n;
1812 19 : return n;
1813 : }
1814 :
1815 30 : void local_folder_list_free(char **folders, int count) {
1816 30 : if (!folders) return;
1817 64 : for (int i = 0; i < count; i++) free(folders[i]);
1818 19 : 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 778 : static int contact_cmp_freq(const void *a, const void *b) {
1846 778 : return ((const ContactEntry *)b)->freq - ((const ContactEntry *)a)->freq;
1847 : }
1848 :
1849 : typedef struct { ContactEntry *arr; int count; int cap; } ContactBuf;
1850 :
1851 206 : static void contact_add_cb(const char *addr, const char *name, void *ud) {
1852 206 : ContactBuf *cb = (ContactBuf *)ud;
1853 206 : if (!addr || !addr[0]) return;
1854 : /* case-insensitive dedup on address */
1855 472 : for (int i = 0; i < cb->count; i++) {
1856 439 : if (strcasecmp(cb->arr[i].addr, addr) == 0) {
1857 173 : cb->arr[i].freq++;
1858 : /* update name if we now have one and didn't before */
1859 173 : if (name && name[0] && !cb->arr[i].name[0]) {
1860 0 : size_t _n = strlen(name);
1861 0 : if (_n >= sizeof(cb->arr[i].name)) _n = sizeof(cb->arr[i].name) - 1;
1862 0 : memcpy(cb->arr[i].name, name, _n); cb->arr[i].name[_n] = '\0';
1863 : }
1864 173 : return;
1865 : }
1866 : }
1867 33 : if (cb->count >= cb->cap) return; /* full */
1868 33 : { size_t _a = strlen(addr); if (_a >= sizeof(cb->arr[cb->count].addr)) _a = sizeof(cb->arr[cb->count].addr) - 1;
1869 33 : memcpy(cb->arr[cb->count].addr, addr, _a); cb->arr[cb->count].addr[_a] = '\0'; }
1870 33 : { const char *_nm = name ? name : "";
1871 33 : size_t _n = strlen(_nm); if (_n >= sizeof(cb->arr[cb->count].name)) _n = sizeof(cb->arr[cb->count].name) - 1;
1872 33 : memcpy(cb->arr[cb->count].name, _nm, _n); cb->arr[cb->count].name[_n] = '\0'; }
1873 33 : cb->arr[cb->count].freq = 1;
1874 33 : cb->count++;
1875 : }
1876 :
1877 6 : void local_contacts_rebuild(void) {
1878 6 : const char *data_base = platform_data_dir();
1879 6 : if (!data_base || !g_account_name[0]) return;
1880 :
1881 6 : ContactEntry *arr = calloc(CONTACTS_MAX, sizeof(ContactEntry));
1882 6 : if (!arr) return;
1883 6 : ContactBuf cb = { arr, 0, CONTACTS_MAX };
1884 :
1885 6 : int fcount = 0;
1886 6 : char **folders = local_folder_list_load(&fcount, NULL);
1887 :
1888 6 : if (fcount > 0 && folders) {
1889 : /* IMAP account: .hdr files contain raw RFC 2822 headers */
1890 45 : for (int fi = 0; fi < fcount && cb.count < CONTACTS_MAX; fi++) {
1891 40 : char (*uids)[17] = NULL;
1892 40 : int uid_count = 0;
1893 40 : local_hdr_list_all_uids(folders[fi], &uids, &uid_count);
1894 51 : for (int u = 0; u < uid_count && cb.count < CONTACTS_MAX; u++) {
1895 11 : char *raw = local_hdr_load(folders[fi], uids[u]);
1896 11 : if (!raw) continue;
1897 11 : char *from_h = mime_get_header(raw, "From");
1898 11 : char *to_h = mime_get_header(raw, "To");
1899 11 : char *cc_h = mime_get_header(raw, "Cc");
1900 11 : parse_addr_list(from_h, contact_add_cb, &cb);
1901 11 : parse_addr_list(to_h, contact_add_cb, &cb);
1902 11 : parse_addr_list(cc_h, contact_add_cb, &cb);
1903 11 : free(from_h); free(to_h); free(cc_h);
1904 11 : free(raw);
1905 : }
1906 40 : free(uids);
1907 : }
1908 45 : for (int i = 0; i < fcount; i++) free(folders[i]);
1909 5 : 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 1 : if (folders) {
1914 0 : for (int i = 0; i < fcount; i++) free(folders[i]);
1915 0 : free(folders);
1916 : }
1917 1 : char (*uids)[17] = NULL;
1918 1 : int uid_count = 0;
1919 1 : local_hdr_list_all_uids("", &uids, &uid_count);
1920 1 : 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 1 : free(uids);
1933 : }
1934 :
1935 6 : qsort(arr, (size_t)cb.count, sizeof(ContactEntry), contact_cmp_freq);
1936 :
1937 : char path[8192];
1938 6 : snprintf(path, sizeof(path), "%s/email-cli/accounts/%s/contacts.tsv",
1939 : data_base, g_account_name);
1940 6 : FILE *f = fopen(path, "w");
1941 6 : if (f) {
1942 11 : for (int i = 0; i < cb.count; i++)
1943 5 : fprintf(f, "%s\t%s\t%d\n", arr[i].addr, arr[i].name, arr[i].freq);
1944 6 : 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 6 : logger_log(LOG_INFO, "contacts rebuilt: %d entries written to %s", cb.count, path);
1950 6 : free(arr);
1951 : }
1952 :
1953 195 : void local_contacts_update(const char *from_hdr,
1954 : const char *to_hdr,
1955 : const char *cc_hdr) {
1956 195 : const char *data_base = platform_data_dir();
1957 195 : if (!data_base || !g_account_name[0]) return;
1958 :
1959 : char path[8192];
1960 195 : snprintf(path, sizeof(path), "%s/email-cli/accounts/%s/contacts.tsv",
1961 : data_base, g_account_name);
1962 :
1963 : /* Load existing entries */
1964 195 : ContactEntry *arr = calloc(CONTACTS_MAX, sizeof(ContactEntry));
1965 195 : if (!arr) return;
1966 195 : ContactBuf cb = { arr, 0, CONTACTS_MAX };
1967 :
1968 195 : FILE *f = fopen(path, "r");
1969 195 : if (f) {
1970 : char line[512];
1971 839 : while (cb.count < CONTACTS_MAX && fgets(line, sizeof(line), f)) {
1972 : /* format: addr\tname\tfreq\n */
1973 661 : char *t1 = strchr(line, '\t');
1974 661 : if (!t1) continue;
1975 661 : *t1 = '\0';
1976 661 : char *t2 = strchr(t1 + 1, '\t');
1977 661 : char *name = t1 + 1;
1978 661 : int freq = 1;
1979 661 : if (t2) { *t2 = '\0'; freq = atoi(t2 + 1); if (freq < 1) freq = 1; }
1980 661 : char *nl = strchr(name, '\n'); if (nl) *nl = '\0';
1981 661 : size_t _al = strlen(line); if (_al >= sizeof(arr[cb.count].addr)) _al = sizeof(arr[cb.count].addr) - 1;
1982 661 : memcpy(arr[cb.count].addr, line, _al); arr[cb.count].addr[_al] = '\0';
1983 661 : size_t _nl = strlen(name); if (_nl >= sizeof(arr[cb.count].name)) _nl = sizeof(arr[cb.count].name) - 1;
1984 661 : memcpy(arr[cb.count].name, name, _nl); arr[cb.count].name[_nl] = '\0';
1985 661 : arr[cb.count].freq = freq;
1986 661 : cb.count++;
1987 : }
1988 178 : fclose(f);
1989 : }
1990 :
1991 : /* Add new addresses from headers */
1992 195 : parse_addr_list(from_hdr, contact_add_cb, &cb);
1993 195 : parse_addr_list(to_hdr, contact_add_cb, &cb);
1994 195 : parse_addr_list(cc_hdr, contact_add_cb, &cb);
1995 :
1996 : /* Sort by frequency descending */
1997 195 : qsort(arr, (size_t)cb.count, sizeof(ContactEntry), contact_cmp_freq);
1998 :
1999 : /* Write back */
2000 195 : f = fopen(path, "w");
2001 195 : if (f) {
2002 884 : for (int i = 0; i < cb.count; i++)
2003 689 : fprintf(f, "%s\t%s\t%d\n", arr[i].addr, arr[i].name, arr[i].freq);
2004 195 : fclose(f);
2005 : }
2006 195 : free(arr);
2007 : }
2008 :
2009 : /* ── Pending APPEND queue ────────────────────────────────────────────── */
2010 :
2011 39 : static char *pending_append_path(void) {
2012 39 : if (!g_account_base[0]) return NULL;
2013 39 : char *path = NULL;
2014 39 : if (asprintf(&path, "%s/pending_appends.tsv", g_account_base) == -1)
2015 0 : return NULL;
2016 39 : return path;
2017 : }
2018 :
2019 12 : int local_pending_append_add(const char *folder, const char *uid) {
2020 24 : RAII_STRING char *path = pending_append_path();
2021 12 : if (!path) return -1;
2022 24 : RAII_FILE FILE *fp = fopen(path, "a");
2023 12 : if (!fp) return -1;
2024 12 : fprintf(fp, "%s\t%s\n", folder, uid);
2025 12 : return 0;
2026 : }
2027 :
2028 26 : PendingAppend *local_pending_append_load(int *count_out) {
2029 26 : *count_out = 0;
2030 52 : RAII_STRING char *path = pending_append_path();
2031 26 : if (!path) return NULL;
2032 52 : RAII_FILE FILE *fp = fopen(path, "r");
2033 26 : if (!fp) return NULL;
2034 :
2035 1 : int cap = 8, count = 0;
2036 1 : PendingAppend *arr = malloc((size_t)cap * sizeof(PendingAppend));
2037 1 : if (!arr) return NULL;
2038 :
2039 : char line[512];
2040 2 : while (fgets(line, sizeof(line), fp)) {
2041 1 : char *tab = strchr(line, '\t');
2042 1 : if (!tab) continue;
2043 1 : *tab = '\0';
2044 1 : char *nl = strchr(tab + 1, '\n'); if (nl) *nl = '\0';
2045 1 : 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 1 : strncpy(arr[count].folder, line, sizeof(arr[count].folder) - 1);
2052 1 : arr[count].folder[sizeof(arr[count].folder) - 1] = '\0';
2053 1 : strncpy(arr[count].uid, tab + 1, sizeof(arr[count].uid) - 1);
2054 1 : arr[count].uid[sizeof(arr[count].uid) - 1] = '\0';
2055 1 : count++;
2056 : }
2057 1 : *count_out = count;
2058 1 : return arr;
2059 : }
2060 :
2061 1 : void local_pending_append_remove(const char *folder, const char *uid) {
2062 2 : RAII_STRING char *path = pending_append_path();
2063 1 : 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 2 : RAII_FILE FILE *rfp = fopen(path, "r");
2071 1 : if (!rfp) return;
2072 :
2073 1 : RAII_STRING char *tmp_path = NULL;
2074 1 : if (asprintf(&tmp_path, "%s.tmp", path) == -1) return;
2075 :
2076 2 : RAII_FILE FILE *wfp = fopen(tmp_path, "w");
2077 1 : if (!wfp) return;
2078 :
2079 : char line[512];
2080 2 : while (fgets(line, sizeof(line), rfp)) {
2081 : char tmp[512];
2082 1 : snprintf(tmp, sizeof(tmp), "%s", line);
2083 1 : char *tab = strchr(tmp, '\t');
2084 1 : if (tab) {
2085 1 : *tab = '\0';
2086 1 : char *nl = strchr(tab + 1, '\n');
2087 1 : if (nl) *nl = '\0';
2088 1 : if (strcmp(tmp, folder) == 0 && strcmp(tab + 1, uid) == 0)
2089 1 : continue; /* this is the entry being removed */
2090 : }
2091 0 : 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 1 : fclose(wfp); wfp = NULL;
2097 1 : fclose(rfp); rfp = NULL;
2098 :
2099 1 : if (rename(tmp_path, path) != 0)
2100 0 : remove(tmp_path);
2101 : }
2102 :
2103 : /* ── Pending Gmail fetch queue ───────────────────────────────────────── */
2104 :
2105 1816 : static char *pending_fetch_path(void) {
2106 1816 : if (!g_account_base[0]) return NULL;
2107 1816 : char *path = NULL;
2108 1816 : if (asprintf(&path, "%s/pending_fetch.tsv", g_account_base) == -1)
2109 0 : return NULL;
2110 1816 : return path;
2111 : }
2112 :
2113 814 : int local_pending_fetch_add(const char *uid) {
2114 1628 : RAII_STRING char *path = pending_fetch_path();
2115 814 : if (!path || !uid) return -1;
2116 1628 : RAII_FILE FILE *fp = fopen(path, "a");
2117 814 : if (!fp) return -1;
2118 814 : fprintf(fp, "%s\n", uid);
2119 814 : return 0;
2120 : }
2121 :
2122 55 : char (*local_pending_fetch_load(int *count_out))[17] {
2123 55 : *count_out = 0;
2124 110 : RAII_STRING char *path = pending_fetch_path();
2125 55 : if (!path) return NULL;
2126 110 : RAII_FILE FILE *fp = fopen(path, "r");
2127 55 : if (!fp) return NULL;
2128 :
2129 55 : int cap = 64, count = 0;
2130 55 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
2131 55 : if (!arr) return NULL;
2132 :
2133 : char line[32];
2134 872 : while (fgets(line, sizeof(line), fp)) {
2135 817 : char *nl = strchr(line, '\n'); if (nl) *nl = '\0';
2136 817 : char *cr = strchr(line, '\r'); if (cr) *cr = '\0';
2137 817 : if (line[0] == '\0') continue;
2138 817 : 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 817 : memcpy(arr[count], line, 16);
2145 817 : arr[count][16] = '\0';
2146 817 : count++;
2147 : }
2148 55 : *count_out = count;
2149 55 : return arr;
2150 : }
2151 :
2152 817 : void local_pending_fetch_remove(const char *uid) {
2153 1634 : RAII_STRING char *path = pending_fetch_path();
2154 817 : if (!path || !uid) return;
2155 :
2156 817 : FILE *rfp = fopen(path, "r");
2157 817 : if (!rfp) return;
2158 :
2159 : /* Read all lines, skip the matching UID */
2160 817 : int cap = 64, count = 0;
2161 817 : char (*lines)[32] = malloc((size_t)cap * sizeof(char[32]));
2162 817 : if (!lines) { fclose(rfp); return; }
2163 :
2164 : char line[32];
2165 53312 : while (fgets(line, sizeof(line), rfp)) {
2166 : char tmp[32];
2167 52495 : strncpy(tmp, line, 31); tmp[31] = '\0';
2168 52495 : char *nl = strchr(tmp, '\n'); if (nl) *nl = '\0';
2169 52495 : char *cr = strchr(tmp, '\r'); if (cr) *cr = '\0';
2170 52495 : if (strcmp(tmp, uid) == 0) continue;
2171 51678 : 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 51678 : memcpy(lines[count++], line, 31);
2178 51678 : lines[count - 1][31] = '\0';
2179 : }
2180 817 : fclose(rfp);
2181 :
2182 817 : FILE *wfp = fopen(path, "w");
2183 817 : if (wfp) {
2184 52495 : for (int i = 0; i < count; i++)
2185 51678 : fputs(lines[i], wfp);
2186 817 : fclose(wfp);
2187 : }
2188 817 : free(lines);
2189 : }
2190 :
2191 68 : int local_pending_fetch_count(void) {
2192 136 : RAII_STRING char *path = pending_fetch_path();
2193 68 : if (!path) return 0;
2194 136 : RAII_FILE FILE *fp = fopen(path, "r");
2195 68 : if (!fp) return 0;
2196 12 : int count = 0;
2197 : char line[32];
2198 15 : while (fgets(line, sizeof(line), fp)) {
2199 3 : if (line[0] != '\n' && line[0] != '\r' && line[0] != '\0')
2200 3 : count++;
2201 : }
2202 12 : return count;
2203 : }
2204 :
2205 62 : void local_pending_fetch_clear(void) {
2206 124 : RAII_STRING char *path = pending_fetch_path();
2207 62 : if (path) remove(path);
2208 62 : }
2209 :
2210 : /* ── Local outgoing message save ─────────────────────────────────────── */
2211 :
2212 14 : int local_save_outgoing(const char *folder, const char *msg, size_t msg_len) {
2213 14 : 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 14 : clock_gettime(CLOCK_REALTIME, &ts);
2220 14 : long long ms = (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
2221 14 : snprintf(uid, sizeof(uid), "t%lld", ms);
2222 : }
2223 :
2224 : /* Save full message */
2225 14 : 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 12 : const char *blank = strstr(msg, "\r\n\r\n");
2229 12 : if (!blank) blank = strstr(msg, "\n\n");
2230 12 : size_t hdr_len = blank ? (size_t)(blank - msg) : msg_len;
2231 12 : local_hdr_save(folder, uid, msg, hdr_len);
2232 :
2233 : /* Decode fields for the manifest */
2234 12 : char *from_raw = mime_get_header(msg, "From");
2235 12 : char *subj_raw = mime_get_header(msg, "Subject");
2236 12 : char *date_raw = mime_get_header(msg, "Date");
2237 12 : char *from_dec = from_raw ? mime_decode_words(from_raw) : strdup("");
2238 12 : char *subj_dec = subj_raw ? mime_decode_words(subj_raw) : strdup("");
2239 12 : char *date_dec = date_raw ? mime_format_date(date_raw) : strdup("");
2240 12 : free(from_raw); free(subj_raw); free(date_raw);
2241 :
2242 : /* Update manifest (MSG_FLAG_SEEN: sent messages are already read) */
2243 12 : Manifest *mf = manifest_load(folder);
2244 12 : if (!mf) mf = calloc(1, sizeof(Manifest));
2245 12 : if (mf) {
2246 : /* flags=0: no UNSEEN bit → sent message is already read */
2247 12 : manifest_upsert(mf, uid, from_dec, subj_dec, date_dec, 0);
2248 12 : manifest_save(folder, mf);
2249 12 : 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 12 : local_pending_append_add(folder, uid);
2256 :
2257 12 : logger_log(LOG_INFO, "local_save_outgoing: saved %s/%s, queued for APPEND",
2258 : folder, uid);
2259 12 : 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 : }
|