]> diplodocus.org Git - minc/blob - minc
3e30d8cfd03759cf9f424fec0b4aeac3697d3e88
[minc] / minc
1 #! /usr/bin/env perl
2
3 # $Id$
4
5 =head1 NAME
6
7 B<minc> - incorporate mail from a maildir into mh folders
8
9 =head1 SYNOPSIS
10
11 B<minc> [B<-m> I<MAX>] [B<-n>]
12 B<minc> B<-d>
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 use Data::Dumper;
39 use Errno;
40 use Fcntl qw(O_WRONLY O_EXCL O_CREAT);
41 use FileHandle;
42 use File::Temp qw(tempfile);
43 use File::stat;
44 use Getopt::Long qw(:config gnu_getopt no_ignore_case);
45 use POSIX qw(strftime WEXITSTATUS WIFEXITED);
46 use Pod::Usage;
47
48 our $VERSION = 1;
49
50 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
51 # against headers matching this regex (taken from procmail).
52 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
53 my $MAGIC_TO_TOKEN = ' TO';
54
55 # List of SPAM message numbers, scanned at the end so the user can
56 # check for false positives.
57 my @SPAM;
58
59 =head1 OPTIONS
60
61 =over 4
62
63 =item B<-d>
64
65 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
66 for testing the syntax of .minc.
67
68 =item B<-h>
69
70 Show help.
71
72 =item B<-m> I<MAX>
73
74 Stop processing after I<MAX> messages.
75
76 =item B<-n>
77
78 Dry run; do not actually incorporate the mail, but log and report to
79 stdout/stderr as normal.
80
81 =item B<-r>
82
83 Rebuild `mhpath +`/.folders from scratch, processing no mail.
84
85 =back
86
87 =cut
88
89 my $dumpfilters;
90 my $help;
91 my $maxmsgs;
92 my $norun;
93 my $rebuild_dot_folders;
94
95 GetOptions(
96 'd' => \$dumpfilters,
97 'h|help' => \$help,
98 'm=i' => \$maxmsgs,
99 'n' => \$norun,
100 'r' => \$rebuild_dot_folders,
101 ) or pod2usage();
102 $help and pod2usage(-exitstatus=>0, -verbose=>1);
103 @ARGV == 0 or pod2usage();
104
105 our $run = !$norun;
106
107 =head1 ENVIRONMENT
108
109 =over 4
110
111 =item COLUMNS
112
113 How many columns the user's terminal can hold, used to print scan
114 lines for each processed message. Defaults to 80.
115
116 =item HOME
117
118 Where the configuration file (.minc) is found. Also,
119 $HOME/Maildir is used for the maildir if MAILDIR is not set.
120
121 =item MAILDIR
122
123 Where mail is delivered.
124
125 =back
126
127 =cut
128
129 use Env qw(COLUMNS HOME MAILDIR);
130
131 $COLUMNS ||= 80;
132
133 if (not $HOME) {
134 die("HOME environment variable must be set.\n");
135 }
136 if (not $MAILDIR) {
137 $MAILDIR = "$HOME/Maildir";
138 }
139
140 =head1 FILES
141
142 =over 4
143
144 =item $HOME/.minc
145
146 This file is Perl code (included via the 'require' directive) which
147 may define the FILTERS list, @start_hooks, @filter_hooks,
148 @post_store_hooks, and @stop_hooks.
149
150 =item `mhpath +`/.folders
151
152 B<minc> adds all folders it filters into to this file, which is used
153 by lukem's B<new(1)> (XXX need a link).
154
155 =item `mhpath +`/logs/minc.log
156
157 Where B<minc> logs what it does, unless in -n mode.
158
159 =item `mhpath +`/.minc.context
160
161 B<minc> uses this file for context (i.e. current folder) instead of
162 `mhpath +`/context. This allows you some amount of freedom to use mh
163 while B<minc> is running. For example, you can changed folders without
164 causing a message to be stored in the wrong folder. Care must still
165 be taken, however, about the unseen sequence; if you change it
166 (whether via show, or mark, or anything else) while in the same folder
167 as B<minc>, it is likely the new message B<minc> stores will not end up
168 marked unseen.
169
170 =back
171
172 =cut
173
174 our @FILTERS;
175 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
176 our $SCAN_P_FOLDER = 0.1;
177 our $SCAN_P_MESSAGE = 0.0625;
178 our $SCAN_P_FROM = 0.175;
179
180 our $folder_sorter =
181 sub { my ($a,$b) = @_;
182 $a eq 'inbox' and return -1;
183 $b eq 'inbox' and return 1;
184 return $a cmp $b;
185 };
186
187 my $mh;
188
189 $mh = `mhpath +`;
190 chomp($mh);
191
192 my $logfile = $mh . '/logs/minc.log';
193
194 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
195
196 \f
197 ###############################################################################
198 # Logging
199
200 sub mylog {
201 $run or return;
202
203 my $timestamp = strftime('%b %e %H:%M:%S', localtime);
204 my $msg;
205 foreach my $part (@_) {
206 if (defined($part)) {
207 $msg .= $part;
208 }
209 }
210 # no newlines in the log message, thanks
211 $msg =~ s/\n/ /gm;
212
213 open(LOG, ">>$logfile") or die("open(>>$logfile): $!");
214 print(LOG "$timestamp $msg\n") or die("print(>>$logfile): $!");
215 close(LOG) or die("close($logfile): $!");
216 }
217
218 sub logheader {
219 my ($text, @contents) = @_;
220 my $last;
221
222 if (@contents) {
223 $last = $contents[-1];
224 } else {
225 $last = '';
226 }
227
228 mylog('<< ', $text, $last);
229 }
230
231 sub log_headers {
232 my %headers = @_;
233
234 # For an explanation of the %headers structure, see the
235 # get_headers function below.
236 logheader('From: ', @{$headers{'return-path'}});
237 logheader('To: ', @{$headers{'to'}});
238 logheader('Subject: ', @{$headers{'subject'}});
239 logheader('Message-Id: ', @{$headers{'message-id'}});
240 }
241
242 \f
243 ###############################################################################
244 # Utility procedures
245
246 sub mkfolder {
247 my $folder = shift;
248 my $target;
249 my $component;
250
251 $target = $mh;
252 foreach $component (split('/', $folder)) {
253 $target = join('/', $target, $component);
254 -d $target or mkdir($target) or die("mkdir($target): $!");
255 }
256 }
257
258 sub getfiles {
259 my $dir = shift;
260 my @result;
261
262 if (not opendir(DIR, $dir)) {
263 die("opendir($dir): $!");
264 }
265
266 # Initialize $! to 0 (success) because stupid stupid Perl provides
267 # no way to distinguish an error from an empty directory; that
268 # means setting $! to 0 and testing it afterwards is the only way
269 # to detect an error. Real Programmers don't handle errors,
270 # right? >sigh<
271 $! = 0;
272 @result = readdir(DIR);
273 if ($! != 0) {
274 die("readdir($dir): $!");
275 }
276
277 if (@result <= 2) {
278 exit(0);
279 }
280
281 STDOUT->autoflush(1);
282 print(@result - 2, " messages...");
283
284 closedir(DIR);
285
286 return @result;
287 }
288
289 sub get_highest_msgnum {
290 my $mhfolder = shift;
291 my $dir;
292 my @list;
293 my $highest;
294 my $msgnum;
295
296 $dir = "$mh/$mhfolder";
297 if (not opendir(DIR, $dir)) {
298 die("opendir($dir): $!");
299 }
300
301 # Insert rant from getfiles here.
302 $! = 0;
303 @list = readdir(DIR);
304
305 if ($! != 0) {
306 die("readdir($dir): $!");
307 }
308
309 closedir(DIR);
310
311 $highest = 0;
312 foreach $msgnum (@list) {
313 # Look for integers.
314 if ($msgnum =~ /^[0-9]+$/) {
315 if ($msgnum > $highest) {
316 $highest = $msgnum;
317 }
318 }
319 }
320
321 return $highest;
322 }
323
324 sub store_message {
325 my $msg = shift;
326 my $mhfolder = shift;
327 my $msgnum;
328 my $try;
329 my $mhmsg;
330 my $status;
331
332 # We must do this even in -n mode because later steps fail without
333 # it. This should be harmless.
334 mkfolder($mhfolder);
335
336 # This loop is a modified version of the maildir delivery algorithm.
337 $msgnum = get_highest_msgnum($mhfolder);
338 for ($try = 0; ; $try++) {
339 $msgnum++;
340 $mhmsg = "$mh/$mhfolder/$msgnum";
341
342 if (not stat($mhmsg)) {
343 if ($!{ENOENT}) {
344 # Now we have a non-existent file, let's try to create
345 # it. We must create a zero-byte file first because a
346 # file my appear between our happy stat results and
347 # our later rename(2), which would clobber said file.
348 # So attempt to create a file with this name. If it
349 # succeeds, in just a bit here we'll knowingly clobber
350 # this file with the rename(2) call.
351
352 # Another way to do this is not to use rename(2), but
353 # use link(2) + unlink(2) instead. That's how the
354 # standard maildir algorithm does it. Each method has
355 # a disadvantage: the program may crash between the
356 # link(2) and unlink(2) calls. With the standard
357 # maildir algorithm, that means the message will end
358 # up duplicated. The advantage of creating an empty
359 # file followed by rename(2) is that an extra empty
360 # file is left behind as opposed to a duplicate
361 # message. This is more easily detected by the user.
362 if ($run) {
363 if (sysopen(MSG, $mhmsg,
364 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
365 close(MSG);
366 last;
367 }
368 } else {
369 last;
370 }
371 }
372 }
373
374 # This algorithm is different from the maildir one; let's make
375 # 10 tries instead of 3.
376 if ($try == 9) {
377 die("Attempted filename $mhmsg exists.");
378 }
379
380 # This algorithm is different; i don't think we need to sleep.
381 #sleep(2);
382 }
383
384 mylog('+', $mhfolder);
385
386 if ($run) {
387 if (not rename($msg, $mhmsg)) {
388 die("rename($msg, $mhmsg): $!");
389 }
390
391 # Mark each message as soon as we store it and bomb if that
392 # fails. While it is slow, it is not safe to store multiple
393 # messages and then have a failure before marking some (or
394 # all).
395 if ($mhfolder eq 'SPAM') {
396 push(@SPAM, $msgnum);
397 } else {
398 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
399 'unseen', '-add');
400 # XXX need to handle signalled and stopped, and print
401 # the exit code or signal number.
402 if (not WIFEXITED($status)) {
403 die("Failed to run mark");
404 } elsif (WEXITSTATUS($status) != 0) {
405 die("Failed to mark message unseen.");
406 }
407 }
408 }
409
410 return $msgnum;
411 }
412
413 # Parse a message file into a structure describing the headers. The
414 # structure is a hash of arrays. The hash keys are the names of the
415 # headers found in the message, made all lower-case. Each item in
416 # the hash is an array of header text. The array has one element
417 # per occurrence of the header. Most headers will only have a single
418 # element because they occur only once. The Received header is the
419 # most common header with multiple texts.
420 sub get_headers {
421 my $msg = shift;
422 my %headers;
423 my $current; # current header, used for unfolding lines
424 my $fieldname; # unmolested header name
425 my $contents; # contents of header
426
427 open(MSG, $msg) or die("open(MSG, $msg): $!");
428 while (<MSG>) {
429 chomp;
430 if (length == 0) {
431 last;
432 }
433
434 if (/^\s/) {
435 # folded header continuation
436
437 if (not defined($current)) {
438 warn('Malformed message, cannot parse headers.');
439 next;
440 }
441
442 @{$headers{$current}}[-1] .= $_;
443 } else {
444 ($fieldname) = split(/:/);
445 $current = lc($fieldname);
446 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
447
448 if (defined($headers{$current})) {
449 # multiple occurence of same header
450 push(@{$headers{$current}}, $contents);
451 } else {
452 # first occurence of a header
453 $headers{$current} = [$contents];
454 }
455 }
456 }
457 close(MSG);
458
459 return %headers;
460 }
461
462 \f
463 ###############################################################################
464 # Filtering
465
466 sub find_mh_folder {
467 my $msg = shift;
468 my %headers = @_;
469 my $filterref;
470 my @filter;
471 my $header;
472 my $contents;
473 my $pair;
474 my $match;
475 my $expression;
476 my $result;
477
478 if (not %headers) {
479 return 'malformed';
480 }
481
482 # Walk the list of filters. This structure is documented in
483 # pod at the end of the program.
484 foreach $filterref (@FILTERS) {
485 @filter = @$filterref;
486 $header = shift(@filter);
487
488 # Handle filters using the magic TO header specially.
489 if ($header eq $MAGIC_TO_TOKEN) {
490 foreach $header (keys(%headers)) {
491 if ($header =~ /$MAGIC_TO_REGEX/i) {
492 foreach $contents (@{$headers{lc($header)}}) {
493 foreach $pair (@filter) {
494 ($match, $expression) = @$pair;
495 if ($contents =~ /$match/i) {
496 if (eval "\$result = \"$expression\"") {
497 return $result;
498 }
499 }
500 }
501 }
502 }
503 }
504
505 # Now that it's been processed specially, skip normal handling.
506 next;
507 }
508
509 # Walk the list of message headers matching the filter's
510 # specified header.
511 foreach $contents (@{$headers{lc($header)}}) {
512 # Walk the filter's list of match/expression pairs.
513 foreach $pair (@filter) {
514 ($match, $expression) = @$pair;
515 if ($contents =~ /$match/i) {
516 if (eval "\$result = \"$expression\"") {
517 return $result;
518 }
519 }
520 }
521 }
522 }
523
524 return 'inbox';
525 }
526
527 sub filter_mail {
528 my $msgcount = @_ - 2; # don't count . and ..
529 my $len = length($msgcount);
530 my @baton;
531 my $msg;
532 my $mhfolder;
533 my $spam = 0;
534 my $saved = 0;
535 my $msgnum;
536 my %folders;
537
538 if (-f "$HOME/.minc") {
539 require "$HOME/.minc";
540 }
541
542 # XXX lame names
543 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
544 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
545 my $nF = int($COLUMNS * $SCAN_P_FROM);
546 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
547
548 my %batons;
549 for my $hook (@start_hooks) {
550 my ($handle, @baton) = $hook->();
551 if (defined($handle)) {
552 $batons{$handle} = [@baton];
553 }
554 }
555
556 for $msg (@_) {
557 ($msg eq '.' or $msg eq '..') and next;
558
559 my %headers = get_headers($msg);
560 log_headers(%headers);
561
562 undef($mhfolder);
563 for my $hook (@filter_hooks) {
564 my $result = $hook->(\%batons, \%headers, $msg);
565 defined($result) and ($mhfolder = $result);
566 }
567
568 defined($mhfolder) or ($mhfolder = find_mh_folder($msg, %headers));
569
570 $msgnum = store_message($msg, $mhfolder);
571 $folders{$mhfolder}++;
572
573 if ($mhfolder eq 'SPAM') {
574 $spam++;
575 } else {
576 $saved++;
577 my $from = '';
578 my $subject = '';
579 # Sometimes these headers are missing...
580 eval { $from = [@{$headers{'from'}}]->[-1] };
581 eval { $subject = [@{$headers{'subject'}}]->[-1] };
582 for ($from, $subject) {
583 tr/\x00-\x1f\x80-\xff/ /;
584 }
585 print("\r");
586 print(' ' x $COLUMNS);
587 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
588 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
589 substr($from, 0, $nF),
590 substr($subject, 0, $ns));
591 }
592
593 for my $hook (@post_store_hooks) {
594 $hook->(\%batons, \%headers, $mhfolder, $msgnum);
595 }
596
597 print("\r");
598 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
599 $spam, $saved, $spam + $saved, $msgcount);
600
601 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
602 }
603 print("\n");
604
605 for my $hook (@stop_hooks) {
606 $hook->(\%batons);
607 }
608
609 return %folders;
610 }
611
612 sub build_dot_folders {
613 my $folders = shift;
614 my $fh = shift;
615 my $fn;
616
617 if (defined($fh)) {
618 while (<$fh>) {
619 chomp;
620 $folders->{$_}++;
621 }
622 }
623
624 eval { ($fh, $fn) = tempfile("$mh/.folders.XXXXX") };
625 if ($@) {
626 warn("$@");
627 return;
628 }
629
630 for my $folder (sort { $folder_sorter->($a,$b) } keys(%$folders)) {
631 print($fh "$folder\n");
632 }
633
634 if (not close($fh)) {
635 warn("close($fn): $!");
636 unlink($fn) or warn("unlink($fn): $!");
637 return;
638 }
639
640 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
641 }
642
643 sub create_dot_folders {
644 my %folders;
645 open(my $fh, '-|', 'folders', '-fast', '-recur')
646 or die("open(folders|): $!");
647 build_dot_folders(\%folders, $fh);
648 return 0;
649 }
650
651 sub update_dot_folders {
652 my $folders = shift;
653 my $fh;
654
655 if (not open($fh, "$mh/.folders") and not $!{ENOENT}) {
656 # For ENOENT, we go ahead and create it, else we error and
657 # don't clobber it.
658 warn("open($mh/.folders): $!");
659 return;
660 }
661
662 build_dot_folders($folders, $fh);
663 }
664
665 \f
666 MAIN: {
667 my $st;
668
669 if ($dumpfilters) {
670 require "$HOME/.minc";
671 $Data::Dumper::Indent = 1;
672 print(Dumper(\@FILTERS));
673 exit;
674 }
675
676 $rebuild_dot_folders and exit(create_dot_folders);
677
678 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
679 my %folders = filter_mail(map { $_->[1] }
680 sort { $a->[0] <=> $b->[0] }
681 map {
682 if (not ($st = stat($_))) {
683 die("stat($_): $!");
684 }
685 [$st->mtime, $_]
686 }
687 getfiles('.'));
688
689 $run and update_dot_folders(\%folders);
690
691 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
692 }
693
694 \f
695 __END__
696
697 =head1 THE FILTERS STRUCTURE
698
699 Each element of @FILTERS is a filter. A filter is itself an array.
700 The first element of a filter is a string, the name of the header this
701 filter acts upon. The header name is not case-sensitive. Each
702 subsequent element of a filter is a pair (i.e. a two-element array):
703 first, a regular expression B<minc> uses to determine whether this
704 filter matches or not, and second, an expression which B<minc>
705 evaluates to get the folder name.
706
707 B<minc> decides where to store a message by iterating over the
708 @FILTERS array. It tests each regexp of each filter against all
709 headers matching that filter's specified header. As soon as a match
710 is found, B<minc> evaluates the second part of the pair. This part
711 may contain positional parameters from the matched regexp ($1, $2,
712 etc.). The result of this expression is used as the folder name.
713
714 Multiple occurrences of the same header are preserved and tested
715 individually against the filters. That means, for example, that
716 filters can search all the Received headers for a certain string.
717
718 It is important to note that all the arrays of the @FILTERS structure
719 are traversed I<in order>. This means the filters can be arranged so
720 that some have priority over others. XXX: get Doug to write an
721 example of having the same header matched more than once.
722
723 Lastly, B<minc> supports a magic ' TO' header. Filters using this
724 header are matched against a collection of headers related to (and
725 including) the To. Which headers to use is determined by a regular
726 expression borrowed from procmail.
727
728 =head1 HOOKS
729
730 Filter hooks take a reference to a hash of batons, a reference to a
731 hash of headers, and the message filename as arguments. It returns
732 undef to decline filtering of this message (thus falling back to
733 subsequent filter hooks, and finally @FILTERS), or the name of the
734 folder to store this message into.
735
736 One of B<minc>'s global variables is available to the user-defined
737 hooks: $run. This boolean should be honored; hooks should only take
738 real action (i.e. removing or creating files, running external
739 programs, etc.) if $run is non-zero.
740
741 The baton hash is created simply from the start hooks; if the hook
742 returns at least one defined value, this value is used as the key and
743 all other return values are put into a list reference as the value.
744 This hash is then passed by reference to all filter, post-store, and
745 stop hooks.
746
747 Post store hooks take a reference to a hash of batons, a reference to
748 a hash of headers, the folder this message was stored in, and its new
749 message number.
750
751 XXX: need more details about the hook process; for now read the code.
752
753 =head1 EXAMPLES
754
755 @FILTERS = (
756
757 ['List-Id',
758 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
759 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
760 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
761 ],
762
763 ['List-Post',
764 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
765 ],
766
767 ['To',
768 ['root', 'root'],
769 ],
770 );
771
772 The first List-Id filter is a surprisingly effective one which creates
773 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
774 all Sourceforge lists to which i subscribe. Certainly there are lists
775 hosted at Sourceforge which do not work well with this, and this is
776 where it is important to remember that the first match is the one
777 uses. Simply put the more specific rules before this one.
778
779 The next List-Id example is simple. The swig example demonstrates
780 that the folder name does not have to use a portion of the matched
781 header; you can just hardcode the folder name.
782
783 The List-Post example is a nice one. Most ASF projects have their own
784 apache.org subdomain, with mailing lists hosted there. So, given a
785 list such as dev@httpd.apache.org, this filter will create the folder
786 name l/apache/httpd/dev.
787
788 XXX Need hook examples.
789
790 =head1 AUTHORS
791
792 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
793 Gillespie and Doug Porter <dsp@waterspout.com>.
794
795 This program is free software; you can redistribute it and/or modify
796 it under the same terms as Perl itself.
797
798 =cut
799
800 # Local variables:
801 # cperl-indent-level: 4
802 # perl-indent-level: 4
803 # indent-tabs-mode: nil
804 # End:
805
806 # vi: set tabstop=4 expandtab: