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