]> diplodocus.org Git - minc/blob - minc
e240da6984b780e205565bb238a94981542ce738
[minc] / minc
1 #!/usr/bin/perl
2
3 =head1 NAME
4
5 B<minc> - incorporate mail from a maildir into mh folders
6
7 =head1 SYNOPSIS
8
9 B<minc> [B<-m> I<MAX>] [B<-n>]
10
11 B<minc> B<-d>
12
13 B<minc> B<-r>
14
15 =head1 DESCRIPTION
16
17 B<minc> incorporates mail from a maildir to a mh folder hierarchy. It
18 takes mail from a maildir folder (not a maildir folder hierarchy),
19 running each message through regular expression based filter and hook
20 functions to determine in which folder to store it or whether it's
21 spam. Post-processing hooks may be applied to each message.
22
23 As it processes each message, B<minc> prints a line for each message
24 similar to B<inc(1)> and B<scan(1)>. This line includes the folder
25 and message number in which the message was stored, the last 'From'
26 header, and the last 'Subject' header. These fields are truncated to
27 fit in the user's terminal (see COLUMNS in B<ENVIRONMENT> below) in
28 the following proportions: folder (0.1), message number (0.0625), from
29 header (0.175). Any of these may be overridden with $SCAN_P_FOLDER,
30 $SCAN_P_MESSAGE, or $SCAN_P_FROM. The subject always fills out the
31 rest of the line.
32
33 =cut
34
35 use strict;
36 use warnings;
37
38 $SIG{'PIPE'} = 'IGNORE';
39
40 use Data::Dumper;
41 use Errno;
42 use Fcntl;
43 use FileHandle;
44 use File::FcntlLock;
45 use File::Temp qw(tempfile);
46 use File::stat;
47 use Getopt::Long qw(:config gnu_getopt no_ignore_case);
48 use POSIX qw(WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG);
49 use Pod::Usage;
50
51 use constant LOCK_TRIES => 60;
52
53 our $VERSION = 1;
54
55 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
56 # against headers matching this regex (taken from procmail).
57 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
58 my $MAGIC_TO_TOKEN = ' TO';
59
60 # Mapping of message numbers to array references. The first element is set by
61 # filter_mail to a reference to a header hash for the message; the second is
62 # set by maildier_spam to the name of the message file in the spam maildir.
63 # scan_spam scans this at the end so the user can check for false positives.
64 my %SPAM;
65
66 =head1 OPTIONS
67
68 =over 4
69
70 =item B<-d>
71
72 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
73 for testing the syntax of .minc.
74
75 =item B<-h>
76
77 Show help.
78
79 =item B<-m> I<MAX>
80
81 Stop processing after I<MAX> messages.
82
83 =item B<-n>
84
85 Dry run; do not actually incorporate the mail, but log and report to
86 stdout/stderr as normal.
87
88 =item B<-r>
89
90 Rebuild `mhpath +`/.folders from scratch, processing no mail.
91
92 =back
93
94 =cut
95
96 my $dumpfilters;
97 my $help;
98 my $maxmsgs;
99 my $norun;
100 my $rebuild_dot_folders;
101
102 if (!caller()) {
103 GetOptions(
104 'd' => \$dumpfilters,
105 'h|help' => \$help,
106 'm=i' => \$maxmsgs,
107 'n' => \$norun,
108 'r' => \$rebuild_dot_folders,
109 ) or pod2usage();
110 $help and pod2usage(-exitstatus=>0, -verbose=>1);
111 }
112
113 our $run = !$norun;
114
115 =head1 ENVIRONMENT
116
117 =over 4
118
119 =item COLUMNS
120
121 How many columns the user's terminal can hold, used to print scan
122 lines for each processed message. Defaults to 80.
123
124 =item HOME
125
126 Where the configuration file (.minc) is found. Also,
127 $HOME/Maildir is used for the maildir if MAILDIR is not set.
128
129 =item MAILDIR
130
131 Where mail is delivered.
132
133 =back
134
135 =cut
136
137 use Env qw(COLUMNS HOME MAILDIR);
138
139 $COLUMNS ||= 80;
140
141 if (not $HOME) {
142 die("HOME environment variable must be set.\n");
143 }
144
145 =head1 FILES
146
147 =over 4
148
149 =item $HOME/.minc
150
151 This file is Perl code (included via the 'require' directive) which
152 may define the FILTERS list, @start_hooks, @filter_hooks,
153 @post_store_hooks, and @stop_hooks.
154
155 =item `mhpath +`/.folders
156
157 B<minc> adds all folders it filters into to this file, which is used
158 by lukem's B<new(1)> (XXX need a link).
159
160 =item `mhpath +`/.minc.context
161
162 B<minc> uses this file for context (i.e. current folder) instead of
163 `mhpath +`/context. This allows you some amount of freedom to use mh
164 while B<minc> is running. For example, you can changed folders without
165 causing a message to be stored in the wrong folder. Care must still
166 be taken, however, about the unseen sequence; if you change it
167 (whether via show, or mark, or anything else) while in the same folder
168 as B<minc>, it is likely the new message B<minc> stores will not end up
169 marked unseen.
170
171 =back
172
173 =cut
174
175 our @FILTERS;
176 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
177 our $SCAN_P_FOLDER = 0.1;
178 our $SCAN_P_MESSAGE = 0.0625;
179 our $SCAN_P_FROM = 0.175;
180 our @folder_sort_list = (qr/^inbox$/);
181 our $folder_sorter = sub { sort_by_list(@_, @folder_sort_list) };
182
183 my $mh;
184
185 $mh = `mhpath +`; $? >= 0 or die;
186 chomp($mh);
187 if (!$mh) {
188 die('mhpath did not give MH mail path: ' . exit_msg($?));
189 }
190
191 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
192
193 my $SEQ_FILE;
194 my $UNSEEN;
195
196 \f
197 ###############################################################################
198 # Utility procedures
199
200 sub exit_msg {
201 my $status = shift;
202 WIFEXITED($status) && return 'exited with status ' . WEXITSTATUS($status);
203 WIFSIGNALED($status) && return 'killed with signal ' . WTERMSIG($status);
204 # WTF
205 "died ($status)";
206 }
207
208 sub sort_by_list {
209 my $a = shift;
210 my $b = shift;
211
212 for my $i (@_) {
213 my $am = $a =~ $i;
214 my $bm = $b =~ $i;
215 if ($am) {
216 if ($bm) {
217 last;
218 }
219 return -1;
220 } elsif ($bm) {
221 return 1;
222 }
223 }
224
225 return $a cmp $b;
226 }
227
228 sub mkfolder {
229 my $folder = shift;
230 my $target;
231 my $component;
232
233 $target = $mh;
234 foreach $component (split('/', $folder)) {
235 $target = join('/', $target, $component);
236 -d $target or mkdir($target) or die("mkdir($target): $!");
237 }
238 }
239
240 sub getfiles {
241 my $dir = shift;
242 my @result;
243
244 if (not opendir(DIR, $dir)) {
245 die("opendir($dir): $!");
246 }
247
248 # Initialize $! to 0 (success) because stupid stupid Perl provides
249 # no way to distinguish an error from an empty directory; that
250 # means setting $! to 0 and testing it afterwards is the only way
251 # to detect an error. Real Programmers don't handle errors,
252 # right? >sigh<
253 $! = 0;
254 @result = readdir(DIR);
255 if ($! != 0) {
256 die("readdir($dir): $!");
257 }
258
259 if (@result <= 2) {
260 return ();
261 }
262
263 STDOUT->autoflush(1);
264 if (@result == 3) {
265 print('1 message...');
266 } else {
267 print(@result - 2, ' messages...');
268 }
269
270 closedir(DIR);
271
272 return @result;
273 }
274
275 my %msgnum_cache;
276 sub get_highest_msgnum {
277 my $mhfolder = shift;
278 my $dir;
279 my @list;
280 my $highest;
281 my $msgnum;
282
283 if (defined($msgnum_cache{$mhfolder})) {
284 return $msgnum_cache{$mhfolder}++;
285 }
286
287 $dir = "$mh/$mhfolder";
288 if (not opendir(DIR, $dir)) {
289 die("opendir($dir): $!");
290 }
291
292 # Insert rant from getfiles here.
293 $! = 0;
294 @list = readdir(DIR);
295
296 if ($! != 0) {
297 die("readdir($dir): $!");
298 }
299
300 closedir(DIR);
301
302 $highest = 0;
303 foreach $msgnum (@list) {
304 # Look for integers.
305 if ($msgnum =~ /^[0-9]+$/) {
306 if ($msgnum > $highest) {
307 $highest = $msgnum;
308 }
309 }
310 }
311
312 $msgnum_cache{$mhfolder} = $highest;
313 return $msgnum_cache{$mhfolder}++;
314 }
315
316 sub mark {
317 $SEQ_FILE or return;
318 my $folder = shift;
319 my $msgnum = shift;
320 my $seq = shift;
321
322 my $fn = "$mh/$folder/$SEQ_FILE";
323 my ($fh, $e) = lkopen_fcntl($fn, O_RDWR | O_CREAT, 0600);
324
325 my @sequences = <$fh>;
326 chomp(@sequences);
327
328 seek($fh, 0, SEEK_SET) or die("seek($fn): $!");
329 truncate($fh, 0) or die("truncate($fn): $!");
330
331 my $marked = 0;
332 for $_ (@sequences) {
333 if (/^$seq: (.*)/) {
334 my @parts;
335 my @result;
336 my $done = 0;
337 for my $part (split(' ', $1)) {
338 if (not $done) {
339 my ($st, $en) = split('-', $part);
340 if ((defined($en) and ($msgnum >= $st and $msgnum <= $en))
341 or $msgnum == $st) {
342 # It's already there.
343 $done = 1;
344 }
345 if (defined($en)) {
346 if ($st - 1 == $msgnum) {
347 $part = "$msgnum-$en";
348 $done = 1;
349 } elsif ($en + 1 == $msgnum) {
350 $part = "$st-$msgnum";
351 $done = 1;
352 }
353 } else {
354 if ($part - 1 == $msgnum) {
355 $part = "$msgnum-$part";
356 $done = 1;
357 } elsif ($part + 1 == $msgnum) {
358 $part = "$part-$msgnum";
359 $done = 1;
360 }
361 }
362 }
363 push(@result, $part);
364 }
365 if (not $done) {
366 push(@result, $msgnum);
367 }
368 print($fh "$seq: ", join(' ', @result), "\n");
369 $marked = 1;
370 } else {
371 print($fh "$_\n");
372 }
373 }
374 $marked or print($fh "$seq: $msgnum\n");
375 close($fh) or die("close(>$fn): $!");
376 }
377
378 # Based on nmh's lkopen_fcntl
379 # Return 0 for success, errno on failure.
380 sub lkopen_fcntl {
381 my $fn = shift;
382 my $access = shift;
383 my $mode = shift;
384 my $errno;
385
386 # The assumption here is that if you open the file for writing, you
387 # need an exclusive lock.
388
389 my $tries = LOCK_TRIES;
390 for (;;) {
391 sysopen(my $fh, $fn, $access, $mode) or die("sysopen($fn): $!");
392
393 my $flk = File::FcntlLock->new;
394 $flk->l_start(0);
395 $flk->l_len(0);
396 $flk->l_type(($access & O_ACCMODE) == O_RDONLY ? F_RDLCK : F_WRLCK);
397 $flk->l_whence(SEEK_SET);
398
399 # Really should only retry on EAGAIN and EINTR...
400 if ($flk->lock($fh, F_SETLK)) {
401 return $fh;
402 }
403
404 $errno = $flk->lock_errno;
405 close($fh) or die("close($fn): $!");
406
407 if (--$tries == 0) {
408 last;
409 }
410 sleep(1);
411 }
412
413 local $! = $errno;
414 die("failed to lock $fn: $!");
415 }
416
417 my @filtered;
418 sub store_message {
419 my $inbox = shift;
420 my $src_msgpath = shift;
421 my $mhfolder = shift;
422 my ($dst_msgnum, $dst_msgpath);
423
424 # We must do this even in -n mode because later steps fail without
425 # it. This should be harmless.
426 mkfolder($mhfolder);
427
428 if ("+$mhfolder" eq $inbox) {
429 # If @filtered is empty, this message already has the right number.
430 if (!($dst_msgpath = shift(@filtered))) {
431 $src_msgpath =~ m|.*/(\d+)$|;
432 return $1;
433 }
434 $dst_msgpath =~ m|.*/(\d+)$|;
435 $dst_msgnum = $1;
436 } else {
437 # This loop is a modified version of the maildir delivery algorithm.
438 $dst_msgnum = get_highest_msgnum($mhfolder);
439 my $try;
440 for ($try = 0; ; $try++) {
441 $dst_msgnum++;
442 $dst_msgpath = "$mh/$mhfolder/$dst_msgnum";
443
444 if (not stat($dst_msgpath)) {
445 if ($!{ENOENT}) {
446 # Now we have a non-existent file, let's try to create
447 # it. We must create a zero-byte file first because a
448 # file my appear between our happy stat results and
449 # our later rename(2), which would clobber said file.
450 # So attempt to create a file with this name. If it
451 # succeeds, in just a bit here we'll knowingly clobber
452 # this file with the rename(2) call.
453
454 # Another way to do this is not to use rename(2), but
455 # use link(2) + unlink(2) instead. That's how the
456 # standard maildir algorithm does it. Each method has
457 # a disadvantage: the program may crash between the
458 # link(2) and unlink(2) calls. With the standard
459 # maildir algorithm, that means the message will end
460 # up duplicated. The advantage of creating an empty
461 # file followed by rename(2) is that an extra empty
462 # file is left behind as opposed to a duplicate
463 # message. This is more easily detected by the user.
464 if ($run) {
465 if (sysopen(MSG, $dst_msgpath,
466 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
467 close(MSG);
468 last;
469 }
470 } else {
471 last;
472 }
473 }
474 }
475
476 # This algorithm is different from the maildir one; let's make
477 # 10 tries instead of 3.
478 if ($try == 9) {
479 die("Attempted filename $dst_msgpath exists.");
480 }
481
482 # This algorithm is different; i don't think we need to sleep.
483 #sleep(2);
484 }
485
486 if ($run) {
487 # Mark each message as soon as we store it and bomb if that
488 # fails. While it is slow, it is not safe to store multiple
489 # messages and then have a failure before marking some (or
490 # all).
491 if ($UNSEEN and $mhfolder ne 'SPAM') {
492 mark($mhfolder, $dst_msgnum, $UNSEEN);
493 }
494 }
495 }
496 push(@filtered, $src_msgpath);
497
498 if ($run) {
499 if (not rename($src_msgpath, $dst_msgpath)) {
500 die("rename($src_msgpath, $dst_msgpath): $!");
501 }
502 }
503
504 return $dst_msgnum;
505 }
506
507 # Parse a message file into a structure describing the headers. The
508 # structure is a hash of arrays. The hash keys are the names of the
509 # headers found in the message, made all lower-case. Each item in
510 # the hash is an array of header text. The array has one element
511 # per occurrence of the header. Most headers will only have a single
512 # element because they occur only once. The Received header is the
513 # most common header with multiple texts.
514 sub get_headers {
515 my $msg = shift;
516 my %headers;
517 my $current; # current header, used for unfolding lines
518 my $fieldname; # unmolested header name
519 my $contents; # contents of header
520
521 open(MSG, $msg) or die("open(MSG, $msg): $!");
522 while (<MSG>) {
523 chomp;
524 if (length == 0) {
525 last;
526 }
527
528 if (/^\s/) {
529 # folded header continuation
530
531 if (not defined($current)) {
532 warn('Malformed message, cannot parse headers.');
533 next;
534 }
535
536 @{$headers{$current}}[-1] .= $_;
537 } else {
538 ($fieldname) = split(/:/);
539 $current = lc($fieldname);
540 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
541
542 if (defined($headers{$current})) {
543 # multiple occurence of same header
544 push(@{$headers{$current}}, $contents);
545 } else {
546 # first occurence of a header
547 $headers{$current} = [$contents];
548 }
549 }
550 }
551 close(MSG);
552
553 return \%headers;
554 }
555
556 \f
557 ###############################################################################
558 # Filtering
559
560 sub find_mh_folder {
561 my $msg = shift;
562 my $header = shift;
563
564 if (not %$header) {
565 return 'malformed';
566 }
567
568 # Walk the list of filters. This structure is documented in
569 # pod at the end of the program.
570 for my $filterref (@FILTERS) {
571 if (ref($filterref) eq 'CODE') {
572 my $m = $filterref->($header, $msg);
573 $m && return $m;
574 next;
575 }
576
577 my $m = match($header, @$filterref);
578 $m && return $m;
579 }
580
581 return 'inbox';
582 }
583
584 # Test all the header fields against each [regexp, folder-expression] pair.
585 sub match {
586 my $header = shift;
587 my $filter_field = shift;
588 my @filters = @_;
589
590 # Handle filters using the magic TO header specially.
591 if ($filter_field eq $MAGIC_TO_TOKEN) {
592 for my $field_name (keys(%$header)) {
593 if ($field_name =~ /$MAGIC_TO_REGEX/i) {
594 my $m = match_one_field($header->{$field_name}, \@filters);
595 $m && return $m;
596 }
597 }
598 # Now that it's been processed specially, skip normal handling.
599 return;
600 }
601
602 # Walk the list of header fields matching the filter's specified header.
603 my $m = match_one_field($header->{lc($filter_field)}, \@filters);
604 $m && return $m;
605 }
606
607 # Test all the values of one header field against each [regexp,
608 # folder-expression] pair.
609 sub match_one_field {
610 my $values = shift;
611 my $filters = shift;
612 for my $value (@$values) {
613 for my $pair (@$filters) {
614 my ($regexp, $expression) = @$pair;
615 if ($value =~ $regexp) {
616 my $result;
617 if (eval "\$result = \"$expression\"") {
618 return $result;
619 }
620 }
621 }
622 }
623 }
624
625 sub scan_line {
626 my ($headers, $mhfolder, $msgnum, $nf, $nm, $nF, $ns) = @_;
627 my $from = '';
628 my $subject = '';
629 # Sometimes these headers are missing...
630 eval { $from = [@{$headers->{'from'}}]->[-1] };
631 eval { $subject = [@{$headers->{'subject'}}]->[-1] };
632 # Replace garbage characters.
633 for ($from, $subject) {
634 tr/\x00-\x1f\x80-\xff/?/;
635 }
636 return sprintf("\%-${nf}s \%${nm}d \%-${nF}s \%s",
637 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
638 substr($from, 0, $nF),
639 substr($subject, 0, $ns));
640 }
641
642 sub filter_mail {
643 my $inbox = shift;
644 @_ or return ();
645 my $msgcount = @_ - 2; # don't count . and ..
646 my $len = length($msgcount);
647 my @baton;
648 my $msg;
649 my $mhfolder;
650 my $spam = 0;
651 my $saved = 0;
652 my $msgnum;
653 my %folders;
654
655 # XXX lame names
656 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
657 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
658 my $nF = int($COLUMNS * $SCAN_P_FROM);
659 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
660
661 my %batons;
662 for my $hook (@start_hooks) {
663 my ($handle, @baton) = $hook->();
664 if (defined($handle)) {
665 $batons{$handle} = [@baton];
666 }
667 }
668
669 my $tty = -t STDOUT;
670 if (not $tty) {
671 # Print a newline after the incomplete "N messages..." line.
672 print("\n");
673 }
674
675 for $msg (@_) {
676 ($msg eq '.' or $msg eq '..') and next;
677
678 my $headers = get_headers($msg);
679
680 undef($mhfolder);
681 for my $hook (@filter_hooks) {
682 my $result = $hook->(\%batons, $headers, $msg);
683 defined($result) and ($mhfolder = $result);
684 }
685
686 defined($mhfolder) or ($mhfolder = find_mh_folder($msg, $headers));
687
688 $msgnum = store_message($inbox, $msg, $mhfolder);
689 $folders{$mhfolder}++;
690
691 if ($tty) {
692 print("\r");
693 }
694
695 if ($mhfolder eq 'SPAM') {
696 $spam++;
697 $SPAM{$msgnum} = [$headers, undef];
698 } else {
699 $saved++;
700 print(scan_line($headers, $mhfolder, $msgnum, $nf, $nm, $nF, $ns),
701 "\n");
702 }
703
704 for my $hook (@post_store_hooks) {
705 $hook->(\%batons, $headers, $mhfolder, $msgnum);
706 }
707
708 if ($tty) {
709 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
710 $spam, $saved, $spam + $saved, $msgcount);
711 }
712
713 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
714 }
715 print("\n");
716
717 for my $hook (@stop_hooks) {
718 $hook->(\%batons);
719 }
720
721 return %folders;
722 }
723
724 sub build_dot_folders {
725 my $folders = shift;
726 my $fh = shift;
727 my $fn;
728
729 if (defined($fh)) {
730 while (<$fh>) {
731 chomp;
732 $folders->{$_}++;
733 }
734 }
735
736 eval { ($fh, $fn) = tempfile("$mh/.folders.XXXXX") };
737 if ($@) {
738 warn("$@");
739 return;
740 }
741
742 for my $folder (sort { $folder_sorter->($a,$b) } keys(%$folders)) {
743 print($fh "$folder\n");
744 }
745
746 if (not close($fh)) {
747 warn("close($fn): $!");
748 unlink($fn) or warn("unlink($fn): $!");
749 return;
750 }
751
752 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
753 }
754
755 sub create_dot_folders {
756 if (-f "$HOME/.minc") {
757 require "$HOME/.minc";
758 }
759
760 my %folders;
761 open(my $fh, '-|', 'folders', '-fast', '-recur')
762 or die("open(folders|): $!");
763 build_dot_folders(\%folders, $fh);
764 return 0;
765 }
766
767 sub update_dot_folders {
768 my $folders = shift;
769 my $fh;
770
771 if (not open($fh, "$mh/.folders") and not $!{ENOENT}) {
772 # For ENOENT, we go ahead and create it, else we error and
773 # don't clobber it.
774 warn("open($mh/.folders): $!");
775 return;
776 }
777
778 build_dot_folders($folders, $fh);
779 }
780
781 # XXX Could use some unification with getfiles...
782 sub maildir_spam {
783 my $dir = "$MAILDIR/spam/new";
784
785 if (not chdir($dir)) {
786 $!{ENOENT} or print(STDERR "skipping maildir spam: chdir($dir): $!\n");
787 return;
788 }
789
790 if (not opendir(DIR, '.')) {
791 print(STDERR "skipping maildir spam: opendir($dir): $!\n");
792 return;
793 }
794
795 $! = 0;
796 my @spams = readdir(DIR);
797 if ($! != 0) {
798 print(STDERR "skipping maildir spam: readdir($dir): $!\n");
799 return;
800 }
801
802 closedir(DIR);
803
804 for my $msg (@spams) {
805 ($msg eq '.' or $msg eq '..') and next;
806 my $msgnum = store_message($msg, 'SPAM');
807 # Store the original file name for scan_spam in -n mode.
808 $SPAM{$msgnum} = [undef, $msg];
809 }
810 }
811
812 sub scan_spam {
813 my ($msgnum, $header, $tuple, $msg);
814
815 # Unlike filter_mail, we don't need to print the folder name.
816 # Calculate how many columns would be allocated to it...
817 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
818 # ...and add that amount to COLUMNS to calculate the number of columns to
819 # allocate to msgnum and from snippet, thus filling the line without
820 # printing the folder name.
821 my $nm = int(($COLUMNS + $nf) * $SCAN_P_MESSAGE);
822 my $nF = int(($COLUMNS + $nf) * $SCAN_P_FROM);
823 my $ns = $COLUMNS - $nm - $nF - 3;
824
825 for $msgnum (sort(keys(%SPAM))) {
826 $tuple = $SPAM{$msgnum};
827 if (defined($tuple->[0])) {
828 # Filed by filter_mail, so we have the header.
829 $header = $tuple->[0];
830 } elsif (defined($tuple->[1])) {
831 # Filed by maildir_spam, so we don't have the header.
832 if ($run) {
833 # The message has been filed, load it from $mh.
834 $msg = "$mh/SPAM/$msgnum";
835 } else {
836 # The message has not been filed, load it from the maildir.
837 # $tuple->[1] is just a basename, not a path; this works
838 # because maildir_spam did chdir(Maildir/spam/new).
839 $msg = $tuple->[1];
840 }
841 $header = get_headers($msg);
842 } else {
843 print(STDERR
844 "BUG: corrupt SPAM tuple, neither element defined",
845 " for message $msgnum\n");
846 next;
847 }
848 print(scan_line($header, '', $msgnum, 0, $nm, $nF, $ns),
849 "\n");
850 }
851 }
852
853 \f
854 if (!caller()) {
855 my $st;
856
857 if ($dumpfilters) {
858 require "$HOME/.minc";
859 $Data::Dumper::Indent = 1;
860 print(Dumper(\@FILTERS));
861 exit;
862 }
863
864 $rebuild_dot_folders and exit(create_dot_folders);
865
866 if (-f "$HOME/.minc") {
867 require "$HOME/.minc";
868 }
869
870 my $locking = '';
871 open(my $fh, '-|', 'mhparam',
872 'datalocking', 'mh-sequences', 'unseen-sequence') or die("open(mhparam|): $!");
873 while (<$fh>) {
874 chomp;
875 if (s/^fcntl:\s*//) {
876 $locking = $_;
877 } elsif (s/^mh-sequences:\s*//) {
878 $SEQ_FILE = $_;
879 } elsif (s/unseen-sequence:\s*//) {
880 $UNSEEN = $_;
881 }
882 }
883 if (!close($fh)) {
884 $! == 0 or die("open(mhparam|): $!");
885 die('mhparam '. exit_msg($?));
886 }
887
888 # TODO: Support dot-locking too. This works on FreeBSD and Linux.
889 if ($locking ne 'fcntl') {
890 die("mh locking '$locking' not supported; only fcntl");
891 }
892
893 my $inbox;
894 if ($MAILDIR) {
895 $inbox = $MAILDIR;
896 } elsif (-d "$HOME/Maildir") {
897 $inbox = "$HOME/Maildir";
898 }
899
900 if (@ARGV > 0) {
901 $inbox = shift(@ARGV);
902 }
903
904 my @files;
905 if (substr($inbox, 0, 1) eq '+') {
906 my @msgs = @ARGV;
907 if (!@msgs) {
908 @msgs = ('cur-last');
909 }
910 open(my $fh, '-|', 'mhpath', @msgs) or die("open(mhpath|): $!");
911 chomp(@files = <$fh>);
912 if (!close($fh)) {
913 $! == 0 or die("open(mhpath|): $!");
914 die('mhpath '. exit_msg($?));
915 }
916 @files > 0 or exit 0;
917 } else {
918 @ARGV == 0 or pod2usage();
919 chdir("$inbox/new") or die("chdir($inbox/new): $!");
920 @files = map { $_->[1] }
921 sort { $a->[0] <=> $b->[0] }
922 map {
923 $st = stat($_) or die("stat($_): $!");
924 [ $st->mtime, $_ ]
925 } getfiles('.');
926
927 @files = map { $_->[1] }
928 sort { $a->[0] <=> $b->[0] }
929 map {
930 if (not ($st = stat($_))) {
931 die("stat($_): $!");
932 }
933 [$st->mtime, $_]
934 }
935 getfiles('.');
936 }
937
938 my %folders = filter_mail($inbox, @files);
939
940 $run and %folders and update_dot_folders(\%folders);
941
942 maildir_spam();
943 scan_spam();
944 }
945
946 1;
947
948 \f
949 __END__
950
951 =head1 THE FILTERS STRUCTURE
952
953 Each element of @FILTERS is a filter. A filter is itself an array.
954 The first element of a filter is a string, the name of the header this
955 filter acts upon. The header name is not case-sensitive. Each
956 subsequent element of a filter is a pair (i.e. a two-element array):
957 first, a regular expression B<minc> uses to determine whether this
958 filter matches or not, and second, an expression which B<minc>
959 evaluates to get the folder name.
960
961 B<minc> decides where to store a message by iterating over the
962 @FILTERS array. It tests each regexp of each filter against all
963 headers matching that filter's specified header. As soon as a match
964 is found, B<minc> evaluates the second part of the pair. This part
965 may contain positional parameters from the matched regexp ($1, $2,
966 etc.). The result of this expression is used as the folder name.
967
968 Multiple occurrences of the same header are preserved and tested
969 individually against the filters. That means, for example, that
970 filters can search all the Received headers for a certain string.
971
972 It is important to note that all the arrays of the @FILTERS structure
973 are traversed I<in order>. This means the filters can be arranged so
974 that some have priority over others. XXX: get Doug to write an
975 example of having the same header matched more than once.
976
977 Lastly, B<minc> supports a magic ' TO' header. Filters using this
978 header are matched against a collection of headers related to (and
979 including) the To. Which headers to use is determined by a regular
980 expression borrowed from procmail.
981
982 =head1 HOOKS
983
984 Filter hooks take a reference to a hash of batons, a reference to a
985 hash of headers, and the message filename as arguments. It returns
986 undef to decline filtering of this message (thus falling back to
987 subsequent filter hooks, and finally @FILTERS), or the name of the
988 folder to store this message into.
989
990 One of B<minc>'s global variables is available to the user-defined
991 hooks: $run. This boolean should be honored; hooks should only take
992 real action (i.e. removing or creating files, running external
993 programs, etc.) if $run is non-zero.
994
995 The baton hash is created simply from the start hooks; if the hook
996 returns at least one defined value, this value is used as the key and
997 all other return values are put into a list reference as the value.
998 This hash is then passed by reference to all filter, post-store, and
999 stop hooks.
1000
1001 Post store hooks take a reference to a hash of batons, a reference to
1002 a hash of headers, the folder this message was stored in, and its new
1003 message number.
1004
1005 XXX: need more details about the hook process; for now read the code.
1006
1007 =head1 EXAMPLES
1008
1009 @FILTERS = (
1010
1011 ['List-Id',
1012 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
1013 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
1014 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
1015 ],
1016
1017 ['List-Post',
1018 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
1019 ],
1020
1021 ['To',
1022 ['root', 'root'],
1023 ],
1024 );
1025
1026 The first List-Id filter is a surprisingly effective one which creates
1027 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
1028 all Sourceforge lists to which i subscribe. Certainly there are lists
1029 hosted at Sourceforge which do not work well with this, and this is
1030 where it is important to remember that the first match is the one
1031 uses. Simply put the more specific rules before this one.
1032
1033 The next List-Id example is simple. The swig example demonstrates
1034 that the folder name does not have to use a portion of the matched
1035 header; you can just hardcode the folder name.
1036
1037 The List-Post example is a nice one. Most ASF projects have their own
1038 apache.org subdomain, with mailing lists hosted there. So, given a
1039 list such as dev@httpd.apache.org, this filter will create the folder
1040 name l/apache/httpd/dev.
1041
1042 XXX Need hook examples.
1043
1044 =head1 AUTHORS
1045
1046 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
1047 Gillespie and Doug Porter <dsp@waterspout.com>.
1048
1049 This program is free software; you can redistribute it and/or modify
1050 it under the same terms as Perl itself.
1051
1052 =cut
1053
1054 # Local variables:
1055 # cperl-indent-level: 4
1056 # perl-indent-level: 4
1057 # indent-tabs-mode: nil
1058 # End:
1059
1060 # vi: set tabstop=4 expandtab: