LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - local_store.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 91.6 % 1520 1392
Test Date: 2026-08-21 10:11:28 Functions: 97.0 % 100 97

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

Generated by: LCOV version 2.0-1