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 <stdio.h>
9 : #include <stdlib.h>
10 : #include <string.h>
11 : #include <dirent.h>
12 :
13 : /* ── Account base path (set by local_store_init) ─────────────────────── */
14 :
15 : static char g_account_base[8192];
16 :
17 102 : int local_store_init(const char *host_url, const char *username) {
18 102 : const char *data_base = platform_data_dir();
19 102 : if (!data_base || !host_url) return -1;
20 :
21 : /* The email address (username) uniquely identifies an account.
22 : * Use it directly as the directory key so two accounts on the same
23 : * server get separate local stores without a double-@ suffix.
24 : * Falls back to hostname-only for legacy single-account setups. */
25 102 : if (username && username[0]) {
26 102 : snprintf(g_account_base, sizeof(g_account_base),
27 : "%s/email-cli/accounts/%s", data_base, username);
28 : } else {
29 : /* Extract hostname from URL: imaps://host:port → host */
30 0 : const char *p = strstr(host_url, "://");
31 0 : p = p ? p + 3 : host_url;
32 0 : char hostname[512];
33 0 : int i = 0;
34 0 : while (*p && *p != ':' && *p != '/' && i < (int)sizeof(hostname) - 1)
35 0 : hostname[i++] = *p++;
36 0 : hostname[i] = '\0';
37 0 : for (char *c = hostname; *c; c++) *c = (char)tolower((unsigned char)*c);
38 0 : snprintf(g_account_base, sizeof(g_account_base),
39 : "%s/email-cli/accounts/imap.%s", data_base, hostname);
40 : }
41 :
42 102 : logger_log(LOG_DEBUG, "local_store: account base = %s", g_account_base);
43 102 : return 0;
44 : }
45 :
46 : /* ── Reverse digit bucketing helpers ─────────────────────────────────── */
47 :
48 108 : static char digit1(int uid) { return (char)('0' + (uid % 10)); }
49 108 : static char digit2(int uid) { return (char)('0' + ((uid / 10) % 10)); }
50 :
51 : /* ── Shared file I/O ─────────────────────────────────────────────────── */
52 :
53 73 : static char *load_file(const char *path) {
54 146 : RAII_FILE FILE *fp = fopen(path, "r");
55 73 : if (!fp) return NULL;
56 27 : if (fseek(fp, 0, SEEK_END) != 0) return NULL;
57 27 : long size = ftell(fp);
58 27 : if (size <= 0) return NULL;
59 27 : rewind(fp);
60 27 : char *buf = malloc((size_t)size + 1);
61 27 : if (!buf) return NULL;
62 27 : if ((long)fread(buf, 1, (size_t)size, fp) != size) { free(buf); return NULL; }
63 27 : buf[size] = '\0';
64 27 : return buf;
65 : }
66 :
67 34 : static int write_file(const char *path, const char *content, size_t len) {
68 68 : RAII_FILE FILE *fp = fopen(path, "w");
69 34 : if (!fp) return -1;
70 34 : if (fwrite(content, 1, len, fp) != len) return -1;
71 34 : return 0;
72 : }
73 :
74 : /** @brief Ensures the parent directory of a bucketed path exists. */
75 34 : static int ensure_bucket_dir(const char *area, const char *folder, int uid) {
76 68 : RAII_STRING char *dir = NULL;
77 34 : if (asprintf(&dir, "%s/%s/%s/%c/%c",
78 34 : g_account_base, area, folder, digit1(uid), digit2(uid)) == -1)
79 0 : return -1;
80 34 : return fs_mkdir_p(dir, 0700);
81 : }
82 :
83 : /* ── Message store ───────────────────────────────────────────────────── */
84 :
85 20 : static char *msg_path(const char *folder, int uid) {
86 20 : if (!g_account_base[0]) return NULL;
87 20 : char *path = NULL;
88 20 : if (asprintf(&path, "%s/store/%s/%c/%c/%d.eml",
89 20 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
90 0 : return NULL;
91 20 : return path;
92 : }
93 :
94 10 : int local_msg_exists(const char *folder, int uid) {
95 20 : RAII_STRING char *path = msg_path(folder, uid);
96 10 : if (!path) return 0;
97 10 : RAII_FILE FILE *fp = fopen(path, "r");
98 10 : return fp != NULL;
99 : }
100 :
101 8 : int local_msg_save(const char *folder, int uid, const char *content, size_t len) {
102 8 : if (!g_account_base[0]) return -1;
103 8 : if (ensure_bucket_dir("store", folder, uid) != 0) {
104 0 : logger_log(LOG_ERROR, "Failed to create store bucket for %s/%d", folder, uid);
105 0 : return -1;
106 : }
107 16 : RAII_STRING char *path = msg_path(folder, uid);
108 8 : if (!path) return -1;
109 8 : if (write_file(path, content, len) != 0) {
110 0 : logger_log(LOG_ERROR, "Failed to write store file: %s", path);
111 0 : return -1;
112 : }
113 8 : logger_log(LOG_DEBUG, "Stored %s/%d at %s", folder, uid, path);
114 8 : return 0;
115 : }
116 :
117 2 : char *local_msg_load(const char *folder, int uid) {
118 4 : RAII_STRING char *path = msg_path(folder, uid);
119 2 : if (!path) return NULL;
120 2 : return load_file(path);
121 : }
122 :
123 : /* ── Header store ────────────────────────────────────────────────────── */
124 :
125 54 : static char *hdr_path(const char *folder, int uid) {
126 54 : if (!g_account_base[0]) return NULL;
127 54 : char *path = NULL;
128 54 : if (asprintf(&path, "%s/headers/%s/%c/%c/%d.hdr",
129 54 : g_account_base, folder, digit1(uid), digit2(uid), uid) == -1)
130 0 : return NULL;
131 54 : return path;
132 : }
133 :
134 28 : int local_hdr_exists(const char *folder, int uid) {
135 56 : RAII_STRING char *path = hdr_path(folder, uid);
136 28 : if (!path) return 0;
137 28 : RAII_FILE FILE *fp = fopen(path, "r");
138 28 : return fp != NULL;
139 : }
140 :
141 26 : int local_hdr_save(const char *folder, int uid, const char *content, size_t len) {
142 26 : if (!g_account_base[0]) return -1;
143 26 : if (ensure_bucket_dir("headers", folder, uid) != 0) {
144 0 : logger_log(LOG_ERROR, "Failed to create header bucket for %s/%d", folder, uid);
145 0 : return -1;
146 : }
147 52 : RAII_STRING char *path = hdr_path(folder, uid);
148 26 : if (!path) return -1;
149 26 : if (write_file(path, content, len) != 0) return -1;
150 26 : logger_log(LOG_DEBUG, "Stored header %s/%d", folder, uid);
151 26 : return 0;
152 : }
153 :
154 0 : char *local_hdr_load(const char *folder, int uid) {
155 0 : RAII_STRING char *path = hdr_path(folder, uid);
156 0 : if (!path) return NULL;
157 0 : return load_file(path);
158 : }
159 :
160 19 : static int cmp_int_evict(const void *a, const void *b) {
161 19 : return *(const int *)a - *(const int *)b;
162 : }
163 :
164 43 : void local_hdr_evict_stale(const char *folder,
165 : const int *keep_uids, int keep_count) {
166 43 : if (!g_account_base[0]) return;
167 :
168 43 : int *sorted = malloc((size_t)keep_count * sizeof(int));
169 43 : if (!sorted) return;
170 43 : memcpy(sorted, keep_uids, (size_t)keep_count * sizeof(int));
171 43 : qsort(sorted, (size_t)keep_count, sizeof(int), cmp_int_evict);
172 :
173 : /* Walk all 100 buckets (10 × 10) */
174 473 : for (int d1 = 0; d1 <= 9; d1++) {
175 4730 : for (int d2 = 0; d2 <= 9; d2++) {
176 8600 : RAII_STRING char *dir = NULL;
177 4300 : if (asprintf(&dir, "%s/headers/%s/%d/%d",
178 : g_account_base, folder, d1, d2) == -1)
179 0 : continue;
180 :
181 4300 : DIR *d = opendir(dir);
182 4300 : if (!d) continue;
183 :
184 : struct dirent *ent;
185 83 : while ((ent = readdir(d)) != NULL) {
186 62 : const char *name = ent->d_name;
187 62 : const char *dot = strrchr(name, '.');
188 62 : if (!dot || strcmp(dot, ".hdr") != 0) continue;
189 20 : char *end;
190 20 : long uid = strtol(name, &end, 10);
191 20 : if (end != dot || uid <= 0) continue;
192 20 : int key = (int)uid;
193 20 : if (!bsearch(&key, sorted, (size_t)keep_count,
194 : sizeof(int), cmp_int_evict)) {
195 4 : RAII_STRING char *path = NULL;
196 2 : if (asprintf(&path, "%s/%s", dir, name) != -1) {
197 2 : remove(path);
198 2 : logger_log(LOG_DEBUG,
199 : "Evicted stale header: UID %ld in %s", uid, folder);
200 : }
201 : }
202 : }
203 21 : closedir(d);
204 : }
205 : }
206 43 : free(sorted);
207 : }
208 :
209 : /* ── Index helpers ───────────────────────────────────────────────────── */
210 :
211 : /** @brief Checks if a reference line already exists in an index file. */
212 12 : static int index_has_ref(const char *path, const char *ref) {
213 12 : char *content = load_file(path);
214 12 : if (!content) return 0;
215 2 : size_t ref_len = strlen(ref);
216 2 : const char *p = content;
217 2 : while (*p) {
218 2 : if (strncmp(p, ref, ref_len) == 0 &&
219 2 : (p[ref_len] == '\n' || p[ref_len] == '\0')) {
220 2 : free(content);
221 2 : return 1;
222 : }
223 0 : const char *nl = strchr(p, '\n');
224 0 : if (!nl) break;
225 0 : p = nl + 1;
226 : }
227 0 : free(content);
228 0 : return 0;
229 : }
230 :
231 : /** @brief Appends a reference to an index file (skips duplicates). */
232 12 : static int index_append(const char *dir_path, const char *file_name,
233 : const char *ref) {
234 12 : if (fs_mkdir_p(dir_path, 0700) != 0) return -1;
235 :
236 24 : RAII_STRING char *path = NULL;
237 12 : if (asprintf(&path, "%s/%s", dir_path, file_name) == -1) return -1;
238 :
239 12 : if (index_has_ref(path, ref)) return 0; /* already indexed */
240 :
241 10 : FILE *fp = fopen(path, "a");
242 10 : if (!fp) return -1;
243 10 : fprintf(fp, "%s\n", ref);
244 10 : fclose(fp);
245 10 : return 0;
246 : }
247 :
248 : /** @brief Removes a reference from an index file. */
249 : __attribute__((unused))
250 0 : static void index_remove_ref(const char *path, const char *ref) {
251 0 : char *content = load_file(path);
252 0 : if (!content) return;
253 :
254 0 : RAII_FILE FILE *fp = fopen(path, "w");
255 0 : if (!fp) { free(content); return; }
256 :
257 0 : size_t ref_len = strlen(ref);
258 0 : char *p = content;
259 0 : while (*p) {
260 0 : char *nl = strchr(p, '\n');
261 0 : size_t llen = nl ? (size_t)(nl - p + 1) : strlen(p);
262 : /* Skip the matching line */
263 0 : if (!(strncmp(p, ref, ref_len) == 0 &&
264 0 : (p[ref_len] == '\n' || p[ref_len] == '\0')))
265 0 : fwrite(p, 1, llen, fp);
266 0 : p += llen;
267 : }
268 0 : free(content);
269 : }
270 :
271 : /** @brief Extracts email address parts from a From header value. */
272 6 : static void extract_email_parts(const char *from,
273 : char *domain, size_t dlen,
274 : char *local_part, size_t llen) {
275 6 : domain[0] = '\0';
276 6 : local_part[0] = '\0';
277 :
278 : /* Try "Name <user@domain>" format first */
279 6 : const char *lt = strchr(from, '<');
280 6 : const char *gt = lt ? strchr(lt, '>') : NULL;
281 : const char *email;
282 : size_t elen;
283 6 : if (lt && gt && gt > lt + 1) {
284 4 : email = lt + 1;
285 4 : elen = (size_t)(gt - email);
286 : } else {
287 : /* Bare address: skip leading whitespace */
288 2 : email = from;
289 2 : while (*email == ' ' || *email == '\t') email++;
290 2 : elen = strlen(email);
291 : /* Trim trailing whitespace */
292 2 : while (elen > 0 && (email[elen - 1] == ' ' || email[elen - 1] == '\n'
293 2 : || email[elen - 1] == '\r'))
294 0 : elen--;
295 : }
296 :
297 6 : const char *at = memchr(email, '@', elen);
298 6 : if (!at) return;
299 :
300 6 : size_t ll = (size_t)(at - email);
301 6 : size_t dl = elen - ll - 1;
302 6 : if (ll >= llen) ll = llen - 1;
303 6 : if (dl >= dlen) dl = dlen - 1;
304 6 : memcpy(local_part, email, ll);
305 6 : local_part[ll] = '\0';
306 6 : memcpy(domain, at + 1, dl);
307 6 : domain[dl] = '\0';
308 :
309 : /* Lowercase domain */
310 70 : for (char *c = domain; *c; c++)
311 64 : *c = (char)tolower((unsigned char)*c);
312 : /* Lowercase local part */
313 36 : for (char *c = local_part; *c; c++)
314 30 : *c = (char)tolower((unsigned char)*c);
315 : }
316 :
317 6 : int local_index_update(const char *folder, int uid, const char *raw_msg) {
318 6 : if (!g_account_base[0] || !raw_msg) return -1;
319 :
320 6 : char ref[512];
321 6 : snprintf(ref, sizeof(ref), "%s/%d", folder, uid);
322 :
323 : /* 1. From index: index/from/<domain>/<localpart> */
324 12 : RAII_STRING char *from_raw = mime_get_header(raw_msg, "From");
325 6 : if (from_raw) {
326 6 : char domain[256], local_part[256];
327 6 : extract_email_parts(from_raw, domain, sizeof(domain),
328 : local_part, sizeof(local_part));
329 6 : if (domain[0] && local_part[0]) {
330 12 : RAII_STRING char *idx_dir = NULL;
331 6 : if (asprintf(&idx_dir, "%s/index/from/%s",
332 : g_account_base, domain) != -1)
333 6 : index_append(idx_dir, local_part, ref);
334 : }
335 : }
336 :
337 : /* 2. Date index: index/date/<year>/<month>/<day> */
338 6 : RAII_STRING char *date_raw = mime_get_header(raw_msg, "Date");
339 6 : if (date_raw) {
340 12 : RAII_STRING char *formatted = mime_format_date(date_raw);
341 6 : if (formatted && strlen(formatted) >= 10) {
342 6 : int year, month, day;
343 6 : if (sscanf(formatted, "%d-%d-%d", &year, &month, &day) == 3) {
344 12 : RAII_STRING char *idx_dir = NULL;
345 6 : char day_str[4];
346 6 : snprintf(day_str, sizeof(day_str), "%02d", day);
347 6 : if (asprintf(&idx_dir, "%s/index/date/%04d/%02d",
348 : g_account_base, year, month) != -1)
349 6 : index_append(idx_dir, day_str, ref);
350 : }
351 : }
352 : }
353 :
354 6 : return 0;
355 : }
356 :
357 0 : int local_msg_delete(const char *folder, int uid) {
358 0 : if (!g_account_base[0]) return -1;
359 :
360 0 : char ref[512];
361 0 : snprintf(ref, sizeof(ref), "%s/%d", folder, uid);
362 :
363 : /* 1. Remove .eml file */
364 0 : RAII_STRING char *mpath = msg_path(folder, uid);
365 0 : if (mpath) remove(mpath);
366 :
367 : /* 2. Remove .hdr file */
368 0 : RAII_STRING char *hpath = hdr_path(folder, uid);
369 0 : if (hpath) remove(hpath);
370 :
371 : /* 3. Remove from indexes — best effort scan of from/ and date/ */
372 : /* For from/: we'd need to know which file has this ref.
373 : * Since we don't track that, just load the message (if still cached)
374 : * or accept the stale entry. A full re-index can clean up. */
375 0 : logger_log(LOG_DEBUG, "Deleted %s/%d", folder, uid);
376 0 : return 0;
377 : }
378 :
379 : /* ── UI preferences ──────────────────────────────────────────────────── */
380 :
381 10 : static char *ui_pref_path(void) {
382 10 : const char *data_base = platform_data_dir();
383 10 : if (!data_base) return NULL;
384 10 : char *path = NULL;
385 10 : if (asprintf(&path, "%s/email-cli/ui.ini", data_base) == -1)
386 0 : return NULL;
387 10 : return path;
388 : }
389 :
390 7 : int ui_pref_get_int(const char *key, int default_val) {
391 14 : RAII_STRING char *path = ui_pref_path();
392 7 : if (!path) return default_val;
393 14 : RAII_FILE FILE *fp = fopen(path, "r");
394 7 : if (!fp) return default_val;
395 5 : char line[256];
396 5 : size_t klen = strlen(key);
397 8 : while (fgets(line, sizeof(line), fp))
398 7 : if (strncmp(line, key, klen) == 0 && line[klen] == '=')
399 4 : return atoi(line + klen + 1);
400 1 : return default_val;
401 : }
402 :
403 3 : int ui_pref_set_int(const char *key, int value) {
404 3 : const char *data_base = platform_data_dir();
405 3 : if (!data_base) return -1;
406 6 : RAII_STRING char *dir = NULL;
407 3 : if (asprintf(&dir, "%s/email-cli", data_base) == -1) return -1;
408 3 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
409 6 : RAII_STRING char *path = ui_pref_path();
410 3 : if (!path) return -1;
411 :
412 3 : char *existing = load_file(path);
413 :
414 6 : RAII_FILE FILE *fp = fopen(path, "w");
415 3 : if (!fp) { free(existing); return -1; }
416 :
417 3 : size_t klen = strlen(key);
418 3 : if (existing) {
419 2 : char *line = existing;
420 4 : while (*line) {
421 2 : char *nl = strchr(line, '\n');
422 2 : size_t llen = nl ? (size_t)(nl - line + 1) : strlen(line);
423 2 : if (!(strncmp(line, key, klen) == 0 && line[klen] == '='))
424 1 : fwrite(line, 1, llen, fp);
425 2 : line += llen;
426 : }
427 2 : free(existing);
428 : }
429 3 : fprintf(fp, "%s=%d\n", key, value);
430 3 : logger_log(LOG_DEBUG, "UI pref %s=%d saved", key, value);
431 3 : return 0;
432 : }
433 :
434 : /* ── Folder manifest ─────────────────────────────────────────────────── */
435 :
436 82 : static char *manifest_path(const char *folder) {
437 82 : if (!g_account_base[0]) return NULL;
438 82 : char *path = NULL;
439 82 : if (asprintf(&path, "%s/manifests/%s.tsv", g_account_base, folder) == -1)
440 0 : return NULL;
441 82 : return path;
442 : }
443 :
444 : /** @brief Duplicates a string, replacing tabs with spaces. */
445 81 : static char *sanitise(const char *s) {
446 81 : if (!s) return strdup("");
447 81 : char *d = strdup(s);
448 1612 : if (d) for (char *p = d; *p; p++) if (*p == '\t') *p = ' ';
449 81 : return d;
450 : }
451 :
452 56 : Manifest *manifest_load(const char *folder) {
453 112 : RAII_STRING char *path = manifest_path(folder);
454 56 : logger_log(LOG_DEBUG, "manifest_load: folder=%s account_base=%s path=%s",
455 56 : folder, g_account_base, path ? path : "(null)");
456 56 : if (!path) return NULL;
457 :
458 56 : char *data = load_file(path);
459 56 : if (!data) return NULL;
460 :
461 21 : Manifest *m = calloc(1, sizeof(*m));
462 21 : if (!m) { free(data); return NULL; }
463 21 : m->capacity = 64;
464 21 : m->entries = malloc((size_t)m->capacity * sizeof(ManifestEntry));
465 21 : if (!m->entries) { free(m); free(data); return NULL; }
466 :
467 21 : char *line = data;
468 43 : while (*line) {
469 22 : char *nl = strchr(line, '\n');
470 22 : if (nl) *nl = '\0';
471 :
472 : /* Parse: uid\tfrom\tsubject\tdate */
473 22 : char *end;
474 22 : long uid = strtol(line, &end, 10);
475 22 : if (end == line || *end != '\t' || uid <= 0) {
476 0 : line = nl ? nl + 1 : line + strlen(line);
477 0 : continue;
478 : }
479 22 : char *from_start = end + 1;
480 22 : char *t2 = strchr(from_start, '\t');
481 22 : if (!t2) { line = nl ? nl + 1 : line + strlen(line); continue; }
482 22 : *t2 = '\0';
483 22 : char *subj_start = t2 + 1;
484 22 : char *t3 = strchr(subj_start, '\t');
485 22 : if (!t3) { line = nl ? nl + 1 : line + strlen(line); continue; }
486 22 : *t3 = '\0';
487 22 : char *date_start = t3 + 1;
488 : /* Optional 5th field: unseen flag */
489 22 : int unseen_val = 0;
490 22 : char *t4 = strchr(date_start, '\t');
491 22 : if (t4) {
492 22 : *t4 = '\0';
493 22 : unseen_val = atoi(t4 + 1);
494 : }
495 :
496 22 : if (m->count == m->capacity) {
497 0 : m->capacity *= 2;
498 0 : ManifestEntry *tmp = realloc(m->entries,
499 0 : (size_t)m->capacity * sizeof(ManifestEntry));
500 0 : if (!tmp) break;
501 0 : m->entries = tmp;
502 : }
503 22 : ManifestEntry *e = &m->entries[m->count++];
504 22 : e->uid = (int)uid;
505 22 : e->from = strdup(from_start);
506 22 : e->subject = strdup(subj_start);
507 22 : e->date = strdup(date_start);
508 22 : e->flags = unseen_val;
509 :
510 22 : line = nl ? nl + 1 : line + strlen(line);
511 : }
512 21 : free(data);
513 21 : return m;
514 : }
515 :
516 26 : int manifest_save(const char *folder, const Manifest *m) {
517 26 : if (!g_account_base[0] || !m) return -1;
518 :
519 52 : RAII_STRING char *dir = NULL;
520 26 : if (asprintf(&dir, "%s/manifests", g_account_base) == -1) return -1;
521 26 : if (fs_mkdir_p(dir, 0700) != 0) return -1;
522 :
523 : /* For nested folders like "munka/ai" we need the parent dir */
524 52 : RAII_STRING char *path = manifest_path(folder);
525 26 : if (!path) return -1;
526 :
527 : /* Ensure parent directory exists (folder path may have slashes) */
528 26 : char *last_slash = strrchr(path, '/');
529 26 : if (last_slash) {
530 26 : char saved = *last_slash;
531 26 : *last_slash = '\0';
532 26 : fs_mkdir_p(path, 0700);
533 26 : *last_slash = saved;
534 : }
535 :
536 52 : RAII_FILE FILE *fp = fopen(path, "w");
537 26 : if (!fp) return -1;
538 :
539 53 : for (int i = 0; i < m->count; i++) {
540 27 : ManifestEntry *e = &m->entries[i];
541 54 : RAII_STRING char *f = sanitise(e->from);
542 54 : RAII_STRING char *s = sanitise(e->subject);
543 54 : RAII_STRING char *d = sanitise(e->date);
544 27 : fprintf(fp, "%d\t%s\t%s\t%s\t%d\n", e->uid, f ? f : "", s ? s : "", d ? d : "", e->flags);
545 : }
546 26 : logger_log(LOG_DEBUG, "Manifest saved: %s (%d entries)", folder, m->count);
547 26 : return 0;
548 : }
549 :
550 51 : void manifest_free(Manifest *m) {
551 51 : if (!m) return;
552 99 : for (int i = 0; i < m->count; i++) {
553 48 : free(m->entries[i].from);
554 48 : free(m->entries[i].subject);
555 48 : free(m->entries[i].date);
556 : }
557 51 : free(m->entries);
558 51 : free(m);
559 : }
560 :
561 157 : ManifestEntry *manifest_find(const Manifest *m, int uid) {
562 157 : if (!m) return NULL;
563 162 : for (int i = 0; i < m->count; i++)
564 85 : if (m->entries[i].uid == uid) return &m->entries[i];
565 77 : return NULL;
566 : }
567 :
568 28 : void manifest_upsert(Manifest *m, int uid,
569 : char *from, char *subject, char *date, int flags) {
570 28 : if (!m) return;
571 28 : ManifestEntry *existing = manifest_find(m, uid);
572 28 : if (existing) {
573 1 : free(existing->from); existing->from = from;
574 1 : free(existing->subject); existing->subject = subject;
575 1 : free(existing->date); existing->date = date;
576 1 : existing->flags = flags;
577 1 : return;
578 : }
579 27 : if (m->count == m->capacity) {
580 26 : int new_cap = m->capacity ? m->capacity * 2 : 64;
581 26 : ManifestEntry *tmp = realloc(m->entries,
582 26 : (size_t)new_cap * sizeof(ManifestEntry));
583 26 : if (!tmp) { free(from); free(subject); free(date); return; }
584 26 : m->entries = tmp;
585 26 : m->capacity = new_cap;
586 : }
587 27 : ManifestEntry *e = &m->entries[m->count++];
588 27 : e->uid = uid; e->from = from; e->subject = subject; e->date = date;
589 27 : e->flags = flags;
590 : }
591 :
592 42 : void manifest_retain(Manifest *m, const int *keep_uids, int keep_count) {
593 42 : if (!m) return;
594 42 : int dst = 0;
595 61 : for (int i = 0; i < m->count; i++) {
596 19 : int found = 0;
597 20 : for (int j = 0; j < keep_count; j++) {
598 19 : if (keep_uids[j] == m->entries[i].uid) { found = 1; break; }
599 : }
600 19 : if (found) {
601 18 : if (dst != i) m->entries[dst] = m->entries[i];
602 18 : dst++;
603 : } else {
604 1 : free(m->entries[i].from);
605 1 : free(m->entries[i].subject);
606 1 : free(m->entries[i].date);
607 : }
608 : }
609 42 : m->count = dst;
610 : }
611 :
612 : /* ── Folder list cache ───────────────────────────────────────────────── */
613 :
614 1 : int local_folder_list_save(const char **folders, int count, char sep) {
615 1 : if (!g_account_base[0]) return -1;
616 2 : RAII_STRING char *path = NULL;
617 1 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return -1;
618 2 : RAII_FILE FILE *fp = fopen(path, "w");
619 1 : if (!fp) return -1;
620 1 : fprintf(fp, "sep=%c\n", sep);
621 5 : for (int i = 0; i < count; i++)
622 4 : fprintf(fp, "%s\n", folders[i] ? folders[i] : "");
623 1 : logger_log(LOG_DEBUG, "Folder list cache saved: %d folders", count);
624 1 : return 0;
625 : }
626 :
627 47 : char **local_folder_list_load(int *count_out, char *sep_out) {
628 47 : *count_out = 0;
629 47 : if (!g_account_base[0]) return NULL;
630 94 : RAII_STRING char *path = NULL;
631 47 : if (asprintf(&path, "%s/folders.cache", g_account_base) == -1) return NULL;
632 94 : RAII_FILE FILE *fp = fopen(path, "r");
633 47 : if (!fp) return NULL;
634 :
635 6 : char line[1024];
636 6 : char sep = '.';
637 : /* First line: sep=<char> */
638 6 : if (!fgets(line, sizeof(line), fp)) return NULL;
639 6 : if (strncmp(line, "sep=", 4) == 0 && line[4] != '\n')
640 6 : sep = line[4];
641 :
642 6 : int cap = 32, cnt = 0;
643 6 : char **folders = malloc((size_t)cap * sizeof(char *));
644 6 : if (!folders) return NULL;
645 30 : while (fgets(line, sizeof(line), fp)) {
646 : /* strip trailing newline */
647 24 : size_t len = strlen(line);
648 48 : while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
649 24 : line[--len] = '\0';
650 24 : if (len == 0) continue;
651 24 : if (cnt == cap) {
652 0 : cap *= 2;
653 0 : char **tmp = realloc(folders, (size_t)cap * sizeof(char *));
654 0 : if (!tmp) { for (int i = 0; i < cnt; i++) free(folders[i]); free(folders); return NULL; }
655 0 : folders = tmp;
656 : }
657 24 : folders[cnt] = strdup(line);
658 24 : if (!folders[cnt]) { for (int i = 0; i < cnt; i++) free(folders[i]); free(folders); return NULL; }
659 24 : cnt++;
660 : }
661 6 : *count_out = cnt;
662 6 : if (sep_out) *sep_out = sep;
663 6 : logger_log(LOG_DEBUG, "Folder list cache loaded: %d folders", cnt);
664 6 : return folders;
665 : }
666 :
667 8 : void manifest_count_folder(const char *folder, int *total_out,
668 : int *unseen_out, int *flagged_out) {
669 8 : *total_out = 0; *unseen_out = 0; *flagged_out = 0;
670 8 : Manifest *m = manifest_load(folder);
671 8 : if (!m) return;
672 2 : *total_out = m->count;
673 4 : for (int i = 0; i < m->count; i++) {
674 2 : if (m->entries[i].flags & MSG_FLAG_UNSEEN) (*unseen_out)++;
675 2 : if (m->entries[i].flags & MSG_FLAG_FLAGGED) (*flagged_out)++;
676 : }
677 2 : manifest_free(m);
678 : }
679 :
680 : /* ── Pending flag changes ─────────────────────────────────────────────── */
681 :
682 0 : static char *pending_flag_path(const char *folder) {
683 0 : if (!g_account_base[0]) return NULL;
684 0 : char *path = NULL;
685 0 : if (asprintf(&path, "%s/pending_flags/%s.tsv", g_account_base, folder) == -1)
686 0 : return NULL;
687 0 : return path;
688 : }
689 :
690 0 : int local_pending_flag_add(const char *folder, int uid,
691 : const char *flag_name, int add) {
692 0 : RAII_STRING char *path = pending_flag_path(folder);
693 0 : if (!path) return -1;
694 :
695 : /* Ensure parent directory exists (folder path may have slashes) */
696 0 : char *dir_end = strrchr(path, '/');
697 0 : if (dir_end) {
698 0 : char saved = *dir_end;
699 0 : *dir_end = '\0';
700 0 : fs_mkdir_p(path, 0700);
701 0 : *dir_end = saved;
702 : }
703 :
704 0 : RAII_FILE FILE *fp = fopen(path, "a");
705 0 : if (!fp) return -1;
706 0 : fprintf(fp, "%d\t%s\t%d\n", uid, flag_name, add);
707 0 : return 0;
708 : }
709 :
710 0 : PendingFlag *local_pending_flag_load(const char *folder, int *count_out) {
711 0 : *count_out = 0;
712 0 : RAII_STRING char *path = pending_flag_path(folder);
713 0 : if (!path) return NULL;
714 :
715 0 : RAII_FILE FILE *fp = fopen(path, "r");
716 0 : if (!fp) return NULL;
717 :
718 0 : int cap = 16, count = 0;
719 0 : PendingFlag *arr = malloc((size_t)cap * sizeof(PendingFlag));
720 0 : if (!arr) return NULL;
721 :
722 0 : char line[256];
723 0 : while (fgets(line, sizeof(line), fp)) {
724 0 : int uid, add_val;
725 0 : char flag[64];
726 0 : if (sscanf(line, "%d\t%63[^\t]\t%d", &uid, flag, &add_val) != 3)
727 0 : continue;
728 0 : if (count == cap) {
729 0 : cap *= 2;
730 0 : PendingFlag *tmp = realloc(arr, (size_t)cap * sizeof(PendingFlag));
731 0 : if (!tmp) break;
732 0 : arr = tmp;
733 : }
734 0 : arr[count].uid = uid;
735 0 : arr[count].add = add_val;
736 0 : strncpy(arr[count].flag_name, flag, sizeof(arr[count].flag_name) - 1);
737 0 : arr[count].flag_name[sizeof(arr[count].flag_name) - 1] = '\0';
738 0 : count++;
739 : }
740 0 : *count_out = count;
741 0 : return arr;
742 : }
743 :
744 0 : void local_pending_flag_clear(const char *folder) {
745 0 : RAII_STRING char *path = pending_flag_path(folder);
746 0 : if (path) remove(path);
747 0 : }
|