LCOV - code coverage report
Current view: top level - src - main_import_rules.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 79.3 % 434 344
Test Date: 2026-05-07 15:53:08 Functions: 84.6 % 13 11

            Line data    Source code
       1              : /**
       2              :  * @file main_import_rules.c
       3              :  * @brief Import mail sorting rules from Thunderbird into email-cli rules.ini format.
       4              :  *
       5              :  * Thunderbird stores message filters in:
       6              :  *   <profile>/ImapMail/<server>/msgFilterRules.dat
       7              :  *   <profile>/Mail/<server>/msgFilterRules.dat
       8              :  *
       9              :  * Usage:
      10              :  *   email-import-rules [--thunderbird-path <dir>] [--account <email>]
      11              :  *                      [--dry-run] [--output <path>]
      12              :  */
      13              : 
      14              : #include <stdio.h>
      15              : #include <stdlib.h>
      16              : #include <string.h>
      17              : #include <locale.h>
      18              : #include <dirent.h>
      19              : #include <sys/stat.h>
      20              : #include "mail_rules.h"
      21              : #include "when_expr.h"
      22              : #include "config_store.h"
      23              : #include "fs_util.h"
      24              : #include "imap_util.h"
      25              : #include "platform/path.h"
      26              : #include "logger.h"
      27              : #include "raii.h"
      28              : 
      29              : #ifndef EMAIL_CLI_VERSION
      30              : #define EMAIL_CLI_VERSION "unknown"
      31              : #endif
      32              : 
      33              : /* ── Thunderbird profile detection ───────────────────────────────── */
      34              : 
      35              : /* Tries to find a Thunderbird profile directory.
      36              :  * Looks for ~/.thunderbird/<profile>/ImapMail or Mail subdirectories.
      37              :  * Returns heap-allocated path of the first profile found, or NULL. */
      38            0 : static char *find_thunderbird_profile(void) {
      39            0 :     const char *home = platform_home_dir();
      40            0 :     if (!home) return NULL;
      41              : 
      42              :     char tb_dir[8192];
      43            0 :     snprintf(tb_dir, sizeof(tb_dir), "%s/.thunderbird", home);
      44              : 
      45            0 :     DIR *dp = opendir(tb_dir);
      46            0 :     if (!dp) return NULL;
      47              : 
      48            0 :     char *result = NULL;
      49              :     struct dirent *de;
      50            0 :     while ((de = readdir(dp)) != NULL) {
      51            0 :         if (de->d_name[0] == '.') continue;
      52              : 
      53            0 :         char *candidate = NULL;
      54            0 :         if (asprintf(&candidate, "%s/%s", tb_dir, de->d_name) == -1) continue;
      55              : 
      56              :         struct stat st;
      57            0 :         if (stat(candidate, &st) != 0 || !S_ISDIR(st.st_mode)) { free(candidate); continue; }
      58              : 
      59              :         /* Check for ImapMail or Mail subdirectory */
      60            0 :         char *sub = NULL;
      61            0 :         if (asprintf(&sub, "%s/ImapMail", candidate) != -1 &&
      62            0 :             stat(sub, &st) == 0 && S_ISDIR(st.st_mode)) {
      63            0 :             result = candidate; candidate = NULL;
      64            0 :             free(sub); sub = NULL;
      65            0 :             break;
      66              :         }
      67            0 :         free(sub); sub = NULL;
      68            0 :         if (asprintf(&sub, "%s/Mail", candidate) != -1 &&
      69            0 :             stat(sub, &st) == 0 && S_ISDIR(st.st_mode)) {
      70            0 :             result = candidate; candidate = NULL;
      71            0 :             free(sub); sub = NULL;
      72            0 :             break;
      73              :         }
      74            0 :         free(sub);
      75            0 :         free(candidate);
      76              :     }
      77            0 :     closedir(dp);
      78            0 :     return result;
      79              : }
      80              : 
      81              : /* ── Thunderbird filter file parser ──────────────────────────────── */
      82              : 
      83              : /* Thunderbird msgFilterRules.dat format (simplified):
      84              :  *   name="Rule Name"
      85              :  *   enabled="yes"
      86              :  *   condition="AND (from,contains,@github.com)"
      87              :  *   action="Move to folder"
      88              :  *   actionValue="imap://user@server/GitHub"
      89              :  *
      90              :  *   (one blank line between rules)
      91              :  */
      92              : 
      93          203 : static char *trim_quotes(char *s) {
      94          203 :     if (!s) return s;
      95          203 :     size_t len = strlen(s);
      96          203 :     if (len >= 2 && s[0] == '"' && s[len-1] == '"') {
      97          203 :         s[len-1] = '\0';
      98          203 :         return s + 1;
      99              :     }
     100            0 :     return s;
     101              : }
     102              : 
     103              : /* Maximum number of conditions in one Thunderbird filter rule */
     104              : #define TB_MAX_CONDS 256
     105              : 
     106              : typedef struct {
     107              :     char field[32];    /* "from", "to", "subject", "body", "age" */
     108              :     char glob[1024];   /* glob pattern (empty for age) */
     109              :     int  is_negated;
     110              :     int  is_age_gt;    /* 1 = age-gt, 0 = age-lt */
     111              :     int  age_val;
     112              : } TBCond;
     113              : 
     114              : /* Grow MailRules array by one; return pointer to new zero-initialised entry. */
     115           42 : static MailRule *rules_append(MailRules *r) {
     116           42 :     if (r->count >= r->cap) {
     117           13 :         int nc = r->cap ? r->cap * 2 : 8;
     118           13 :         MailRule *tmp = realloc(r->rules, (size_t)nc * sizeof(MailRule));
     119           13 :         if (!tmp) return NULL;
     120           13 :         r->rules = tmp;
     121           13 :         r->cap   = nc;
     122              :     }
     123           42 :     MailRule *nr = &r->rules[r->count++];
     124           42 :     memset(nr, 0, sizeof(*nr));
     125           42 :     return nr;
     126              : }
     127              : 
     128              : 
     129              : /* Parse one Thunderbird filter file and append rules to *out.
     130              :  * Prints warnings to stderr for any rule elements that could not be converted.
     131              :  * OR-logic filters with multiple conditions are expanded into one rule each.
     132              :  * Returns the number of rules added (may be 0 if none could be converted). */
     133           12 : static int parse_tb_filter_file(const char *path, MailRules **out) {
     134           12 :     FILE *fp = fopen(path, "r");
     135           12 :     if (!fp) return 0;
     136              : 
     137           12 :     if (!*out) {
     138           12 :         *out = calloc(1, sizeof(MailRules));
     139           12 :         if (!*out) { fclose(fp); return -1; }
     140              :     }
     141              : 
     142              :     /* ── Per-filter state (accumulated until blank line or next name=) ── */
     143           12 :     char cur_name[512]          = {0};
     144              :     TBCond conds[TB_MAX_CONDS];
     145           12 :     int nconds                  = 0;
     146           12 :     int cur_is_or               = 0;  /* 1 = OR-logic filter */
     147           12 :     int cur_converted_cond      = 0;
     148           12 :     int cur_skipped_cond        = 0;
     149              : 
     150              :     /* Actions */
     151           12 :     char then_move_folder[1024] = {0};
     152           12 :     int  pending_move           = 0;
     153              :     char then_add_labels[MAIL_RULE_MAX_LABELS][256];
     154           12 :     int  then_add_count         = 0;
     155              :     char then_rm_labels[MAIL_RULE_MAX_LABELS][256];
     156           12 :     int  then_rm_count          = 0;
     157           12 :     char then_forward_to[512]   = {0};
     158           12 :     int  pending_forward        = 0;
     159           12 :     int  pending_label          = 0;
     160           12 :     int  cur_converted_act      = 0;
     161           12 :     int  cur_skipped_act        = 0;
     162              : 
     163           12 :     int rules_added = 0;
     164              : 
     165              :     /* ── Flush: convert TB filter to MailRule with when expression ── */
     166              : #define FLUSH_RULE() do { \
     167              :     if (!cur_name[0]) break; \
     168              :     if (cur_converted_cond == 0 && cur_converted_act == 0 \
     169              :             && (cur_skipped_cond > 0 || cur_skipped_act > 0)) { \
     170              :         fprintf(stderr, "  [warn] Rule \"%s\": no conditions or actions could be " \
     171              :                 "converted — rule will be empty\n", cur_name); \
     172              :     } \
     173              :     MailRule *_r = rules_append(*out); \
     174              :     if (!_r) break; \
     175              :     _r->name = strdup(cur_name); \
     176              :     /* Build when expression from collected conditions (US-81) */ \
     177              :     if (nconds > 0) { \
     178              :         WhenCond _wc[TB_MAX_CONDS]; \
     179              :         int _nwc = 0; \
     180              :         for (int _ci = 0; _ci < nconds; _ci++) { \
     181              :             _wc[_nwc].negated = conds[_ci].is_negated; \
     182              :             if (strcmp(conds[_ci].field, "age") == 0) { \
     183              :                 _wc[_nwc].field   = conds[_ci].is_age_gt ? "age-gt" : "age-lt"; \
     184              :                 char _abuf[16]; \
     185              :                 snprintf(_abuf, sizeof(_abuf), "%d", conds[_ci].age_val); \
     186              :                 _wc[_nwc].pattern = strdup(_abuf); \
     187              :             } else { \
     188              :                 _wc[_nwc].field   = conds[_ci].field; \
     189              :                 _wc[_nwc].pattern = conds[_ci].glob; \
     190              :             } \
     191              :             _nwc++; \
     192              :         } \
     193              :         _r->when = when_from_conds(_wc, _nwc, cur_is_or); \
     194              :         /* Free age pattern copies */ \
     195              :         for (int _ci = 0; _ci < nconds; _ci++) \
     196              :             if (strcmp(conds[_ci].field, "age") == 0) \
     197              :                 free((char *)_wc[_ci].pattern); \
     198              :     } \
     199              :     if (then_move_folder[0]) \
     200              :         _r->then_move_folder = strdup(then_move_folder); \
     201              :     for (int _li = 0; _li < then_add_count; _li++) \
     202              :         _r->then_add_label[_r->then_add_count++] = strdup(then_add_labels[_li]); \
     203              :     for (int _li = 0; _li < then_rm_count; _li++) \
     204              :         _r->then_rm_label[_r->then_rm_count++]   = strdup(then_rm_labels[_li]); \
     205              :     if (then_forward_to[0]) \
     206              :         _r->then_forward_to = strdup(then_forward_to); \
     207              :     rules_added++; \
     208              : } while (0)
     209              : 
     210              : #define RESET_RULE() do { \
     211              :     cur_name[0] = '\0'; nconds = 0; cur_is_or = 0; \
     212              :     cur_converted_cond = cur_skipped_cond = 0; \
     213              :     then_move_folder[0] = '\0'; pending_move = 0; \
     214              :     then_add_count = then_rm_count = 0; \
     215              :     then_forward_to[0] = '\0'; pending_forward = pending_label = 0; \
     216              :     cur_converted_act = cur_skipped_act = 0; \
     217              : } while (0)
     218              : 
     219              :     char line[4096];
     220          257 :     while (fgets(line, sizeof(line), fp)) {
     221          245 :         char *nl = strchr(line, '\n');
     222          245 :         if (nl) *nl = '\0';
     223          245 :         char *p = line;
     224          245 :         while (*p == ' ' || *p == '\t') p++;
     225              : 
     226          245 :         if (!*p) {
     227          194 :             FLUSH_RULE();
     228           42 :             RESET_RULE();
     229           42 :             continue;
     230              :         }
     231              : 
     232          203 :         char *eq = strchr(p, '=');
     233          203 :         if (!eq) continue;
     234          203 :         *eq = '\0';
     235          203 :         char *key = p;
     236          203 :         char *val = trim_quotes(eq + 1);
     237              : 
     238          203 :         if (strcmp(key, "name") == 0) {
     239           42 :             FLUSH_RULE();
     240           42 :             RESET_RULE();
     241           42 :             snprintf(cur_name, sizeof(cur_name), "%s", val);
     242           42 :             continue;
     243              :         }
     244              : 
     245          161 :         if (!cur_name[0]) continue;
     246              : 
     247          159 :         if (strcmp(key, "condition") == 0) {
     248           42 :             cur_is_or = (strncmp(val, "OR", 2) == 0);
     249           42 :             char *v = strdup(val);
     250           42 :             if (!v) continue;
     251           42 :             char *tok = strstr(v, "(");
     252           87 :             while (tok && nconds < TB_MAX_CONDS) {
     253           45 :                 tok++;
     254           45 :                 char *end = strchr(tok, ')');
     255           45 :                 if (!end) break;
     256           45 :                 *end = '\0';
     257           45 :                 char *f1 = tok, *f2 = NULL, *f3 = NULL;
     258           45 :                 char *c1 = strchr(f1, ',');
     259           45 :                 if (c1) { *c1 = '\0'; f2 = c1 + 1; }
     260           45 :                 char *c2 = f2 ? strchr(f2, ',') : NULL;
     261           45 :                 if (c2) { *c2 = '\0'; f3 = c2 + 1; }
     262              : 
     263           45 :                 if (f1 && f2 && f3) {
     264           45 :                     int is_body  = (strcmp(f1, "body") == 0);
     265           45 :                     int is_age   = (strcmp(f1, "age")  == 0);
     266           14 :                     int ok_field = (strcmp(f1, "from") == 0 || strcmp(f1, "subject") == 0 ||
     267           59 :                                     strcmp(f1, "to")   == 0 || is_body || is_age);
     268           88 :                     int negated  = (strcmp(f2, "doesn't contain") == 0 ||
     269           43 :                                     strcmp(f2, "isn't")            == 0);
     270              :                     /* BUG-001: exact comparisons prevent "isn't"/"doesn't contain"
     271              :                      * being treated as positive "is"/"contains". */
     272          101 :                     int ok_match = (strcmp(f2, "contains")     == 0 ||
     273           11 :                                     strcmp(f2, "is")            == 0 ||
     274           10 :                                     strcmp(f2, "begins with")   == 0 ||
     275            8 :                                     strcmp(f2, "ends with")     == 0 ||
     276            7 :                                     strcmp(f2, "greater than")  == 0 ||
     277           56 :                                     strcmp(f2, "less than")     == 0 || negated);
     278              : 
     279           45 :                     if (!ok_field) {
     280            1 :                         fprintf(stderr, "  [warn] Rule \"%s\": condition field \"%s\" "
     281              :                                 "is not supported, skipping term\n", cur_name, f1);
     282            1 :                         cur_skipped_cond++;
     283           44 :                     } else if (!ok_match) {
     284            0 :                         fprintf(stderr, "  [warn] Rule \"%s\": match type \"%s\" "
     285              :                                 "is not supported, skipping term\n", cur_name, f2);
     286            0 :                         cur_skipped_cond++;
     287              :                     } else {
     288           44 :                         TBCond *c = &conds[nconds++];
     289           44 :                         memset(c, 0, sizeof(*c));
     290           44 :                         snprintf(c->field, sizeof(c->field), "%s", f1);
     291           44 :                         c->is_negated = negated;
     292           44 :                         if (is_age) {
     293            3 :                             c->age_val   = atoi(f3);
     294            3 :                             c->is_age_gt = (strcmp(f2, "greater than") == 0);
     295              :                         } else {
     296           41 :                             if (strcmp(f2, "contains") == 0 || negated)
     297           37 :                                 snprintf(c->glob, sizeof(c->glob), "*%s*", f3);
     298            4 :                             else if (strcmp(f2, "begins with") == 0)
     299            2 :                                 snprintf(c->glob, sizeof(c->glob), "%s*", f3);
     300            2 :                             else if (strcmp(f2, "ends with") == 0)
     301            1 :                                 snprintf(c->glob, sizeof(c->glob), "*%s", f3);
     302              :                             else
     303            1 :                                 snprintf(c->glob, sizeof(c->glob), "%s", f3);
     304              :                         }
     305           44 :                         cur_converted_cond++;
     306              :                     }
     307              :                 }
     308           45 :                 tok = strstr(end + 1, "(");
     309              :             }
     310           42 :             free(v);
     311           42 :             continue;
     312              :         }
     313              : 
     314          117 :         if (strcmp(key, "action") == 0) {
     315           44 :             pending_label = pending_forward = 0;
     316           44 :             if (strstr(val, "Move")) {
     317           18 :                 pending_move = 1;
     318           18 :                 cur_converted_act++;
     319           26 :             } else if (strcmp(val, "Mark as read") == 0 || strcmp(val, "Mark read") == 0) {
     320            4 :                 if (then_rm_count < MAIL_RULE_MAX_LABELS)
     321            4 :                     snprintf(then_rm_labels[then_rm_count++], 256, "UNREAD");
     322            4 :                 cur_converted_act++;
     323           22 :             } else if (strcmp(val, "Mark as unread") == 0 || strcmp(val, "Mark unread") == 0) {
     324            2 :                 if (then_add_count < MAIL_RULE_MAX_LABELS)
     325            2 :                     snprintf(then_add_labels[then_add_count++], 256, "UNREAD");
     326            2 :                 cur_converted_act++;
     327           20 :             } else if (strcmp(val, "Mark as starred") == 0 || strcmp(val, "Mark as flagged") == 0) {
     328            3 :                 if (then_add_count < MAIL_RULE_MAX_LABELS)
     329            3 :                     snprintf(then_add_labels[then_add_count++], 256, "_flagged");
     330            3 :                 cur_converted_act++;
     331           17 :             } else if (strcmp(val, "Mark as junk") == 0 || strcmp(val, "JunkScore") == 0) {
     332            4 :                 if (then_add_count < MAIL_RULE_MAX_LABELS)
     333            4 :                     snprintf(then_add_labels[then_add_count++], 256, "_junk");
     334            4 :                 cur_converted_act++;
     335           13 :             } else if (strcmp(val, "Delete") == 0) {
     336            2 :                 if (then_add_count < MAIL_RULE_MAX_LABELS)
     337            2 :                     snprintf(then_add_labels[then_add_count++], 256, "_trash");
     338            2 :                 cur_converted_act++;
     339           11 :             } else if (strcmp(val, "Forward") == 0) {
     340            2 :                 pending_forward = 1;
     341            2 :                 cur_converted_act++;
     342            9 :             } else if (strcmp(val, "Label") == 0) {
     343            8 :                 pending_label = 1;
     344            8 :                 cur_converted_act++;
     345              :             } else {
     346            1 :                 fprintf(stderr, "  [warn] Rule \"%s\": action \"%s\" "
     347              :                         "is not supported, skipping\n", cur_name, val);
     348            1 :                 cur_skipped_act++;
     349              :             }
     350           44 :             continue;
     351              :         }
     352              : 
     353           73 :         if (strcmp(key, "actionValue") == 0) {
     354           31 :             if (pending_move) {
     355           18 :                 const char *last_slash = strrchr(val, '/');
     356           18 :                 const char *raw = last_slash ? last_slash + 1 : val;
     357           18 :                 char *decoded = imap_utf7_decode(raw);
     358           18 :                 snprintf(then_move_folder, sizeof(then_move_folder),
     359              :                          "%s", decoded ? decoded : raw);
     360           18 :                 free(decoded);
     361           18 :                 pending_move = 0;
     362              :             }
     363           31 :             if (pending_forward) {
     364            2 :                 snprintf(then_forward_to, sizeof(then_forward_to), "%s", val);
     365            2 :                 pending_forward = 0;
     366              :             }
     367           31 :             if (pending_label) {
     368              :                 static const char *tb_labels[] = {
     369              :                     NULL, "Important", "Work", "Personal", "TODO", "Later"
     370              :                 };
     371            8 :                 if (strncmp(val, "$label", 6) == 0) {
     372            8 :                     int n = atoi(val + 6);
     373            8 :                     const char *lname = (n >= 1 && n <= 5) ? tb_labels[n] : NULL;
     374            8 :                     if (lname) {
     375            7 :                         if (then_add_count < MAIL_RULE_MAX_LABELS)
     376            7 :                             snprintf(then_add_labels[then_add_count++], 256, "%s", lname);
     377              :                     } else {
     378            1 :                         if (then_add_count < MAIL_RULE_MAX_LABELS)
     379            1 :                             snprintf(then_add_labels[then_add_count++], 256, "Label%d", n);
     380              :                     }
     381              :                 } else {
     382            0 :                     if (then_add_count < MAIL_RULE_MAX_LABELS)
     383            0 :                         snprintf(then_add_labels[then_add_count++], 256, "%s", val);
     384              :                 }
     385            8 :                 pending_label = 0;
     386              :             }
     387           31 :             continue;
     388              :         }
     389              :     }
     390              : 
     391              :     /* EOF without trailing blank line */
     392           12 :     FLUSH_RULE();
     393              : 
     394              : #undef FLUSH_RULE
     395              : #undef RESET_RULE
     396              : 
     397           12 :     fclose(fp);
     398           12 :     return rules_added;
     399              : }
     400              : 
     401              : /* ── Thunderbird prefs.js account mapping ────────────────────────── */
     402              : 
     403              : #define TB_PREFS_MAX 128
     404              : 
     405              : typedef struct {
     406              :     char hostname[256]; /* "imap.gmail.com" */
     407              :     char username[256]; /* "csjpeter@gmail.com" */
     408              :     char dir[256];      /* dirname under ImapMail/, e.g. "imap.gmail.com" */
     409              : } TBAccountEntry;
     410              : 
     411              : /* Extract hostname from URL like "imaps://box.csaszar.email:993".
     412              :  * Writes into buf[buflen]; returns buf on success, NULL on failure. */
     413           15 : static const char *extract_hostname(const char *url, char *buf, size_t buflen) {
     414           15 :     if (!url || !buf || buflen == 0) return NULL;
     415           15 :     const char *p = strstr(url, "://");
     416           15 :     p = p ? p + 3 : url;
     417           15 :     size_t i = 0;
     418          151 :     while (*p && *p != ':' && *p != '/' && i + 1 < buflen)
     419          136 :         buf[i++] = *p++;
     420           15 :     buf[i] = '\0';
     421           15 :     return i > 0 ? buf : NULL;
     422              : }
     423              : 
     424              : /* Parse Thunderbird prefs.js: build TBAccountEntry[] from mail.server.serverN.* lines.
     425              :  * Returns number of entries filled, 0 if file not found or no entries. */
     426           13 : static int parse_tb_prefs(const char *profile_path,
     427              :                            TBAccountEntry *entries, int max_entries) {
     428              :     char prefs_path[8300];
     429           13 :     snprintf(prefs_path, sizeof(prefs_path), "%s/prefs.js", profile_path);
     430           13 :     FILE *fp = fopen(prefs_path, "r");
     431           13 :     if (!fp) return 0;
     432              : 
     433              :     /* Temporary storage indexed by server number (1-based) */
     434              :     static char h[TB_PREFS_MAX][256]; /* hostname */
     435              :     static char u[TB_PREFS_MAX][256]; /* userName */
     436              :     static char d[TB_PREFS_MAX][256]; /* dir name extracted from directory-rel */
     437              :     static char used[TB_PREFS_MAX];
     438           11 :     memset(used, 0, sizeof(used));
     439         1419 :     for (int i = 0; i < TB_PREFS_MAX; i++) { h[i][0]=u[i][0]=d[i][0]='\0'; }
     440              : 
     441              :     char line[4096];
     442           59 :     while (fgets(line, sizeof(line), fp)) {
     443              :         /* user_pref("mail.server.serverN.attr", "value"); */
     444           48 :         const char *prefix = "user_pref(\"mail.server.server";
     445           48 :         if (strncmp(line, prefix, strlen(prefix)) != 0) continue;
     446           48 :         const char *p = line + strlen(prefix);
     447              : 
     448           48 :         int n = 0;
     449           96 :         while (*p >= '0' && *p <= '9') { n = n * 10 + (*p - '0'); p++; }
     450           48 :         if (*p != '.' || n <= 0 || n >= TB_PREFS_MAX) continue;
     451           48 :         p++;
     452              : 
     453              :         /* Read attribute name up to ',' */
     454           48 :         char attr[64] = ""; int ai = 0;
     455          444 :         while (*p && *p != '"' && *p != ',' && ai + 1 < (int)sizeof(attr))
     456          396 :             attr[ai++] = *p++;
     457           48 :         attr[ai] = '\0';
     458              : 
     459              :         /* Skip to first '"' after ',' to find value */
     460           48 :         char *comma = strchr(p, ',');
     461           48 :         if (!comma) continue;
     462           48 :         char *vs = strchr(comma + 1, '"');
     463           48 :         if (!vs) continue;
     464           48 :         vs++;
     465           48 :         char *ve = strchr(vs, '"');
     466           48 :         if (!ve) continue;
     467           48 :         size_t vl = (size_t)(ve - vs);
     468              : 
     469           48 :         used[n] = 1;
     470           48 :         if (strcmp(attr, "hostname") == 0 && vl < sizeof(h[n])) {
     471           12 :             memcpy(h[n], vs, vl); h[n][vl] = '\0';
     472           36 :         } else if (strcmp(attr, "userName") == 0 && vl < sizeof(u[n])) {
     473           12 :             memcpy(u[n], vs, vl); u[n][vl] = '\0';
     474           24 :         } else if (strcmp(attr, "directory-rel") == 0 && vl < sizeof(d[n])) {
     475              :             /* "[ProfD]ImapMail/imap.gmail.com" → "imap.gmail.com"
     476              :              * "[ProfD]Mail/Local Folders"      → "Local Folders"  */
     477           12 :             char tmp[256]; memcpy(tmp, vs, vl); tmp[vl] = '\0';
     478           12 :             char *slash = NULL;
     479           12 :             char *im = strstr(tmp, "ImapMail/");
     480           12 :             if (im) slash = im + 8; /* points to '/' before dir name */
     481              :             else {
     482            0 :                 char *m = strstr(tmp, "Mail/");
     483            0 :                 if (m) slash = m + 4;
     484              :             }
     485           12 :             if (slash) {
     486           12 :                 slash++; /* skip '/' */
     487           12 :                 strncpy(d[n], slash, sizeof(d[n]) - 1);
     488              :             }
     489              :         }
     490              :     }
     491           11 :     fclose(fp);
     492              : 
     493           11 :     int count = 0;
     494         1408 :     for (int n = 1; n < TB_PREFS_MAX && count < max_entries; n++) {
     495         1397 :         if (!used[n] || !h[n][0]) continue;
     496           12 :         strncpy(entries[count].hostname, h[n], sizeof(entries[count].hostname) - 1);
     497           12 :         strncpy(entries[count].username, u[n], sizeof(entries[count].username) - 1);
     498           12 :         strncpy(entries[count].dir,      d[n], sizeof(entries[count].dir)      - 1);
     499           12 :         entries[count].hostname[sizeof(entries[count].hostname)-1] = '\0';
     500           12 :         entries[count].username[sizeof(entries[count].username)-1] = '\0';
     501           12 :         entries[count].dir     [sizeof(entries[count].dir)     -1] = '\0';
     502           12 :         count++;
     503              :     }
     504           11 :     return count;
     505              : }
     506              : 
     507              : /* Find the Thunderbird directory name for an email-cli account.
     508              :  * Matches by (hostname, email address).  email is compared to TB userName both
     509              :  * as full address and as local part (before '@'), case-insensitively.
     510              :  * Returns 1 and fills dir_out on success; returns 0 if no match. */
     511           13 : static int find_tb_dir_for_account(const TBAccountEntry *entries, int count,
     512              :                                     const char *email, const char *host,
     513              :                                     char *dir_out, size_t dir_out_size) {
     514           13 :     if (!email || !host || !entries || count <= 0) return 0;
     515              : 
     516              :     /* Local part of email (before '@') for fallback matching */
     517           13 :     const char *at = strchr(email, '@');
     518           13 :     size_t local_len = at ? (size_t)(at - email) : strlen(email);
     519              : 
     520           16 :     for (int i = 0; i < count; i++) {
     521           15 :         if (strcasecmp(entries[i].hostname, host) != 0) continue;
     522           13 :         const char *uname = entries[i].username;
     523           14 :         int match = (strcasecmp(uname, email) == 0) ||
     524            1 :                     (strlen(uname) == local_len &&
     525            0 :                      strncasecmp(uname, email, local_len) == 0);
     526           13 :         if (!match) continue;
     527           12 :         if (entries[i].dir[0]) {
     528           12 :             strncpy(dir_out, entries[i].dir, dir_out_size - 1);
     529           12 :             dir_out[dir_out_size - 1] = '\0';
     530           12 :             return 1;
     531              :         }
     532              :     }
     533            1 :     return 0;
     534              : }
     535              : 
     536              : /* ── Thunderbird scanner ─────────────────────────────────────────── */
     537              : 
     538              : /* Scan a single named subdirectory under ImapMail/ or Mail/ for rules. */
     539           24 : static int scan_tb_named_dir(const char *parent, const char *dir_name, MailRules **out) {
     540              :     char path[8300];
     541           24 :     snprintf(path, sizeof(path), "%s/%s/msgFilterRules.dat", parent, dir_name);
     542              :     struct stat st;
     543           24 :     if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) return 0;
     544           12 :     printf("  Found: %s\n", path);
     545           12 :     int n = parse_tb_filter_file(path, out);
     546           12 :     return n > 0 ? n : 0;
     547              : }
     548              : 
     549              : /* ── Per-rule output helpers ─────────────────────────────────────── */
     550              : 
     551           42 : static void print_rule(const MailRule *r) {
     552           42 :     printf("[rule \"%s\"]\n", r->name ? r->name : "(unnamed)");
     553           42 :     if (r->when && r->when[0])
     554           41 :         printf("  when             = %s\n", r->when);
     555           61 :     for (int j = 0; j < r->then_add_count; j++)
     556           19 :         printf("  then-add-label    = %s\n", r->then_add_label[j]);
     557           46 :     for (int j = 0; j < r->then_rm_count; j++)
     558            4 :         printf("  then-remove-label = %s\n", r->then_rm_label[j]);
     559           42 :     if (r->then_move_folder)
     560           18 :         printf("  then-move-folder  = %s\n", r->then_move_folder);
     561           42 :     if (r->then_forward_to)
     562            2 :         printf("  then-forward-to   = %s\n", r->then_forward_to);
     563           42 :     printf("\n");
     564           42 : }
     565              : 
     566            0 : static int write_rules_to_file(const MailRules *rules, const char *path) {
     567            0 :     char *slash = strrchr(path, '/');
     568            0 :     if (slash) {
     569              :         char dir[4096];
     570            0 :         size_t dl = (size_t)(slash - path);
     571            0 :         if (dl < sizeof(dir)) {
     572            0 :             memcpy(dir, path, dl); dir[dl] = '\0';
     573            0 :             fs_mkdir_p(dir, 0700);
     574              :         }
     575              :     }
     576            0 :     FILE *fp = fopen(path, "w");
     577            0 :     if (!fp) { fprintf(stderr, "Error: Cannot write to %s\n", path); return -1; }
     578            0 :     for (int i = 0; i < rules->count; i++) {
     579            0 :         const MailRule *r = &rules->rules[i];
     580            0 :         fprintf(fp, "[rule \"%s\"]\n", r->name ? r->name : "");
     581            0 :         if (r->when && r->when[0])
     582            0 :             fprintf(fp, "when = %s\n", r->when);
     583            0 :         for (int j = 0; j < r->then_add_count; j++)
     584            0 :             fprintf(fp, "then-add-label    = %s\n", r->then_add_label[j]);
     585            0 :         for (int j = 0; j < r->then_rm_count; j++)
     586            0 :             fprintf(fp, "then-remove-label = %s\n", r->then_rm_label[j]);
     587            0 :         if (r->then_move_folder)
     588            0 :             fprintf(fp, "then-move-folder  = %s\n", r->then_move_folder);
     589            0 :         if (r->then_forward_to)
     590            0 :             fprintf(fp, "then-forward-to   = %s\n", r->then_forward_to);
     591            0 :         fprintf(fp, "\n");
     592              :     }
     593            0 :     fclose(fp);
     594            0 :     return 0;
     595              : }
     596              : 
     597              : /* ── Per-account processing ──────────────────────────────────────── */
     598              : 
     599              : /* Scan the Thunderbird directory for tb_dir_name (exact), print rules, save.
     600              :  * tb_dir_name: specific subdirectory name under ImapMail/ (from prefs.js lookup).
     601              :  * output: NULL → default rules.ini; non-NULL → write to that path.
     602              :  * Returns EXIT_SUCCESS / EXIT_FAILURE. */
     603           12 : static int process_account(const char *account_name, const char *tb_dir_name,
     604              :                             const char *tb_path, int dry_run, const char *output) {
     605              :     char imap_dir[8210], mail_dir[8210];
     606           12 :     snprintf(imap_dir, sizeof(imap_dir), "%s/ImapMail", tb_path);
     607           12 :     snprintf(mail_dir, sizeof(mail_dir), "%s/Mail",     tb_path);
     608              : 
     609           12 :     MailRules *rules = NULL;
     610           12 :     int total = 0;
     611           12 :     total += scan_tb_named_dir(imap_dir, tb_dir_name, &rules);
     612           12 :     total += scan_tb_named_dir(mail_dir, tb_dir_name, &rules);
     613              : 
     614           12 :     if (total == 0 || !rules || rules->count == 0) {
     615            0 :         printf("  No rules found.\n");
     616            0 :         mail_rules_free(rules);
     617            0 :         return EXIT_SUCCESS;
     618              :     }
     619              : 
     620           12 :     printf("Found %d rule(s):\n\n", rules->count);
     621           54 :     for (int i = 0; i < rules->count; i++)
     622           42 :         print_rule(&rules->rules[i]);
     623              : 
     624           12 :     if (dry_run) {
     625            9 :         printf("[dry-run] Rules NOT saved.\n");
     626            9 :         mail_rules_free(rules);
     627            9 :         return EXIT_SUCCESS;
     628              :     }
     629              : 
     630            3 :     int rc = 0;
     631            3 :     if (output) {
     632            0 :         rc = write_rules_to_file(rules, output);
     633            0 :         if (rc == 0) printf("Rules saved to: %s\n", output);
     634              :     } else {
     635            3 :         rc = mail_rules_save(account_name, rules);
     636            3 :         if (rc == 0)
     637            3 :             printf("Rules saved to ~/.config/email-cli/accounts/%s/rules.ini\n",
     638              :                    account_name);
     639              :         else
     640            0 :             fprintf(stderr, "Error: Failed to save rules for '%s'.\n", account_name);
     641              :     }
     642              : 
     643            3 :     mail_rules_free(rules);
     644            3 :     return rc == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
     645              : }
     646              : 
     647              : /* ── Help ────────────────────────────────────────────────────────── */
     648              : 
     649            2 : static void help(void) {
     650            2 :     printf(
     651              :         "Usage: email-import-rules [OPTIONS]\n"
     652              :         "\n"
     653              :         "Import mail sorting rules from Thunderbird into email-cli rules.ini format.\n"
     654              :         "\n"
     655              :         "Without --account: processes ALL configured email-cli accounts, importing\n"
     656              :         "only the Thunderbird filters that belong to each account's IMAP server.\n"
     657              :         "\n"
     658              :         "Options:\n"
     659              :         "  --thunderbird-path <dir>  Path to Thunderbird profile directory\n"
     660              :         "                            (auto-detected from ~/.thunderbird if omitted)\n"
     661              :         "  --account <email>         Import rules for this account only\n"
     662              :         "  --output <path>           Write rules to this file (requires --account)\n"
     663              :         "  --dry-run                 Print rules without saving\n"
     664              :         "  --version                 Show version\n"
     665              :         "  --help, -h                Show this help message\n"
     666              :         "\n"
     667              :         "Rule file location (default):\n"
     668              :         "  ~/.config/email-cli/accounts/<account>/rules.ini\n"
     669              :     );
     670            2 : }
     671              : 
     672              : /* ── Main ────────────────────────────────────────────────────────── */
     673              : 
     674           15 : int main(int argc, char *argv[]) {
     675           15 :     setlocale(LC_ALL, "");
     676              : 
     677           15 :     const char *tb_path = NULL;
     678           15 :     const char *account = NULL;
     679           15 :     const char *output  = NULL;
     680           15 :     int         dry_run = 0;
     681              : 
     682           49 :     for (int i = 1; i < argc; i++) {
     683           36 :         if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) {
     684            2 :             help(); return EXIT_SUCCESS;
     685              :         }
     686           34 :         if (strcmp(argv[i], "--version") == 0 || strcmp(argv[i], "-V") == 0) {
     687            0 :             printf("email-import-rules %s\n", EMAIL_CLI_VERSION);
     688            0 :             return EXIT_SUCCESS;
     689              :         }
     690           34 :         if (strcmp(argv[i], "--thunderbird-path") == 0) {
     691           13 :             if (i + 1 >= argc) {
     692            0 :                 fprintf(stderr, "Error: --thunderbird-path requires a path.\n");
     693            0 :                 return EXIT_FAILURE;
     694              :             }
     695           13 :             tb_path = argv[++i]; continue;
     696              :         }
     697           21 :         if (strcmp(argv[i], "--account") == 0) {
     698           11 :             if (i + 1 >= argc) {
     699            0 :                 fprintf(stderr, "Error: --account requires an email address.\n");
     700            0 :                 return EXIT_FAILURE;
     701              :             }
     702           11 :             account = argv[++i]; continue;
     703              :         }
     704           10 :         if (strcmp(argv[i], "--output") == 0) {
     705            0 :             if (i + 1 >= argc) {
     706            0 :                 fprintf(stderr, "Error: --output requires a path.\n");
     707            0 :                 return EXIT_FAILURE;
     708              :             }
     709            0 :             output = argv[++i]; continue;
     710              :         }
     711           10 :         if (strcmp(argv[i], "--dry-run") == 0) { dry_run = 1; continue; }
     712            0 :         fprintf(stderr, "Unknown option '%s'.\nRun 'email-import-rules --help'.\n",
     713            0 :                 argv[i]);
     714            0 :         return EXIT_FAILURE;
     715              :     }
     716              : 
     717              :     /* --output without --account is ambiguous in multi-account mode */
     718           13 :     if (output && !account) {
     719            0 :         fprintf(stderr,
     720              :                 "Error: --output requires --account when multiple accounts exist.\n"
     721              :                 "Use: email-import-rules --account <email> --output <path>\n");
     722            0 :         return EXIT_FAILURE;
     723              :     }
     724              : 
     725              :     /* Auto-detect Thunderbird profile */
     726           13 :     RAII_STRING char *tb_auto = NULL;
     727           13 :     if (!tb_path) {
     728            0 :         tb_auto = find_thunderbird_profile();
     729            0 :         if (!tb_auto) {
     730            0 :             fprintf(stderr, "Error: No Thunderbird profile found at ~/.thunderbird.\n"
     731              :                             "Use --thunderbird-path to specify the profile directory.\n");
     732            0 :             return EXIT_FAILURE;
     733              :         }
     734            0 :         tb_path = tb_auto;
     735            0 :         printf("Thunderbird profile: %s\n", tb_path);
     736              :     }
     737              : 
     738              :     /* Parse prefs.js once for account→directory mapping */
     739              :     TBAccountEntry tb_entries[TB_PREFS_MAX];
     740           13 :     int tb_count = parse_tb_prefs(tb_path, tb_entries, TB_PREFS_MAX);
     741              : 
     742           13 :     if (account) {
     743              :         /* ── Single-account mode ── */
     744           11 :         Config *cfg = config_load_account(account);
     745           11 :         char host_buf[512] = "";
     746           11 :         if (cfg && cfg->host)
     747           11 :             extract_hostname(cfg->host, host_buf, sizeof(host_buf));
     748           11 :         config_free(cfg);
     749              : 
     750           11 :         char dir_buf[256] = "";
     751           11 :         if (tb_count > 0 && host_buf[0])
     752            9 :             find_tb_dir_for_account(tb_entries, tb_count, account, host_buf,
     753              :                                     dir_buf, sizeof(dir_buf));
     754              : 
     755           11 :         if (!dir_buf[0] && host_buf[0]) {
     756              :             /* prefs.js unavailable or no match: warn and skip */
     757            2 :             fprintf(stderr,
     758              :                     "Warning: No Thunderbird account found for '%s' (host: %s).\n"
     759              :                     "Check that Thunderbird is configured with this account.\n",
     760              :                     account, host_buf);
     761            2 :             return EXIT_FAILURE;
     762              :         }
     763              : 
     764            9 :         printf("Account: %s → Thunderbird dir: %s\n", account, dir_buf);
     765            9 :         printf("Scanning Thunderbird filters...\n");
     766            9 :         return process_account(account, dir_buf, tb_path, dry_run, output);
     767              :     }
     768              : 
     769              :     /* ── Multi-account mode ── */
     770            2 :     int count = 0;
     771            2 :     AccountEntry *accounts = config_list_accounts(&count);
     772            2 :     if (!accounts || count == 0) {
     773            0 :         fprintf(stderr, "Error: No account configured. Run the setup wizard first.\n");
     774            0 :         return EXIT_FAILURE;
     775              :     }
     776              : 
     777            2 :     int any_error = 0;
     778            6 :     for (int i = 0; i < count; i++) {
     779            4 :         char host_buf[512] = "";
     780            4 :         if (accounts[i].cfg && accounts[i].cfg->host)
     781            4 :             extract_hostname(accounts[i].cfg->host, host_buf, sizeof(host_buf));
     782              : 
     783            4 :         char dir_buf[256] = "";
     784            4 :         if (tb_count > 0 && host_buf[0])
     785            4 :             find_tb_dir_for_account(tb_entries, tb_count,
     786            4 :                                     accounts[i].name, host_buf,
     787              :                                     dir_buf, sizeof(dir_buf));
     788              : 
     789            4 :         printf("\n--- Account: %s ---\n", accounts[i].name);
     790            4 :         if (!dir_buf[0]) {
     791            1 :             printf("  No matching Thunderbird account found — skipping.\n");
     792            1 :             continue;
     793              :         }
     794            3 :         printf("Scanning Thunderbird filters (dir: %s)...\n", dir_buf);
     795            3 :         int rc = process_account(accounts[i].name, dir_buf,
     796              :                                  tb_path, dry_run, NULL);
     797            3 :         if (rc != EXIT_SUCCESS) any_error = 1;
     798              :     }
     799            2 :     config_free_account_list(accounts, count);
     800            2 :     return any_error ? EXIT_FAILURE : EXIT_SUCCESS;
     801              : }
        

Generated by: LCOV version 2.0-1