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