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