LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - local_store.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 87.2 % 1520 1325
Test Date: 2026-08-21 10:11:30 Functions: 92.0 % 100 92

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

Generated by: LCOV version 2.0-1