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 1165 : int local_store_init(const char *host_url, const char *username) {
22 1165 : const char *data_base = platform_data_dir();
23 1165 : if (!data_base) return -1;
24 1165 : 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 1165 : if (username && username[0]) {
31 1138 : snprintf(g_account_base, sizeof(g_account_base),
32 : "%s/email-cli/accounts/%s", data_base, username);
33 1138 : snprintf(g_account_name, sizeof(g_account_name), "%s", username);
34 : } else {
35 : /* Extract hostname from URL: imaps://host:port → host */
36 27 : const char *p = strstr(host_url, "://");
37 27 : p = p ? p + 3 : host_url;
38 : char hostname[512];
39 27 : int i = 0;
40 825 : while (*p && *p != ':' && *p != '/' && i < (int)sizeof(hostname) - 1)
41 798 : hostname[i++] = *p++;
42 27 : hostname[i] = '\0';
43 825 : for (char *c = hostname; *c; c++) *c = (char)tolower((unsigned char)*c);
44 27 : snprintf(g_account_base, sizeof(g_account_base),
45 : "%s/email-cli/accounts/imap.%s", data_base, hostname);
46 27 : snprintf(g_account_name, sizeof(g_account_name), "imap.%s", hostname);
47 : }
48 :
49 1165 : 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 1165 : fs_mkdir_p(g_account_base, 0700);
53 1165 : return 0;
54 : }
55 :
56 40 : const char *local_store_account_name(void) { return g_account_name; }
57 :
58 : /* ── Reverse digit bucketing helpers ─────────────────────────────────── */
59 :
60 14109 : static char digit1(const char *uid) {
61 14109 : size_t len = strlen(uid);
62 14109 : return len > 0 ? uid[len - 1] : '0';
63 : }
64 14109 : static char digit2(const char *uid) {
65 14109 : size_t len = strlen(uid);
66 14109 : return len > 1 ? uid[len - 2] : '0';
67 : }
68 :
69 : /* ── Shared file I/O ─────────────────────────────────────────────────── */
70 :
71 8996 : static char *load_file(const char *path) {
72 17992 : RAII_FILE FILE *fp = fopen(path, "r");
73 8996 : if (!fp) return NULL;
74 8283 : if (fseek(fp, 0, SEEK_END) != 0) return NULL;
75 8283 : long size = ftell(fp);
76 8283 : if (size <= 0) return NULL;
77 8283 : rewind(fp);
78 8283 : char *buf = malloc((size_t)size + 1);
79 8283 : if (!buf) return NULL;
80 8283 : if ((long)fread(buf, 1, (size_t)size, fp) != size) { free(buf); return NULL; }
81 8283 : buf[size] = '\0';
82 8283 : return buf;
83 : }
84 :
85 2252 : static int write_file(const char *path, const char *content, size_t len) {
86 4504 : RAII_FILE FILE *fp = fopen(path, "w");
87 2252 : if (!fp) return -1;
88 2252 : if (fwrite(content, 1, len, fp) != len) return -1;
89 2252 : return 0;
90 : }
91 :
92 : /** @brief Ensures the parent directory of a bucketed path exists. */
93 2180 : static int ensure_bucket_dir(const char *area, const char *folder, const char *uid) {
94 2179 : RAII_STRING char *dir = NULL;
95 2180 : if (asprintf(&dir, "%s/%s/%s/%c/%c",
96 2180 : g_account_base, area, folder, digit1(uid), digit2(uid)) == -1)
97 0 : return -1;
98 2180 : return fs_mkdir_p(dir, 0700);
99 : }
100 :
101 : /* ── Message store ───────────────────────────────────────────────────── */
102 :
103 3597 : static char *msg_path(const char *folder, const char *uid) {
104 3597 : if (!g_account_base[0]) return NULL;
105 3597 : char *path = NULL;
106 3597 : if (asprintf(&path, "%s/store/%s/%c/%c/%s.eml",
107 3597 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
108 0 : return NULL;
109 3597 : return path;
110 : }
111 :
112 2711 : int local_msg_exists(const char *folder, const char *uid) {
113 5422 : RAII_STRING char *path = msg_path(folder, uid);
114 2711 : if (!path) return 0;
115 2711 : RAII_FILE FILE *fp = fopen(path, "r");
116 2711 : return fp != NULL;
117 : }
118 :
119 809 : int local_msg_save(const char *folder, const char *uid, const char *content, size_t len) {
120 809 : if (!g_account_base[0]) return -1;
121 809 : 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 1616 : RAII_STRING char *path = msg_path(folder, uid);
126 808 : if (!path) return -1;
127 808 : 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 808 : logger_log(LOG_DEBUG, "Stored %s/%s at %s", folder, uid, path);
132 808 : return 0;
133 : }
134 :
135 73 : char *local_msg_load(const char *folder, const char *uid) {
136 146 : RAII_STRING char *path = msg_path(folder, uid);
137 73 : if (!path) return NULL;
138 73 : return load_file(path);
139 : }
140 :
141 : /* ── Header store ────────────────────────────────────────────────────── */
142 :
143 8332 : static char *hdr_path(const char *folder, const char *uid) {
144 8332 : if (!g_account_base[0]) return NULL;
145 8332 : char *path = NULL;
146 8332 : if (asprintf(&path, "%s/headers/%s/%c/%c/%s.hdr",
147 8332 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
148 0 : return NULL;
149 8332 : return path;
150 : }
151 :
152 2022 : int local_hdr_exists(const char *folder, const char *uid) {
153 4044 : RAII_STRING char *path = hdr_path(folder, uid);
154 2022 : if (!path) return 0;
155 2022 : RAII_FILE FILE *fp = fopen(path, "r");
156 2022 : return fp != NULL;
157 : }
158 :
159 1371 : int local_hdr_save(const char *folder, const char *uid, const char *content, size_t len) {
160 1371 : if (!g_account_base[0]) return -1;
161 1371 : 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 2742 : RAII_STRING char *path = hdr_path(folder, uid);
166 1371 : if (!path) return -1;
167 1371 : if (write_file(path, content, len) != 0) return -1;
168 1371 : logger_log(LOG_DEBUG, "Stored header %s/%s", folder, uid);
169 1371 : return 0;
170 : }
171 :
172 4934 : char *local_hdr_load(const char *folder, const char *uid) {
173 9868 : RAII_STRING char *path = hdr_path(folder, uid);
174 4934 : if (!path) return NULL;
175 4934 : return load_file(path);
176 : }
177 :
178 22 : int local_hdr_update_flags(const char *folder, const char *uid, int new_flags) {
179 22 : char *hdr = local_hdr_load(folder, uid);
180 22 : if (!hdr) return -1;
181 :
182 : /* Find the last tab → flags field starts after it */
183 20 : char *last_tab = strrchr(hdr, '\t');
184 20 : if (!last_tab) { free(hdr); return -1; }
185 :
186 : /* Rebuild: keep everything up to and including last tab, replace flags */
187 20 : *last_tab = '\0';
188 20 : char *updated = NULL;
189 20 : if (asprintf(&updated, "%s\t%d", hdr, new_flags) == -1) {
190 0 : free(hdr);
191 0 : return -1;
192 : }
193 20 : free(hdr);
194 :
195 20 : int rc = local_hdr_save(folder, uid, updated, strlen(updated));
196 20 : free(updated);
197 20 : return rc;
198 : }
199 :
200 42 : 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 42 : char *hdr = local_hdr_load(folder, uid);
204 42 : if (!hdr) return -1;
205 :
206 : /* .hdr format: from\tsubject\tdate\tlabels\tflags
207 : * Locate the labels field (4th tab-separated token). */
208 42 : char *t1 = strchr(hdr, '\t');
209 42 : if (!t1) { free(hdr); return -1; }
210 42 : char *t2 = strchr(t1 + 1, '\t');
211 42 : if (!t2) { free(hdr); return -1; }
212 42 : char *t3 = strchr(t2 + 1, '\t');
213 42 : if (!t3) { free(hdr); return -1; }
214 42 : 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 42 : *t3 = '\0'; /* NUL-terminate prefix (from\tsubject\tdate) */
218 42 : const char *prefix = hdr;
219 42 : const char *lbl_str = t3 + 1;
220 42 : const char *suffix = t4 ? t4 + 1 : ""; /* flags value */
221 42 : if (t4) *t4 = '\0';
222 :
223 : /* Build new label set: start from existing labels */
224 42 : int cap = 64, cnt = 0;
225 42 : char **labels = malloc((size_t)cap * sizeof(char *));
226 42 : if (!labels) { free(hdr); return -1; }
227 :
228 42 : char *lbl_copy = strdup(lbl_str);
229 42 : if (!lbl_copy) { free(labels); free(hdr); return -1; }
230 42 : char *saveptr = NULL;
231 42 : for (char *tok = strtok_r(lbl_copy, ",", &saveptr);
232 109 : tok; tok = strtok_r(NULL, ",", &saveptr)) {
233 67 : if (!tok[0]) continue;
234 : /* skip labels in rm_ids */
235 67 : int rm = 0;
236 90 : for (int i = 0; i < rm_count; i++)
237 44 : if (rm_ids && rm_ids[i] && strcmp(tok, rm_ids[i]) == 0) { rm = 1; break; }
238 67 : if (rm) continue;
239 46 : 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 46 : labels[cnt++] = tok; /* points into lbl_copy */
246 : }
247 :
248 : /* append add_ids (skip duplicates) */
249 63 : for (int i = 0; i < add_count; i++) {
250 21 : if (!add_ids || !add_ids[i] || !add_ids[i][0]) continue;
251 21 : int dup = 0;
252 40 : for (int j = 0; j < cnt; j++)
253 23 : if (strcmp(labels[j], add_ids[i]) == 0) { dup = 1; break; }
254 21 : if (!dup) {
255 17 : 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 17 : labels[cnt++] = (char *)add_ids[i]; /* borrows caller's pointer */
262 : }
263 : }
264 :
265 : /* Rebuild labels CSV */
266 42 : size_t lbl_len = 0;
267 105 : for (int i = 0; i < cnt; i++) lbl_len += strlen(labels[i]) + 1;
268 42 : char *new_lbl = malloc(lbl_len + 1);
269 42 : if (!new_lbl) { free(lbl_copy); free(labels); free(hdr); return -1; }
270 42 : new_lbl[0] = '\0';
271 105 : for (int i = 0; i < cnt; i++) {
272 63 : if (i) strcat(new_lbl, ",");
273 63 : 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 42 : int old_flags = (suffix && suffix[0]) ? atoi(suffix) : 0;
280 42 : int new_flags = old_flags & ~(MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED);
281 105 : for (int i = 0; i < cnt; i++) {
282 63 : if (strcmp(labels[i], "UNREAD") == 0) new_flags |= MSG_FLAG_UNSEEN;
283 63 : if (strcmp(labels[i], "STARRED") == 0) new_flags |= MSG_FLAG_FLAGGED;
284 : }
285 : char flags_str[16];
286 42 : snprintf(flags_str, sizeof(flags_str), "%d", new_flags);
287 :
288 42 : free(lbl_copy);
289 42 : free(labels);
290 :
291 : /* Reassemble: prefix already NUL-terminated at t3 */
292 42 : char *updated = NULL;
293 42 : int rc = asprintf(&updated, "%s\t%s\t%s", prefix, new_lbl, flags_str);
294 42 : free(new_lbl);
295 42 : free(hdr);
296 42 : if (rc == -1) return -1;
297 :
298 42 : rc = local_hdr_save(folder, uid, updated, strlen(updated));
299 42 : free(updated);
300 42 : return rc;
301 : }
302 :
303 8778 : static int cmp_uid_evict(const void *a, const void *b) {
304 8778 : return memcmp(a, b, 16);
305 : }
306 :
307 166 : void local_hdr_evict_stale(const char *folder,
308 : const char (*keep_uids)[17], int keep_count) {
309 166 : if (!g_account_base[0]) return;
310 :
311 166 : char (*sorted)[17] = malloc((size_t)keep_count * sizeof(char[17]));
312 166 : if (!sorted) return;
313 166 : memcpy(sorted, keep_uids, (size_t)keep_count * sizeof(char[17]));
314 166 : qsort(sorted, (size_t)keep_count, sizeof(char[17]), cmp_uid_evict);
315 :
316 : /* Walk all 100 buckets (10 × 10) */
317 1826 : for (int d1 = 0; d1 <= 9; d1++) {
318 18260 : for (int d2 = 0; d2 <= 9; d2++) {
319 16600 : RAII_STRING char *dir = NULL;
320 16600 : if (asprintf(&dir, "%s/headers/%s/%d/%d",
321 : g_account_base, folder, d1, d2) == -1)
322 0 : continue;
323 :
324 33200 : RAII_DIR DIR *d = opendir(dir);
325 16600 : if (!d) continue;
326 :
327 : struct dirent *ent;
328 2085 : while ((ent = readdir(d)) != NULL) {
329 1651 : const char *name = ent->d_name;
330 1651 : const char *dot = strrchr(name, '.');
331 1651 : if (!dot || strcmp(dot, ".hdr") != 0) continue;
332 783 : size_t stem_len = (size_t)(dot - name);
333 783 : if (stem_len == 0 || stem_len > 16) continue;
334 783 : char key[17] = {0};
335 783 : memcpy(key, name, stem_len);
336 783 : if (!bsearch(key, sorted, (size_t)keep_count,
337 : sizeof(char[17]), cmp_uid_evict)) {
338 2 : RAII_STRING char *path = NULL;
339 2 : if (asprintf(&path, "%s/%s", dir, name) != -1) {
340 2 : remove(path);
341 2 : logger_log(LOG_DEBUG,
342 : "Evicted stale header: UID %s in %s", key, folder);
343 : }
344 : }
345 : }
346 : }
347 : }
348 166 : free(sorted);
349 : }
350 :
351 48 : int local_hdr_list_all_uids(const char *folder,
352 : char (**uids_out)[17], int *count_out) {
353 48 : *uids_out = NULL;
354 48 : *count_out = 0;
355 :
356 48 : int cap = 256;
357 48 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
358 48 : if (!arr) return -1;
359 48 : 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 816 : for (int i1 = 0; i1 < 16; i1++) {
366 13056 : for (int i2 = 0; i2 < 16; i2++) {
367 12288 : char d1 = hex[i1], d2 = hex[i2];
368 12288 : RAII_STRING char *dir = NULL;
369 12288 : if (asprintf(&dir, "%s/headers/%s/%c/%c",
370 : g_account_base, folder, d1, d2) == -1)
371 0 : continue;
372 24576 : RAII_DIR DIR *dp = opendir(dir);
373 12288 : if (!dp) continue;
374 :
375 : struct dirent *ent;
376 14646 : while ((ent = readdir(dp)) != NULL) {
377 10990 : const char *name = ent->d_name;
378 10990 : const char *dot = strrchr(name, '.');
379 10990 : if (!dot || strcmp(dot, ".hdr") != 0) continue;
380 3678 : size_t stem_len = (size_t)(dot - name);
381 3678 : if (stem_len == 0 || stem_len > 16) continue;
382 :
383 3678 : 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 3678 : memset(arr[count], 0, 17);
390 3678 : memcpy(arr[count], name, stem_len);
391 3678 : count++;
392 : }
393 : }
394 : }
395 :
396 48 : *uids_out = arr;
397 48 : *count_out = count;
398 48 : return 0;
399 : }
400 :
401 : /* ── Index helpers ───────────────────────────────────────────────────── */
402 :
403 : /** @brief Checks if a reference line already exists in an index file. */
404 357 : static int index_has_ref(const char *path, const char *ref) {
405 357 : char *content = load_file(path);
406 357 : if (!content) return 0;
407 270 : size_t ref_len = strlen(ref);
408 270 : const char *p = content;
409 1210 : while (*p) {
410 942 : if (strncmp(p, ref, ref_len) == 0 &&
411 2 : (p[ref_len] == '\n' || p[ref_len] == '\0')) {
412 2 : free(content);
413 2 : 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 357 : static int index_append(const char *dir_path, const char *file_name,
425 : const char *ref) {
426 357 : if (fs_mkdir_p(dir_path, 0700) != 0) return -1;
427 :
428 357 : RAII_STRING char *path = NULL;
429 357 : if (asprintf(&path, "%s/%s", dir_path, file_name) == -1) return -1;
430 :
431 357 : if (index_has_ref(path, ref)) return 0; /* already indexed */
432 :
433 710 : RAII_FILE FILE *fp = fopen(path, "a");
434 355 : if (!fp) return -1;
435 355 : fprintf(fp, "%s\n", ref);
436 355 : 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 179 : static void extract_email_parts(const char *from,
443 : char *domain, size_t dlen,
444 : char *local_part, size_t llen) {
445 179 : domain[0] = '\0';
446 179 : local_part[0] = '\0';
447 :
448 : /* Try "Name <user@domain>" format first */
449 179 : const char *lt = strchr(from, '<');
450 179 : const char *gt = lt ? strchr(lt, '>') : NULL;
451 : const char *email;
452 : size_t elen;
453 179 : if (lt && gt && gt > lt + 1) {
454 175 : email = lt + 1;
455 175 : elen = (size_t)(gt - email);
456 : } else {
457 : /* Bare address: skip leading whitespace */
458 4 : email = from;
459 4 : while (*email == ' ' || *email == '\t') email++;
460 4 : elen = strlen(email);
461 : /* Trim trailing whitespace */
462 4 : while (elen > 0 && (email[elen - 1] == ' ' || email[elen - 1] == '\n'
463 4 : || email[elen - 1] == '\r'))
464 0 : elen--;
465 : }
466 :
467 179 : const char *at = memchr(email, '@', elen);
468 179 : if (!at) return;
469 :
470 178 : size_t ll = (size_t)(at - email);
471 178 : size_t dl = elen - ll - 1;
472 178 : if (ll >= llen) ll = llen - 1;
473 178 : if (dl >= dlen) dl = dlen - 1;
474 178 : memcpy(local_part, email, ll);
475 178 : local_part[ll] = '\0';
476 178 : memcpy(domain, at + 1, dl);
477 178 : domain[dl] = '\0';
478 :
479 : /* Lowercase domain */
480 2145 : for (char *c = domain; *c; c++)
481 1967 : *c = (char)tolower((unsigned char)*c);
482 : /* Lowercase local part */
483 1192 : for (char *c = local_part; *c; c++)
484 1014 : *c = (char)tolower((unsigned char)*c);
485 : }
486 :
487 179 : int local_index_update(const char *folder, const char *uid, const char *raw_msg) {
488 179 : if (!g_account_base[0] || !raw_msg) return -1;
489 :
490 : char ref[512];
491 179 : snprintf(ref, sizeof(ref), "%s/%s", folder, uid);
492 :
493 : /* 1. From index: index/from/<domain>/<localpart> */
494 358 : RAII_STRING char *from_raw = mime_get_header(raw_msg, "From");
495 179 : if (from_raw) {
496 : char domain[256], local_part[256];
497 179 : extract_email_parts(from_raw, domain, sizeof(domain),
498 : local_part, sizeof(local_part));
499 179 : if (domain[0] && local_part[0]) {
500 178 : RAII_STRING char *idx_dir = NULL;
501 178 : if (asprintf(&idx_dir, "%s/index/from/%s",
502 : g_account_base, domain) != -1)
503 178 : index_append(idx_dir, local_part, ref);
504 : }
505 : }
506 :
507 : /* 2. Date index: index/date/<year>/<month>/<day> */
508 179 : RAII_STRING char *date_raw = mime_get_header(raw_msg, "Date");
509 179 : if (date_raw) {
510 358 : RAII_STRING char *formatted = mime_format_date(date_raw);
511 179 : if (formatted && strlen(formatted) >= 10) {
512 : int year, month, day;
513 179 : if (sscanf(formatted, "%d-%d-%d", &year, &month, &day) == 3) {
514 179 : RAII_STRING char *idx_dir = NULL;
515 : char day_str[4];
516 179 : snprintf(day_str, sizeof(day_str), "%02d", day);
517 179 : if (asprintf(&idx_dir, "%s/index/date/%04d/%02d",
518 : g_account_base, year, month) != -1)
519 179 : index_append(idx_dir, day_str, ref);
520 : }
521 : }
522 : }
523 :
524 179 : return 0;
525 : }
526 :
527 5 : int local_msg_delete(const char *folder, const char *uid) {
528 5 : if (!g_account_base[0]) return -1;
529 :
530 : char ref[512];
531 5 : snprintf(ref, sizeof(ref), "%s/%s", folder, uid);
532 :
533 : /* 1. Remove .eml file */
534 10 : RAII_STRING char *mpath = msg_path(folder, uid);
535 5 : if (mpath) remove(mpath);
536 :
537 : /* 2. Remove .hdr file */
538 5 : RAII_STRING char *hpath = hdr_path(folder, uid);
539 5 : 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 5 : logger_log(LOG_DEBUG, "Deleted %s/%s", folder, uid);
546 5 : return 0;
547 : }
548 :
549 : /* ── UI preferences ──────────────────────────────────────────────────── */
550 :
551 503 : static char *ui_pref_path(void) {
552 503 : const char *data_base = platform_data_dir();
553 503 : if (!data_base) return NULL;
554 503 : char *path = NULL;
555 503 : if (asprintf(&path, "%s/email-cli/ui.ini", data_base) == -1)
556 0 : return NULL;
557 503 : return path;
558 : }
559 :
560 120 : int ui_pref_get_int(const char *key, int default_val) {
561 240 : RAII_STRING char *path = ui_pref_path();
562 120 : if (!path) return default_val;
563 240 : RAII_FILE FILE *fp = fopen(path, "r");
564 120 : if (!fp) return default_val;
565 : char line[256];
566 117 : size_t klen = strlen(key);
567 282 : while (fgets(line, sizeof(line), fp))
568 200 : if (strncmp(line, key, klen) == 0 && line[klen] == '=')
569 35 : return atoi(line + klen + 1);
570 82 : return default_val;
571 : }
572 :
573 15 : int ui_pref_set_int(const char *key, int value) {
574 15 : const char *data_base = platform_data_dir();
575 15 : if (!data_base) return -1;
576 15 : RAII_STRING char *dir = NULL;
577 15 : if (asprintf(&dir, "%s/email-cli", data_base) == -1) return -1;
578 15 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
579 30 : RAII_STRING char *path = ui_pref_path();
580 15 : if (!path) return -1;
581 :
582 15 : char *existing = load_file(path);
583 :
584 30 : RAII_FILE FILE *fp = fopen(path, "w");
585 15 : if (!fp) { free(existing); return -1; }
586 :
587 15 : size_t klen = strlen(key);
588 15 : if (existing) {
589 13 : char *line = existing;
590 33 : while (*line) {
591 20 : char *nl = strchr(line, '\n');
592 20 : size_t llen = nl ? (size_t)(nl - line + 1) : strlen(line);
593 20 : if (!(strncmp(line, key, klen) == 0 && line[klen] == '='))
594 9 : fwrite(line, 1, llen, fp);
595 20 : line += llen;
596 : }
597 13 : free(existing);
598 : }
599 15 : fprintf(fp, "%s=%d\n", key, value);
600 15 : logger_log(LOG_DEBUG, "UI pref %s=%d saved", key, value);
601 15 : return 0;
602 : }
603 :
604 191 : char *ui_pref_get_str(const char *key) {
605 382 : RAII_STRING char *path = ui_pref_path();
606 191 : if (!path) return NULL;
607 382 : RAII_FILE FILE *fp = fopen(path, "r");
608 191 : if (!fp) return NULL;
609 : char line[1024];
610 188 : size_t klen = strlen(key);
611 228 : while (fgets(line, sizeof(line), fp)) {
612 224 : if (strncmp(line, key, klen) == 0 && line[klen] == '=') {
613 184 : char *val = line + klen + 1;
614 184 : size_t vlen = strlen(val);
615 368 : while (vlen > 0 && (val[vlen-1] == '\n' || val[vlen-1] == '\r'))
616 184 : val[--vlen] = '\0';
617 184 : return strdup(val);
618 : }
619 : }
620 4 : return NULL;
621 : }
622 :
623 177 : int ui_pref_set_str(const char *key, const char *value) {
624 177 : const char *data_base = platform_data_dir();
625 177 : if (!data_base) return -1;
626 177 : RAII_STRING char *dir = NULL;
627 177 : if (asprintf(&dir, "%s/email-cli", data_base) == -1) return -1;
628 177 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
629 354 : RAII_STRING char *path = ui_pref_path();
630 177 : if (!path) return -1;
631 :
632 177 : char *existing = load_file(path);
633 :
634 354 : RAII_FILE FILE *fp = fopen(path, "w");
635 177 : if (!fp) { free(existing); return -1; }
636 :
637 177 : size_t klen = strlen(key);
638 177 : if (existing) {
639 174 : char *line = existing;
640 548 : while (*line) {
641 374 : char *nl = strchr(line, '\n');
642 374 : size_t llen = nl ? (size_t)(nl - line + 1) : strlen(line);
643 374 : if (!(strncmp(line, key, klen) == 0 && line[klen] == '='))
644 203 : fwrite(line, 1, llen, fp);
645 374 : line += llen;
646 : }
647 174 : free(existing);
648 : }
649 177 : fprintf(fp, "%s=%s\n", key, value);
650 177 : logger_log(LOG_DEBUG, "UI pref %s=%s saved", key, value);
651 177 : return 0;
652 : }
653 :
654 : /* ── Folder manifest ─────────────────────────────────────────────────── */
655 :
656 3449 : static char *manifest_path(const char *folder) {
657 3449 : if (!g_account_base[0]) return NULL;
658 3449 : char *path = NULL;
659 3449 : if (asprintf(&path, "%s/manifests/%s.tsv", g_account_base, folder) == -1)
660 0 : return NULL;
661 3449 : return path;
662 : }
663 :
664 : /** @brief Duplicates a string, replacing tabs with spaces. */
665 2235 : static char *sanitise(const char *s) {
666 2235 : if (!s) return strdup("");
667 2235 : char *d = strdup(s);
668 50972 : if (d) for (char *p = d; *p; p++) if (*p == '\t') *p = ' ';
669 2235 : return d;
670 : }
671 :
672 3247 : Manifest *manifest_load(const char *folder) {
673 6494 : RAII_STRING char *path = manifest_path(folder);
674 3247 : logger_log(LOG_DEBUG, "manifest_load: folder=%s account_base=%s path=%s",
675 3247 : folder, g_account_base, path ? path : "(null)");
676 3247 : if (!path) return NULL;
677 :
678 3247 : char *data = load_file(path);
679 3247 : if (!data) return NULL;
680 :
681 2767 : Manifest *m = calloc(1, sizeof(*m));
682 2767 : if (!m) { free(data); return NULL; }
683 2767 : m->capacity = 64;
684 2767 : m->entries = malloc((size_t)m->capacity * sizeof(ManifestEntry));
685 2767 : if (!m->entries) { free(m); free(data); return NULL; }
686 :
687 2767 : char *line = data;
688 6442 : while (*line) {
689 3675 : char *nl = strchr(line, '\n');
690 3675 : if (nl) *nl = '\0';
691 :
692 : /* Parse: uid\tfrom\tsubject\tdate */
693 3675 : char *t1 = strchr(line, '\t');
694 3675 : if (!t1 || t1 == line) {
695 0 : line = nl ? nl + 1 : line + strlen(line);
696 0 : continue;
697 : }
698 3675 : *t1 = '\0';
699 3675 : char *uid_field = line;
700 3675 : char *from_start = t1 + 1;
701 3675 : char *t2 = strchr(from_start, '\t');
702 3675 : if (!t2) { line = nl ? nl + 1 : line + strlen(line); continue; }
703 3675 : *t2 = '\0';
704 3675 : char *subj_start = t2 + 1;
705 3675 : char *t3 = strchr(subj_start, '\t');
706 3675 : if (!t3) { line = nl ? nl + 1 : line + strlen(line); continue; }
707 3675 : *t3 = '\0';
708 3675 : char *date_start = t3 + 1;
709 : /* Optional 5th field: unseen flag */
710 3675 : int unseen_val = 0;
711 3675 : char *t4 = strchr(date_start, '\t');
712 3675 : if (t4) {
713 3675 : *t4 = '\0';
714 3675 : unseen_val = atoi(t4 + 1);
715 : }
716 :
717 3675 : 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 3675 : ManifestEntry *e = &m->entries[m->count++];
725 3675 : snprintf(e->uid, sizeof(e->uid), "%s", uid_field);
726 3675 : e->from = strdup(from_start);
727 3675 : e->subject = strdup(subj_start);
728 3675 : e->date = strdup(date_start);
729 3675 : e->flags = unseen_val;
730 :
731 3675 : line = nl ? nl + 1 : line + strlen(line);
732 : }
733 2767 : free(data);
734 2767 : return m;
735 : }
736 :
737 202 : int manifest_save(const char *folder, const Manifest *m) {
738 202 : if (!g_account_base[0] || !m) return -1;
739 :
740 202 : RAII_STRING char *dir = NULL;
741 202 : if (asprintf(&dir, "%s/manifests", g_account_base) == -1) return -1;
742 202 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
743 :
744 : /* For nested folders like "munka/ai" we need the parent dir */
745 404 : RAII_STRING char *path = manifest_path(folder);
746 202 : if (!path) return -1;
747 :
748 : /* Ensure parent directory exists (folder path may have slashes) */
749 202 : char *last_slash = strrchr(path, '/');
750 202 : if (last_slash) {
751 202 : char saved = *last_slash;
752 202 : *last_slash = '\0';
753 202 : fs_mkdir_p(path, 0700);
754 202 : *last_slash = saved;
755 : }
756 :
757 404 : RAII_FILE FILE *fp = fopen(path, "w");
758 202 : if (!fp) return -1;
759 :
760 947 : for (int i = 0; i < m->count; i++) {
761 745 : ManifestEntry *e = &m->entries[i];
762 1490 : RAII_STRING char *f = sanitise(e->from);
763 1490 : RAII_STRING char *s = sanitise(e->subject);
764 1490 : RAII_STRING char *d = sanitise(e->date);
765 745 : fprintf(fp, "%s\t%s\t%s\t%s\t%d\n", e->uid, f ? f : "", s ? s : "", d ? d : "", e->flags);
766 : }
767 202 : logger_log(LOG_DEBUG, "Manifest saved: %s (%d entries)", folder, m->count);
768 202 : return 0;
769 : }
770 :
771 2881 : void manifest_free(Manifest *m) {
772 2881 : if (!m) return;
773 8152 : for (int i = 0; i < m->count; i++) {
774 5271 : free(m->entries[i].from);
775 5271 : free(m->entries[i].subject);
776 5271 : free(m->entries[i].date);
777 : }
778 2881 : free(m->entries);
779 2881 : free(m);
780 : }
781 :
782 9927 : ManifestEntry *manifest_find(const Manifest *m, const char *uid) {
783 9927 : if (!m) return NULL;
784 803555 : for (int i = 0; i < m->count; i++)
785 800759 : if (strcmp(m->entries[i].uid, uid) == 0) return &m->entries[i];
786 2796 : return NULL;
787 : }
788 :
789 1766 : void manifest_upsert(Manifest *m, const char *uid,
790 : char *from, char *subject, char *date, int flags) {
791 1766 : if (!m) return;
792 1766 : ManifestEntry *existing = manifest_find(m, uid);
793 1766 : if (existing) {
794 98 : free(existing->from); existing->from = from;
795 98 : free(existing->subject); existing->subject = subject;
796 98 : free(existing->date); existing->date = date;
797 98 : existing->flags = flags;
798 98 : return;
799 : }
800 1668 : if (m->count == m->capacity) {
801 171 : int new_cap = m->capacity ? m->capacity * 2 : 64;
802 171 : ManifestEntry *tmp = realloc(m->entries,
803 171 : (size_t)new_cap * sizeof(ManifestEntry));
804 171 : if (!tmp) { free(from); free(subject); free(date); return; }
805 171 : m->entries = tmp;
806 171 : m->capacity = new_cap;
807 : }
808 1668 : ManifestEntry *e = &m->entries[m->count++];
809 1668 : snprintf(e->uid, sizeof(e->uid), "%s", uid);
810 1668 : e->from = from; e->subject = subject; e->date = date;
811 1668 : e->flags = flags;
812 : }
813 :
814 277 : void manifest_retain(Manifest *m, const char (*keep_uids)[17], int keep_count) {
815 277 : if (!m) return;
816 277 : int dst = 0;
817 1096 : for (int i = 0; i < m->count; i++) {
818 819 : int found = 0;
819 71774 : for (int j = 0; j < keep_count; j++) {
820 71772 : if (strcmp(keep_uids[j], m->entries[i].uid) == 0) { found = 1; break; }
821 : }
822 819 : if (found) {
823 817 : if (dst != i) m->entries[dst] = m->entries[i];
824 817 : dst++;
825 : } else {
826 2 : free(m->entries[i].from);
827 2 : free(m->entries[i].subject);
828 2 : free(m->entries[i].date);
829 : }
830 : }
831 277 : 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 25 : int local_folder_list_save(const char **folders, int count, char sep) {
853 25 : if (!g_account_base[0]) return -1;
854 25 : RAII_STRING char *path = NULL;
855 25 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return -1;
856 50 : RAII_FILE FILE *fp = fopen(path, "w");
857 25 : if (!fp) return -1;
858 25 : fprintf(fp, "sep=%c\n", sep);
859 207 : for (int i = 0; i < count; i++)
860 182 : fprintf(fp, "%s\n", folders[i] ? folders[i] : "");
861 25 : logger_log(LOG_DEBUG, "Folder list cache saved: %d folders", count);
862 25 : return 0;
863 : }
864 :
865 470 : char **local_folder_list_load(int *count_out, char *sep_out) {
866 470 : *count_out = 0;
867 470 : if (!g_account_base[0]) return NULL;
868 470 : RAII_STRING char *path = NULL;
869 470 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return NULL;
870 940 : RAII_FILE FILE *fp = fopen(path, "r");
871 470 : if (!fp) return NULL;
872 :
873 : char line[1024];
874 373 : char sep = '.';
875 : /* First line: sep=<char> */
876 373 : if (!fgets(line, sizeof(line), fp)) return NULL;
877 373 : if (strncmp(line, "sep=", 4) == 0 && line[4] != '\n')
878 373 : sep = line[4];
879 :
880 373 : int cap = 32, cnt = 0;
881 373 : char **folders = malloc((size_t)cap * sizeof(char *));
882 373 : if (!folders) return NULL;
883 3295 : while (fgets(line, sizeof(line), fp)) {
884 : /* strip trailing newline */
885 2922 : size_t len = strlen(line);
886 5844 : while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
887 2922 : line[--len] = '\0';
888 2922 : if (len == 0) continue;
889 2922 : 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 2922 : folders[cnt] = strdup(line);
896 2922 : if (!folders[cnt]) { for (int i = 0; i < cnt; i++) free(folders[i]); free(folders); return NULL; }
897 2922 : cnt++;
898 : }
899 373 : *count_out = cnt;
900 373 : if (sep_out) *sep_out = sep;
901 373 : logger_log(LOG_DEBUG, "Folder list cache loaded: %d folders", cnt);
902 373 : return folders;
903 : }
904 :
905 1841 : void manifest_count_folder(const char *folder, int *total_out,
906 : int *unseen_out, int *flagged_out) {
907 1841 : *total_out = 0; *unseen_out = 0; *flagged_out = 0;
908 1841 : Manifest *m = manifest_load(folder);
909 1841 : 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 117 : 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 117 : if (unread_out) *unread_out = 0;
952 117 : if (flagged_out) *flagged_out = 0;
953 117 : if (junk_out) *junk_out = 0;
954 117 : if (phishing_out) *phishing_out = 0;
955 117 : if (answered_out) *answered_out = 0;
956 117 : if (forwarded_out)*forwarded_out= 0;
957 127 : if (!g_account_base[0]) return;
958 : char dir_path[8300];
959 117 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
960 234 : RAII_DIR DIR *dp = opendir(dir_path);
961 117 : if (!dp) return;
962 : struct dirent *ent;
963 1076 : while ((ent = readdir(dp)) != NULL) {
964 969 : const char *name = ent->d_name;
965 969 : size_t nlen = strlen(name);
966 969 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
967 755 : RAII_STRING char *folder = strndup(name, nlen - 4);
968 755 : if (!folder) continue;
969 755 : Manifest *m = manifest_load(folder);
970 755 : if (!m) continue;
971 1525 : for (int i = 0; i < m->count; i++) {
972 770 : int f = m->entries[i].flags;
973 770 : if (unread_out && (f & MSG_FLAG_UNSEEN)) (*unread_out)++;
974 770 : if (flagged_out && (f & MSG_FLAG_FLAGGED)) (*flagged_out)++;
975 770 : if (junk_out && (f & MSG_FLAG_JUNK)) (*junk_out)++;
976 770 : if (phishing_out && (f & MSG_FLAG_PHISHING)) (*phishing_out)++;
977 770 : if (answered_out && (f & MSG_FLAG_ANSWERED)) (*answered_out)++;
978 770 : if (forwarded_out&& (f & MSG_FLAG_FORWARDED)) (*forwarded_out)++;
979 : }
980 755 : manifest_free(m);
981 : }
982 : }
983 :
984 : /* ── Cross-folder flag search ────────────────────────────────────────── */
985 :
986 18 : int local_flag_search(int flag_mask,
987 : SearchResult **results_out, int *count_out)
988 : {
989 18 : *results_out = NULL;
990 18 : *count_out = 0;
991 18 : if (!g_account_base[0]) return 0;
992 :
993 18 : int cap = 64, cnt = 0;
994 18 : SearchResult *res = malloc((size_t)cap * sizeof(SearchResult));
995 18 : if (!res) return -1;
996 :
997 : char dir_path[8300];
998 18 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
999 36 : RAII_DIR DIR *dp = opendir(dir_path);
1000 18 : if (!dp) { free(res); return 0; }
1001 :
1002 : struct dirent *ent;
1003 168 : while ((ent = readdir(dp)) != NULL) {
1004 150 : const char *name = ent->d_name;
1005 150 : size_t nlen = strlen(name);
1006 150 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
1007 114 : RAII_STRING char *folder = strndup(name, nlen - 4);
1008 114 : if (!folder) continue;
1009 114 : Manifest *m = manifest_load(folder);
1010 114 : if (!m) continue;
1011 241 : for (int i = 0; i < m->count; i++) {
1012 127 : if (!(m->entries[i].flags & flag_mask)) continue;
1013 60 : 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 60 : SearchResult *r = &res[cnt++];
1020 60 : snprintf(r->uid, sizeof(r->uid), "%s", m->entries[i].uid);
1021 60 : snprintf(r->folder, sizeof(r->folder), "%s", folder);
1022 60 : r->flags = m->entries[i].flags;
1023 60 : r->from = strdup(m->entries[i].from ? m->entries[i].from : "");
1024 60 : r->subject = strdup(m->entries[i].subject ? m->entries[i].subject : "");
1025 60 : r->date = strdup(m->entries[i].date ? m->entries[i].date : "");
1026 : }
1027 114 : manifest_free(m);
1028 : }
1029 18 : *results_out = res;
1030 18 : *count_out = cnt;
1031 18 : return 0;
1032 : }
1033 :
1034 : /* ── Cross-folder text search ─────────────────────────────────────────── */
1035 :
1036 11 : int local_search(const char *query, int scope,
1037 : SearchResult **results_out, int *count_out)
1038 : {
1039 11 : *results_out = NULL;
1040 11 : *count_out = 0;
1041 11 : if (!query || !query[0] || !g_account_base[0]) return 0;
1042 :
1043 : char dir_path[8300];
1044 9 : snprintf(dir_path, sizeof(dir_path), "%s/manifests", g_account_base);
1045 18 : RAII_DIR DIR *dp = opendir(dir_path);
1046 9 : if (!dp) return 0; /* no manifests — not an error */
1047 :
1048 9 : int cap = 64;
1049 9 : SearchResult *results = malloc((size_t)cap * sizeof(SearchResult));
1050 9 : if (!results) return -1;
1051 9 : int count = 0;
1052 :
1053 : struct dirent *ent;
1054 71 : while ((ent = readdir(dp)) != NULL) {
1055 62 : const char *name = ent->d_name;
1056 62 : size_t nlen = strlen(name);
1057 62 : if (nlen <= 4 || strcmp(name + nlen - 4, ".tsv") != 0) continue;
1058 :
1059 44 : RAII_STRING char *fold = strndup(name, nlen - 4);
1060 44 : if (!fold) continue;
1061 :
1062 44 : Manifest *m = manifest_load(fold);
1063 44 : if (!m) continue;
1064 :
1065 92 : for (int i = 0; i < m->count; i++) {
1066 48 : ManifestEntry *me = &m->entries[i];
1067 48 : int match = 0;
1068 48 : if (scope == 0) {
1069 22 : const char *s = (me->subject && me->subject[0]) ? me->subject : "";
1070 22 : match = strcasestr(s, query) != NULL;
1071 26 : } else if (scope == 1) {
1072 10 : const char *s = (me->from && me->from[0]) ? me->from : "";
1073 10 : 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 48 : if (!match) continue;
1086 :
1087 18 : 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 18 : SearchResult *r = &results[count++];
1094 18 : memcpy(r->uid, me->uid, 17);
1095 18 : snprintf(r->folder, sizeof(r->folder), "%s", fold);
1096 18 : r->flags = me->flags;
1097 18 : r->from = me->from ? strdup(me->from) : strdup("");
1098 18 : r->subject = me->subject ? strdup(me->subject) : strdup("");
1099 18 : r->date = me->date ? strdup(me->date) : strdup("");
1100 : }
1101 44 : manifest_free(m);
1102 : }
1103 :
1104 9 : *results_out = results;
1105 9 : *count_out = count;
1106 9 : return 0;
1107 : }
1108 :
1109 14 : void local_search_free(SearchResult *results, int count)
1110 : {
1111 14 : if (!results) return;
1112 42 : for (int i = 0; i < count; i++) {
1113 28 : free(results[i].from);
1114 28 : free(results[i].subject);
1115 28 : free(results[i].date);
1116 : }
1117 14 : free(results);
1118 : }
1119 :
1120 : /* ── Pending flag changes ─────────────────────────────────────────────── */
1121 :
1122 157 : static char *pending_flag_path(const char *folder) {
1123 157 : if (!g_account_base[0]) return NULL;
1124 157 : char *path = NULL;
1125 157 : if (asprintf(&path, "%s/pending_flags/%s.tsv", g_account_base, folder) == -1)
1126 0 : return NULL;
1127 157 : return path;
1128 : }
1129 :
1130 44 : int local_pending_flag_add(const char *folder, const char *uid,
1131 : const char *flag_name, int add) {
1132 88 : RAII_STRING char *path = pending_flag_path(folder);
1133 44 : if (!path) return -1;
1134 :
1135 : /* Ensure parent directory exists (folder path may have slashes) */
1136 44 : char *dir_end = strrchr(path, '/');
1137 44 : if (dir_end) {
1138 44 : char saved = *dir_end;
1139 44 : *dir_end = '\0';
1140 44 : fs_mkdir_p(path, 0700);
1141 44 : *dir_end = saved;
1142 : }
1143 :
1144 88 : RAII_FILE FILE *fp = fopen(path, "a");
1145 44 : if (!fp) return -1;
1146 44 : fprintf(fp, "%s\t%s\t%d\n", uid, flag_name, add);
1147 44 : 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 113 : static char *pending_move_path(const char *folder) {
1192 113 : if (!g_account_base[0]) return NULL;
1193 113 : char *path = NULL;
1194 113 : if (asprintf(&path, "%s/pending_moves/%s.tsv", g_account_base, folder) == -1)
1195 0 : return NULL;
1196 113 : return path;
1197 : }
1198 :
1199 1 : int local_pending_move_add(const char *folder, const char *uid,
1200 : const char *target_folder) {
1201 2 : RAII_STRING char *path = pending_move_path(folder);
1202 1 : if (!path) return -1;
1203 1 : char *dir_end = strrchr(path, '/');
1204 1 : if (dir_end) {
1205 1 : char saved = *dir_end; *dir_end = '\0';
1206 1 : fs_mkdir_p(path, 0700);
1207 1 : *dir_end = saved;
1208 : }
1209 2 : RAII_FILE FILE *fp = fopen(path, "a");
1210 1 : if (!fp) return -1;
1211 1 : fprintf(fp, "%s\t%s\n", uid, target_folder);
1212 1 : 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 4047 : static char *label_idx_path(const char *label) {
1254 4047 : if (!g_account_base[0] || !label) return NULL;
1255 4047 : char *path = NULL;
1256 4047 : if (asprintf(&path, "%s/labels/%s.idx", g_account_base, label) == -1)
1257 0 : return NULL;
1258 4047 : return path;
1259 : }
1260 :
1261 : /** @brief Ensures the labels/ directory (and any parent for nested labels) exists. */
1262 1292 : static int ensure_label_dir(const char *label) {
1263 2584 : RAII_STRING char *path = label_idx_path(label);
1264 1292 : if (!path) return -1;
1265 : /* Find last slash and mkdir_p up to it */
1266 1292 : char *last_slash = strrchr(path, '/');
1267 1292 : if (!last_slash) return -1;
1268 1292 : *last_slash = '\0';
1269 1292 : int rc = fs_mkdir_p(path, 0700);
1270 1292 : return rc;
1271 : }
1272 :
1273 66 : int label_idx_contains(const char *label, const char *uid) {
1274 66 : char (*arr)[17] = NULL;
1275 66 : int n = 0;
1276 66 : if (label_idx_load(label, &arr, &n) != 0 || n == 0) {
1277 29 : free(arr);
1278 29 : return 0;
1279 : }
1280 :
1281 : /* In-memory binary search (file is kept sorted) */
1282 37 : int lo = 0, hi = n - 1, found = 0;
1283 60 : while (lo <= hi) {
1284 56 : int mid = lo + (hi - lo) / 2;
1285 56 : int cmp = strcmp(arr[mid], uid);
1286 56 : if (cmp == 0) { found = 1; break; }
1287 23 : if (cmp < 0) lo = mid + 1;
1288 8 : else hi = mid - 1;
1289 : }
1290 37 : free(arr);
1291 37 : return found;
1292 : }
1293 :
1294 211 : int label_idx_count(const char *label) {
1295 211 : char (*arr)[17] = NULL;
1296 211 : int n = 0;
1297 211 : label_idx_load(label, &arr, &n);
1298 211 : free(arr);
1299 211 : 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 1463 : int label_idx_load(const char *label, char (**uids_out)[17], int *count_out) {
1324 1463 : *uids_out = NULL;
1325 1463 : *count_out = 0;
1326 :
1327 2926 : RAII_STRING char *path = label_idx_path(label);
1328 1463 : if (!path) return -1;
1329 :
1330 2926 : RAII_FILE FILE *fp = fopen(path, "r");
1331 1463 : 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 1157 : int cap = 256;
1337 1157 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
1338 1157 : if (!arr) return -1;
1339 :
1340 1157 : int count = 0;
1341 : char line[64];
1342 61017 : while (fgets(line, sizeof(line), fp)) {
1343 : /* fgets stops at '\n'; strip trailing whitespace/newline */
1344 59860 : size_t len = strlen(line);
1345 119680 : while (len > 0 && ((unsigned char)line[len-1] <= ' '))
1346 59820 : line[--len] = '\0';
1347 59860 : if (len == 0 || len > 16) continue;
1348 :
1349 59860 : 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 59860 : memset(arr[count], 0, sizeof(arr[count]));
1356 59860 : memcpy(arr[count], line, len);
1357 59860 : count++;
1358 : }
1359 :
1360 1157 : *uids_out = arr;
1361 1157 : *count_out = count;
1362 1157 : return 0;
1363 : }
1364 :
1365 1292 : int label_idx_write(const char *label, const char (*uids)[17], int count) {
1366 1292 : if (ensure_label_dir(label) != 0) return -1;
1367 :
1368 2584 : RAII_STRING char *path = label_idx_path(label);
1369 1292 : if (!path) return -1;
1370 :
1371 2584 : RAII_FILE FILE *fp = fopen(path, "w");
1372 1292 : 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 66699 : for (int i = 0; i < count; i++) {
1378 : char padded[17];
1379 65407 : size_t uid_len = strlen(uids[i]);
1380 65407 : if (uid_len > 16) uid_len = 16;
1381 65407 : memset(padded, 0, 16);
1382 65407 : memcpy(padded, uids[i], uid_len);
1383 65407 : padded[16] = '\n';
1384 65407 : if (fwrite(padded, 1, 17, fp) != 17) return -1;
1385 : }
1386 :
1387 1292 : logger_log(LOG_DEBUG, "label_idx_write: %s → %d entries", label, count);
1388 1292 : return 0;
1389 : }
1390 :
1391 28 : char *local_hdr_get_labels(const char *folder, const char *uid) {
1392 28 : char *hdr = local_hdr_load(folder, uid);
1393 28 : if (!hdr) return NULL;
1394 :
1395 : /* Parse 4th tab-separated field: from\tsubject\tdate\tLABELS\tflags */
1396 26 : const char *p = hdr;
1397 103 : for (int t = 0; t < 3; t++) {
1398 78 : p = strchr(p, '\t');
1399 78 : if (!p) { free(hdr); return NULL; }
1400 77 : p++;
1401 : }
1402 : /* p now points to the start of the labels field */
1403 25 : const char *end = strchr(p, '\t');
1404 25 : size_t len = end ? (size_t)(end - p) : strlen(p);
1405 25 : char *result = strndup(p, len);
1406 25 : free(hdr);
1407 25 : return result;
1408 : }
1409 :
1410 20 : int label_idx_list(char ***labels_out, int *count_out) {
1411 20 : *labels_out = NULL;
1412 20 : *count_out = 0;
1413 :
1414 : char dir_path[8300];
1415 20 : snprintf(dir_path, sizeof(dir_path), "%s/labels", g_account_base);
1416 :
1417 40 : RAII_DIR DIR *dp = opendir(dir_path);
1418 20 : if (!dp) return 0; /* No labels directory → 0 labels */
1419 :
1420 10 : char **list = NULL;
1421 10 : int count = 0, cap = 0;
1422 :
1423 : struct dirent *ent;
1424 55 : while ((ent = readdir(dp)) != NULL) {
1425 45 : const char *name = ent->d_name;
1426 45 : size_t nlen = strlen(name);
1427 45 : if (nlen <= 4) continue;
1428 25 : if (strcmp(name + nlen - 4, ".idx") != 0) continue;
1429 :
1430 : /* Extract label name (strip .idx) */
1431 25 : char *label = strndup(name, nlen - 4);
1432 25 : if (!label) continue;
1433 :
1434 25 : if (count == cap) {
1435 10 : int newcap = cap ? cap * 2 : 16;
1436 10 : char **tmp = realloc(list, (size_t)newcap * sizeof(char *));
1437 10 : if (!tmp) { free(label); break; }
1438 10 : list = tmp;
1439 10 : cap = newcap;
1440 : }
1441 25 : list[count++] = label;
1442 : }
1443 :
1444 10 : *labels_out = list;
1445 10 : *count_out = count;
1446 10 : return 0;
1447 : }
1448 :
1449 1094 : int label_idx_add(const char *label, const char *uid) {
1450 1094 : if (!uid || strlen(uid) < 1) return -1;
1451 :
1452 : /* Load existing entries */
1453 1094 : char (*existing)[17] = NULL;
1454 1094 : int ecount = 0;
1455 1094 : label_idx_load(label, &existing, &ecount);
1456 :
1457 : /* Check if already present (binary search) */
1458 1094 : int lo = 0, hi = ecount - 1, insert_pos = ecount;
1459 6435 : while (lo <= hi) {
1460 5351 : int mid = lo + (hi - lo) / 2;
1461 5351 : int cmp = strcmp(existing[mid], uid);
1462 5351 : if (cmp == 0) { free(existing); return 0; } /* Already present */
1463 5341 : if (cmp < 0) lo = mid + 1;
1464 48 : else { insert_pos = mid; hi = mid - 1; }
1465 : }
1466 1084 : if (lo < ecount && insert_pos == ecount) insert_pos = lo;
1467 :
1468 : /* Build new array with uid inserted at insert_pos */
1469 1084 : int newcount = ecount + 1;
1470 1084 : char (*arr)[17] = malloc((size_t)newcount * sizeof(char[17]));
1471 1084 : if (!arr) { free(existing); return -1; }
1472 :
1473 1084 : if (insert_pos > 0 && existing)
1474 968 : memcpy(arr, existing, (size_t)insert_pos * sizeof(char[17]));
1475 1084 : snprintf(arr[insert_pos], 17, "%.16s", uid);
1476 1084 : if (insert_pos < ecount && existing)
1477 16 : memcpy(arr + insert_pos + 1, existing + insert_pos,
1478 16 : (size_t)(ecount - insert_pos) * sizeof(char[17]));
1479 1084 : free(existing);
1480 :
1481 1084 : int rc = label_idx_write(label, (const char (*)[17])arr, newcount);
1482 1084 : free(arr);
1483 1084 : return rc;
1484 : }
1485 :
1486 59 : int label_idx_remove(const char *label, const char *uid) {
1487 59 : if (!uid) return -1;
1488 :
1489 59 : char (*existing)[17] = NULL;
1490 59 : int ecount = 0;
1491 59 : label_idx_load(label, &existing, &ecount);
1492 59 : if (!existing || ecount == 0) { free(existing); return 0; }
1493 :
1494 : /* Find uid with binary search */
1495 46 : int lo = 0, hi = ecount - 1, found = -1;
1496 84 : while (lo <= hi) {
1497 81 : int mid = lo + (hi - lo) / 2;
1498 81 : int cmp = strcmp(existing[mid], uid);
1499 81 : if (cmp == 0) { found = mid; break; }
1500 38 : if (cmp < 0) lo = mid + 1;
1501 33 : else hi = mid - 1;
1502 : }
1503 :
1504 46 : if (found < 0) { free(existing); return 0; } /* Not present */
1505 :
1506 : /* Shift down */
1507 43 : if (found < ecount - 1)
1508 12 : memmove(existing + found, existing + found + 1,
1509 12 : (size_t)(ecount - found - 1) * sizeof(char[17]));
1510 :
1511 43 : int rc = label_idx_write(label, (const char (*)[17])existing, ecount - 1);
1512 43 : free(existing);
1513 43 : return rc;
1514 : }
1515 :
1516 : /* ── Gmail history ID ─────────────────────────────────────────────── */
1517 :
1518 : /* ── Trash label backup (for untrash restore) ────────────────────── */
1519 :
1520 15 : static char *trash_labels_path(const char *uid) {
1521 15 : if (!g_account_base[0] || !uid) return NULL;
1522 15 : char *path = NULL;
1523 15 : if (asprintf(&path, "%s/trash_labels/%s.lbl", g_account_base, uid) == -1)
1524 0 : return NULL;
1525 15 : return path;
1526 : }
1527 :
1528 3 : int local_trash_labels_save(const char *uid, const char *labels) {
1529 3 : if (!uid || !labels) return -1;
1530 : /* Ensure directory exists */
1531 : char dir[8300];
1532 3 : snprintf(dir, sizeof(dir), "%s/trash_labels", g_account_base);
1533 3 : fs_mkdir_p(dir, 0700);
1534 :
1535 6 : RAII_STRING char *path = trash_labels_path(uid);
1536 3 : if (!path) return -1;
1537 6 : RAII_FILE FILE *fp = fopen(path, "w");
1538 3 : if (!fp) return -1;
1539 3 : fprintf(fp, "%s\n", labels);
1540 3 : return 0;
1541 : }
1542 :
1543 8 : char *local_trash_labels_load(const char *uid) {
1544 16 : RAII_STRING char *path = trash_labels_path(uid);
1545 8 : if (!path) return NULL;
1546 16 : RAII_FILE FILE *fp = fopen(path, "r");
1547 8 : if (!fp) return NULL;
1548 : char buf[4096];
1549 3 : if (!fgets(buf, (int)sizeof(buf), fp)) return NULL;
1550 3 : buf[strcspn(buf, "\r\n")] = '\0';
1551 3 : return strdup(buf);
1552 : }
1553 :
1554 4 : void local_trash_labels_remove(const char *uid) {
1555 8 : RAII_STRING char *path = trash_labels_path(uid);
1556 4 : if (path) unlink(path);
1557 4 : }
1558 :
1559 23 : int local_gmail_label_names_save(char **ids, char **names, int count) {
1560 23 : if (!g_account_base[0]) return -1;
1561 23 : if (fs_mkdir_p(g_account_base, 0700) != 0) return -1;
1562 23 : RAII_STRING char *path = NULL;
1563 23 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return -1;
1564 46 : RAII_FILE FILE *fp = fopen(path, "w");
1565 23 : if (!fp) return -1;
1566 195 : for (int i = 0; i < count; i++)
1567 172 : fprintf(fp, "%s\t%s\n", ids[i], names[i]);
1568 23 : return 0;
1569 : }
1570 :
1571 26 : char *local_gmail_label_name_lookup(const char *id) {
1572 26 : if (!g_account_base[0] || !id) return NULL;
1573 26 : RAII_STRING char *path = NULL;
1574 26 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return NULL;
1575 52 : RAII_FILE FILE *fp = fopen(path, "r");
1576 26 : 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 15 : char *local_gmail_label_id_lookup(const char *name) {
1590 15 : if (!g_account_base[0] || !name) return NULL;
1591 15 : RAII_STRING char *path = NULL;
1592 15 : if (asprintf(&path, "%s/gmail_label_names", g_account_base) == -1) return NULL;
1593 30 : RAII_FILE FILE *fp = fopen(path, "r");
1594 15 : 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 34 : int local_gmail_history_save(const char *history_id) {
1608 34 : if (!g_account_base[0] || !history_id) return -1;
1609 33 : if (fs_mkdir_p(g_account_base, 0700) != 0) return -1;
1610 33 : RAII_STRING char *path = NULL;
1611 33 : if (asprintf(&path, "%s/gmail_history_id", g_account_base) == -1) return -1;
1612 33 : return write_file(path, history_id, strlen(history_id));
1613 : }
1614 :
1615 41 : char *local_gmail_history_load(void) {
1616 41 : if (!g_account_base[0]) return NULL;
1617 41 : RAII_STRING char *path = NULL;
1618 41 : if (asprintf(&path, "%s/gmail_history_id", g_account_base) == -1) return NULL;
1619 41 : char *data = load_file(path);
1620 41 : if (!data) return NULL;
1621 : /* Trim trailing whitespace */
1622 20 : size_t len = strlen(data);
1623 20 : while (len > 0 && (data[len-1] == '\n' || data[len-1] == '\r' || data[len-1] == ' '))
1624 0 : data[--len] = '\0';
1625 20 : 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 534 : static void parse_addr_list(const char *hdr,
1635 : void (*cb)(const char *, const char *, void *),
1636 : void *ud) {
1637 534 : if (!hdr || !hdr[0]) return;
1638 : /* Walk comma-separated tokens */
1639 : char buf[512];
1640 179 : const char *p = hdr;
1641 360 : while (*p) {
1642 : /* skip leading whitespace / commas / semicolons */
1643 364 : while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' ||
1644 366 : *p == ',' || *p == ';') p++;
1645 181 : if (!*p) break;
1646 :
1647 : /* Copy until the next top-level comma (respecting quoted strings
1648 : * and angle-bracket groups). */
1649 181 : int depth = 0; int in_q = 0; const char *start = p;
1650 181 : size_t i = 0;
1651 6193 : while (*p) {
1652 6014 : if (*p == '"') { in_q = !in_q; }
1653 6014 : else if (!in_q && *p == '<') { depth++; }
1654 5851 : else if (!in_q && *p == '>') { depth--; }
1655 5688 : else if (!in_q && depth == 0 && (*p == ',' || *p == ';')) break;
1656 6012 : if (i < sizeof(buf) - 1) buf[i++] = *p;
1657 6012 : p++;
1658 : }
1659 181 : buf[i] = '\0';
1660 181 : if (buf[0] == '\0') continue;
1661 :
1662 : /* Extract: "Display Name <addr>" or bare "addr" */
1663 181 : char addr[256] = ""; char name[256] = "";
1664 181 : char *lt = strchr(buf, '<');
1665 181 : char *gt = lt ? strchr(lt, '>') : NULL;
1666 344 : if (lt && gt) {
1667 163 : size_t alen = (size_t)(gt - lt - 1);
1668 163 : if (alen < sizeof(addr)) {
1669 163 : memcpy(addr, lt + 1, alen); addr[alen] = '\0';
1670 : }
1671 : /* display name: everything before '<', trimmed, dequoted */
1672 163 : size_t nlen = (size_t)(lt - buf);
1673 163 : if (nlen > 0 && nlen < sizeof(name)) {
1674 163 : memcpy(name, buf, nlen); name[nlen] = '\0';
1675 : /* trim whitespace */
1676 163 : char *ns = name;
1677 163 : while (*ns == ' ' || *ns == '\t') ns++;
1678 163 : char *ne = ns + strlen(ns);
1679 326 : while (ne > ns && (*(ne-1) == ' ' || *(ne-1) == '\t' ||
1680 326 : *(ne-1) == '"')) ne--;
1681 163 : if (*ns == '"') ns++;
1682 163 : *ne = '\0';
1683 163 : memmove(name, ns, strlen(ns) + 1);
1684 : }
1685 : } else {
1686 : /* bare address */
1687 18 : char *ns = buf;
1688 18 : while (*ns == ' ' || *ns == '\t') ns++;
1689 18 : char *ne = ns + strlen(ns);
1690 18 : while (ne > ns && (*(ne-1) == ' ' || *(ne-1) == '\t')) ne--;
1691 18 : size_t alen = (size_t)(ne - ns);
1692 18 : if (alen < sizeof(addr)) { memcpy(addr, ns, alen); addr[alen] = '\0'; }
1693 : }
1694 181 : 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 784 : static int contact_cmp_freq(const void *a, const void *b) {
1710 784 : return ((const ContactEntry *)b)->freq - ((const ContactEntry *)a)->freq;
1711 : }
1712 :
1713 : typedef struct { ContactEntry *arr; int count; int cap; } ContactBuf;
1714 :
1715 181 : static void contact_add_cb(const char *addr, const char *name, void *ud) {
1716 181 : ContactBuf *cb = (ContactBuf *)ud;
1717 181 : if (!addr || !addr[0]) return;
1718 : /* case-insensitive dedup on address */
1719 453 : for (int i = 0; i < cb->count; i++) {
1720 414 : if (strcasecmp(cb->arr[i].addr, addr) == 0) {
1721 142 : cb->arr[i].freq++;
1722 : /* update name if we now have one and didn't before */
1723 142 : if (name && name[0] && !cb->arr[i].name[0]) {
1724 1 : size_t _n = strlen(name);
1725 1 : if (_n >= sizeof(cb->arr[i].name)) _n = sizeof(cb->arr[i].name) - 1;
1726 1 : memcpy(cb->arr[i].name, name, _n); cb->arr[i].name[_n] = '\0';
1727 : }
1728 142 : return;
1729 : }
1730 : }
1731 39 : if (cb->count >= cb->cap) return; /* full */
1732 39 : { size_t _a = strlen(addr); if (_a >= sizeof(cb->arr[cb->count].addr)) _a = sizeof(cb->arr[cb->count].addr) - 1;
1733 39 : memcpy(cb->arr[cb->count].addr, addr, _a); cb->arr[cb->count].addr[_a] = '\0'; }
1734 39 : { const char *_nm = name ? name : "";
1735 39 : size_t _n = strlen(_nm); if (_n >= sizeof(cb->arr[cb->count].name)) _n = sizeof(cb->arr[cb->count].name) - 1;
1736 39 : memcpy(cb->arr[cb->count].name, _nm, _n); cb->arr[cb->count].name[_n] = '\0'; }
1737 39 : cb->arr[cb->count].freq = 1;
1738 39 : cb->count++;
1739 : }
1740 :
1741 3 : void local_contacts_rebuild(void) {
1742 3 : const char *data_base = platform_data_dir();
1743 3 : if (!data_base || !g_account_name[0]) return;
1744 :
1745 3 : ContactEntry *arr = calloc(CONTACTS_MAX, sizeof(ContactEntry));
1746 3 : if (!arr) return;
1747 3 : ContactBuf cb = { arr, 0, CONTACTS_MAX };
1748 :
1749 3 : int fcount = 0;
1750 3 : char **folders = local_folder_list_load(&fcount, NULL);
1751 :
1752 3 : if (fcount > 0 && folders) {
1753 : /* IMAP account: .hdr files contain raw RFC 2822 headers */
1754 11 : for (int fi = 0; fi < fcount && cb.count < CONTACTS_MAX; fi++) {
1755 9 : char (*uids)[17] = NULL;
1756 9 : int uid_count = 0;
1757 9 : local_hdr_list_all_uids(folders[fi], &uids, &uid_count);
1758 17 : for (int u = 0; u < uid_count && cb.count < CONTACTS_MAX; u++) {
1759 8 : char *raw = local_hdr_load(folders[fi], uids[u]);
1760 8 : if (!raw) continue;
1761 8 : char *from_h = mime_get_header(raw, "From");
1762 8 : char *to_h = mime_get_header(raw, "To");
1763 8 : char *cc_h = mime_get_header(raw, "Cc");
1764 8 : parse_addr_list(from_h, contact_add_cb, &cb);
1765 8 : parse_addr_list(to_h, contact_add_cb, &cb);
1766 8 : parse_addr_list(cc_h, contact_add_cb, &cb);
1767 8 : free(from_h); free(to_h); free(cc_h);
1768 8 : free(raw);
1769 : }
1770 9 : free(uids);
1771 : }
1772 11 : for (int i = 0; i < fcount; i++) free(folders[i]);
1773 2 : 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 1 : if (folders) {
1778 0 : for (int i = 0; i < fcount; i++) free(folders[i]);
1779 0 : free(folders);
1780 : }
1781 1 : char (*uids)[17] = NULL;
1782 1 : int uid_count = 0;
1783 1 : local_hdr_list_all_uids("", &uids, &uid_count);
1784 1 : 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 1 : free(uids);
1797 : }
1798 :
1799 3 : qsort(arr, (size_t)cb.count, sizeof(ContactEntry), contact_cmp_freq);
1800 :
1801 : char path[8192];
1802 3 : snprintf(path, sizeof(path), "%s/email-cli/accounts/%s/contacts.tsv",
1803 : data_base, g_account_name);
1804 3 : FILE *f = fopen(path, "w");
1805 3 : if (f) {
1806 6 : for (int i = 0; i < cb.count; i++)
1807 3 : fprintf(f, "%s\t%s\t%d\n", arr[i].addr, arr[i].name, arr[i].freq);
1808 3 : fclose(f);
1809 : }
1810 3 : printf("Contacts rebuilt: %d entries written to %s\n", cb.count, path);
1811 3 : free(arr);
1812 : }
1813 :
1814 170 : void local_contacts_update(const char *from_hdr,
1815 : const char *to_hdr,
1816 : const char *cc_hdr) {
1817 170 : const char *data_base = platform_data_dir();
1818 170 : if (!data_base || !g_account_name[0]) return;
1819 :
1820 : char path[8192];
1821 170 : snprintf(path, sizeof(path), "%s/email-cli/accounts/%s/contacts.tsv",
1822 : data_base, g_account_name);
1823 :
1824 : /* Load existing entries */
1825 170 : ContactEntry *arr = calloc(CONTACTS_MAX, sizeof(ContactEntry));
1826 170 : if (!arr) return;
1827 170 : ContactBuf cb = { arr, 0, CONTACTS_MAX };
1828 :
1829 170 : FILE *f = fopen(path, "r");
1830 170 : if (f) {
1831 : char line[512];
1832 780 : while (cb.count < CONTACTS_MAX && fgets(line, sizeof(line), f)) {
1833 : /* format: addr\tname\tfreq\n */
1834 632 : char *t1 = strchr(line, '\t');
1835 632 : if (!t1) continue;
1836 632 : *t1 = '\0';
1837 632 : char *t2 = strchr(t1 + 1, '\t');
1838 632 : char *name = t1 + 1;
1839 632 : int freq = 1;
1840 632 : if (t2) { *t2 = '\0'; freq = atoi(t2 + 1); if (freq < 1) freq = 1; }
1841 632 : char *nl = strchr(name, '\n'); if (nl) *nl = '\0';
1842 632 : size_t _al = strlen(line); if (_al >= sizeof(arr[cb.count].addr)) _al = sizeof(arr[cb.count].addr) - 1;
1843 632 : memcpy(arr[cb.count].addr, line, _al); arr[cb.count].addr[_al] = '\0';
1844 632 : size_t _nl = strlen(name); if (_nl >= sizeof(arr[cb.count].name)) _nl = sizeof(arr[cb.count].name) - 1;
1845 632 : memcpy(arr[cb.count].name, name, _nl); arr[cb.count].name[_nl] = '\0';
1846 632 : arr[cb.count].freq = freq;
1847 632 : cb.count++;
1848 : }
1849 148 : fclose(f);
1850 : }
1851 :
1852 : /* Add new addresses from headers */
1853 170 : parse_addr_list(from_hdr, contact_add_cb, &cb);
1854 170 : parse_addr_list(to_hdr, contact_add_cb, &cb);
1855 170 : parse_addr_list(cc_hdr, contact_add_cb, &cb);
1856 :
1857 : /* Sort by frequency descending */
1858 170 : qsort(arr, (size_t)cb.count, sizeof(ContactEntry), contact_cmp_freq);
1859 :
1860 : /* Write back */
1861 170 : f = fopen(path, "w");
1862 170 : if (f) {
1863 838 : for (int i = 0; i < cb.count; i++)
1864 668 : fprintf(f, "%s\t%s\t%d\n", arr[i].addr, arr[i].name, arr[i].freq);
1865 170 : fclose(f);
1866 : }
1867 170 : free(arr);
1868 : }
1869 :
1870 : /* ── Pending APPEND queue ────────────────────────────────────────────── */
1871 :
1872 33 : static char *pending_append_path(void) {
1873 33 : if (!g_account_base[0]) return NULL;
1874 33 : char *path = NULL;
1875 33 : if (asprintf(&path, "%s/pending_appends.tsv", g_account_base) == -1)
1876 0 : return NULL;
1877 33 : return path;
1878 : }
1879 :
1880 8 : int local_pending_append_add(const char *folder, const char *uid) {
1881 16 : RAII_STRING char *path = pending_append_path();
1882 8 : if (!path) return -1;
1883 16 : RAII_FILE FILE *fp = fopen(path, "a");
1884 8 : if (!fp) return -1;
1885 8 : fprintf(fp, "%s\t%s\n", folder, uid);
1886 8 : return 0;
1887 : }
1888 :
1889 22 : PendingAppend *local_pending_append_load(int *count_out) {
1890 22 : *count_out = 0;
1891 44 : RAII_STRING char *path = pending_append_path();
1892 22 : if (!path) return NULL;
1893 44 : RAII_FILE FILE *fp = fopen(path, "r");
1894 22 : if (!fp) return NULL;
1895 :
1896 4 : int cap = 8, count = 0;
1897 4 : PendingAppend *arr = malloc((size_t)cap * sizeof(PendingAppend));
1898 4 : if (!arr) return NULL;
1899 :
1900 : char line[512];
1901 9 : while (fgets(line, sizeof(line), fp)) {
1902 5 : char *tab = strchr(line, '\t');
1903 5 : if (!tab) continue;
1904 5 : *tab = '\0';
1905 5 : char *nl = strchr(tab + 1, '\n'); if (nl) *nl = '\0';
1906 5 : 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 5 : strncpy(arr[count].folder, line, sizeof(arr[count].folder) - 1);
1913 5 : arr[count].folder[sizeof(arr[count].folder) - 1] = '\0';
1914 5 : strncpy(arr[count].uid, tab + 1, sizeof(arr[count].uid) - 1);
1915 5 : arr[count].uid[sizeof(arr[count].uid) - 1] = '\0';
1916 5 : count++;
1917 : }
1918 4 : *count_out = count;
1919 4 : return arr;
1920 : }
1921 :
1922 3 : void local_pending_append_remove(const char *folder, const char *uid) {
1923 6 : RAII_STRING char *path = pending_append_path();
1924 3 : if (!path) return;
1925 :
1926 : /* Read all lines except the matching one */
1927 3 : FILE *rfp = fopen(path, "r");
1928 3 : if (!rfp) return;
1929 :
1930 : char lines[4096][512];
1931 3 : int lcount = 0;
1932 : char line[512];
1933 7 : while (lcount < 4096 && fgets(line, sizeof(line), rfp)) {
1934 : char tmp[512];
1935 4 : strncpy(tmp, line, sizeof(tmp) - 1); tmp[sizeof(tmp) - 1] = '\0';
1936 4 : char *tab = strchr(tmp, '\t');
1937 6 : if (!tab) { snprintf(lines[lcount++], 512, "%s", line); continue; }
1938 4 : *tab = '\0';
1939 4 : char *nl = strchr(tab + 1, '\n'); if (nl) *nl = '\0';
1940 4 : if (strcmp(tmp, folder) == 0 && strcmp(tab + 1, uid) == 0)
1941 2 : continue; /* skip this entry */
1942 2 : snprintf(lines[lcount++], 512, "%s", line);
1943 : }
1944 3 : fclose(rfp);
1945 :
1946 3 : FILE *wfp = fopen(path, "w");
1947 3 : if (!wfp) return;
1948 5 : for (int i = 0; i < lcount; i++)
1949 2 : fputs(lines[i], wfp);
1950 3 : fclose(wfp);
1951 : }
1952 :
1953 : /* ── Pending Gmail fetch queue ───────────────────────────────────────── */
1954 :
1955 1333 : static char *pending_fetch_path(void) {
1956 1333 : if (!g_account_base[0]) return NULL;
1957 1333 : char *path = NULL;
1958 1333 : if (asprintf(&path, "%s/pending_fetch.tsv", g_account_base) == -1)
1959 0 : return NULL;
1960 1333 : return path;
1961 : }
1962 :
1963 624 : int local_pending_fetch_add(const char *uid) {
1964 1248 : RAII_STRING char *path = pending_fetch_path();
1965 624 : if (!path || !uid) return -1;
1966 1246 : RAII_FILE FILE *fp = fopen(path, "a");
1967 623 : if (!fp) return -1;
1968 623 : fprintf(fp, "%s\n", uid);
1969 623 : return 0;
1970 : }
1971 :
1972 22 : char (*local_pending_fetch_load(int *count_out))[17] {
1973 22 : *count_out = 0;
1974 44 : RAII_STRING char *path = pending_fetch_path();
1975 22 : if (!path) return NULL;
1976 44 : RAII_FILE FILE *fp = fopen(path, "r");
1977 22 : if (!fp) return NULL;
1978 :
1979 20 : int cap = 64, count = 0;
1980 20 : char (*arr)[17] = malloc((size_t)cap * sizeof(char[17]));
1981 20 : if (!arr) return NULL;
1982 :
1983 : char line[32];
1984 638 : while (fgets(line, sizeof(line), fp)) {
1985 618 : char *nl = strchr(line, '\n'); if (nl) *nl = '\0';
1986 618 : char *cr = strchr(line, '\r'); if (cr) *cr = '\0';
1987 618 : if (line[0] == '\0') continue;
1988 618 : 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 618 : memcpy(arr[count], line, 16);
1995 618 : arr[count][16] = '\0';
1996 618 : count++;
1997 : }
1998 20 : *count_out = count;
1999 20 : return arr;
2000 : }
2001 :
2002 608 : void local_pending_fetch_remove(const char *uid) {
2003 1216 : RAII_STRING char *path = pending_fetch_path();
2004 608 : if (!path || !uid) return;
2005 :
2006 608 : FILE *rfp = fopen(path, "r");
2007 608 : if (!rfp) return;
2008 :
2009 : /* Read all lines, skip the matching UID */
2010 608 : int cap = 64, count = 0;
2011 608 : char (*lines)[32] = malloc((size_t)cap * sizeof(char[32]));
2012 608 : if (!lines) { fclose(rfp); return; }
2013 :
2014 : char line[32];
2015 52468 : while (fgets(line, sizeof(line), rfp)) {
2016 : char tmp[32];
2017 51860 : strncpy(tmp, line, 31); tmp[31] = '\0';
2018 51860 : char *nl = strchr(tmp, '\n'); if (nl) *nl = '\0';
2019 51860 : char *cr = strchr(tmp, '\r'); if (cr) *cr = '\0';
2020 51860 : if (strcmp(tmp, uid) == 0) continue;
2021 51252 : 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 51252 : memcpy(lines[count++], line, 31);
2028 51252 : lines[count - 1][31] = '\0';
2029 : }
2030 608 : fclose(rfp);
2031 :
2032 608 : FILE *wfp = fopen(path, "w");
2033 608 : if (wfp) {
2034 51860 : for (int i = 0; i < count; i++)
2035 51252 : fputs(lines[i], wfp);
2036 608 : fclose(wfp);
2037 : }
2038 608 : free(lines);
2039 : }
2040 :
2041 38 : int local_pending_fetch_count(void) {
2042 76 : RAII_STRING char *path = pending_fetch_path();
2043 38 : if (!path) return 0;
2044 76 : RAII_FILE FILE *fp = fopen(path, "r");
2045 38 : if (!fp) return 0;
2046 18 : int count = 0;
2047 : char line[32];
2048 37 : while (fgets(line, sizeof(line), fp)) {
2049 19 : if (line[0] != '\n' && line[0] != '\r' && line[0] != '\0')
2050 19 : count++;
2051 : }
2052 18 : return count;
2053 : }
2054 :
2055 41 : void local_pending_fetch_clear(void) {
2056 82 : RAII_STRING char *path = pending_fetch_path();
2057 41 : if (path) remove(path);
2058 41 : }
2059 :
2060 : /* ── Local outgoing message save ─────────────────────────────────────── */
2061 :
2062 10 : int local_save_outgoing(const char *folder, const char *msg, size_t msg_len) {
2063 10 : 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 7 : clock_gettime(CLOCK_REALTIME, &ts);
2070 7 : long long ms = (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
2071 7 : snprintf(uid, sizeof(uid), "t%lld", ms);
2072 : }
2073 :
2074 : /* Save full message */
2075 7 : 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 6 : const char *blank = strstr(msg, "\r\n\r\n");
2079 6 : if (!blank) blank = strstr(msg, "\n\n");
2080 6 : size_t hdr_len = blank ? (size_t)(blank - msg) : msg_len;
2081 6 : local_hdr_save(folder, uid, msg, hdr_len);
2082 :
2083 : /* Decode fields for the manifest */
2084 6 : char *from_raw = mime_get_header(msg, "From");
2085 6 : char *subj_raw = mime_get_header(msg, "Subject");
2086 6 : char *date_raw = mime_get_header(msg, "Date");
2087 6 : char *from_dec = from_raw ? mime_decode_words(from_raw) : strdup("");
2088 6 : char *subj_dec = subj_raw ? mime_decode_words(subj_raw) : strdup("");
2089 6 : char *date_dec = date_raw ? mime_format_date(date_raw) : strdup("");
2090 6 : free(from_raw); free(subj_raw); free(date_raw);
2091 :
2092 : /* Update manifest (MSG_FLAG_SEEN: sent messages are already read) */
2093 6 : Manifest *mf = manifest_load(folder);
2094 6 : if (!mf) mf = calloc(1, sizeof(Manifest));
2095 6 : if (mf) {
2096 : /* flags=0: no UNSEEN bit → sent message is already read */
2097 6 : manifest_upsert(mf, uid, from_dec, subj_dec, date_dec, 0);
2098 6 : manifest_save(folder, mf);
2099 6 : 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 6 : local_pending_append_add(folder, uid);
2106 :
2107 6 : logger_log(LOG_INFO, "local_save_outgoing: saved %s/%s, queued for APPEND",
2108 : folder, uid);
2109 6 : 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 : }
|