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