LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - gmail_client.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 91.7 % 422 387
Test Date: 2026-05-07 15:53:07 Functions: 100.0 % 26 26

            Line data    Source code
       1              : #include "gmail_client.h"
       2              : #include "gmail_auth.h"
       3              : #include "json_util.h"
       4              : #include "logger.h"
       5              : #include "raii.h"
       6              : #include <curl/curl.h>
       7              : #include <stdio.h>
       8              : #include <stdlib.h>
       9              : #include <string.h>
      10              : 
      11              : /* ── Constants ────────────────────────────────────────────────────── */
      12              : 
      13              : #define GMAIL_API "https://gmail.googleapis.com/gmail/v1/users/me"
      14              : 
      15              : /**
      16              :  * Return the base URL for all Gmail API calls.
      17              :  * If the environment variable GMAIL_API_BASE_URL is set and non-empty,
      18              :  * it overrides the default (useful for pointing at a mock server in tests).
      19              :  */
      20          767 : static const char *gmail_api_base(void) {
      21          767 :     const char *override = getenv("GMAIL_API_BASE_URL");
      22          767 :     return (override && override[0]) ? override : GMAIL_API;
      23              : }
      24              : 
      25              : /* ── Client struct ────────────────────────────────────────────────── */
      26              : 
      27              : struct GmailClient {
      28              :     char   *access_token;
      29              :     Config *cfg;             /* borrowed, not owned */
      30              :     GmailProgressFn progress_fn;
      31              :     void   *progress_ctx;
      32              : };
      33              : 
      34              : /* ── libcurl write callback ───────────────────────────────────────── */
      35              : 
      36              : typedef struct {
      37              :     char  *data;
      38              :     size_t len;
      39              :     size_t cap;
      40              : } CurlBuf;
      41              : 
      42          762 : static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
      43          762 :     CurlBuf *buf = userdata;
      44          762 :     size_t bytes = size * nmemb;
      45          762 :     if (buf->len + bytes + 1 > buf->cap) {
      46          762 :         size_t newcap = (buf->cap ? buf->cap * 2 : 4096);
      47          779 :         while (newcap < buf->len + bytes + 1) newcap *= 2;
      48          762 :         char *tmp = realloc(buf->data, newcap);
      49          762 :         if (!tmp) return 0;
      50          762 :         buf->data = tmp;
      51          762 :         buf->cap = newcap;
      52              :     }
      53          762 :     memcpy(buf->data + buf->len, ptr, bytes);
      54          762 :     buf->len += bytes;
      55          762 :     buf->data[buf->len] = '\0';
      56          762 :     return bytes;
      57              : }
      58              : 
      59              : /* ── HTTP helpers ─────────────────────────────────────────────────── */
      60              : 
      61              : /**
      62              :  * Perform an authenticated GET request.
      63              :  * Returns heap-allocated response body; sets *http_code.
      64              :  */
      65          714 : static char *api_get(GmailClient *c, const char *url, long *http_code) {
      66          714 :     CURL *curl = curl_easy_init();
      67          714 :     if (!curl) return NULL;
      68              : 
      69          714 :     CurlBuf buf = {0};
      70              :     char auth_hdr[2048];
      71          714 :     snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", c->access_token);
      72              : 
      73          714 :     struct curl_slist *headers = NULL;
      74          714 :     headers = curl_slist_append(headers, auth_hdr);
      75              : 
      76          714 :     curl_easy_setopt(curl, CURLOPT_URL, url);
      77          714 :     curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
      78          714 :     curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
      79          714 :     curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
      80          714 :     curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
      81          714 :     curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
      82          714 :     curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
      83              : 
      84              :     /* Disable SSL verification when talking to a plain HTTP test server */
      85          714 :     if (strncmp(url, "http://", 7) == 0) {
      86          714 :         curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
      87          714 :         curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
      88              :     }
      89              : 
      90          714 :     CURLcode res = curl_easy_perform(curl);
      91          714 :     curl_slist_free_all(headers);
      92              : 
      93          714 :     if (res != CURLE_OK) {
      94            3 :         logger_log(LOG_ERROR, "gmail: GET %s failed: %s", url, curl_easy_strerror(res));
      95            3 :         free(buf.data);
      96            3 :         curl_easy_cleanup(curl);
      97            3 :         return NULL;
      98              :     }
      99              : 
     100          711 :     curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code);
     101          711 :     curl_easy_cleanup(curl);
     102          711 :     return buf.data;
     103              : }
     104              : 
     105              : /**
     106              :  * Perform an authenticated POST with JSON body.
     107              :  * json_body may be NULL for empty-body POSTs (e.g. trash).
     108              :  */
     109           49 : static char *api_post_json(GmailClient *c, const char *url,
     110              :                            const char *json_body, long *http_code) {
     111           49 :     CURL *curl = curl_easy_init();
     112           49 :     if (!curl) return NULL;
     113              : 
     114           49 :     CurlBuf buf = {0};
     115              :     char auth_hdr[2048];
     116           49 :     snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", c->access_token);
     117              : 
     118           49 :     struct curl_slist *headers = NULL;
     119           49 :     headers = curl_slist_append(headers, auth_hdr);
     120           49 :     headers = curl_slist_append(headers, "Content-Type: application/json");
     121              : 
     122           49 :     curl_easy_setopt(curl, CURLOPT_URL, url);
     123           49 :     curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
     124           49 :     curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
     125           49 :     curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
     126           49 :     curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
     127           49 :     curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
     128           49 :     curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
     129              : 
     130              :     /* Disable SSL verification when talking to a plain HTTP test server */
     131           49 :     if (strncmp(url, "http://", 7) == 0) {
     132           49 :         curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
     133           49 :         curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
     134              :     }
     135              : 
     136           49 :     if (json_body) {
     137           44 :         curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body);
     138              :     } else {
     139            5 :         curl_easy_setopt(curl, CURLOPT_POST, 1L);
     140            5 :         curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L);
     141              :     }
     142              : 
     143           49 :     CURLcode res = curl_easy_perform(curl);
     144           49 :     curl_slist_free_all(headers);
     145              : 
     146           49 :     if (res != CURLE_OK) {
     147            0 :         logger_log(LOG_ERROR, "gmail: POST %s failed: %s", url, curl_easy_strerror(res));
     148            0 :         free(buf.data);
     149            0 :         curl_easy_cleanup(curl);
     150            0 :         return NULL;
     151              :     }
     152              : 
     153           49 :     curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code);
     154           49 :     curl_easy_cleanup(curl);
     155           49 :     return buf.data;
     156              : }
     157              : 
     158              : /**
     159              :  * Wrapper that auto-retries once on HTTP 401 (token expired).
     160              :  */
     161          714 : static char *api_get_retry(GmailClient *c, const char *url, long *http_code) {
     162          714 :     char *resp = api_get(c, url, http_code);
     163          714 :     if (resp && *http_code == 401) {
     164            0 :         free(resp);
     165            0 :         char *new_token = gmail_auth_refresh(c->cfg);
     166            0 :         if (!new_token) return NULL;
     167            0 :         free(c->access_token);
     168            0 :         c->access_token = new_token;
     169            0 :         resp = api_get(c, url, http_code);
     170              :     }
     171          714 :     return resp;
     172              : }
     173              : 
     174           49 : static char *api_post_retry(GmailClient *c, const char *url,
     175              :                             const char *json_body, long *http_code) {
     176           49 :     char *resp = api_post_json(c, url, json_body, http_code);
     177           49 :     if (resp && *http_code == 401) {
     178            0 :         free(resp);
     179            0 :         char *new_token = gmail_auth_refresh(c->cfg);
     180            0 :         if (!new_token) return NULL;
     181            0 :         free(c->access_token);
     182            0 :         c->access_token = new_token;
     183            0 :         resp = api_post_json(c, url, json_body, http_code);
     184              :     }
     185           49 :     return resp;
     186              : }
     187              : 
     188              : /* ── Base64url decode ─────────────────────────────────────────────── */
     189              : 
     190              : static const signed char b64url_table[256] = {
     191              :     ['A']=0,  ['B']=1,  ['C']=2,  ['D']=3,  ['E']=4,  ['F']=5,
     192              :     ['G']=6,  ['H']=7,  ['I']=8,  ['J']=9,  ['K']=10, ['L']=11,
     193              :     ['M']=12, ['N']=13, ['O']=14, ['P']=15, ['Q']=16, ['R']=17,
     194              :     ['S']=18, ['T']=19, ['U']=20, ['V']=21, ['W']=22, ['X']=23,
     195              :     ['Y']=24, ['Z']=25,
     196              :     ['a']=0+26, ['b']=1+26, ['c']=2+26, ['d']=3+26, ['e']=4+26,
     197              :     ['f']=5+26, ['g']=6+26, ['h']=7+26, ['i']=8+26, ['j']=9+26,
     198              :     ['k']=10+26,['l']=11+26,['m']=12+26,['n']=13+26,['o']=14+26,
     199              :     ['p']=15+26,['q']=16+26,['r']=17+26,['s']=18+26,['t']=19+26,
     200              :     ['u']=20+26,['v']=21+26,['w']=22+26,['x']=23+26,['y']=24+26,
     201              :     ['z']=25+26,
     202              :     ['0']=52, ['1']=53, ['2']=54, ['3']=55, ['4']=56,
     203              :     ['5']=57, ['6']=58, ['7']=59, ['8']=60, ['9']=61,
     204              :     ['-']=62, ['_']=63,
     205              : };
     206              : 
     207              : /**
     208              :  * Decode a base64url string (no padding) into a binary buffer.
     209              :  * Returns heap-allocated NUL-terminated buffer; sets *out_len.
     210              :  */
     211          624 : char *gmail_base64url_decode(const char *input, size_t in_len, size_t *out_len) {
     212          624 :     size_t alloc = (in_len / 4 + 1) * 3 + 1;
     213          624 :     char *out = malloc(alloc);
     214          624 :     if (!out) return NULL;
     215              : 
     216          624 :     size_t o = 0;
     217          624 :     unsigned int acc = 0;
     218          624 :     int bits = 0;
     219              : 
     220       196574 :     for (size_t i = 0; i < in_len; i++) {
     221       195950 :         unsigned char ch = (unsigned char)input[i];
     222       195950 :         if (ch == '=' || ch == '\n' || ch == '\r' || ch == ' ') continue;
     223       195950 :         int val = b64url_table[ch];
     224              :         /* Non-base64url chars have value 0 in the table; 'A' is also 0.
     225              :          * For safety, skip obvious non-alphabet chars. */
     226       195950 :         if (val == 0 && ch != 'A') continue;
     227       195950 :         acc = (acc << 6) | (unsigned int)val;
     228       195950 :         bits += 6;
     229       195950 :         if (bits >= 8) {
     230       146859 :             bits -= 8;
     231       146859 :             out[o++] = (char)((acc >> bits) & 0xFF);
     232              :         }
     233              :     }
     234              : 
     235          624 :     out[o] = '\0';
     236          624 :     if (out_len) *out_len = o;
     237          624 :     return out;
     238              : }
     239              : 
     240              : /* ── Base64url encode ─────────────────────────────────────────────── */
     241              : 
     242              : static const char b64url_chars[] =
     243              :     "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
     244              : 
     245              : /**
     246              :  * Encode binary data as base64url (no padding).
     247              :  * Returns heap-allocated NUL-terminated string.
     248              :  */
     249            7 : char *gmail_base64url_encode(const unsigned char *data, size_t len) {
     250            7 :     size_t alloc = ((len + 2) / 3) * 4 + 1;
     251            7 :     char *out = malloc(alloc);
     252            7 :     if (!out) return NULL;
     253              : 
     254            7 :     size_t o = 0;
     255          143 :     for (size_t i = 0; i < len; i += 3) {
     256          136 :         unsigned int n = ((unsigned int)data[i]) << 16;
     257          136 :         if (i + 1 < len) n |= ((unsigned int)data[i + 1]) << 8;
     258          136 :         if (i + 2 < len) n |= ((unsigned int)data[i + 2]);
     259              : 
     260          136 :         out[o++] = b64url_chars[(n >> 18) & 0x3F];
     261          136 :         out[o++] = b64url_chars[(n >> 12) & 0x3F];
     262          136 :         if (i + 1 < len) out[o++] = b64url_chars[(n >> 6) & 0x3F];
     263          136 :         if (i + 2 < len) out[o++] = b64url_chars[n & 0x3F];
     264              :     }
     265            7 :     out[o] = '\0';
     266            7 :     return out;
     267              : }
     268              : 
     269              : /* ── Connect / Disconnect ─────────────────────────────────────────── */
     270              : 
     271          142 : GmailClient *gmail_connect(Config *cfg) {
     272          142 :     if (!cfg || !cfg->gmail_mode) {
     273            1 :         logger_log(LOG_ERROR, "gmail_connect: not a Gmail account");
     274            1 :         return NULL;
     275              :     }
     276              : 
     277          141 :     char *token = gmail_auth_refresh(cfg);
     278          141 :     if (!token) {
     279            8 :         logger_log(LOG_ERROR, "gmail_connect: failed to obtain access token");
     280            8 :         return NULL;
     281              :     }
     282              : 
     283          133 :     GmailClient *c = calloc(1, sizeof(*c));
     284          133 :     if (!c) { free(token); return NULL; }
     285          133 :     c->access_token = token;
     286          133 :     c->cfg = cfg;
     287          133 :     logger_log(LOG_DEBUG, "gmail_connect: connected for %s", cfg->user ? cfg->user : "(unknown)");
     288          133 :     return c;
     289              : }
     290              : 
     291          134 : void gmail_disconnect(GmailClient *c) {
     292          134 :     if (!c) return;
     293          133 :     free(c->access_token);
     294          133 :     free(c);
     295              : }
     296              : 
     297           50 : void gmail_set_progress(GmailClient *c, GmailProgressFn fn, void *ctx) {
     298           50 :     if (!c) return;
     299           49 :     c->progress_fn = fn;
     300           49 :     c->progress_ctx = ctx;
     301              : }
     302              : 
     303              : /* ── DELETE helper ────────────────────────────────────────────────── */
     304              : 
     305              : /**
     306              :  * Perform an authenticated DELETE request.
     307              :  * Absorbs the response body (usually empty for 204 responses).
     308              :  */
     309            4 : static char *api_delete(GmailClient *c, const char *url, long *http_code) {
     310            4 :     CURL *curl = curl_easy_init();
     311            4 :     if (!curl) return NULL;
     312              : 
     313            4 :     CurlBuf buf = {0};
     314              :     char auth_hdr[2048];
     315            4 :     snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", c->access_token);
     316              : 
     317            4 :     struct curl_slist *headers = NULL;
     318            4 :     headers = curl_slist_append(headers, auth_hdr);
     319              : 
     320            4 :     curl_easy_setopt(curl, CURLOPT_URL, url);
     321            4 :     curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
     322            4 :     curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE");
     323            4 :     curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
     324            4 :     curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
     325            4 :     curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
     326            4 :     curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
     327            4 :     curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
     328              : 
     329              :     /* Disable SSL verification when talking to a plain HTTP test server */
     330            4 :     if (strncmp(url, "http://", 7) == 0) {
     331            4 :         curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
     332            4 :         curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
     333              :     }
     334              : 
     335            4 :     CURLcode res = curl_easy_perform(curl);
     336            4 :     curl_slist_free_all(headers);
     337              : 
     338            4 :     if (res != CURLE_OK) {
     339            0 :         logger_log(LOG_ERROR, "gmail: DELETE %s failed: %s", url, curl_easy_strerror(res));
     340            0 :         free(buf.data);
     341            0 :         curl_easy_cleanup(curl);
     342            0 :         return NULL;
     343              :     }
     344              : 
     345            4 :     curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code);
     346            4 :     curl_easy_cleanup(curl);
     347              :     /* Return an empty string (not NULL) so caller can distinguish curl error from HTTP error */
     348            4 :     if (!buf.data) {
     349            2 :         char *empty = malloc(1);
     350            2 :         if (empty) *empty = '\0';
     351            2 :         return empty;
     352              :     }
     353            2 :     return buf.data;
     354              : }
     355              : 
     356            4 : static char *api_delete_retry(GmailClient *c, const char *url, long *http_code) {
     357            4 :     char *resp = api_delete(c, url, http_code);
     358            4 :     if (resp && *http_code == 401) {
     359            0 :         free(resp);
     360            0 :         char *new_token = gmail_auth_refresh(c->cfg);
     361            0 :         if (!new_token) return NULL;
     362            0 :         free(c->access_token);
     363            0 :         c->access_token = new_token;
     364            0 :         resp = api_delete(c, url, http_code);
     365              :     }
     366            4 :     return resp;
     367              : }
     368              : 
     369              : /* ── Create / delete label ────────────────────────────────────────── */
     370              : 
     371            5 : int gmail_create_label(GmailClient *c, const char *name, char **id_out) {
     372            5 :     if (id_out) *id_out = NULL;
     373              : 
     374            5 :     RAII_STRING char *url = NULL;
     375            5 :     if (asprintf(&url, "%s/labels", gmail_api_base()) == -1) return -1;
     376              : 
     377              :     char body[1024];
     378            5 :     snprintf(body, sizeof(body), "{\"name\":\"%s\"}", name);
     379              : 
     380            5 :     long code = 0;
     381           10 :     RAII_STRING char *resp = api_post_retry(c, url, body, &code);
     382            5 :     if (!resp || code != 200) {
     383            1 :         logger_log(LOG_ERROR, "gmail_create_label: HTTP %ld", code);
     384            1 :         return -1;
     385              :     }
     386              : 
     387            4 :     if (id_out) {
     388            3 :         *id_out = json_get_string(resp, "id");
     389              :     }
     390            4 :     return 0;
     391              : }
     392              : 
     393            4 : int gmail_delete_label(GmailClient *c, const char *label_id) {
     394            4 :     RAII_STRING char *url = NULL;
     395            4 :     if (asprintf(&url, "%s/labels/%s", gmail_api_base(), label_id) == -1) return -1;
     396              : 
     397            4 :     long code = 0;
     398            8 :     RAII_STRING char *resp = api_delete_retry(c, url, &code);
     399            4 :     if (!resp) {
     400            0 :         logger_log(LOG_ERROR, "gmail_delete_label: curl error for label %s", label_id);
     401            0 :         return -1;
     402              :     }
     403            4 :     if (code != 204 && code != 200) {
     404            1 :         logger_log(LOG_ERROR, "gmail_delete_label: HTTP %ld for label %s", code, label_id);
     405            1 :         return -1;
     406              :     }
     407            3 :     return 0;
     408              : }
     409              : 
     410              : /* ── List labels ──────────────────────────────────────────────────── */
     411              : 
     412              : struct label_ctx {
     413              :     char **names;
     414              :     char **ids;
     415              :     int    count;
     416              :     int    cap;
     417              : };
     418              : 
     419          223 : static void collect_label(const char *obj, int index, void *ctx) {
     420              :     (void)index;
     421          223 :     struct label_ctx *lc = ctx;
     422          223 :     char *name = json_get_string(obj, "name");
     423          223 :     char *id   = json_get_string(obj, "id");
     424          223 :     if (!name || !id) { free(name); free(id); return; }
     425              : 
     426          223 :     if (lc->count == lc->cap) {
     427           32 :         int newcap = lc->cap ? lc->cap * 2 : 32;
     428           32 :         char **nn = realloc(lc->names, (size_t)newcap * sizeof(char *));
     429           32 :         char **ni = realloc(lc->ids,   (size_t)newcap * sizeof(char *));
     430           32 :         if (!nn || !ni) { free(name); free(id); free(nn); return; }
     431           32 :         lc->names = nn;
     432           32 :         lc->ids   = ni;
     433           32 :         lc->cap   = newcap;
     434              :     }
     435          223 :     lc->names[lc->count] = name;
     436          223 :     lc->ids[lc->count]   = id;
     437          223 :     lc->count++;
     438              : }
     439              : 
     440           35 : int gmail_list_labels(GmailClient *c, char ***names_out,
     441              :                       char ***ids_out, int *count_out) {
     442           35 :     *names_out = NULL;
     443           35 :     *ids_out   = NULL;
     444           35 :     *count_out = 0;
     445              : 
     446           35 :     RAII_STRING char *url = NULL;
     447           35 :     if (asprintf(&url, "%s/labels", gmail_api_base()) == -1) return -1;
     448              : 
     449           35 :     long code = 0;
     450           70 :     RAII_STRING char *resp = api_get_retry(c, url, &code);
     451           35 :     if (!resp || code != 200) {
     452            3 :         logger_log(LOG_ERROR, "gmail_list_labels: HTTP %ld", code);
     453            3 :         return -1;
     454              :     }
     455              : 
     456           32 :     struct label_ctx lc = {0};
     457           32 :     json_foreach_object(resp, "labels", collect_label, &lc);
     458              : 
     459           32 :     *names_out = lc.names;
     460           32 :     *ids_out   = lc.ids;
     461           32 :     *count_out = lc.count;
     462           32 :     return 0;
     463              : }
     464              : 
     465              : /* ── List messages ────────────────────────────────────────────────── */
     466              : 
     467              : struct msg_id_ctx {
     468              :     char (*uids)[17];
     469              :     int   count;
     470              :     int   cap;
     471              : };
     472              : 
     473         1838 : static void collect_msg_id(const char *obj, int index, void *ctx) {
     474              :     (void)index;
     475         1838 :     struct msg_id_ctx *mc = ctx;
     476         1838 :     char *id = json_get_string(obj, "id");
     477         1838 :     if (!id) return;
     478              : 
     479         1838 :     if (mc->count == mc->cap) {
     480           28 :         int newcap = mc->cap ? mc->cap * 2 : 256;
     481           28 :         char (*tmp)[17] = realloc(mc->uids, (size_t)newcap * sizeof(char[17]));
     482           28 :         if (!tmp) { free(id); return; }
     483           28 :         mc->uids = tmp;
     484           28 :         mc->cap  = newcap;
     485              :     }
     486         1838 :     snprintf(mc->uids[mc->count], 17, "%s", id);
     487         1838 :     mc->count++;
     488         1838 :     free(id);
     489              : }
     490              : 
     491           32 : int gmail_list_messages(GmailClient *c, const char *label_id,
     492              :                         const char *query,
     493              :                         char (**uids_out)[17], int *count_out,
     494              :                         char **history_id_out) {
     495           32 :     *uids_out  = NULL;
     496           32 :     *count_out = 0;
     497           32 :     if (history_id_out) *history_id_out = NULL;
     498              : 
     499           32 :     struct msg_id_ctx mc = {0};
     500           32 :     char *page_token = NULL;
     501              : 
     502            9 :     for (;;) {
     503              :         /* Build URL with optional query parameters */
     504              :         char url_buf[2048];
     505           41 :         int n = snprintf(url_buf, sizeof(url_buf), "%s/messages?maxResults=500", gmail_api_base());
     506           41 :         if (label_id)
     507            5 :             n += snprintf(url_buf + n, sizeof(url_buf) - (size_t)n, "&labelIds=%s", label_id);
     508           41 :         if (query)
     509            3 :             n += snprintf(url_buf + n, sizeof(url_buf) - (size_t)n, "&q=%s", query);
     510           41 :         if (page_token)
     511            9 :             n += snprintf(url_buf + n, sizeof(url_buf) - (size_t)n, "&pageToken=%s", page_token);
     512           41 :         free(page_token);
     513           41 :         page_token = NULL;
     514              :         (void)n;
     515              : 
     516           41 :         long code = 0;
     517           41 :         char *resp = api_get_retry(c, url_buf, &code);
     518           41 :         if (!resp || code != 200) {
     519            3 :             free(resp);
     520            3 :             break;
     521              :         }
     522              : 
     523           38 :         json_foreach_object(resp, "messages", collect_msg_id, &mc);
     524              : 
     525           38 :         if (history_id_out) {
     526           33 :             char *hid = json_get_string(resp, "historyId");
     527           33 :             if (hid) {
     528           32 :                 free(*history_id_out);
     529           32 :                 *history_id_out = hid;
     530              :             }
     531              :         }
     532              : 
     533           38 :         page_token = json_get_string(resp, "nextPageToken");
     534           38 :         free(resp);
     535              : 
     536           38 :         if (c->progress_fn)
     537           31 :             c->progress_fn((size_t)mc.count, 0, c->progress_ctx);
     538              : 
     539           38 :         if (!page_token) break;
     540              :     }
     541           32 :     free(page_token);
     542              : 
     543           32 :     *uids_out  = mc.uids;
     544           32 :     *count_out = mc.count;
     545           32 :     return mc.uids ? 0 : (mc.count == 0 ? 0 : -1);
     546              : }
     547              : 
     548              : /* ── Fetch message (raw + labels) ─────────────────────────────────── */
     549              : 
     550          622 : char *gmail_fetch_message(GmailClient *c, const char *uid,
     551              :                           char ***labels_out, int *label_count_out) {
     552          622 :     if (labels_out) *labels_out = NULL;
     553          622 :     if (label_count_out) *label_count_out = 0;
     554              : 
     555          622 :     RAII_STRING char *url = NULL;
     556          622 :     if (asprintf(&url, "%s/messages/%s?format=raw", gmail_api_base(), uid) == -1)
     557            0 :         return NULL;
     558              : 
     559          622 :     long code = 0;
     560         1244 :     RAII_STRING char *resp = api_get_retry(c, url, &code);
     561          622 :     if (!resp || code != 200) {
     562            2 :         if (code == 404) {
     563            2 :             logger_log(LOG_WARN, "gmail: message %s not found (deleted?)", uid);
     564              :         } else {
     565            0 :             logger_log(LOG_ERROR, "gmail_fetch_message %s: HTTP %ld", uid, code);
     566              :         }
     567            2 :         return NULL;
     568              :     }
     569              : 
     570              :     /* Extract and decode raw message */
     571         1240 :     RAII_STRING char *raw_b64 = json_get_string(resp, "raw");
     572          620 :     if (!raw_b64) {
     573            1 :         logger_log(LOG_ERROR, "gmail_fetch_message %s: no 'raw' field", uid);
     574            1 :         return NULL;
     575              :     }
     576              : 
     577          619 :     size_t decoded_len = 0;
     578          619 :     char *decoded = gmail_base64url_decode(raw_b64, strlen(raw_b64), &decoded_len);
     579          619 :     if (!decoded) return NULL;
     580              : 
     581              :     /* Extract labels if requested */
     582          619 :     if (labels_out && label_count_out) {
     583          610 :         json_get_string_array(resp, "labelIds", labels_out, label_count_out);
     584              :     }
     585              : 
     586          619 :     return decoded;
     587              : }
     588              : 
     589              : /* ── Modify labels ────────────────────────────────────────────────── */
     590              : 
     591           36 : int gmail_modify_labels(GmailClient *c, const char *uid,
     592              :                         const char **add_labels, int add_count,
     593              :                         const char **remove_labels, int remove_count) {
     594           36 :     RAII_STRING char *url = NULL;
     595           36 :     if (asprintf(&url, "%s/messages/%s/modify", gmail_api_base(), uid) == -1)
     596            0 :         return -1;
     597              : 
     598              :     /* Build JSON body */
     599              :     /* Worst case: each label ~64 chars + quotes + commas + structure */
     600           36 :     size_t body_cap = 256 + (size_t)(add_count + remove_count) * 80;
     601           36 :     char *body = malloc(body_cap);
     602           36 :     if (!body) return -1;
     603              : 
     604           36 :     size_t off = 0;
     605           36 :     off += (size_t)snprintf(body + off, body_cap - off, "{");
     606              : 
     607           36 :     if (add_count > 0) {
     608           21 :         off += (size_t)snprintf(body + off, body_cap - off, "\"addLabelIds\":[");
     609           42 :         for (int i = 0; i < add_count; i++) {
     610           21 :             if (i > 0) off += (size_t)snprintf(body + off, body_cap - off, ",");
     611           21 :             off += (size_t)snprintf(body + off, body_cap - off, "\"%s\"", add_labels[i]);
     612              :         }
     613           21 :         off += (size_t)snprintf(body + off, body_cap - off, "]");
     614              :     }
     615              : 
     616           36 :     if (remove_count > 0) {
     617           20 :         if (add_count > 0) off += (size_t)snprintf(body + off, body_cap - off, ",");
     618           20 :         off += (size_t)snprintf(body + off, body_cap - off, "\"removeLabelIds\":[");
     619           40 :         for (int i = 0; i < remove_count; i++) {
     620           20 :             if (i > 0) off += (size_t)snprintf(body + off, body_cap - off, ",");
     621           20 :             off += (size_t)snprintf(body + off, body_cap - off, "\"%s\"", remove_labels[i]);
     622              :         }
     623           20 :         off += (size_t)snprintf(body + off, body_cap - off, "]");
     624              :     }
     625              : 
     626           36 :     snprintf(body + off, body_cap - off, "}");
     627              : 
     628           36 :     long code = 0;
     629           36 :     char *resp = api_post_retry(c, url, body, &code);
     630           36 :     free(body);
     631           36 :     free(resp);
     632              : 
     633           36 :     if (code != 200) {
     634            1 :         logger_log(LOG_ERROR, "gmail_modify_labels %s: HTTP %ld", uid, code);
     635            1 :         return -1;
     636              :     }
     637           35 :     return 0;
     638              : }
     639              : 
     640              : /* ── Trash / Untrash ──────────────────────────────────────────────── */
     641              : 
     642            3 : int gmail_trash(GmailClient *c, const char *uid) {
     643            3 :     RAII_STRING char *url = NULL;
     644            3 :     if (asprintf(&url, "%s/messages/%s/trash", gmail_api_base(), uid) == -1)
     645            0 :         return -1;
     646              : 
     647            3 :     long code = 0;
     648            6 :     RAII_STRING char *resp = api_post_retry(c, url, NULL, &code);
     649            3 :     if (code != 200) {
     650            1 :         logger_log(LOG_ERROR, "gmail_trash %s: HTTP %ld", uid, code);
     651            1 :         return -1;
     652              :     }
     653            2 :     return 0;
     654              : }
     655              : 
     656            2 : int gmail_untrash(GmailClient *c, const char *uid) {
     657            2 :     RAII_STRING char *url = NULL;
     658            2 :     if (asprintf(&url, "%s/messages/%s/untrash", gmail_api_base(), uid) == -1)
     659            0 :         return -1;
     660              : 
     661            2 :     long code = 0;
     662            4 :     RAII_STRING char *resp = api_post_retry(c, url, NULL, &code);
     663            2 :     if (code != 200) {
     664            1 :         logger_log(LOG_ERROR, "gmail_untrash %s: HTTP %ld", uid, code);
     665            1 :         return -1;
     666              :     }
     667            1 :     return 0;
     668              : }
     669              : 
     670              : /* ── Send ─────────────────────────────────────────────────────────── */
     671              : 
     672            3 : int gmail_send(GmailClient *c, const char *raw_msg, size_t len) {
     673            3 :     RAII_STRING char *url = NULL;
     674            3 :     if (asprintf(&url, "%s/messages/send", gmail_api_base()) == -1)
     675            0 :         return -1;
     676              : 
     677              :     /* Base64url encode the raw RFC 2822 message */
     678            3 :     char *encoded = gmail_base64url_encode((const unsigned char *)raw_msg, len);
     679            3 :     if (!encoded) return -1;
     680              : 
     681              :     /* Build JSON body: {"raw": "<base64url>"} */
     682            3 :     size_t body_len = strlen(encoded) + 32;
     683            3 :     char *body = malloc(body_len);
     684            3 :     if (!body) { free(encoded); return -1; }
     685            3 :     snprintf(body, body_len, "{\"raw\":\"%s\"}", encoded);
     686            3 :     free(encoded);
     687              : 
     688            3 :     long code = 0;
     689            3 :     char *resp = api_post_retry(c, url, body, &code);
     690            3 :     free(body);
     691            3 :     free(resp);
     692              : 
     693            3 :     if (code != 200) {
     694            1 :         logger_log(LOG_ERROR, "gmail_send: HTTP %ld", code);
     695            1 :         return -1;
     696              :     }
     697            2 :     logger_log(LOG_INFO, "gmail_send: message sent successfully");
     698            2 :     return 0;
     699              : }
     700              : 
     701              : /* ── Profile (historyId) ──────────────────────────────────────────── */
     702              : 
     703            5 : char *gmail_get_history_id(GmailClient *c) {
     704            5 :     RAII_STRING char *url = NULL;
     705            5 :     if (asprintf(&url, "%s/profile", gmail_api_base()) == -1) return NULL;
     706              : 
     707            5 :     long code = 0;
     708           10 :     RAII_STRING char *resp = api_get_retry(c, url, &code);
     709            5 :     if (!resp || code != 200) return NULL;
     710              : 
     711            2 :     return json_get_string(resp, "historyId");
     712              : }
     713              : 
     714              : /* ── History (incremental sync) ───────────────────────────────────── */
     715              : 
     716           11 : char *gmail_get_history(GmailClient *c, const char *history_id) {
     717           11 :     RAII_STRING char *url = NULL;
     718           11 :     if (asprintf(&url, "%s/history?startHistoryId=%s"
     719              :                  "&historyTypes=messageAdded"
     720              :                  "&historyTypes=messageDeleted"
     721              :                  "&historyTypes=labelAdded"
     722              :                  "&historyTypes=labelRemoved",
     723              :                  gmail_api_base(), history_id) == -1)
     724            0 :         return NULL;
     725              : 
     726           11 :     long code = 0;
     727           11 :     char *resp = api_get_retry(c, url, &code);
     728           11 :     if (!resp) return NULL;
     729              : 
     730           11 :     if (code == 404) {
     731            4 :         fprintf(stderr, "  History API: 404 — historyId %s expired.\n", history_id);
     732            4 :         logger_log(LOG_WARN, "gmail: history %s expired — full resync needed", history_id);
     733            4 :         free(resp);
     734            4 :         return NULL;
     735              :     }
     736            7 :     if (code != 200) {
     737            1 :         fprintf(stderr, "  History API: HTTP %ld for historyId %s.\n", code, history_id);
     738            1 :         logger_log(LOG_WARN, "gmail_get_history: HTTP %ld (will retry with full sync)", code);
     739            1 :         free(resp);
     740            1 :         return NULL;
     741              :     }
     742            6 :     fprintf(stderr, "  History API: OK (historyId %s accepted).\n", history_id);
     743              : 
     744            6 :     return resp;
     745              : }
        

Generated by: LCOV version 2.0-1