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