LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - gmail_sync.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 90.9 % 558 507
Test Date: 2026-08-21 10:11:28 Functions: 100.0 % 21 21

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

Generated by: LCOV version 2.0-1