Line data Source code
1 : /**
2 : * @file test_pty_mail_rules.c
3 : * @brief PTY/functional tests for mail sorting rules (US-MR-01 … US-MR-06).
4 : *
5 : * Usage: test-pty-mail-rules <email-sync> <email-tui> <mock-imap-server>
6 : *
7 : * Tests verify that mail sorting rules defined in rules.ini are applied
8 : * during sync (and retroactively via --apply-rules), and that flag bits
9 : * are set correctly in the local manifest.
10 : *
11 : * MSG_FLAG values (from local_store.h):
12 : * MSG_FLAG_UNSEEN = 1 (bit 0)
13 : * MSG_FLAG_FLAGGED = 2 (bit 1)
14 : * MSG_FLAG_DONE = 4 (bit 2)
15 : * MSG_FLAG_ATTACH = 8 (bit 3)
16 : * MSG_FLAG_JUNK = 64 (bit 6)
17 : */
18 :
19 : #define _DEFAULT_SOURCE
20 : #define _XOPEN_SOURCE 600
21 :
22 : #include "ptytest.h"
23 : #include "pty_assert.h"
24 : #include <stdio.h>
25 : #include <stdlib.h>
26 : #include <string.h>
27 : #include <signal.h>
28 : #include <unistd.h>
29 : #include <fcntl.h>
30 : #include <sys/stat.h>
31 : #include <sys/wait.h>
32 : #include <sys/socket.h>
33 : #include <netinet/in.h>
34 :
35 : int g_tests_run = 0;
36 : int g_tests_failed = 0;
37 :
38 : #define RUN_TEST(fn) do { printf(" %s...\n", #fn); fn(); } while(0)
39 :
40 : /* ── Configuration ───────────────────────────────────────────────────── */
41 :
42 : #define WAIT_MS 6000
43 : #define SETTLE_MS 600
44 : #define ROWS 24
45 : #define COLS 100
46 :
47 : /* Flag bit values (mirror local_store.h) */
48 : #define FLAG_FLAGGED (1 << 1) /* MSG_FLAG_FLAGGED */
49 : #define FLAG_JUNK (1 << 6) /* MSG_FLAG_JUNK */
50 :
51 : /* ── Global state ────────────────────────────────────────────────────── */
52 :
53 : static pid_t g_mock_pid = -1;
54 : static char g_test_home[256];
55 : static char g_old_home[512];
56 : static char g_sync_bin[512];
57 : static char g_tui_bin[512];
58 : static char g_mock_bin[512];
59 :
60 : /* ── Infrastructure ──────────────────────────────────────────────────── */
61 :
62 : /** Write the standard IMAP test config. */
63 41 : static void write_config(void) {
64 : char d1[300], d2[300], d3[350], d4[400], path[450];
65 41 : snprintf(d1, sizeof(d1), "%s/.config", g_test_home);
66 41 : snprintf(d2, sizeof(d2), "%s/.config/email-cli", g_test_home);
67 41 : snprintf(d3, sizeof(d3), "%s/.config/email-cli/accounts", g_test_home);
68 41 : snprintf(d4, sizeof(d4), "%s/.config/email-cli/accounts/testuser", g_test_home);
69 41 : mkdir(g_test_home, 0700);
70 41 : mkdir(d1, 0700);
71 41 : mkdir(d2, 0700);
72 41 : mkdir(d3, 0700);
73 41 : mkdir(d4, 0700);
74 41 : snprintf(path, sizeof(path), "%s/config.ini", d4);
75 41 : FILE *fp = fopen(path, "w");
76 41 : if (!fp) return;
77 41 : fprintf(fp,
78 : "EMAIL_HOST=imaps://localhost:9993\n"
79 : "EMAIL_USER=testuser\n"
80 : "EMAIL_PASS=testpass\n"
81 : "EMAIL_FOLDER=INBOX\n"
82 : "SSL_NO_VERIFY=1\n");
83 41 : fclose(fp);
84 41 : chmod(path, 0600);
85 : }
86 :
87 : /**
88 : * Write (or overwrite) rules.ini for the testuser account.
89 : * Pass NULL to delete the file (simulates missing rules.ini).
90 : */
91 35 : static void write_rules(const char *content) {
92 : char dir[450], path[500];
93 35 : snprintf(dir, sizeof(dir),
94 : "%s/.config/email-cli/accounts/testuser", g_test_home);
95 35 : mkdir(dir, 0700); /* ensure it exists */
96 35 : snprintf(path, sizeof(path), "%s/rules.ini", dir);
97 35 : if (!content) {
98 11 : unlink(path);
99 11 : return;
100 : }
101 24 : FILE *fp = fopen(path, "w");
102 24 : if (!fp) return;
103 24 : fputs(content, fp);
104 24 : fclose(fp);
105 24 : chmod(path, 0600);
106 : }
107 :
108 : /** Wipe the entire local email-cli store (account data + UI prefs). */
109 33 : static void reset_all_state(void) {
110 : /* pty_close() only SIGTERMs the child: a previous email-sync may still be
111 : * flushing its manifest. Wiping the store while that write is in flight
112 : * lets stale flags reappear, so give it a moment to finish first. */
113 33 : usleep(500000);
114 : char cmd[600];
115 33 : snprintf(cmd, sizeof(cmd),
116 : "rm -rf '/tmp/email-cli-rules-test-%d/.local/share/email-cli'",
117 33 : (int)getpid());
118 33 : int _rc = system(cmd); (void)_rc;
119 33 : }
120 :
121 : /* ── Mock IMAP server ────────────────────────────────────────────────── */
122 :
123 33 : static int probe_server(void) {
124 33 : int fd = socket(AF_INET, SOCK_STREAM, 0);
125 33 : if (fd < 0) return -1;
126 33 : struct sockaddr_in addr = {
127 : .sin_family = AF_INET,
128 33 : .sin_port = htons(9993),
129 33 : .sin_addr.s_addr = htonl(INADDR_LOOPBACK)
130 : };
131 33 : int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
132 33 : close(fd);
133 33 : return ret;
134 : }
135 :
136 33 : static int start_mock_server(void) {
137 33 : g_mock_pid = fork();
138 39 : if (g_mock_pid < 0) return -1;
139 39 : if (g_mock_pid == 0) {
140 6 : int devnull = open("/dev/null", O_WRONLY);
141 6 : if (devnull >= 0) { dup2(devnull, 1); dup2(devnull, 2); close(devnull); }
142 6 : execl(g_mock_bin, "mock_imap_server", (char *)NULL);
143 6 : _exit(127);
144 : }
145 33 : usleep(800000);
146 33 : return 0;
147 : }
148 :
149 1 : static void stop_mock_server(void) {
150 1 : if (g_mock_pid > 0) {
151 1 : kill(g_mock_pid, SIGKILL);
152 1 : waitpid(g_mock_pid, NULL, 0);
153 1 : g_mock_pid = -1;
154 : }
155 1 : }
156 :
157 33 : static void restart_mock(void) {
158 33 : if (g_mock_pid > 0) {
159 25 : kill(g_mock_pid, SIGKILL);
160 25 : waitpid(g_mock_pid, NULL, 0);
161 25 : g_mock_pid = -1;
162 : }
163 33 : usleep(200000);
164 33 : start_mock_server();
165 33 : for (int i = 0; i < 30 && probe_server() != 0; i++)
166 0 : usleep(100000);
167 33 : }
168 :
169 : /* ── Sync / TUI helpers ──────────────────────────────────────────────── */
170 :
171 : /** Run email-sync (possibly with extra args) in a PTY and return the session. */
172 35 : static PtySession *sync_run(const char **extra_args) {
173 : const char *argv[16];
174 35 : int n = 0;
175 35 : argv[n++] = g_sync_bin;
176 35 : if (extra_args)
177 4 : for (int i = 0; extra_args[i] && n < 15; i++)
178 2 : argv[n++] = extra_args[i];
179 35 : argv[n] = NULL;
180 :
181 35 : PtySession *s = pty_open(COLS, ROWS);
182 35 : if (!s) return NULL;
183 35 : if (pty_run(s, argv) != 0) { pty_close(s); return NULL; }
184 28 : return s;
185 : }
186 :
187 : /**
188 : * Scan the manifest TSV for the testuser/INBOX folder and return the flags
189 : * integer of the first entry whose subject field contains @p subject_needle.
190 : *
191 : * Manifest line format (tab-separated):
192 : * uid from subject date flags
193 : *
194 : * Returns -1 if the manifest cannot be opened or the entry is not found.
195 : */
196 21 : static int find_manifest_flags(const char *subject_needle) {
197 : char path[600];
198 21 : snprintf(path, sizeof(path),
199 : "%s/.local/share/email-cli/accounts/testuser/manifests/INBOX.tsv",
200 : g_test_home);
201 21 : FILE *fp = fopen(path, "r");
202 21 : if (!fp) return -1;
203 :
204 : char line[1024];
205 21 : int found_flags = -1;
206 30 : while (fgets(line, sizeof(line), fp)) {
207 : /* Strip trailing newline */
208 21 : size_t len = strlen(line);
209 42 : while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
210 21 : line[--len] = '\0';
211 :
212 : /* Split by tabs: uid, from, subject, date, flags */
213 21 : (void)strtok(line, "\t"); /* uid — discard */
214 21 : char *from = strtok(NULL, "\t"); /* from */
215 21 : char *subj = strtok(NULL, "\t"); /* subject */
216 21 : char *date = strtok(NULL, "\t"); /* date */
217 21 : char *fstr = strtok(NULL, "\t"); /* flags */
218 : (void)from; (void)date;
219 :
220 21 : if (!subj || !fstr) continue;
221 21 : if (subject_needle && strstr(subj, subject_needle) == NULL) continue;
222 :
223 12 : found_flags = atoi(fstr);
224 12 : break;
225 : }
226 21 : fclose(fp);
227 21 : return found_flags;
228 : }
229 :
230 : /* ══════════════════════════════════════════════════════════════════════
231 : * US-MR-05: Missing rules.ini must not break sync
232 : * ══════════════════════════════════════════════════════════════════════ */
233 :
234 : /**
235 : * US-MR-05: If no rules.ini exists the sync must complete normally and
236 : * print "Sync complete".
237 : */
238 8 : static void test_rule_no_rules_sync_succeeds(void) {
239 8 : reset_all_state();
240 8 : write_config();
241 8 : write_rules(NULL); /* ensure rules.ini is absent */
242 8 : restart_mock();
243 :
244 8 : PtySession *s = sync_run(NULL);
245 7 : ASSERT(s != NULL, "no-rules sync: PTY opens");
246 7 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
247 7 : pty_settle(s, SETTLE_MS);
248 7 : pty_close(s);
249 : }
250 :
251 : /* ══════════════════════════════════════════════════════════════════════
252 : * US-MR-01: if-from glob flags matching messages
253 : * ══════════════════════════════════════════════════════════════════════ */
254 :
255 : /**
256 : * US-MR-01: A rule matching the sender via if-from glob adds _flagged
257 : * label, which maps to MSG_FLAG_FLAGGED (bit 1) in the manifest.
258 : * Mock sends "From: Test User <test@example.com>".
259 : */
260 7 : static void test_rule_from_glob_flags_message(void) {
261 7 : reset_all_state();
262 7 : write_config();
263 7 : write_rules(
264 : "[rule \"Flag example.com\"]\n"
265 : "if-from = *@example.com*\n"
266 : "then-add-label = _flagged\n"
267 : );
268 7 : restart_mock();
269 :
270 7 : PtySession *s = sync_run(NULL);
271 6 : ASSERT(s != NULL, "from-glob flag: PTY opens");
272 6 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
273 6 : pty_settle(s, SETTLE_MS);
274 6 : pty_close(s);
275 :
276 6 : int flags = find_manifest_flags("Test Message");
277 6 : ASSERT(flags >= 0, "from-glob flag: manifest entry found");
278 6 : ASSERT((flags & FLAG_FLAGGED) != 0,
279 : "from-glob flag: MSG_FLAG_FLAGGED bit set in manifest");
280 : }
281 :
282 : /* ══════════════════════════════════════════════════════════════════════
283 : * US-MR-02: if-subject glob flags matching messages
284 : * ══════════════════════════════════════════════════════════════════════ */
285 :
286 : /**
287 : * US-MR-02: A rule matching the subject via if-subject glob adds _flagged
288 : * label. Mock message subject is "Test Message".
289 : */
290 6 : static void test_rule_subject_glob_flags_message(void) {
291 6 : reset_all_state();
292 6 : write_config();
293 6 : write_rules(
294 : "[rule \"Flag Test subjects\"]\n"
295 : "if-subject = *Test*\n"
296 : "then-add-label = _flagged\n"
297 : );
298 6 : restart_mock();
299 :
300 6 : PtySession *s = sync_run(NULL);
301 5 : ASSERT(s != NULL, "subject-glob flag: PTY opens");
302 5 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
303 5 : pty_settle(s, SETTLE_MS);
304 5 : pty_close(s);
305 :
306 5 : int flags = find_manifest_flags("Test Message");
307 5 : ASSERT(flags >= 0, "subject-glob flag: manifest entry found");
308 0 : ASSERT((flags & FLAG_FLAGGED) != 0,
309 : "subject-glob flag: MSG_FLAG_FLAGGED bit set in manifest");
310 : }
311 :
312 : /* ══════════════════════════════════════════════════════════════════════
313 : * US-MR-03: Non-matching rule must not affect unrelated messages
314 : * ══════════════════════════════════════════════════════════════════════ */
315 :
316 : /**
317 : * US-MR-03: A rule whose if-from glob does not match the sender must not
318 : * set any flag bits. Mock sends "test@example.com", rule matches
319 : * "*@nomatch.example.com" which will never fire.
320 : */
321 5 : static void test_rule_nonmatch_no_effect(void) {
322 5 : reset_all_state();
323 5 : write_config();
324 5 : write_rules(
325 : "[rule \"No match\"]\n"
326 : "if-from = *@nomatch.example.com*\n"
327 : "then-add-label = _flagged\n"
328 : );
329 5 : restart_mock();
330 :
331 5 : PtySession *s = sync_run(NULL);
332 4 : ASSERT(s != NULL, "nonmatch rule: PTY opens");
333 4 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
334 4 : pty_settle(s, SETTLE_MS);
335 4 : pty_close(s);
336 :
337 4 : int flags = find_manifest_flags("Test Message");
338 4 : ASSERT(flags >= 0, "nonmatch rule: manifest entry found");
339 0 : ASSERT((flags & FLAG_FLAGGED) == 0,
340 : "nonmatch rule: MSG_FLAG_FLAGGED must NOT be set");
341 : }
342 :
343 : /* ══════════════════════════════════════════════════════════════════════
344 : * US-MR-04: Multiple rules run in order; all matching actions apply
345 : * ══════════════════════════════════════════════════════════════════════ */
346 :
347 : /**
348 : * US-MR-04: When two rules both match (one on subject, one on from),
349 : * both actions must be applied. Expect FLAG_FLAGGED | FLAG_JUNK.
350 : */
351 4 : static void test_rule_multiple_rules_both_apply(void) {
352 4 : reset_all_state();
353 4 : write_config();
354 4 : write_rules(
355 : "[rule \"Flag by subject\"]\n"
356 : "if-subject = *Test*\n"
357 : "then-add-label = _flagged\n"
358 : "\n"
359 : "[rule \"Junk by from\"]\n"
360 : "if-from = *@example.com*\n"
361 : "then-add-label = _junk\n"
362 : );
363 4 : restart_mock();
364 :
365 4 : PtySession *s = sync_run(NULL);
366 3 : ASSERT(s != NULL, "multi-rule: PTY opens");
367 3 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
368 3 : pty_settle(s, SETTLE_MS);
369 3 : pty_close(s);
370 :
371 3 : int flags = find_manifest_flags("Test Message");
372 3 : ASSERT(flags >= 0, "multi-rule: manifest entry found");
373 3 : ASSERT((flags & FLAG_FLAGGED) != 0,
374 : "multi-rule: MSG_FLAG_FLAGGED bit set (rule 1 fired)");
375 3 : ASSERT((flags & FLAG_JUNK) != 0,
376 : "multi-rule: MSG_FLAG_JUNK bit set (rule 2 fired)");
377 : }
378 :
379 : /* ══════════════════════════════════════════════════════════════════════
380 : * US-MR-06: --apply-rules retroactively applies rules to existing messages
381 : * ══════════════════════════════════════════════════════════════════════ */
382 :
383 : /**
384 : * US-MR-06: Sync without rules (message is downloaded, no flags set),
385 : * then create rules.ini, then sync --apply-rules → message is now flagged.
386 : */
387 3 : static void test_rule_apply_rules_retroactive(void) {
388 : /* Step 1: sync without any rules */
389 3 : reset_all_state();
390 3 : write_config();
391 3 : write_rules(NULL); /* no rules.ini */
392 3 : restart_mock();
393 :
394 : {
395 3 : PtySession *s = sync_run(NULL);
396 2 : ASSERT(s != NULL, "apply-rules retro: initial sync PTY opens");
397 2 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
398 2 : pty_settle(s, SETTLE_MS);
399 2 : pty_close(s);
400 : }
401 :
402 : /* Verify no flags set after initial sync */
403 2 : int flags_before = find_manifest_flags("Test Message");
404 2 : ASSERT(flags_before >= 0, "apply-rules retro: manifest entry found after initial sync");
405 2 : ASSERT((flags_before & FLAG_FLAGGED) == 0,
406 : "apply-rules retro: no FLAGGED bit before rules are created");
407 :
408 : /* Step 2: create rules.ini targeting the already-downloaded message */
409 2 : write_rules(
410 : "[rule \"Retroactive flag\"]\n"
411 : "if-from = *@example.com*\n"
412 : "then-add-label = _flagged\n"
413 : );
414 :
415 : /* Step 3: run email-sync --apply-rules.
416 : * It applies the rules locally, then syncs the resulting flag changes to
417 : * the server. Wait for that whole sequence: "ule" alone would already
418 : * match the "=== Applying rules: … ===" banner printed on entry, and
419 : * pty_close() would then SIGTERM the process before it saves the
420 : * manifest. */
421 : {
422 2 : const char *args[] = {"--apply-rules", NULL};
423 2 : PtySession *s = sync_run(args);
424 1 : ASSERT(s != NULL, "apply-rules retro: --apply-rules PTY opens");
425 1 : ASSERT_WAIT_FOR(s, "Rules applied:", WAIT_MS);
426 1 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
427 1 : pty_settle(s, SETTLE_MS);
428 1 : pty_close(s);
429 : }
430 :
431 : /* Step 4: verify the flag bit is now set */
432 1 : int flags_after = find_manifest_flags("Test Message");
433 1 : ASSERT(flags_after >= 0, "apply-rules retro: manifest entry found after --apply-rules");
434 1 : ASSERT((flags_after & FLAG_FLAGGED) != 0,
435 : "apply-rules retro: MSG_FLAG_FLAGGED bit set after --apply-rules");
436 : }
437 :
438 : /* ══════════════════════════════════════════════════════════════════════
439 : * main
440 : * ══════════════════════════════════════════════════════════════════════ */
441 :
442 8 : int main(int argc, char *argv[]) {
443 8 : printf("--- email-cli PTY Mail Rules Tests ---\n\n");
444 :
445 8 : if (argc < 4) {
446 0 : fprintf(stderr,
447 : "Usage: %s <email-sync> <email-tui> <mock-imap-server>\n",
448 : argv[0]);
449 0 : return EXIT_FAILURE;
450 : }
451 8 : snprintf(g_sync_bin, sizeof(g_sync_bin), "%s", argv[1]);
452 8 : snprintf(g_tui_bin, sizeof(g_tui_bin), "%s", argv[2]);
453 8 : snprintf(g_mock_bin, sizeof(g_mock_bin), "%s", argv[3]);
454 :
455 8 : const char *home = getenv("HOME");
456 8 : if (home) snprintf(g_old_home, sizeof(g_old_home), "%s", home);
457 :
458 8 : snprintf(g_test_home, sizeof(g_test_home),
459 : "/tmp/email-cli-rules-test-%d", getpid());
460 8 : setenv("HOME", g_test_home, 1);
461 8 : unsetenv("XDG_CONFIG_HOME");
462 8 : unsetenv("XDG_CACHE_HOME");
463 8 : unsetenv("XDG_DATA_HOME");
464 :
465 8 : mkdir(g_test_home, 0700);
466 8 : write_config();
467 :
468 8 : printf("--- Mail rules: no rules ---\n");
469 8 : RUN_TEST(test_rule_no_rules_sync_succeeds);
470 :
471 7 : printf("\n--- Mail rules: from-glob (US-MR-01) ---\n");
472 7 : RUN_TEST(test_rule_from_glob_flags_message);
473 :
474 6 : printf("\n--- Mail rules: subject-glob (US-MR-02) ---\n");
475 6 : RUN_TEST(test_rule_subject_glob_flags_message);
476 :
477 5 : printf("\n--- Mail rules: non-matching rule (US-MR-03) ---\n");
478 5 : RUN_TEST(test_rule_nonmatch_no_effect);
479 :
480 4 : printf("\n--- Mail rules: multiple rules (US-MR-04) ---\n");
481 4 : RUN_TEST(test_rule_multiple_rules_both_apply);
482 :
483 3 : printf("\n--- Mail rules: retroactive --apply-rules (US-MR-06) ---\n");
484 3 : RUN_TEST(test_rule_apply_rules_retroactive);
485 :
486 1 : stop_mock_server();
487 1 : if (g_old_home[0]) setenv("HOME", g_old_home, 1);
488 :
489 1 : printf("\n--- PTY Mail Rules Test Results ---\n");
490 1 : printf("Tests Run: %d\n", g_tests_run);
491 1 : printf("Tests Passed: %d\n", g_tests_run - g_tests_failed);
492 1 : printf("Tests Failed: %d\n", g_tests_failed);
493 :
494 1 : return g_tests_failed > 0 ? EXIT_FAILURE : EXIT_SUCCESS;
495 : }
|