LCOV - code coverage report
Current view: top level - libemail/src/infrastructure - mail_rules.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 78.1 % 233 182
Test Date: 2026-05-07 15:53:08 Functions: 84.6 % 13 11

            Line data    Source code
       1              : #include "mail_rules.h"
       2              : #include "when_expr.h"
       3              : #include "fs_util.h"
       4              : #include "imap_util.h"
       5              : #include "platform/path.h"
       6              : #include "raii.h"
       7              : #include "logger.h"
       8              : #include <ctype.h>
       9              : #include <fnmatch.h>
      10              : #include <stdio.h>
      11              : #include <stdlib.h>
      12              : #include <string.h>
      13              : #include <time.h>
      14              : 
      15              : #define CONFIG_APP_DIR "email-cli"
      16              : 
      17              : /* ── Path helpers ────────────────────────────────────────────────────── */
      18              : 
      19          110 : static char *rules_path(const char *account_name) {
      20          110 :     const char *cfg = platform_config_dir();
      21          110 :     if (!cfg || !account_name) return NULL;
      22          110 :     char *path = NULL;
      23          110 :     if (asprintf(&path, "%s/%s/accounts/%s/rules.ini",
      24              :                  cfg, CONFIG_APP_DIR, account_name) == -1)
      25            0 :         return NULL;
      26          110 :     return path;
      27              : }
      28              : 
      29              : /* ── Parsing helpers ─────────────────────────────────────────────────── */
      30              : 
      31         1224 : static char *trim(char *s) {
      32         1670 :     while (isspace((unsigned char)*s)) s++;
      33         1224 :     char *e = s + strlen(s);
      34         2394 :     while (e > s && isspace((unsigned char)*(e-1))) e--;
      35         1224 :     *e = '\0';
      36         1224 :     return s;
      37              : }
      38              : 
      39          158 : static MailRule *rules_grow(MailRules *r) {
      40          158 :     if (r->count >= r->cap) {
      41           66 :         int nc = r->cap ? r->cap * 2 : 8;
      42           66 :         MailRule *tmp = realloc(r->rules, (size_t)nc * sizeof(MailRule));
      43           66 :         if (!tmp) return NULL;
      44           66 :         r->rules = tmp;
      45           66 :         r->cap = nc;
      46              :     }
      47          158 :     MailRule *rule = &r->rules[r->count++];
      48          158 :     memset(rule, 0, sizeof(*rule));
      49          158 :     return rule;
      50              : }
      51              : 
      52              : /* ── Public API ──────────────────────────────────────────────────────── */
      53              : 
      54          100 : MailRules *mail_rules_load_path(const char *path) {
      55          100 :     if (!path) return NULL;
      56              : 
      57          200 :     RAII_FILE FILE *fp = fopen(path, "r");
      58          100 :     if (!fp) return NULL;   /* no rules file — not an error */
      59              : 
      60           66 :     MailRules *r = calloc(1, sizeof(MailRules));
      61           66 :     if (!r) return NULL;
      62              : 
      63              :     char line[1024];
      64           66 :     MailRule *cur = NULL;
      65              : 
      66          670 :     while (fgets(line, sizeof(line), fp)) {
      67          604 :         char *p = trim(line);
      68          604 :         if (!*p || *p == '#' || *p == ';') continue;
      69              : 
      70              :         /* Section header: [rule "name"] or [rule "name" action N] */
      71          468 :         if (*p == '[') {
      72          158 :             cur = rules_grow(r);
      73          158 :             if (!cur) { mail_rules_free(r); return NULL; }
      74          158 :             char *qs = strchr(p, '"');
      75          158 :             char *qe = qs ? strchr(qs + 1, '"') : NULL;
      76          158 :             if (qs && qe)
      77          158 :                 cur->name = strndup(qs + 1, (size_t)(qe - qs - 1));
      78              :             else
      79            0 :                 cur->name = strdup("(unnamed)");
      80              :             /* Parse optional "action N" suffix */
      81          158 :             char *act = qe ? strstr(qe + 1, "action ") : NULL;
      82          158 :             cur->action_index = act ? atoi(act + 7) : 0;
      83          158 :             continue;
      84              :         }
      85              : 
      86          310 :         if (!cur) continue;   /* key-value before any section — skip */
      87              : 
      88          310 :         char *eq = strchr(p, '=');
      89          310 :         if (!eq) continue;
      90          310 :         *eq = '\0';
      91          310 :         char *key = trim(p);
      92          310 :         char *val = trim(eq + 1);
      93              : 
      94              :         /* New boolean expression field (US-81) */
      95          310 :         if (strcmp(key, "when") == 0) {
      96           20 :             free(cur->when);
      97           20 :             cur->when = strdup(val);
      98              :         }
      99              :         /* Legacy flat fields — still loaded for in-memory backward compat */
     100          290 :         else if (strcmp(key, "if-from")        == 0) { free(cur->if_from);        cur->if_from        = strdup(val); }
     101          237 :         else if (strcmp(key, "if-subject")     == 0) { free(cur->if_subject);     cur->if_subject     = strdup(val); }
     102          198 :         else if (strcmp(key, "if-to")          == 0) { free(cur->if_to);          cur->if_to          = strdup(val); }
     103          195 :         else if (strcmp(key, "if-label")       == 0) { free(cur->if_label);       cur->if_label       = strdup(val); }
     104          192 :         else if (strcmp(key, "if-not-from")    == 0) { free(cur->if_not_from);    cur->if_not_from    = strdup(val); }
     105          189 :         else if (strcmp(key, "if-not-subject") == 0) { free(cur->if_not_subject); cur->if_not_subject = strdup(val); }
     106          189 :         else if (strcmp(key, "if-not-to")      == 0) { free(cur->if_not_to);      cur->if_not_to      = strdup(val); }
     107          189 :         else if (strcmp(key, "if-body")        == 0) { free(cur->if_body);        cur->if_body        = strdup(val); }
     108          186 :         else if (strcmp(key, "if-age-gt")      == 0) { cur->if_age_gt = atoi(val); }
     109          183 :         else if (strcmp(key, "if-age-lt")      == 0) { cur->if_age_lt = atoi(val); }
     110          180 :         else if (strcmp(key, "then-add-label") == 0) {
     111          158 :             if (cur->then_add_count < MAIL_RULE_MAX_LABELS)
     112          158 :                 cur->then_add_label[cur->then_add_count++] = strdup(val);
     113              :         }
     114           22 :         else if (strcmp(key, "then-remove-label") == 0) {
     115            8 :             if (cur->then_rm_count < MAIL_RULE_MAX_LABELS)
     116            8 :                 cur->then_rm_label[cur->then_rm_count++] = strdup(val);
     117              :         }
     118           14 :         else if (strcmp(key, "then-move-folder") == 0) {
     119           11 :             free(cur->then_move_folder);
     120           11 :             cur->then_move_folder = imap_utf7_decode(val);
     121           11 :             if (!cur->then_move_folder) cur->then_move_folder = strdup(val);
     122              :         }
     123            3 :         else if (strcmp(key, "then-forward-to") == 0) {
     124            3 :             free(cur->then_forward_to);
     125            3 :             cur->then_forward_to = strdup(val);
     126              :         }
     127              :     }
     128              : 
     129              :     /* Convert legacy flat fields → when expression for rules that have no when yet */
     130          224 :     for (int i = 0; i < r->count; i++) {
     131          158 :         MailRule *rule = &r->rules[i];
     132          158 :         if (!rule->when) {
     133          138 :             rule->when = when_from_flat(
     134          138 :                 rule->if_from, rule->if_subject, rule->if_to, rule->if_label,
     135          138 :                 rule->if_not_from, rule->if_not_subject, rule->if_not_to,
     136          138 :                 rule->if_body, rule->if_age_gt, rule->if_age_lt);
     137              :         }
     138              :     }
     139              : 
     140           66 :     logger_log(LOG_INFO, "mail_rules_load: loaded %d rule(s) from %s", r->count, path);
     141           66 :     return r;
     142              : }
     143              : 
     144          100 : MailRules *mail_rules_load(const char *account_name) {
     145          200 :     RAII_STRING char *path = rules_path(account_name);
     146          100 :     return mail_rules_load_path(path);
     147              : }
     148              : 
     149           10 : int mail_rules_save(const char *account_name, const MailRules *rules) {
     150           20 :     RAII_STRING char *path = rules_path(account_name);
     151           10 :     if (!path) return -1;
     152              : 
     153              :     /* Ensure directory exists */
     154              :     {
     155           10 :         char *slash = strrchr(path, '/');
     156           10 :         if (slash) {
     157              :             char dir[4096];
     158           10 :             size_t dl = (size_t)(slash - path);
     159           10 :             if (dl >= sizeof(dir)) return -1;
     160           10 :             memcpy(dir, path, dl); dir[dl] = '\0';
     161           10 :             if (fs_mkdir_p(dir, 0700) != 0) return -1;
     162              :         }
     163              :     }
     164              : 
     165           20 :     RAII_FILE FILE *fp = fopen(path, "w");
     166           10 :     if (!fp) return -1;
     167              : 
     168           27 :     for (int i = 0; i < rules->count; i++) {
     169           17 :         const MailRule *r = &rules->rules[i];
     170              :         /* US-82: write [rule "name" action N] header for N >= 2 */
     171           17 :         if (r->action_index >= 2)
     172            0 :             fprintf(fp, "[rule \"%s\" action %d]\n", r->name ? r->name : "", r->action_index);
     173              :         else
     174           17 :             fprintf(fp, "[rule \"%s\"]\n", r->name ? r->name : "");
     175              :         /* US-81: write when expression; skip old flat if-* fields */
     176           17 :         if (r->when && r->when[0])
     177           15 :             fprintf(fp, "when = %s\n", r->when);
     178           30 :         for (int j = 0; j < r->then_add_count; j++)
     179           13 :             fprintf(fp, "then-add-label    = %s\n", r->then_add_label[j]);
     180           20 :         for (int j = 0; j < r->then_rm_count; j++)
     181            3 :             fprintf(fp, "then-remove-label = %s\n", r->then_rm_label[j]);
     182           17 :         if (r->then_move_folder)
     183            5 :             fprintf(fp, "then-move-folder  = %s\n", r->then_move_folder);
     184           17 :         if (r->then_forward_to)
     185            1 :             fprintf(fp, "then-forward-to   = %s\n", r->then_forward_to);
     186           17 :         fprintf(fp, "\n");
     187              :     }
     188           10 :     return 0;
     189              : }
     190              : 
     191          112 : void mail_rules_free(MailRules *rules) {
     192          112 :     if (!rules) return;
     193          281 :     for (int i = 0; i < rules->count; i++) {
     194          201 :         MailRule *r = &rules->rules[i];
     195          201 :         free(r->name);
     196          201 :         free(r->when);
     197          201 :         free(r->if_from);
     198          201 :         free(r->if_not_from);
     199          201 :         free(r->if_subject);
     200          201 :         free(r->if_not_subject);
     201          201 :         free(r->if_to);
     202          201 :         free(r->if_not_to);
     203          201 :         free(r->if_label);
     204          201 :         free(r->if_body);
     205          379 :         for (int j = 0; j < r->then_add_count; j++) free(r->then_add_label[j]);
     206          213 :         for (int j = 0; j < r->then_rm_count;  j++) free(r->then_rm_label[j]);
     207          201 :         free(r->then_move_folder);
     208          201 :         free(r->then_forward_to);
     209              :     }
     210           80 :     free(rules->rules);
     211           80 :     free(rules);
     212              : }
     213              : 
     214              : /* Check if val matches glob pattern (case-insensitive, NULL pattern = always match) */
     215            0 : static int glob_match(const char *pattern, const char *val) {
     216            0 :     if (!pattern) return 1;      /* no condition → always matches */
     217            0 :     if (!val || !val[0]) return 0;
     218            0 :     return fnmatch(pattern, val, FNM_CASEFOLD) == 0;
     219              : }
     220              : 
     221              : /* Check if labels_csv contains a label matching glob pattern */
     222            0 : static int csv_label_match(const char *pattern, const char *csv) {
     223            0 :     if (!pattern) return 1;
     224            0 :     if (!csv || !csv[0]) return 0;
     225            0 :     char *copy = strdup(csv);
     226            0 :     if (!copy) return 0;
     227            0 :     int found = 0;
     228            0 :     char *tok = copy, *s;
     229            0 :     while (tok && *tok) {
     230            0 :         s = strchr(tok, ',');
     231            0 :         if (s) *s = '\0';
     232            0 :         if (fnmatch(pattern, tok, FNM_CASEFOLD) == 0) { found = 1; break; }
     233            0 :         tok = s ? s + 1 : NULL;
     234              :     }
     235            0 :     free(copy);
     236            0 :     return found;
     237              : }
     238              : 
     239              : /* Append a string to a dynamic array; skips duplicates */
     240           41 : static int str_array_add(char ***arr, int *count, const char *s) {
     241           41 :     for (int i = 0; i < *count; i++)
     242            0 :         if (strcmp((*arr)[i], s) == 0) return 0;   /* already present */
     243           41 :     char **tmp = realloc(*arr, (size_t)(*count + 1) * sizeof(char *));
     244           41 :     if (!tmp) return -1;
     245           41 :     *arr = tmp;
     246           41 :     (*arr)[(*count)++] = strdup(s);
     247           41 :     return 0;
     248              : }
     249              : 
     250          105 : int mail_rule_matches(const MailRule *rule,
     251              :                       const char *from, const char *subject,
     252              :                       const char *to, const char *labels_csv,
     253              :                       const char *body, time_t message_date)
     254              : {
     255          105 :     if (!rule) return 0;
     256              : 
     257              :     /* US-81: evaluate boolean when expression when present */
     258          105 :     if (rule->when && rule->when[0]) {
     259          105 :         WhenNode *tree = when_parse(rule->when);
     260          105 :         if (!tree) return 0;   /* syntax error → rule skipped */
     261          105 :         int result = when_eval(tree, from, subject, to, labels_csv, body, message_date);
     262          105 :         when_node_free(tree);
     263          105 :         return result;
     264              :     }
     265              : 
     266              :     /* Fallback: legacy flat-field AND chain (for in-memory-only rules) */
     267            0 :     if (!glob_match(rule->if_from,    from))    return 0;
     268            0 :     if (!glob_match(rule->if_subject, subject)) return 0;
     269            0 :     if (!glob_match(rule->if_to,      to))      return 0;
     270            0 :     if (rule->if_label && !csv_label_match(rule->if_label, labels_csv)) return 0;
     271            0 :     if (rule->if_not_from    && glob_match(rule->if_not_from,    from))    return 0;
     272            0 :     if (rule->if_not_subject && glob_match(rule->if_not_subject, subject)) return 0;
     273            0 :     if (rule->if_not_to      && glob_match(rule->if_not_to,      to))      return 0;
     274            0 :     if (rule->if_body) {
     275            0 :         if (!body) return 0;
     276            0 :         if (!glob_match(rule->if_body, body)) return 0;
     277              :     }
     278            0 :     if (message_date > 0) {
     279            0 :         int age = (int)((time(NULL) - message_date) / 86400);
     280            0 :         if (rule->if_age_gt > 0 && age <= rule->if_age_gt) return 0;
     281            0 :         if (rule->if_age_lt > 0 && age >= rule->if_age_lt) return 0;
     282              :     }
     283            0 :     return 1;
     284              : }
     285              : 
     286           49 : int mail_rules_apply_ex(const MailRules *rules,
     287              :                         const char *from, const char *subject,
     288              :                         const char *to, const char *labels_csv,
     289              :                         const char *body, time_t message_date,
     290              :                         char ***add_out, int *add_count,
     291              :                         char ***rm_out,  int *rm_count,
     292              :                         char **move_folder_out)
     293              : {
     294           49 :     *add_out = NULL; *add_count = 0;
     295           49 :     *rm_out  = NULL; *rm_count  = 0;
     296           49 :     if (move_folder_out) *move_folder_out = NULL;
     297           49 :     if (!rules || rules->count == 0) return 0;
     298              : 
     299              :     /* Working copy of labels for incremental if-label checks */
     300           49 :     char *working_labels = labels_csv ? strdup(labels_csv) : strdup("");
     301           49 :     if (!working_labels) return -1;
     302              : 
     303           49 :     int fired = 0;
     304              : 
     305          138 :     for (int i = 0; i < rules->count; i++) {
     306           89 :         const MailRule *r = &rules->rules[i];
     307              : 
     308           89 :         if (!mail_rule_matches(r, from, subject, to, working_labels, body, message_date))
     309           48 :             continue;
     310              : 
     311           41 :         fired++;
     312              : 
     313              :         /* Capture first fired rule's then_move_folder */
     314           41 :         if (move_folder_out && !*move_folder_out && r->then_move_folder)
     315            0 :             *move_folder_out = strdup(r->then_move_folder);
     316              : 
     317              :         /* Accumulate add/remove actions */
     318           82 :         for (int j = 0; j < r->then_add_count; j++)
     319           41 :             str_array_add(add_out, add_count, r->then_add_label[j]);
     320           41 :         for (int j = 0; j < r->then_rm_count; j++)
     321            0 :             str_array_add(rm_out, rm_count, r->then_rm_label[j]);
     322              : 
     323              :         /* Update working labels so subsequent if-label checks see the new state */
     324           41 :         if (r->then_add_count > 0 || r->then_rm_count > 0) {
     325              :             /* Rebuild working_labels from current add/rm state */
     326           41 :             size_t need = strlen(labels_csv ? labels_csv : "") + 1;
     327           82 :             for (int j = 0; j < *add_count; j++) need += strlen((*add_out)[j]) + 2;
     328           41 :             char *nb = malloc(need);
     329           41 :             if (nb) {
     330           41 :                 nb[0] = '\0';
     331              :                 /* Start from original + all added so far */
     332           41 :                 if (labels_csv && labels_csv[0]) strcpy(nb, labels_csv);
     333           82 :                 for (int j = 0; j < *add_count; j++) {
     334           41 :                     if (nb[0]) strcat(nb, ",");
     335           41 :                     strcat(nb, (*add_out)[j]);
     336              :                 }
     337              :                 /* Remove entries in rm_out */
     338           41 :                 for (int j = 0; j < *rm_count; j++) {
     339            0 :                     char *copy2 = strdup(nb);
     340            0 :                     if (!copy2) continue;
     341            0 :                     nb[0] = '\0';
     342            0 :                     char *tok2 = copy2, *s2;
     343            0 :                     while (tok2 && *tok2) {
     344            0 :                         s2 = strchr(tok2, ',');
     345            0 :                         if (s2) *s2 = '\0';
     346            0 :                         if (strcmp(tok2, (*rm_out)[j]) != 0) {
     347            0 :                             if (nb[0]) strcat(nb, ",");
     348            0 :                             strcat(nb, tok2);
     349              :                         }
     350            0 :                         tok2 = s2 ? s2 + 1 : NULL;
     351              :                     }
     352            0 :                     free(copy2);
     353              :                 }
     354           41 :                 free(working_labels);
     355           41 :                 working_labels = nb;
     356              :             }
     357              :         }
     358              :     }
     359              : 
     360           49 :     free(working_labels);
     361           49 :     return fired;
     362              : }
     363              : 
     364           42 : int mail_rules_apply(const MailRules *rules,
     365              :                      const char *from, const char *subject,
     366              :                      const char *to, const char *labels_csv,
     367              :                      const char *body, time_t message_date,
     368              :                      char ***add_out, int *add_count,
     369              :                      char ***rm_out,  int *rm_count)
     370              : {
     371           42 :     return mail_rules_apply_ex(rules, from, subject, to, labels_csv, body,
     372              :                                message_date, add_out, add_count, rm_out, rm_count,
     373              :                                NULL);
     374              : }
        

Generated by: LCOV version 2.0-1