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