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