LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - config_store.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 88.9 % 413 367
Test Date: 2026-08-21 10:11:30 Functions: 95.5 % 22 21

            Line data    Source code
       1              : #include "config_store.h"
       2              : #include "config.h"
       3              : #include "fs_util.h"
       4              : #include "platform/path.h"
       5              : #include "platform/credential_key.h"
       6              : #include "raii.h"
       7              : #include "logger.h"
       8              : #include <openssl/evp.h>
       9              : #include <openssl/rand.h>
      10              : #include <stdio.h>
      11              : #include <stdlib.h>
      12              : #include <string.h>
      13              : #include <ctype.h>
      14              : #include <sys/stat.h>
      15              : #include <dirent.h>
      16              : #include <unistd.h>
      17              : #include <errno.h>
      18              : 
      19              : #define CONFIG_APP_DIR "email-cli"
      20              : 
      21              : /** @brief Trims leading and trailing whitespace from a string in-place. */
      22        21046 : static char* trim(char *str) {
      23              :     char *end;
      24        21600 :     while (isspace((unsigned char)*str)) str++;
      25        21046 :     if (*str == 0) return str;
      26        20492 :     end = str + strlen(str) - 1;
      27        29827 :     while (end > str && isspace((unsigned char)*end)) end--;
      28        20492 :     end[1] = '\0';
      29        20492 :     return str;
      30              : }
      31              : 
      32              : /** Returns heap-allocated path to the accounts/ directory. Caller must free. */
      33         1169 : static char *get_accounts_dir(void) {
      34         1169 :     const char *config_base = platform_config_dir();
      35         1169 :     if (!config_base) return NULL;
      36         1169 :     char *dir = NULL;
      37         1169 :     if (asprintf(&dir, "%s/%s/accounts", config_base, CONFIG_APP_DIR) == -1)
      38            0 :         return NULL;
      39         1169 :     return dir;
      40              : }
      41              : 
      42              : /* ── Global application settings ────────────────────────────────────────── */
      43              : 
      44              : static int g_obfuscation_loaded = 0;
      45              : static int g_credential_obfuscation = 1; /* default: ON */
      46              : 
      47          708 : static char *get_settings_path(void) {
      48          708 :     const char *config_base = platform_config_dir();
      49          708 :     if (!config_base) return NULL;
      50          708 :     char *path = NULL;
      51          708 :     if (asprintf(&path, "%s/%s/settings.ini", config_base, CONFIG_APP_DIR) == -1)
      52            0 :         return NULL;
      53          708 :     return path;
      54              : }
      55              : 
      56           74 : static void write_settings(const char *path) {
      57           74 :     const char *config_base = platform_config_dir();
      58           74 :     if (!config_base) return;
      59              :     char dir[4096];
      60           74 :     snprintf(dir, sizeof(dir), "%s/%s", config_base, CONFIG_APP_DIR);
      61           74 :     if (fs_mkdir_p(dir, 0700) != 0) return;
      62           74 :     FILE *fp = fopen(path, "w");
      63           74 :     if (!fp) return;
      64           74 :     fprintf(fp, "credential_obfuscation=%s\n", g_credential_obfuscation ? "true" : "false");
      65           74 :     fclose(fp);
      66           74 :     fs_ensure_permissions(path, 0600);
      67              : }
      68              : 
      69         1602 : static void load_settings_once(void) {
      70         1676 :     if (g_obfuscation_loaded) return;
      71          708 :     g_obfuscation_loaded = 1;
      72              : 
      73         1416 :     RAII_STRING char *path = get_settings_path();
      74          708 :     if (!path) return;
      75              : 
      76          708 :     FILE *fp = fopen(path, "r");
      77          708 :     if (!fp) {
      78              :         /* First run — create settings.ini with defaults */
      79           74 :         write_settings(path);
      80           74 :         return;
      81              :     }
      82              : 
      83              :     char line[256];
      84         1268 :     while (fgets(line, sizeof(line), fp)) {
      85          634 :         char *key = strtok(line, "=");
      86          634 :         char *val = strtok(NULL, "\n");
      87          634 :         if (!key || !val) continue;
      88          634 :         key = trim(key); val = trim(val);
      89          634 :         if (strcmp(key, "credential_obfuscation") == 0)
      90          634 :             g_credential_obfuscation = (strcmp(val, "true") == 0 || strcmp(val, "1") == 0) ? 1 : 0;
      91              :     }
      92          634 :     fclose(fp);
      93              : }
      94              : 
      95          215 : int app_settings_get_obfuscation(void) {
      96          215 :     load_settings_once();
      97          215 :     return g_credential_obfuscation;
      98              : }
      99              : 
     100            0 : int app_settings_set_obfuscation(int enabled) {
     101            0 :     load_settings_once();
     102            0 :     g_credential_obfuscation = enabled ? 1 : 0;
     103            0 :     RAII_STRING char *path = get_settings_path();
     104            0 :     if (!path) return -1;
     105            0 :     write_settings(path);
     106            0 :     return 0;
     107              : }
     108              : 
     109              : /* ── Base64 encode / decode ──────────────────────────────────────────────── */
     110              : 
     111              : static const char B64CHARS[] =
     112              :     "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
     113              : 
     114              : /** Returns heap-allocated base64 string. Caller must free(). */
     115          309 : static char *b64_encode(const unsigned char *src, size_t src_len) {
     116          309 :     size_t out_len = ((src_len + 2) / 3) * 4 + 1;
     117          309 :     char *out = malloc(out_len);
     118          309 :     if (!out) return NULL;
     119          309 :     size_t i, j = 0;
     120         4032 :     for (i = 0; i + 2 < src_len; i += 3) {
     121         3723 :         out[j++] = B64CHARS[src[i] >> 2];
     122         3723 :         out[j++] = B64CHARS[((src[i] & 3) << 4)   | (src[i+1] >> 4)];
     123         3723 :         out[j++] = B64CHARS[((src[i+1] & 0xf) << 2) | (src[i+2] >> 6)];
     124         3723 :         out[j++] = B64CHARS[src[i+2] & 0x3f];
     125              :     }
     126          309 :     size_t rem = src_len - i;
     127          309 :     if (rem == 1) {
     128           13 :         out[j++] = B64CHARS[src[i] >> 2];
     129           13 :         out[j++] = B64CHARS[(src[i] & 3) << 4];
     130           13 :         out[j++] = '='; out[j++] = '=';
     131          296 :     } else if (rem == 2) {
     132            2 :         out[j++] = B64CHARS[src[i] >> 2];
     133            2 :         out[j++] = B64CHARS[((src[i] & 3) << 4) | (src[i+1] >> 4)];
     134            2 :         out[j++] = B64CHARS[(src[i+1] & 0xf) << 2];
     135            2 :         out[j++] = '=';
     136              :     }
     137          309 :     out[j] = '\0';
     138          309 :     return out;
     139              : }
     140              : 
     141        71656 : static int b64_char_val(char c) {
     142        71656 :     if (c >= 'A' && c <= 'Z') return c - 'A';
     143        42845 :     if (c >= 'a' && c <= 'z') return c - 'a' + 26;
     144        14188 :     if (c >= '0' && c <= '9') return c - '0' + 52;
     145         2266 :     if (c == '+') return 62;
     146         1377 :     if (c == '/') return 63;
     147          344 :     if (c == '=') return 0;
     148            0 :     return -1;
     149              : }
     150              : 
     151              : /**
     152              :  * Decode base64 string into *out (heap-allocated). Caller must free().
     153              :  * Returns 0 on success, -1 on error.
     154              :  */
     155         1450 : static int b64_decode(const char *src, unsigned char **out, size_t *out_len) {
     156         1450 :     size_t src_len = strlen(src);
     157         1450 :     if (src_len == 0 || src_len % 4 != 0) return -1;
     158              : 
     159         1450 :     size_t dec_len = (src_len / 4) * 3;
     160         1450 :     if (src[src_len - 1] == '=') dec_len--;
     161         1450 :     if (src[src_len - 2] == '=') dec_len--;
     162              : 
     163         1450 :     unsigned char *buf = malloc(dec_len + 1);
     164         1450 :     if (!buf) return -1;
     165              : 
     166         1450 :     size_t j = 0;
     167        19364 :     for (size_t i = 0; i < src_len; i += 4) {
     168        17914 :         int a = b64_char_val(src[i]);
     169        17914 :         int b = b64_char_val(src[i+1]);
     170        17914 :         int c = b64_char_val(src[i+2]);
     171        17914 :         int d = b64_char_val(src[i+3]);
     172        17914 :         if (a < 0 || b < 0 || c < 0 || d < 0) { free(buf); return -1; }
     173        17914 :         buf[j++] = (unsigned char)((a << 2) | (b >> 4));
     174        17914 :         if (src[i+2] != '=') buf[j++] = (unsigned char)((b << 4) | (c >> 2));
     175        17914 :         if (src[i+3] != '=') buf[j++] = (unsigned char)((c << 6) | d);
     176              :     }
     177         1450 :     buf[j] = '\0';
     178         1450 :     *out = buf;
     179         1450 :     *out_len = j;
     180         1450 :     return 0;
     181              : }
     182              : 
     183              : /* ── Credential encryption / decryption (AES-256-GCM) ───────────────────── */
     184              : 
     185              : /**
     186              :  * Encrypt plaintext with AES-256-GCM using a key derived from the email.
     187              :  * Returns heap-allocated "enc:<base64(iv|ciphertext|tag)>" string,
     188              :  * or NULL if key derivation failed (caller falls back to plaintext).
     189              :  */
     190          309 : static char *encrypt_credential(const char *plaintext, const char *email) {
     191          309 :     if (!plaintext || !*plaintext)
     192            0 :         return strdup(plaintext ? plaintext : "");
     193              : 
     194              :     unsigned char key[32];
     195          309 :     if (platform_derive_credential_key(email, key) != 0)
     196            0 :         return NULL; /* no key source available — store plaintext */
     197              : 
     198              :     unsigned char iv[12];
     199          309 :     if (RAND_bytes(iv, sizeof(iv)) != 1) return NULL;
     200              : 
     201          309 :     size_t pt_len = strlen(plaintext);
     202          309 :     unsigned char *ct = malloc(pt_len + 1);
     203          309 :     if (!ct) return NULL;
     204              : 
     205              :     unsigned char tag[16];
     206          309 :     int outl = 0, finl = 0;
     207          309 :     EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
     208          309 :     if (!ctx) { free(ct); return NULL; }
     209          309 :     EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, key, iv);
     210          309 :     EVP_EncryptUpdate(ctx, ct, &outl, (const unsigned char *)plaintext, (int)pt_len);
     211          309 :     EVP_EncryptFinal_ex(ctx, ct + outl, &finl);
     212          309 :     EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag);
     213          309 :     EVP_CIPHER_CTX_free(ctx);
     214              : 
     215              :     /* Pack: iv[12] | ciphertext[pt_len] | tag[16] */
     216          309 :     size_t packed_len = 12 + pt_len + 16;
     217          309 :     unsigned char *packed = malloc(packed_len);
     218          309 :     if (!packed) { free(ct); return NULL; }
     219          309 :     memcpy(packed,              iv,  12);
     220          309 :     memcpy(packed + 12,         ct,  pt_len);
     221          309 :     memcpy(packed + 12 + pt_len, tag, 16);
     222          309 :     free(ct);
     223              : 
     224          309 :     char *b64 = b64_encode(packed, packed_len);
     225          309 :     free(packed);
     226          309 :     if (!b64) return NULL;
     227              : 
     228          309 :     char *result = NULL;
     229          309 :     if (asprintf(&result, "enc:%s", b64) == -1) result = NULL;
     230          309 :     free(b64);
     231          309 :     return result;
     232              : }
     233              : 
     234              : /**
     235              :  * Decrypt a credential value.
     236              :  * - If value starts with "enc:", decrypt using a key derived from email.
     237              :  * - Otherwise return a copy of the plaintext value.
     238              :  * Returns heap-allocated plaintext, or NULL on decryption failure.
     239              :  */
     240         1450 : static char *decrypt_credential(const char *value, const char *email) {
     241         1450 :     if (!value) return NULL;
     242         1450 :     if (strncmp(value, "enc:", 4) != 0) return strdup(value);
     243              : 
     244         1450 :     unsigned char *packed = NULL;
     245         1450 :     size_t packed_len = 0;
     246         1450 :     if (b64_decode(value + 4, &packed, &packed_len) != 0) return NULL;
     247         1450 :     if (packed_len < 12 + 16) { free(packed); return NULL; }
     248              : 
     249              :     unsigned char key[32];
     250         1450 :     if (platform_derive_credential_key(email, key) != 0) {
     251            0 :         free(packed);
     252            0 :         return NULL;
     253              :     }
     254              : 
     255         1450 :     unsigned char *iv  = packed;
     256         1450 :     size_t ct_len      = packed_len - 12 - 16;
     257         1450 :     unsigned char *ct  = packed + 12;
     258         1450 :     unsigned char *tag = packed + 12 + ct_len;
     259              : 
     260         1450 :     unsigned char *pt = malloc(ct_len + 1);
     261         1450 :     if (!pt) { free(packed); return NULL; }
     262              : 
     263         1450 :     EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
     264         1450 :     if (!ctx) { free(packed); free(pt); return NULL; }
     265              : 
     266         1450 :     EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, key, iv);
     267         1450 :     EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag);
     268         1450 :     int outl = 0, finl = 0;
     269         1450 :     EVP_DecryptUpdate(ctx, pt, &outl, ct, (int)ct_len);
     270         1450 :     int ok = EVP_DecryptFinal_ex(ctx, pt + outl, &finl);
     271         1450 :     EVP_CIPHER_CTX_free(ctx);
     272         1450 :     free(packed);
     273              : 
     274         1450 :     if (ok != 1) {
     275              :         /* Authentication failed — wrong key (system data changed) */
     276            0 :         free(pt);
     277            0 :         return NULL;
     278              :     }
     279         1450 :     pt[ct_len] = '\0';
     280         1450 :     return (char *)pt;
     281              : }
     282              : 
     283              : /* ── Config read / write ────────────────────────────────────────────────── */
     284              : 
     285              : /** Write one config struct to an open FILE, encrypting credentials if enabled. */
     286          207 : static void write_config_to_fp(FILE *fp, const Config *cfg) {
     287          207 :     int obfus = app_settings_get_obfuscation();
     288          207 :     const char *email = cfg->user ? cfg->user : "";
     289              : 
     290          207 :     fprintf(fp, "EMAIL_HOST=%s\n",   cfg->host   ? cfg->host   : "");
     291          207 :     fprintf(fp, "EMAIL_USER=%s\n",   cfg->user   ? cfg->user   : "");
     292              : 
     293              :     /* Credentials: encrypt when obfuscation is on */
     294              :     {
     295          204 :         char *enc = (obfus && cfg->pass && *cfg->pass)
     296          411 :                     ? encrypt_credential(cfg->pass, email) : NULL;
     297          207 :         fprintf(fp, "EMAIL_PASS=%s\n", enc ? enc : (cfg->pass ? cfg->pass : ""));
     298          207 :         free(enc);
     299              :     }
     300              : 
     301          207 :     fprintf(fp, "EMAIL_FOLDER=%s\n", cfg->folder ? cfg->folder : "INBOX");
     302          207 :     if (cfg->sent_folder)  fprintf(fp, "EMAIL_SENT_FOLDER=%s\n", cfg->sent_folder);
     303          207 :     if (cfg->trash_folder) fprintf(fp, "TRASH_FOLDER=%s\n",      cfg->trash_folder);
     304          207 :     if (cfg->ssl_no_verify) fprintf(fp, "SSL_NO_VERIFY=1\n");
     305          207 :     fprintf(fp, "SYNC_INTERVAL=%d\n", cfg->sync_interval);
     306          207 :     if (cfg->smtp_host) fprintf(fp, "SMTP_HOST=%s\n", cfg->smtp_host);
     307          207 :     if (cfg->smtp_port) fprintf(fp, "SMTP_PORT=%d\n", cfg->smtp_port);
     308          207 :     if (cfg->smtp_user) fprintf(fp, "SMTP_USER=%s\n", cfg->smtp_user);
     309          207 :     if (cfg->smtp_pass) {
     310          106 :         char *enc = (obfus && *cfg->smtp_pass)
     311          212 :                     ? encrypt_credential(cfg->smtp_pass, email) : NULL;
     312          106 :         fprintf(fp, "SMTP_PASS=%s\n", enc ? enc : cfg->smtp_pass);
     313          106 :         free(enc);
     314              :     }
     315          207 :     if (cfg->gmail_mode) fprintf(fp, "GMAIL_MODE=1\n");
     316          207 :     if (cfg->gmail_refresh_token) {
     317           12 :         char *enc = (obfus && *cfg->gmail_refresh_token)
     318           24 :                     ? encrypt_credential(cfg->gmail_refresh_token, email) : NULL;
     319           12 :         fprintf(fp, "GMAIL_REFRESH_TOKEN=%s\n", enc ? enc : cfg->gmail_refresh_token);
     320           12 :         free(enc);
     321              :     }
     322          207 :     if (cfg->gmail_client_id) fprintf(fp, "GMAIL_CLIENT_ID=%s\n", cfg->gmail_client_id);
     323          207 :     if (cfg->gmail_client_secret) fprintf(fp, "GMAIL_CLIENT_SECRET=%s\n", cfg->gmail_client_secret);
     324          207 : }
     325              : 
     326              : /** Load a config from a specific file path. Decrypts enc: credentials transparently.
     327              :  *  Sets *out_needs_resave to 1 if any credential was plaintext and obfuscation is ON. */
     328         1247 : static Config *load_config_from_path(const char *path, int *out_needs_resave) {
     329         2494 :     RAII_FILE FILE *fp = fopen(path, "r");
     330         1247 :     if (!fp) return NULL;
     331              : 
     332         1247 :     Config *cfg = calloc(1, sizeof(Config));
     333         1247 :     if (!cfg) return NULL;
     334              : 
     335         1247 :     int plaintext_cred_found  = 0; /* set if any credential lacks enc: prefix */
     336         1247 :     int encrypted_cred_found  = 0; /* set if any credential has  enc: prefix */
     337              :     char line[1024]; /* wider than before — enc: values can be long */
     338        11136 :     while (fgets(line, sizeof(line), fp)) {
     339         9889 :         char *eq = strchr(line, '=');
     340         9889 :         if (!eq) continue;
     341         9889 :         *eq = '\0';
     342         9889 :         char *key = trim(line);
     343         9889 :         char *val = trim(eq + 1);
     344              :         /* Strip trailing newline from val */
     345         9889 :         size_t vlen = strlen(val);
     346         9889 :         while (vlen > 0 && (val[vlen-1] == '\n' || val[vlen-1] == '\r'))
     347            0 :             val[--vlen] = '\0';
     348              : 
     349         9889 :         if      (strcmp(key, "EMAIL_HOST")          == 0) cfg->host               = strdup(val);
     350         8645 :         else if (strcmp(key, "EMAIL_USER")          == 0) cfg->user               = strdup(val);
     351         7398 :         else if (strcmp(key, "EMAIL_PASS")          == 0) {
     352         1235 :             cfg->pass = strdup(val);
     353         1235 :             if (val[0]) { if (strncmp(val, "enc:", 4) == 0) encrypted_cred_found = 1;
     354          174 :                           else                               plaintext_cred_found  = 1; }
     355              :         }
     356         6163 :         else if (strcmp(key, "EMAIL_FOLDER")        == 0) cfg->folder             = strdup(val);
     357         4927 :         else if (strcmp(key, "EMAIL_SENT_FOLDER")   == 0) cfg->sent_folder        = strdup(val);
     358         4673 :         else if (strcmp(key, "TRASH_FOLDER")        == 0) cfg->trash_folder       = strdup(val);
     359         4671 :         else if (strcmp(key, "SSL_NO_VERIFY")       == 0) cfg->ssl_no_verify      = atoi(val);
     360         3704 :         else if (strcmp(key, "SYNC_INTERVAL")       == 0) cfg->sync_interval      = atoi(val);
     361         2619 :         else if (strcmp(key, "SMTP_HOST")           == 0) cfg->smtp_host          = strdup(val);
     362         1849 :         else if (strcmp(key, "SMTP_PORT")           == 0) cfg->smtp_port          = atoi(val);
     363         1557 :         else if (strcmp(key, "SMTP_USER")           == 0) cfg->smtp_user          = strdup(val);
     364         1062 :         else if (strcmp(key, "SMTP_PASS")           == 0) {
     365          492 :             cfg->smtp_pass = strdup(val);
     366          492 :             if (val[0]) { if (strncmp(val, "enc:", 4) == 0) encrypted_cred_found = 1;
     367          103 :                           else                               plaintext_cred_found  = 1; }
     368              :         }
     369          570 :         else if (strcmp(key, "GMAIL_MODE")          == 0) cfg->gmail_mode         = atoi(val);
     370          286 :         else if (strcmp(key, "GMAIL_REFRESH_TOKEN") == 0) {
     371          284 :             cfg->gmail_refresh_token = strdup(val);
     372          284 :             if (val[0]) { if (strncmp(val, "enc:", 4) == 0) encrypted_cred_found = 1;
     373           12 :                           else                               plaintext_cred_found  = 1; }
     374              :         }
     375            2 :         else if (strcmp(key, "GMAIL_CLIENT_ID")     == 0) cfg->gmail_client_id    = strdup(val);
     376            2 :         else if (strcmp(key, "GMAIL_CLIENT_SECRET") == 0) cfg->gmail_client_secret = strdup(val);
     377              :     }
     378         1247 :     if (!cfg->folder) cfg->folder = strdup("INBOX");
     379              : 
     380              :     /* Decrypt any enc: credential fields using the account email as key context */
     381         1247 :     const char *email = cfg->user ? cfg->user : "";
     382              : 
     383         1247 :     if (cfg->pass && strncmp(cfg->pass, "enc:", 4) == 0) {
     384          789 :         char *dec = decrypt_credential(cfg->pass, email);
     385          789 :         free(cfg->pass);
     386          789 :         if (dec) {
     387          789 :             cfg->pass = dec;
     388              :         } else {
     389            0 :             fprintf(stderr,
     390              :                 "Warning: Could not decrypt stored password for '%s'.\n"
     391              :                 "  The system key may have changed. Re-enter the password with:\n"
     392              :                 "    email-cli config password\n", email);
     393            0 :             logger_log(LOG_WARN, "credential decrypt failed for %s", email);
     394            0 :             cfg->pass = NULL;
     395              :         }
     396              :     }
     397              : 
     398         1247 :     if (cfg->smtp_pass && strncmp(cfg->smtp_pass, "enc:", 4) == 0) {
     399          389 :         char *dec = decrypt_credential(cfg->smtp_pass, email);
     400          389 :         free(cfg->smtp_pass);
     401          389 :         cfg->smtp_pass = dec; /* NULL on failure is acceptable for SMTP */
     402              :     }
     403              : 
     404         1247 :     if (cfg->gmail_refresh_token && strncmp(cfg->gmail_refresh_token, "enc:", 4) == 0) {
     405          272 :         char *dec = decrypt_credential(cfg->gmail_refresh_token, email);
     406          272 :         free(cfg->gmail_refresh_token);
     407          272 :         if (dec) {
     408          272 :             cfg->gmail_refresh_token = dec;
     409              :         } else {
     410            0 :             fprintf(stderr,
     411              :                 "Warning: Could not decrypt stored refresh token for '%s'.\n"
     412              :                 "  Re-run the Gmail OAuth2 setup: email-cli add-account\n", email);
     413            0 :             logger_log(LOG_WARN, "refresh token decrypt failed for %s", email);
     414            0 :             cfg->gmail_refresh_token = NULL;
     415              :         }
     416              :     }
     417              : 
     418              :     /* Gmail mode requires user + refresh token; IMAP mode requires host + user + pass */
     419         1247 :     if (cfg->gmail_mode) {
     420          284 :         if (!cfg->user || !cfg->gmail_refresh_token) { config_free(cfg); return NULL; }
     421              :     } else {
     422          963 :         if (!cfg->host || !cfg->user || !cfg->pass) { config_free(cfg); return NULL; }
     423              :     }
     424              : 
     425              :     /* TLS enforcement (IMAP mode only — Gmail uses OAuth2 over HTTPS) */
     426         1246 :     if (!cfg->gmail_mode && !cfg->ssl_no_verify) {
     427            0 :         if (strncmp(cfg->host, "imaps://", 8) != 0) {
     428            0 :             fprintf(stderr,
     429              :                 "Error: EMAIL_HOST must start with imaps:// (TLS required).\n"
     430              :                 "  Got: %s\n", cfg->host);
     431            0 :             logger_log(LOG_ERROR,
     432              :                        "Rejected insecure EMAIL_HOST in account config: %s",
     433              :                        cfg->host);
     434            0 :             config_free(cfg);
     435            0 :             return NULL;
     436              :         }
     437            0 :         if (cfg->smtp_host && cfg->smtp_host[0] &&
     438            0 :             strncmp(cfg->smtp_host, "smtps://", 8) != 0) {
     439            0 :             fprintf(stderr,
     440              :                 "Error: SMTP_HOST must start with smtps:// (TLS required).\n"
     441              :                 "  Got: %s\n", cfg->smtp_host);
     442            0 :             logger_log(LOG_ERROR,
     443              :                        "Rejected insecure SMTP_HOST in account config: %s",
     444              :                        cfg->smtp_host);
     445            0 :             config_free(cfg);
     446            0 :             return NULL;
     447              :         }
     448         1246 :     } else if (cfg->host) {
     449         1244 :         if (strncmp(cfg->host, "imaps://", 8) != 0)
     450          282 :             logger_log(LOG_WARN,
     451              :                        "SSL_NO_VERIFY=1: connecting without TLS to %s "
     452              :                        "(test/dev mode only)", cfg->host);
     453         1244 :         if (cfg->smtp_host && cfg->smtp_host[0] &&
     454          770 :             strncmp(cfg->smtp_host, "smtps://", 8) != 0)
     455            0 :             logger_log(LOG_WARN,
     456              :                        "SSL_NO_VERIFY=1: SMTP without TLS to %s "
     457              :                        "(test/dev mode only)", cfg->smtp_host);
     458              :     }
     459         1246 :     if (out_needs_resave)
     460         1431 :         *out_needs_resave = (plaintext_cred_found &&  g_credential_obfuscation)  /* plaintext → encrypt */
     461         1431 :                          || (encrypted_cred_found && !g_credential_obfuscation); /* enc: → plaintext  */
     462         1246 :     return cfg;
     463              : }
     464              : 
     465              : /* ── Public API ──────────────────────────────────────────────────────────── */
     466              : 
     467           98 : Config *config_load_account(const char *name) {
     468           98 :     if (!name || !name[0]) return NULL;
     469           98 :     int count = 0;
     470           98 :     AccountEntry *list = config_list_accounts(&count);
     471           98 :     if (!list || count == 0) { config_free_account_list(list, count); return NULL; }
     472           98 :     Config *result = NULL;
     473          116 :     for (int i = 0; i < count; i++) {
     474          132 :         int match = (list[i].name && strcmp(list[i].name, name) == 0) ||
     475           18 :                     (list[i].cfg  && list[i].cfg->user &&
     476           18 :                      strcmp(list[i].cfg->user, name) == 0);
     477          114 :         if (match) { result = list[i].cfg; list[i].cfg = NULL; break; }
     478              :     }
     479           98 :     config_free_account_list(list, count);
     480           98 :     return result;
     481              : }
     482              : 
     483          242 : Config* config_load_from_store(void) {
     484          242 :     load_settings_once(); /* ensure settings.ini exists */
     485          242 :     int count = 0;
     486          242 :     AccountEntry *list = config_list_accounts(&count);
     487          242 :     if (!list || count == 0) {
     488            0 :         config_free_account_list(list, count);
     489            0 :         return NULL;
     490              :     }
     491          242 :     Config *result = list[0].cfg;
     492          242 :     list[0].cfg = NULL;
     493          242 :     config_free_account_list(list, count);
     494          242 :     return result;
     495              : }
     496              : 
     497           22 : int config_save_account(const Config *cfg) {
     498           22 :     if (!cfg || !cfg->user || !cfg->user[0]) return -1;
     499              : 
     500           44 :     RAII_STRING char *accounts_dir = get_accounts_dir();
     501           22 :     if (!accounts_dir) return -1;
     502              : 
     503              :     char account_dir[1024];
     504           22 :     snprintf(account_dir, sizeof(account_dir), "%s/%s", accounts_dir, cfg->user);
     505              : 
     506           22 :     if (fs_mkdir_p(account_dir, 0700) != 0) return -1;
     507              : 
     508              :     char path[1088];
     509           22 :     snprintf(path, sizeof(path), "%s/config.ini", account_dir);
     510              : 
     511           44 :     RAII_FILE FILE *fp = fopen(path, "w");
     512           22 :     if (!fp) return -1;
     513           22 :     write_config_to_fp(fp, cfg);
     514           22 :     fs_ensure_permissions(path, 0600);
     515              : 
     516           22 :     logger_log(LOG_INFO, "Account saved: %s", cfg->user);
     517           22 :     return 0;
     518              : }
     519              : 
     520            8 : int config_save_to_store(const Config *cfg) {
     521            8 :     return config_save_account(cfg);
     522              : }
     523              : 
     524            2 : int config_delete_account(const char *name) {
     525            2 :     if (!name || !name[0]) return -1;
     526              : 
     527            4 :     RAII_STRING char *accounts_dir = get_accounts_dir();
     528            2 :     if (!accounts_dir) return -1;
     529              : 
     530              :     char path[1024];
     531            2 :     snprintf(path, sizeof(path), "%s/%s/config.ini", accounts_dir, name);
     532            2 :     unlink(path);
     533              : 
     534              :     char dir[1024];
     535            2 :     snprintf(dir, sizeof(dir), "%s/%s", accounts_dir, name);
     536            2 :     if (rmdir(dir) != 0 && errno != ENOENT) {
     537            0 :         logger_log(LOG_WARN, "Could not remove account dir %s", dir);
     538            0 :         return -1;
     539              :     }
     540            2 :     logger_log(LOG_INFO, "Account deleted: %s", name);
     541            2 :     return 0;
     542              : }
     543              : 
     544         1145 : AccountEntry *config_list_accounts(int *count_out) {
     545         1145 :     load_settings_once(); /* ensure settings.ini exists */
     546         1145 :     *count_out = 0;
     547              : 
     548         2290 :     RAII_STRING char *accounts_dir = get_accounts_dir();
     549         1145 :     if (!accounts_dir) return NULL;
     550              : 
     551         2290 :     RAII_DIR DIR *d = opendir(accounts_dir);
     552         1145 :     if (!d) return NULL;
     553              : 
     554         1144 :     int cap = 8;
     555         1144 :     AccountEntry *list = malloc((size_t)cap * sizeof(AccountEntry));
     556         1144 :     if (!list) return NULL;
     557         1144 :     int count = 0;
     558              : 
     559              :     struct dirent *ent;
     560         4679 :     while ((ent = readdir(d)) != NULL) {
     561         3536 :         if (ent->d_name[0] == '.') continue;
     562              : 
     563              :         char path[1024];
     564         1247 :         snprintf(path, sizeof(path), "%s/%s/config.ini",
     565         1247 :                  accounts_dir, ent->d_name);
     566              : 
     567         1247 :         int needs_resave = 0;
     568         1247 :         Config *cfg = load_config_from_path(path, &needs_resave);
     569         1247 :         if (!cfg) continue;
     570         1246 :         if (needs_resave) {
     571          185 :             logger_log(LOG_INFO, "Re-encrypting plaintext credentials for %s", ent->d_name);
     572          370 :             RAII_FILE FILE *wfp = fopen(path, "w");
     573          185 :             if (wfp) {
     574          185 :                 write_config_to_fp(wfp, cfg);
     575          185 :                 fs_ensure_permissions(path, 0600);
     576              :             }
     577              :         }
     578              : 
     579         1246 :         if (count >= cap) {
     580            0 :             cap *= 2;
     581            0 :             AccountEntry *tmp = realloc(list, (size_t)cap * sizeof(AccountEntry));
     582            0 :             if (!tmp) { config_free(cfg); break; }
     583            0 :             list = tmp;
     584              :         }
     585         1246 :         list[count].name = strdup(ent->d_name);
     586         1246 :         list[count].cfg  = cfg;
     587         1246 :         count++;
     588              :     }
     589              : 
     590         1144 :     if (count == 0) { free(list); return NULL; }
     591              : 
     592              :     /* Sort by domain first, then by username within domain */
     593         1246 :     for (int i = 0; i < count - 1; i++) {
     594          211 :         for (int j = i + 1; j < count; j++) {
     595          107 :             const char *na = list[i].name ? list[i].name : "";
     596          107 :             const char *nb = list[j].name ? list[j].name : "";
     597          107 :             const char *at_a = strchr(na, '@');
     598          107 :             const char *at_b = strchr(nb, '@');
     599          107 :             const char *dom_a = at_a ? at_a + 1 : na;
     600          107 :             const char *dom_b = at_b ? at_b + 1 : nb;
     601          107 :             int dc = strcmp(dom_a, dom_b);
     602              :             int swap;
     603          107 :             if (dc != 0) {
     604           17 :                 swap = dc > 0;
     605              :             } else {
     606           90 :                 size_t ul_a = at_a ? (size_t)(at_a - na) : strlen(na);
     607           90 :                 size_t ul_b = at_b ? (size_t)(at_b - nb) : strlen(nb);
     608           90 :                 int uc = strncmp(na, nb, ul_a < ul_b ? ul_a : ul_b);
     609           90 :                 swap = (uc != 0) ? (uc > 0) : (ul_a > ul_b);
     610              :             }
     611          107 :             if (swap) {
     612           46 :                 AccountEntry tmp = list[i];
     613           46 :                 list[i] = list[j];
     614           46 :                 list[j] = tmp;
     615              :             }
     616              :         }
     617              :     }
     618              : 
     619         1142 :     *count_out = count;
     620         1142 :     return list;
     621              : }
     622              : 
     623         1130 : void config_free_account_list(AccountEntry *list, int count) {
     624         1130 :     if (!list) return;
     625         2357 :     for (int i = 0; i < count; i++) {
     626         1229 :         free(list[i].name);
     627         1229 :         config_free(list[i].cfg);
     628              :     }
     629         1128 :     free(list);
     630              : }
     631              : 
     632            8 : int config_migrate_credentials(void) {
     633            8 :     int count = 0;
     634            8 :     AccountEntry *list = config_list_accounts(&count);
     635            8 :     if (!list) return 0; /* no accounts — nothing to migrate */
     636              : 
     637            8 :     int errors = 0;
     638           17 :     for (int i = 0; i < count; i++) {
     639            9 :         if (list[i].cfg && config_save_account(list[i].cfg) != 0) {
     640            0 :             fprintf(stderr, "Warning: could not migrate account '%s'\n",
     641            0 :                     list[i].name ? list[i].name : "?");
     642            0 :             errors++;
     643              :         }
     644              :     }
     645            8 :     config_free_account_list(list, count);
     646            8 :     return errors ? -1 : 0;
     647              : }
        

Generated by: LCOV version 2.0-1