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