LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - gmail_sync.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 92.4 % 501 463
Test Date: 2026-05-07 15:53:07 Functions: 100.0 % 19 19

            Line data    Source code
       1              : #include "gmail_sync.h"
       2              : #include "gmail_client.h"
       3              : #include "local_store.h"
       4              : #include "mail_rules.h"
       5              : #include "mime_util.h"
       6              : #include "json_util.h"
       7              : #include "logger.h"
       8              : #include "raii.h"
       9              : #include <stdio.h>
      10              : #include <stdlib.h>
      11              : #include <string.h>
      12              : #include <stddef.h>
      13              : 
      14              : /* ── Progress callbacks ───────────────────────────────────────────── */
      15              : 
      16              : /* Called by gmail_list_messages after each page: cur = messages collected so far */
      17           31 : static void list_progress_cb(size_t cur, size_t total, void *ctx) {
      18              :     (void)total; (void)ctx;
      19           31 :     fprintf(stderr, "\r\033[K  Listing messages... %zu found", cur);
      20           31 :     fflush(stderr);
      21           31 : }
      22              : 
      23              : /* ── Gmail .hdr file format ───────────────────────────────────────── */
      24              : 
      25              : /**
      26              :  * Build a .hdr string from raw message headers and label list.
      27              :  * Format: from\tsubject\tdate\tlabel1,label2,...\tflags\n
      28              :  *
      29              :  * Returns heap-allocated string. Caller must free().
      30              :  */
      31          614 : char *gmail_sync_build_hdr(const char *raw_msg, char **labels, int label_count) {
      32         1228 :     RAII_STRING char *from_raw = mime_get_header(raw_msg, "From");
      33         1228 :     RAII_STRING char *subj_raw = mime_get_header(raw_msg, "Subject");
      34         1228 :     RAII_STRING char *date_raw = mime_get_header(raw_msg, "Date");
      35              : 
      36         1228 :     RAII_STRING char *from_dec = from_raw ? mime_decode_words(from_raw) : NULL;
      37         1228 :     RAII_STRING char *subj_dec = subj_raw ? mime_decode_words(subj_raw) : NULL;
      38          614 :     RAII_STRING char *date_fmt = date_raw ? mime_format_date(date_raw) : NULL;
      39              : 
      40          614 :     const char *from = from_dec ? from_dec : "";
      41          614 :     const char *subj = subj_dec ? subj_dec : "";
      42          614 :     const char *date = date_fmt ? date_fmt : "";
      43              : 
      44              :     /* Build comma-separated label string */
      45          614 :     size_t lbl_len = 1;
      46         1608 :     for (int i = 0; i < label_count; i++)
      47          994 :         lbl_len += strlen(labels[i]) + 1;
      48          614 :     char *lbl_str = calloc(lbl_len, 1);
      49          614 :     if (lbl_str) {
      50         1608 :         for (int i = 0; i < label_count; i++) {
      51          994 :             if (i > 0) strcat(lbl_str, ",");
      52          994 :             strcat(lbl_str, labels[i]);
      53              :         }
      54              :     }
      55              : 
      56              :     /* Compute flags bitmask from labels */
      57          614 :     int flags = 0;
      58         1608 :     for (int i = 0; i < label_count; i++) {
      59          994 :         if (strcmp(labels[i], "UNREAD")  == 0) flags |= MSG_FLAG_UNSEEN;
      60          994 :         if (strcmp(labels[i], "STARRED") == 0) flags |= MSG_FLAG_FLAGGED;
      61          994 :         if (strcmp(labels[i], "SPAM")    == 0) flags |= MSG_FLAG_JUNK;
      62              :     }
      63              : 
      64              :     /* Replace tabs in fields with spaces */
      65          614 :     char *hdr = NULL;
      66          614 :     if (asprintf(&hdr, "%s\t%s\t%s\t%s\t%d",
      67              :                  from, subj, date, lbl_str ? lbl_str : "", flags) == -1)
      68            0 :         hdr = NULL;
      69          614 :     free(lbl_str);
      70              : 
      71              :     /* Sanitise: replace any tabs within field values */
      72          614 :     if (hdr) {
      73              :         /* The first 4 tabs are field separators; tabs within values got
      74              :          * inserted by asprintf if field values contained tabs.  Since we
      75              :          * used tab as separator this is inherently safe (mime_decode_words
      76              :          * doesn't produce tabs), but defend anyway. */
      77              :     }
      78              : 
      79          614 :     return hdr;
      80              : }
      81              : 
      82              : /* ── Filtered labels (metadata-only, excluded from indexing) ──────── */
      83              : 
      84         6944 : int gmail_sync_is_filtered_label(const char *label_id) {
      85         6944 :     if (!label_id) return 1;
      86         6943 :     if (strcmp(label_id, "IMPORTANT") == 0) return 1;
      87         6941 :     if (strcmp(label_id, "CHAT") == 0) return 1;
      88         6941 :     return 0;
      89              : }
      90              : 
      91              : /* Returns 1 if label_id is a Gmail automatic inbox category (CATEGORY_*).
      92              :  * Category labels are indexed like user labels, but a message whose ONLY
      93              :  * non-filtered labels are CATEGORY_* is also added to _nolabel (Archive). */
      94         6916 : static int is_category_label(const char *label_id) {
      95         6916 :     return label_id && strncmp(label_id, "CATEGORY_", 9) == 0;
      96              : }
      97              : 
      98              : /* ── Mail rules helper ───────────────────────────────────────────── */
      99              : 
     100              : /* Apply mail rules to a newly stored message.
     101              :  * Builds labels_csv from Gmail label IDs (resolving user labels to names),
     102              :  * calls mail_rules_apply(), then updates the local .hdr and label indexes. */
     103          607 : static void apply_rules_to_new_message(const MailRules *rules, const char *uid,
     104              :                                         const char *raw_msg,
     105              :                                         char **labels, int label_count)
     106              : {
     107          607 :     if (!rules || rules->count == 0) return;
     108              : 
     109            2 :     RAII_STRING char *from_raw = mime_get_header(raw_msg, "From");
     110            2 :     RAII_STRING char *subj_raw = mime_get_header(raw_msg, "Subject");
     111            2 :     RAII_STRING char *to_raw   = mime_get_header(raw_msg, "To");
     112            2 :     RAII_STRING char *from_dec = from_raw ? mime_decode_words(from_raw) : NULL;
     113            2 :     RAII_STRING char *subj_dec = subj_raw ? mime_decode_words(subj_raw) : NULL;
     114            2 :     RAII_STRING char *to_dec   = to_raw   ? mime_decode_words(to_raw)   : NULL;
     115              : 
     116              :     /* Build labels_csv using friendly names where available */
     117            1 :     size_t lcsz = 1;
     118            3 :     for (int i = 0; i < label_count; i++) {
     119            2 :         char *name = local_gmail_label_name_lookup(labels[i]);
     120            2 :         lcsz += strlen(name ? name : labels[i]) + 2;
     121            2 :         free(name);
     122              :     }
     123            1 :     char *lcsv = calloc(lcsz, 1);
     124            1 :     if (!lcsv) return;
     125            3 :     for (int i = 0; i < label_count; i++) {
     126            2 :         char *name = local_gmail_label_name_lookup(labels[i]);
     127            2 :         const char *display = name ? name : labels[i];
     128            2 :         if (lcsv[0]) strcat(lcsv, ",");
     129            2 :         strcat(lcsv, display);
     130            2 :         free(name);
     131              :     }
     132              : 
     133            1 :     char **add_out = NULL; int add_count = 0;
     134            1 :     char **rm_out  = NULL; int rm_count  = 0;
     135            1 :     int fired = mail_rules_apply(rules,
     136              :                                   from_dec, subj_dec, to_dec, lcsv,
     137              :                                   NULL, (time_t)0,   /* body/date unavailable during Gmail sync */
     138              :                                   &add_out, &add_count,
     139              :                                   &rm_out,  &rm_count);
     140            1 :     free(lcsv);
     141            1 :     if (fired <= 0) return;
     142              : 
     143            1 :     logger_log(LOG_INFO, "gmail_sync: rules fired=%d for %s (add=%d rm=%d)",
     144              :                fired, uid, add_count, rm_count);
     145              : 
     146              :     /* Update local .hdr and label indexes */
     147            1 :     local_hdr_update_labels("", uid,
     148              :                              (const char **)add_out, add_count,
     149              :                              (const char **)rm_out,  rm_count);
     150            2 :     for (int i = 0; i < add_count; i++) {
     151            1 :         label_idx_add(add_out[i], uid);
     152            1 :         free(add_out[i]);
     153              :     }
     154            1 :     for (int i = 0; i < rm_count; i++) {
     155            0 :         label_idx_remove(rm_out[i], uid);
     156            0 :         free(rm_out[i]);
     157              :     }
     158            1 :     free(add_out);
     159            1 :     free(rm_out);
     160              : 
     161              :     /* Update contact suggestion cache */
     162              :     {
     163            1 :         char *from_h = mime_get_header(raw_msg, "From");
     164            1 :         char *to_h   = mime_get_header(raw_msg, "To");
     165            1 :         char *cc_h   = mime_get_header(raw_msg, "Cc");
     166            1 :         local_contacts_update(from_h, to_h, cc_h);
     167            1 :         free(from_h); free(to_h); free(cc_h);
     168              :     }
     169              : }
     170              : 
     171              : /* ── Label index rebuild ──────────────────────────────────────────── */
     172              : 
     173              : typedef struct { char label[64]; char uid[17]; } LabelUidPair;
     174              : 
     175        39856 : static int cmp_lbl_uid_pair(const void *a, const void *b) {
     176        39856 :     const LabelUidPair *pa = a, *pb = b;
     177        39856 :     int c = strcmp(pa->label, pb->label);
     178        39856 :     return c ? c : strcmp(pa->uid, pb->uid);
     179              : }
     180              : 
     181              : /**
     182              :  * Rebuild ALL label .idx files from the .hdr files for the given UIDs.
     183              :  *
     184              :  * Efficient O(N log N) approach:
     185              :  *   1. Read each .hdr and collect (label, uid) pairs in memory.
     186              :  *   2. Sort the flat pair array.
     187              :  *   3. Write each label's .idx file in one grouped pass.
     188              :  *
     189              :  * This is called at the end of every full sync so that cached messages
     190              :  * (whose .idx entries were never written) are correctly indexed.
     191              :  */
     192           35 : static void rebuild_label_indexes(const char (*uids)[17], int uid_count) {
     193           35 :     if (uid_count <= 0) return;
     194              : 
     195           35 :     fprintf(stderr, "  Rebuilding label indexes...");
     196           35 :     fflush(stderr);
     197              : 
     198              :     /* Phase 1: collect (label, uid) pairs from all .hdr files */
     199           35 :     size_t cap = (size_t)uid_count * 5; /* ~5 labels per message */
     200           35 :     LabelUidPair *pairs = malloc(cap * sizeof(LabelUidPair));
     201           35 :     if (!pairs) {
     202            0 :         fprintf(stderr, " [out of memory]\n");
     203            0 :         return;
     204              :     }
     205           35 :     int npairs = 0;
     206              : 
     207         3703 :     for (int i = 0; i < uid_count; i++) {
     208              :         /* Load full .hdr so we can both collect label pairs and sync the
     209              :          * flags integer in one read (avoid a separate local_hdr_get_labels
     210              :          * call followed by local_hdr_update_labels). */
     211         3668 :         char *hdr = local_hdr_load("", uids[i]);
     212         3668 :         if (!hdr) continue;
     213              : 
     214              :         /* Locate labels field (4th tab-separated token).
     215              :          * Track the tab pointer so we can NUL-terminate the prefix later. */
     216         3668 :         char *t3_tab = hdr;
     217        14669 :         for (int f = 0; f < 3; f++) {
     218        11002 :             t3_tab = strchr(t3_tab, '\t');
     219        11002 :             if (!t3_tab) break;
     220        11001 :             if (f < 2) t3_tab++;
     221              :         }
     222         3668 :         if (!t3_tab || t3_tab == hdr) { free(hdr); continue; }
     223         3667 :         char *lbl_start = t3_tab + 1;   /* start of labels CSV field */
     224              : 
     225              :         /* Locate optional flags field (5th token) and read old value */
     226         3667 :         char *t4 = strchr(lbl_start, '\t');
     227         3667 :         int old_flags = 0;
     228         3667 :         if (t4) {
     229         3667 :             old_flags = atoi(t4 + 1);
     230         3667 :             *t4 = '\0';   /* NUL-terminate labels field in-place */
     231              :         } else {
     232            0 :             char *nl = strchr(lbl_start, '\n');
     233            0 :             if (nl) *nl = '\0';
     234              :         }
     235              : 
     236              :         /* Derive new flags from labels (preserving non-label bits) */
     237         3667 :         int new_flags = old_flags & ~(MSG_FLAG_UNSEEN | MSG_FLAG_FLAGGED);
     238         3667 :         int has_real = 0;
     239              : 
     240              :         /* Iterate labels via a copy (tokenising modifies the string) */
     241         3667 :         char *lbl_copy = strdup(lbl_start);
     242         3667 :         if (!lbl_copy) { free(hdr); continue; }
     243              : 
     244         3667 :         char *tok = lbl_copy;
     245         9613 :         while (tok) {
     246         5946 :             char *comma = strchr(tok, ',');
     247         5946 :             if (comma) *comma = '\0';
     248              : 
     249         5946 :             if (tok[0] && !gmail_sync_is_filtered_label(tok)) {
     250         5936 :                 const char *idx_name = tok;
     251         5936 :                 if      (strcmp(tok, "SPAM")  == 0) idx_name = "_spam";
     252         5930 :                 else if (strcmp(tok, "TRASH") == 0) idx_name = "_trash";
     253              : 
     254         5936 :                 if (npairs >= (int)cap) {
     255            0 :                     cap = cap * 2 + 1;
     256            0 :                     LabelUidPair *tmp = realloc(pairs, cap * sizeof(LabelUidPair));
     257            0 :                     if (!tmp) { free(lbl_copy); free(hdr); free(pairs); return; }
     258            0 :                     pairs = tmp;
     259              :                 }
     260         5936 :                 strncpy(pairs[npairs].label, idx_name, 63);
     261         5936 :                 pairs[npairs].label[63] = '\0';
     262         5936 :                 strncpy(pairs[npairs].uid, uids[i], 16);
     263         5936 :                 pairs[npairs].uid[16] = '\0';
     264         5936 :                 npairs++;
     265              : 
     266         5936 :                 if (!is_category_label(tok)) has_real = 1;
     267         5936 :                 if (strcmp(tok, "UNREAD")  == 0) new_flags |= MSG_FLAG_UNSEEN;
     268         5936 :                 if (strcmp(tok, "STARRED") == 0) new_flags |= MSG_FLAG_FLAGGED;
     269              :             }
     270         5946 :             tok = comma ? comma + 1 : NULL;
     271              :         }
     272         3667 :         free(lbl_copy);
     273              : 
     274              :         /* Messages with no real (non-CATEGORY_) label → Archive.
     275              :          * Archived messages are always considered read. */
     276         3667 :         if (!has_real) {
     277           15 :             if (npairs >= (int)cap) {
     278            0 :                 cap = cap * 2 + 1;
     279            0 :                 LabelUidPair *tmp = realloc(pairs, cap * sizeof(LabelUidPair));
     280            0 :                 if (!tmp) { free(hdr); free(pairs); return; }
     281            0 :                 pairs = tmp;
     282              :             }
     283           15 :             strncpy(pairs[npairs].label, "_nolabel", 63);
     284           15 :             strncpy(pairs[npairs].uid, uids[i], 16);
     285           15 :             pairs[npairs].uid[16] = '\0';
     286           15 :             npairs++;
     287           15 :             new_flags &= ~MSG_FLAG_UNSEEN;
     288              :         }
     289              : 
     290              :         /* Sync flags integer if it disagrees with the labels CSV.
     291              :          * lbl_start still points into hdr at the labels field.
     292              :          * NUL-terminate the prefix at t3_tab then reassemble. */
     293         3667 :         if (new_flags != old_flags) {
     294            3 :             *t3_tab = '\0';
     295            3 :             char *updated = NULL;
     296            3 :             if (asprintf(&updated, "%s\t%s\t%d", hdr, lbl_start, new_flags) != -1) {
     297            3 :                 local_hdr_save("", uids[i], updated, strlen(updated));
     298            3 :                 free(updated);
     299              :             }
     300              :         }
     301              : 
     302         3667 :         free(hdr);
     303              :     }
     304              : 
     305              :     /* Phase 2: sort by (label, uid) */
     306           35 :     qsort(pairs, (size_t)npairs, sizeof(LabelUidPair), cmp_lbl_uid_pair);
     307              : 
     308              :     /* Phase 3: group by label and write each .idx file */
     309           35 :     int labels_written = 0;
     310           35 :     int i = 0;
     311          199 :     while (i < npairs) {
     312          164 :         const char *cur_label = pairs[i].label;
     313          164 :         int j = i;
     314         6115 :         while (j < npairs && strcmp(pairs[j].label, cur_label) == 0) j++;
     315          164 :         int run = j - i;
     316              : 
     317          164 :         char (*uid_arr)[17] = malloc((size_t)run * sizeof(char[17]));
     318          164 :         if (uid_arr) {
     319          164 :             int unique = 0;
     320         6115 :             for (int k = i; k < j; k++) {
     321         5951 :                 if (unique == 0 ||
     322         5787 :                     strcmp(uid_arr[unique - 1], pairs[k].uid) != 0) {
     323         5951 :                     memcpy(uid_arr[unique++], pairs[k].uid, 17);
     324              :                 }
     325              :             }
     326          164 :             label_idx_write(cur_label, (const char (*)[17])uid_arr, unique);
     327          164 :             free(uid_arr);
     328          164 :             labels_written++;
     329              :         }
     330          164 :         i = j;
     331              :     }
     332           35 :     free(pairs);
     333              : 
     334           35 :     fprintf(stderr, "\r\033[K  Label indexes rebuilt (%d labels)\n",
     335              :             labels_written);
     336           35 :     logger_log(LOG_INFO,
     337              :                "gmail_sync: rebuilt %d label indexes from %d messages",
     338              :                labels_written, uid_count);
     339              : }
     340              : 
     341              : /**
     342              :  * Rebuild all label .idx files from locally cached .hdr files.
     343              :  * Does NOT contact the Gmail API.
     344              :  * Use this to repair missing or incomplete indexes without re-downloading.
     345              :  */
     346           16 : int gmail_sync_rebuild_indexes(void) {
     347           16 :     char (*uids)[17] = NULL;
     348           16 :     int count = 0;
     349           16 :     if (local_hdr_list_all_uids("", &uids, &count) != 0) {
     350            0 :         fprintf(stderr, "Error: could not scan local message store.\n");
     351            0 :         return -1;
     352              :     }
     353           16 :     fprintf(stderr, "  Found %d cached messages.\n", count);
     354           16 :     rebuild_label_indexes((const char (*)[17])uids, count);
     355           16 :     free(uids);
     356           16 :     return 0;
     357              : }
     358              : 
     359              : /* ── Single message fetch+store helper ───────────────────────────────── */
     360              : 
     361              : /**
     362              :  * Fetch one message from the Gmail API, save .eml + .hdr, apply rules,
     363              :  * update label indexes.
     364              :  * Returns 0 on success, -1 on fetch error (transient; caller should retry).
     365              :  */
     366          605 : static int store_fetched_message(GmailClient *gc, const char *uid,
     367              :                                   const MailRules *rules)
     368              : {
     369          605 :     char **labels = NULL;
     370          605 :     int label_count = 0;
     371          605 :     char *raw = gmail_fetch_message(gc, uid, &labels, &label_count);
     372          605 :     if (!raw) {
     373            1 :         logger_log(LOG_WARN, "gmail_sync: failed to fetch %s", uid);
     374            1 :         for (int j = 0; j < label_count; j++) free(labels[j]);
     375            1 :         free(labels);
     376            1 :         return -1;
     377              :     }
     378              : 
     379          604 :     local_msg_save("", uid, raw, strlen(raw));
     380              : 
     381          604 :     char *hdr = gmail_sync_build_hdr(raw, labels, label_count);
     382          604 :     if (hdr) { local_hdr_save("", uid, hdr, strlen(hdr)); free(hdr); }
     383              : 
     384          604 :     apply_rules_to_new_message(rules, uid, raw, labels, label_count);
     385          604 :     free(raw);
     386              : 
     387          604 :     int has_real_label = 0;
     388         1582 :     for (int j = 0; j < label_count; j++) {
     389          978 :         if (gmail_sync_is_filtered_label(labels[j])) continue;
     390          977 :         const char *idx_name = labels[j];
     391          977 :         if      (strcmp(labels[j], "SPAM")  == 0) idx_name = "_spam";
     392          977 :         else if (strcmp(labels[j], "TRASH") == 0) idx_name = "_trash";
     393          977 :         label_idx_add(idx_name, uid);
     394          977 :         if (!is_category_label(labels[j])) has_real_label = 1;
     395              :     }
     396          604 :     if (!has_real_label) {
     397            1 :         label_idx_add("_nolabel", uid);
     398            1 :         int cur_flags = 0;
     399            3 :         for (int j = 0; j < label_count; j++) {
     400            2 :             if (strcmp(labels[j], "UNREAD")  == 0) cur_flags |= MSG_FLAG_UNSEEN;
     401            2 :             if (strcmp(labels[j], "STARRED") == 0) cur_flags |= MSG_FLAG_FLAGGED;
     402              :         }
     403            1 :         if (cur_flags & MSG_FLAG_UNSEEN)
     404            0 :             local_hdr_update_flags("", uid, cur_flags & ~MSG_FLAG_UNSEEN);
     405              :     }
     406              : 
     407         1582 :     for (int j = 0; j < label_count; j++) free(labels[j]);
     408          604 :     free(labels);
     409          604 :     return 0;
     410              : }
     411              : 
     412              : /* ── Reconcile: discover missing UIDs and queue them ─────────────────── */
     413              : 
     414              : /**
     415              :  * List all server-side message IDs, compare with the local store, and
     416              :  * add any missing UIDs to pending_fetch.tsv.  Does NOT download messages.
     417              :  *
     418              :  * Also updates the historyId and label name mapping so subsequent
     419              :  * incremental syncs know where to resume from.
     420              :  *
     421              :  * Returns the number of UIDs added to the pending-fetch queue, or -1 on
     422              :  * a fatal error (e.g. the server cannot be reached).
     423              :  */
     424           24 : int gmail_sync_reconcile(GmailClient *gc) {
     425           24 :     logger_log(LOG_INFO, "gmail_sync: reconcile — listing server messages");
     426              : 
     427           24 :     fprintf(stderr, "  Listing messages...");
     428           24 :     fflush(stderr);
     429           24 :     gmail_set_progress(gc, list_progress_cb, NULL);
     430              : 
     431           24 :     char (*all_uids)[17] = NULL;
     432           24 :     int uid_count = 0;
     433           24 :     char *list_history_id = NULL;
     434           24 :     if (gmail_list_messages(gc, NULL, NULL, &all_uids, &uid_count, &list_history_id) != 0) {
     435            0 :         gmail_set_progress(gc, NULL, NULL);
     436            0 :         free(list_history_id);
     437            0 :         logger_log(LOG_ERROR, "gmail_sync: reconcile failed to list messages");
     438            0 :         return -1;
     439              :     }
     440           24 :     gmail_set_progress(gc, NULL, NULL);
     441           24 :     fprintf(stderr, "\r\033[K  %d messages on server\n", uid_count);
     442              : 
     443              :     /* Clear any stale pending_fetch entries before repopulating */
     444           24 :     local_pending_fetch_clear();
     445              : 
     446           24 :     int queued = 0, cached = 0;
     447         1848 :     for (int i = 0; i < uid_count; i++) {
     448         1824 :         const char *uid = all_uids[i];
     449         1824 :         if (local_msg_exists("", uid) && local_hdr_exists("", uid)) {
     450         1220 :             cached++;
     451         1220 :             if (i % 500 == 0 || i == uid_count - 1) {
     452           19 :                 fprintf(stderr, "\r\033[K  Scanning local store: %d/%d",
     453              :                         i + 1, uid_count);
     454           19 :                 fflush(stderr);
     455              :             }
     456         1220 :             continue;
     457              :         }
     458          604 :         local_pending_fetch_add(uid);
     459          604 :         queued++;
     460          604 :         if ((cached + queued) % 500 == 0 || i == uid_count - 1) {
     461           12 :             fprintf(stderr, "\r\033[K  Scanning local store: %d/%d",
     462              :                     i + 1, uid_count);
     463           12 :             fflush(stderr);
     464              :         }
     465              :     }
     466           24 :     if (uid_count > 0)
     467           21 :         fprintf(stderr, "\r\033[K  %d cached, %d queued for download\n",
     468              :                 cached, queued);
     469              : 
     470              :     /* Save historyId so next run can use incremental sync.
     471              :      * Prefer the historyId from the messages.list response (always fresh);
     472              :      * fall back to the /profile endpoint only if that field was absent. */
     473           24 :     if (list_history_id) {
     474           21 :         fprintf(stderr, "  historyId from list response: %s\n", list_history_id);
     475           21 :         local_gmail_history_save(list_history_id);
     476           21 :         free(list_history_id);
     477           21 :         list_history_id = NULL;
     478              :     } else {
     479            6 :         RAII_STRING char *hid = gmail_get_history_id(gc);
     480            3 :         if (hid)
     481            1 :             local_gmail_history_save(hid);
     482              :         else
     483            2 :             logger_log(LOG_WARN, "gmail_sync: reconcile: could not retrieve historyId");
     484              :     }
     485              : 
     486              :     /* Save label ID→name mapping */
     487              :     {
     488           24 :         char **lbl_names = NULL, **lbl_ids = NULL;
     489           24 :         int lbl_count = 0;
     490           24 :         if (gmail_list_labels(gc, &lbl_names, &lbl_ids, &lbl_count) == 0) {
     491           22 :             local_gmail_label_names_save(lbl_ids, lbl_names, lbl_count);
     492          191 :             for (int i = 0; i < lbl_count; i++) { free(lbl_names[i]); free(lbl_ids[i]); }
     493           22 :             free(lbl_names); free(lbl_ids);
     494              :         }
     495              :     }
     496              : 
     497           24 :     free(all_uids);
     498           24 :     logger_log(LOG_INFO, "gmail_sync: reconcile done — %d cached, %d queued",
     499              :                cached, queued);
     500           24 :     return queued;
     501              : }
     502              : 
     503              : /* ── Fetch pending: download queued messages ─────────────────────────── */
     504              : 
     505              : /**
     506              :  * Download all message UIDs listed in pending_fetch.tsv.
     507              :  * Removes each entry from the queue on successful download.
     508              :  * Leaves failures in the queue for retry on the next sync.
     509              :  *
     510              :  * Returns number of messages successfully downloaded.
     511              :  */
     512           17 : int gmail_sync_fetch_pending(GmailClient *gc) {
     513           17 :     int count = 0;
     514           17 :     char (*uids)[17] = local_pending_fetch_load(&count);
     515           17 :     if (!uids || count == 0) {
     516            1 :         free(uids);
     517            1 :         return 0;
     518              :     }
     519              : 
     520           16 :     logger_log(LOG_INFO, "gmail_sync: fetch_pending — %d messages to download", count);
     521           16 :     fprintf(stderr, "  Downloading %d message(s)...\n", count);
     522              : 
     523           16 :     MailRules *rules = mail_rules_load(local_store_account_name());
     524           16 :     int fetched = 0;
     525              : #define PROGRESS_STEP 50
     526          623 :     for (int i = 0; i < count; i++) {
     527          607 :         const char *uid = uids[i];
     528              : 
     529          607 :         if (local_msg_exists("", uid) && local_hdr_exists("", uid)) {
     530              :             /* Already present — clean up stale pending entry */
     531            2 :             local_pending_fetch_remove(uid);
     532            2 :             continue;
     533              :         }
     534              : 
     535          605 :         if (store_fetched_message(gc, uid, rules) == 0) {
     536          604 :             local_pending_fetch_remove(uid);
     537          604 :             fetched++;
     538              :         }
     539              :         /* On failure: leave in queue for retry */
     540              : 
     541          605 :         if (i % PROGRESS_STEP == 0 || i == count - 1) {
     542           32 :             fprintf(stderr, "\r\033[K  [%d/%d] downloaded", fetched, count);
     543           32 :             fflush(stderr);
     544              :         }
     545              :     }
     546           16 :     if (count > 0)
     547           16 :         fprintf(stderr, "\r\033[K  %d of %d downloaded\n", fetched, count);
     548              : 
     549           16 :     mail_rules_free(rules);
     550           16 :     free(uids);
     551           16 :     logger_log(LOG_INFO, "gmail_sync: fetch_pending done — %d/%d downloaded",
     552              :                fetched, count);
     553           16 :     return fetched;
     554              : }
     555              : 
     556              : /* ── Full Sync ────────────────────────────────────────────────────── */
     557              : 
     558            2 : int gmail_sync_full(GmailClient *gc) {
     559            2 :     logger_log(LOG_INFO, "gmail_sync: starting full sync");
     560              : 
     561            2 :     int queued = gmail_sync_reconcile(gc);
     562            2 :     if (queued < 0) return -1;
     563              : 
     564            2 :     if (queued > 0)
     565            1 :         gmail_sync_fetch_pending(gc);
     566              : 
     567              :     /* Rebuild label indexes from .hdr files so that even when all messages
     568              :      * were already cached (queued == 0) the indexes are consistent. */
     569              :     {
     570            2 :         char (*all_uids)[17] = NULL;
     571            2 :         int all_count = 0;
     572            2 :         if (local_hdr_list_all_uids("", &all_uids, &all_count) == 0 && all_count > 0)
     573            2 :             rebuild_label_indexes((const char (*)[17])all_uids, all_count);
     574            2 :         free(all_uids);
     575              :     }
     576              : 
     577            2 :     return 0;
     578              : }
     579              : 
     580              : /* ── History delta processing ─────────────────────────────────────── */
     581              : 
     582              : struct history_ctx {
     583              :     GmailClient *gc;
     584              :     MailRules   *rules;
     585              :     int added;
     586              :     int deleted;
     587              :     int label_changes;
     588              : };
     589              : 
     590            3 : static void process_message_added(const char *obj, int index, void *ctx) {
     591              :     (void)index;
     592            3 :     struct history_ctx *hc = ctx;
     593              : 
     594              :     /* obj is a history record with "message" sub-object */
     595              :     /* Extract message ID from the "message" field */
     596              :     /* The history API returns: {"message": {"id": "...", "labelIds": [...]}} */
     597            3 :     char *id = json_get_string(obj, "id");
     598            3 :     if (!id) return;
     599              : 
     600              :     /* Fetch and store the new message */
     601            3 :     char **labels = NULL;
     602            3 :     int label_count = 0;
     603            3 :     char *raw = gmail_fetch_message(hc->gc, id, &labels, &label_count);
     604            3 :     if (raw) {
     605            3 :         local_msg_save("", id, raw, strlen(raw));
     606              : 
     607            3 :         char *hdr = gmail_sync_build_hdr(raw, labels, label_count);
     608            3 :         if (hdr) {
     609            3 :             local_hdr_save("", id, hdr, strlen(hdr));
     610            3 :             free(hdr);
     611              :         }
     612              : 
     613            3 :         apply_rules_to_new_message(hc->rules, id, raw, labels, label_count);
     614            3 :         free(raw);
     615              : 
     616            3 :         int has_label = 0;
     617            9 :         for (int j = 0; j < label_count; j++) {
     618            6 :             if (gmail_sync_is_filtered_label(labels[j])) continue;
     619            6 :             const char *idx_name = labels[j];
     620            6 :             if (strcmp(labels[j], "SPAM") == 0) idx_name = "_spam";
     621            6 :             else if (strcmp(labels[j], "TRASH") == 0) idx_name = "_trash";
     622            6 :             label_idx_add(idx_name, id);
     623            6 :             has_label = 1;
     624              :         }
     625            3 :         if (!has_label) {
     626            0 :             label_idx_add("_nolabel", id);
     627              :             /* Archived messages are always read */
     628            0 :             int cur_flags = 0;
     629            0 :             for (int j = 0; j < label_count; j++) {
     630            0 :                 if (strcmp(labels[j], "UNREAD")  == 0) cur_flags |= MSG_FLAG_UNSEEN;
     631            0 :                 if (strcmp(labels[j], "STARRED") == 0) cur_flags |= MSG_FLAG_FLAGGED;
     632              :             }
     633            0 :             if (cur_flags & MSG_FLAG_UNSEEN)
     634            0 :                 local_hdr_update_flags("", id, cur_flags & ~MSG_FLAG_UNSEEN);
     635              :         }
     636              : 
     637            3 :         hc->added++;
     638              :     }
     639              : 
     640            9 :     for (int j = 0; j < label_count; j++) free(labels[j]);
     641            3 :     free(labels);
     642            3 :     free(id);
     643              : }
     644              : 
     645            1 : static void process_message_deleted(const char *obj, int index, void *ctx) {
     646              :     (void)index;
     647            1 :     struct history_ctx *hc = ctx;
     648              : 
     649            1 :     char *id = json_get_string(obj, "id");
     650            1 :     if (!id) return;
     651              : 
     652            1 :     local_msg_delete("", id);
     653              : 
     654              :     /* Remove from all known label indexes — brute force scan */
     655              :     /* In practice this is rare; deleted messages are few */
     656            1 :     char **names = NULL, **ids = NULL;
     657            1 :     int count = 0;
     658            1 :     if (gmail_list_labels(hc->gc, &names, &ids, &count) == 0) {
     659            4 :         for (int i = 0; i < count; i++) {
     660            3 :             label_idx_remove(ids[i], id);
     661            3 :             free(names[i]);
     662            3 :             free(ids[i]);
     663              :         }
     664            1 :         free(names);
     665            1 :         free(ids);
     666              :     }
     667            1 :     label_idx_remove("_nolabel", id);
     668            1 :     label_idx_remove("_spam", id);
     669            1 :     label_idx_remove("_trash", id);
     670              : 
     671            1 :     hc->deleted++;
     672            1 :     free(id);
     673              : }
     674              : 
     675            1 : static void process_labels_added(const char *obj, int index, void *ctx) {
     676              :     (void)index;
     677            1 :     struct history_ctx *hc = ctx;
     678              : 
     679            1 :     char *id = json_get_string(obj, "id");
     680            1 :     if (!id) return;
     681              : 
     682            1 :     char **add_labels = NULL;
     683            1 :     int add_count = 0;
     684            1 :     json_get_string_array(obj, "labelIds", &add_labels, &add_count);
     685              : 
     686            2 :     for (int i = 0; i < add_count; i++) {
     687            1 :         if (gmail_sync_is_filtered_label(add_labels[i])) continue;
     688            1 :         const char *idx_name = add_labels[i];
     689            1 :         if (strcmp(add_labels[i], "SPAM") == 0) idx_name = "_spam";
     690            1 :         else if (strcmp(add_labels[i], "TRASH") == 0) idx_name = "_trash";
     691            1 :         label_idx_add(idx_name, id);
     692              :         /* Only remove from _nolabel when a real (non-CATEGORY_) label is added */
     693            1 :         if (!is_category_label(add_labels[i]))
     694            1 :             label_idx_remove("_nolabel", id);
     695              :     }
     696              : 
     697              :     /* Keep .hdr labels field in sync so rebuild_label_indexes stays accurate. */
     698            1 :     local_hdr_update_labels("", id,
     699              :                             (const char **)add_labels, add_count, NULL, 0);
     700              : 
     701            2 :     for (int i = 0; i < add_count; i++) free(add_labels[i]);
     702            1 :     free(add_labels);
     703            1 :     free(id);
     704            1 :     hc->label_changes++;
     705              : }
     706              : 
     707            1 : static void process_labels_removed(const char *obj, int index, void *ctx) {
     708              :     (void)index;
     709            1 :     struct history_ctx *hc = ctx;
     710              : 
     711            1 :     char *id = json_get_string(obj, "id");
     712            1 :     if (!id) return;
     713              : 
     714            1 :     char **rm_labels = NULL;
     715            1 :     int rm_count = 0;
     716            1 :     json_get_string_array(obj, "labelIds", &rm_labels, &rm_count);
     717              : 
     718            2 :     for (int i = 0; i < rm_count; i++) {
     719            1 :         if (gmail_sync_is_filtered_label(rm_labels[i])) continue;
     720            1 :         const char *idx_name = rm_labels[i];
     721            1 :         if (strcmp(rm_labels[i], "SPAM") == 0) idx_name = "_spam";
     722            1 :         else if (strcmp(rm_labels[i], "TRASH") == 0) idx_name = "_trash";
     723            1 :         label_idx_remove(idx_name, id);
     724              :     }
     725              : 
     726              :     /* Keep .hdr labels field in sync so rebuild_label_indexes stays accurate.
     727              :      * Must be done BEFORE freeing rm_labels (used-after-free guard). */
     728            1 :     local_hdr_update_labels("", id,
     729              :                             NULL, 0, (const char **)rm_labels, rm_count);
     730              : 
     731            2 :     for (int i = 0; i < rm_count; i++) free(rm_labels[i]);
     732            1 :     free(rm_labels);
     733              : 
     734              :     /* Check if any labels remain; if none → add to _nolabel */
     735              :     /* Quick check: fetch message labels from server */
     736            1 :     char **cur_labels = NULL;
     737            1 :     int cur_count = 0;
     738            1 :     char *raw = gmail_fetch_message(hc->gc, id, &cur_labels, &cur_count);
     739            1 :     free(raw);
     740              : 
     741            1 :     int has_real_label = 0;
     742            3 :     for (int i = 0; i < cur_count; i++) {
     743            4 :         if (!gmail_sync_is_filtered_label(cur_labels[i]) &&
     744            2 :             !is_category_label(cur_labels[i]))
     745            2 :             has_real_label = 1;
     746            2 :         free(cur_labels[i]);
     747              :     }
     748            1 :     free(cur_labels);
     749              : 
     750            1 :     if (!has_real_label) {
     751            0 :         label_idx_add("_nolabel", id);
     752              :         /* Archived messages are always read: clear UNSEEN from .hdr flags */
     753            0 :         char *cur_hdr = local_hdr_load("", id);
     754            0 :         if (cur_hdr) {
     755            0 :             char *last_tab = strrchr(cur_hdr, '\t');
     756            0 :             if (last_tab) {
     757            0 :                 int cur_flags = atoi(last_tab + 1);
     758            0 :                 if (cur_flags & MSG_FLAG_UNSEEN)
     759            0 :                     local_hdr_update_flags("", id, cur_flags & ~MSG_FLAG_UNSEEN);
     760              :             }
     761            0 :             free(cur_hdr);
     762              :         }
     763              :     }
     764              : 
     765            1 :     free(id);
     766            1 :     hc->label_changes++;
     767              : }
     768              : 
     769              : /* ── One-time repair: archived messages must not be unread ─────────── */
     770              : 
     771            8 : void gmail_sync_repair_archive_flags(void) {
     772            8 :     char (*uids)[17] = NULL;
     773            8 :     int count = 0;
     774            8 :     if (label_idx_load("_nolabel", &uids, &count) != 0 || count == 0) {
     775            5 :         free(uids);
     776            5 :         return;
     777              :     }
     778            9 :     for (int i = 0; i < count; i++) {
     779            6 :         char *hdr = local_hdr_load("", uids[i]);
     780            6 :         if (!hdr) continue;
     781            6 :         char *last_tab = strrchr(hdr, '\t');
     782            6 :         if (last_tab) {
     783            6 :             int flags = atoi(last_tab + 1);
     784            6 :             if (flags & MSG_FLAG_UNSEEN)
     785            2 :                 local_hdr_update_flags("", uids[i], flags & ~MSG_FLAG_UNSEEN);
     786              :         }
     787            6 :         free(hdr);
     788              :     }
     789            3 :     free(uids);
     790              : }
     791              : 
     792              : /* ── Incremental Sync ─────────────────────────────────────────────── */
     793              : 
     794           10 : int gmail_sync_incremental(GmailClient *gc) {
     795           10 :     char *history_id = local_gmail_history_load();
     796           10 :     if (!history_id) {
     797            2 :         logger_log(LOG_INFO, "gmail_sync: no historyId, need full sync");
     798            2 :         return -2;
     799              :     }
     800              : 
     801            8 :     logger_log(LOG_INFO, "gmail_sync: incremental from historyId %s", history_id);
     802              : 
     803            8 :     char *resp = gmail_get_history(gc, history_id);
     804            8 :     free(history_id);
     805              : 
     806            8 :     if (!resp) {
     807            3 :         fprintf(stderr, "  Incremental: History API returned error/404 (historyId expired or network issue).\n");
     808            3 :         logger_log(LOG_WARN, "gmail_sync: history expired or error");
     809            3 :         return -2;  /* Signal: need full sync */
     810              :     }
     811              : 
     812            5 :     MailRules *inc_rules = mail_rules_load(local_store_account_name());
     813            5 :     struct history_ctx hc = { .gc = gc, .rules = inc_rules, .added = 0, .deleted = 0, .label_changes = 0 };
     814              : 
     815              :     /* Process each history record */
     816              :     /* The history response has: {"history": [{...}, ...], "historyId": "..."} */
     817              :     /* Each history entry may contain messagesAdded, messagesDeleted,
     818              :      * labelsAdded, labelsRemoved arrays */
     819              : 
     820              :     /* Process delta events from the history response.
     821              :      * Gmail nests events inside history records, but our json_foreach_object
     822              :      * searches the full response for matching keys, so a single scan works. */
     823            5 :     json_foreach_object(resp, "messagesAdded", process_message_added, &hc);
     824            5 :     json_foreach_object(resp, "messagesDeleted", process_message_deleted, &hc);
     825            5 :     json_foreach_object(resp, "labelsAdded", process_labels_added, &hc);
     826            5 :     json_foreach_object(resp, "labelsRemoved", process_labels_removed, &hc);
     827              : 
     828              :     /* Save updated historyId */
     829            5 :     RAII_STRING char *new_history_id = json_get_string(resp, "historyId");
     830            5 :     if (new_history_id)
     831            5 :         local_gmail_history_save(new_history_id);
     832              : 
     833            5 :     free(resp);
     834              : 
     835              :     /* Refresh label name mapping if any label events occurred */
     836            5 :     if (hc.label_changes > 0) {
     837            1 :         char **lbl_names = NULL, **lbl_ids = NULL;
     838            1 :         int lbl_count = 0;
     839            1 :         if (gmail_list_labels(gc, &lbl_names, &lbl_ids, &lbl_count) == 0) {
     840            1 :             local_gmail_label_names_save(lbl_ids, lbl_names, lbl_count);
     841            4 :             for (int i = 0; i < lbl_count; i++) { free(lbl_names[i]); free(lbl_ids[i]); }
     842            1 :             free(lbl_names);
     843            1 :             free(lbl_ids);
     844              :         }
     845              :     }
     846              : 
     847              :     /* Ensure no archived message is marked unread (repair existing data too) */
     848            5 :     gmail_sync_repair_archive_flags();
     849              : 
     850            5 :     mail_rules_free(inc_rules);
     851            5 :     logger_log(LOG_INFO, "gmail_sync: incremental done — added=%d deleted=%d labels=%d",
     852              :                hc.added, hc.deleted, hc.label_changes);
     853            5 :     return 0;
     854              : }
     855              : 
     856              : /* ── Auto Sync (public entry point) ───────────────────────────────── */
     857              : 
     858              : /**
     859              :  * Smart sync flow:
     860              :  *
     861              :  *   1. If pending_fetch.tsv is non-empty, download those first (resuming an
     862              :  *      interrupted previous sync or initial download).
     863              :  *   2. If the local store was already complete (no pending at start) AND a
     864              :  *      valid historyId exists, use the fast incremental path (1–2 API calls).
     865              :  *   3. Otherwise, run reconcile (full UID listing) to discover any missing
     866              :  *      messages, then download them.
     867              :  *
     868              :  * This ensures:
     869              :  *   - First run or expired historyId: O(N) reconcile → O(missing) downloads.
     870              :  *   - Subsequent runs on a mature store: O(1) incremental (no listing at all).
     871              :  *   - Interrupted downloads: resume from pending_fetch.tsv without re-listing.
     872              :  */
     873           22 : int gmail_sync(GmailClient *gc) {
     874              :     /* Step 1: check readiness before downloading anything */
     875           22 :     int had_pending = local_pending_fetch_count() > 0;
     876              : 
     877              :     /* Step 2: drain any queued downloads from a previous (possibly interrupted) sync */
     878           22 :     if (had_pending)
     879            1 :         gmail_sync_fetch_pending(gc);
     880              : 
     881              :     /* Step 3: try fast incremental path if we have a saved historyId.
     882              :      * We do this regardless of whether there were pending downloads —
     883              :      * draining pending_fetch.tsv already brought the local store up to the
     884              :      * reconcile snapshot; incremental then catches anything that arrived
     885              :      * on the server after that snapshot. */
     886           22 :     char *history_id = local_gmail_history_load();
     887           22 :     int have_history = (history_id != NULL);
     888           22 :     free(history_id);
     889              : 
     890           22 :     if (have_history) {
     891            6 :         fprintf(stderr, "  Incremental sync (historyId present)...\n");
     892            6 :         int rc = gmail_sync_incremental(gc);
     893            6 :         if (rc == 0) {
     894            4 :             fprintf(stderr, "  Incremental sync: up to date.\n");
     895            4 :             return 0; /* fast path — done */
     896              :         }
     897            2 :         if (rc != -2) return rc; /* unexpected error */
     898            2 :         fprintf(stderr, "  Incremental sync: historyId expired — falling back to full reconcile.\n");
     899            2 :         logger_log(LOG_INFO, "gmail_sync: historyId expired, falling back to reconcile");
     900              :     } else {
     901           16 :         fprintf(stderr, "  No saved historyId — full reconcile needed.\n");
     902              :     }
     903              : 
     904              :     /* Step 4: reconcile (discover what is missing) */
     905           18 :     int queued = gmail_sync_reconcile(gc);
     906           18 :     if (queued < 0) return -1;
     907              : 
     908              :     /* Step 5: download what reconcile found */
     909           18 :     if (queued > 0)
     910            9 :         gmail_sync_fetch_pending(gc);
     911              : 
     912              :     /* Step 6: rebuild label indexes from .hdr files.
     913              :      * Necessary when all messages were already cached (queued == 0) but
     914              :      * label .idx files were deleted or are missing (e.g. manual deletion
     915              :      * or upgrade from an older version). */
     916              :     {
     917           18 :         char (*all_uids)[17] = NULL;
     918           18 :         int all_count = 0;
     919           18 :         if (local_hdr_list_all_uids("", &all_uids, &all_count) == 0 && all_count > 0)
     920           17 :             rebuild_label_indexes((const char (*)[17])all_uids, all_count);
     921           18 :         free(all_uids);
     922              :     }
     923              : 
     924           18 :     return 0;
     925              : }
        

Generated by: LCOV version 2.0-1