LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - local_store.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 76.6 % 501 384
Test Date: 2026-04-15 21:12:52 Functions: 82.5 % 40 33

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

Generated by: LCOV version 2.0-1