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 : }
|