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