]> diplodocus.org Git - minc/blob - minc
f33fddbfd69f777c0e0f8668ec30a6f9dbbf25f1
[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::stat;
33 use Getopt::Std; $Getopt::Std::STANDARD_HELP_VERSION = 1;
34 use Log::Dispatch;
35 use Log::Dispatch::File;
36 use POSIX qw(strftime WEXITSTATUS WIFEXITED);
37
38 our $VERSION = 1;
39
40 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
41 # against headers matching this regex (taken from procmail).
42 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
43 my $MAGIC_TO_TOKEN = ' TO';
44
45 # List of SPAM message numbers, scanned at the end so the user can
46 # check for false positives.
47 my @SPAM;
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<--help>
59
60 Show help.
61
62 =item B<-m> I<MAX>
63
64 Stop processing after I<MAX> messages.
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 =back
78
79 =cut
80
81 my $dumpfilters = 0;
82 my $maxmsgs;
83 our $run = 1;
84 my $printfilenames = 0;
85
86 my %opts;
87
88 if (not getopts('dm:np', \%opts)) {
89 exit(2);
90 }
91
92 if ($opts{'d'}) {
93 $dumpfilters = 1;
94 } elsif ($opts{'n'}) {
95 $run = 0;
96 }
97
98 if (defined($opts{'m'})) {
99 $maxmsgs = $opts{'m'};
100 }
101
102 if ($opts{'p'}) {
103 $printfilenames = 1;
104 }
105
106 =head1 ENVIRONMENT
107
108 =over 4
109
110 =item HOME
111
112 Where configuration files (.mincfilter) are found. Also,
113 $HOME/Maildir is used for the maildir if MAILDIR is not set.
114
115 =item MAILDIR
116
117 Where mail is delivered.
118
119 =back
120
121 =cut
122
123 use Env qw(HOME MAILDIR);
124
125 if (not $HOME) {
126 die("HOME environment variable must be set.\n");
127 }
128 if (not $MAILDIR) {
129 $MAILDIR = "$HOME/Maildir";
130 }
131
132 =head1 FILES
133
134 =over 4
135
136 =item $HOME/.mincfilter
137
138 This file is Perl code (included via the 'require' directive) which is
139 expected to define the FILTERS list.
140
141 =item $HOME/.mincspam
142
143 If this file exists, B<minc> will include it with the expectation that
144 it will define a B<spam_check> function. This function takes a
145 message filename as an argument and returns 1 if the message is spam,
146 else 0. If this file does not exist, B<minc> will define a simple
147 function that always returns 0.
148
149 One of B<minc>'s global variables is available to the user-defined
150 B<spam_check> function: $run. This boolean should be honored;
151 B<spam_check> should only take real action (i.e. removing or creating
152 files, running external programs, etc.) if $run is non-zero.
153
154 This file may also declare two other functions: B<spam_start_hook> and
155 B<spam_stop_hook>. The former is passed no arguments and is expected
156 to return a list. This list is a "baton" that will also be passed to
157 B<spam_check> and B<spam_stop_hook>. It can hold anything
158 B<spam_check> will need to do its job, whether network sockets, pipes,
159 or whatever.
160
161 XXX: need more details about the spam-handling process; for now read
162 the code.
163
164 =item `mhpath +`/logs/minc.log
165
166 Where B<minc> logs what it does, unless in -n mode.
167
168 =item `mhpath +`/logs/dryrun.log
169
170 Where B<minc> logs what it would do; used in -n mode.
171
172 =item `mhpath +`/.minc.context
173
174 B<minc> uses this file for context (i.e. current folder) instead of
175 `mhpath +`/context. This allows you some amount of freedom to use mh
176 while B<minc> is running. For example, you can changed folders without
177 causing a message to be stored in the wrong folder. Care must still
178 be taken, however, about the unseen sequence; if you change it
179 (whether via show, or mark, or anything else) while in the same folder
180 as B<minc>, it is likely the new message B<minc> stores will not end up
181 marked unseen.
182
183 =back
184
185 =cut
186
187 our @FILTERS;
188 require "$HOME/.mincfilter";
189
190 my $mh;
191 my $logfile;
192
193 $mh = `mhpath +`;
194 chomp($mh);
195
196 if ($run) {
197 $logfile = $mh . '/logs/minc.log';
198 } else {
199 $logfile = $mh . '/logs/dryrun.log';
200 }
201
202 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
203
204 \f
205 ###############################################################################
206 # Logging
207
208 # debuglevels for the logger
209 use constant LOG_DEBUGLEVEL => 'info';
210 use constant SCREEN_DEBUGLEVEL => 'debug';
211
212 # characters used to wrap around the id field in the log so I can more
213 # easily parse the msg flow
214 use constant DEBUGCHAR => '%';
215 use constant INFOCHAR => '=';
216 use constant INCOMINGCHAR => '<';
217 use constant SAVECHAR => '>';
218 use constant WARNCHAR => 'W';
219 use constant ERRORCHAR => 'E';
220
221 my $logger = new Log::Dispatch;
222 $logger->add(new Log::Dispatch::File (name=>'logfile',
223 filename=>$logfile,
224 min_level=>'info',
225 mode=>'append'));
226
227 # log alias, handles getting passed vars that are undef
228 sub mylog {
229 my $level = shift;
230 my $act = shift;
231 my $timestamp = strftime('%b %e %H:%M:%S', localtime);
232 my $msg;
233 foreach my $part (@_) {
234 if (defined($part)) {
235 $msg .= $part;
236 }
237 }
238 # no newlines in the log message, thanks
239 $msg =~ s/\n/ /gm;
240 $msg = sprintf("%s %s%s %s\n", $timestamp, $act, $act, $msg);
241 $logger->log(level=>$level, message=>$msg);
242
243 if ($act eq SAVECHAR) {
244 $logger->log(level=>$level, message=>"\n");
245 }
246 }
247
248 sub logsave { mylog('notice', SAVECHAR, @_); }
249 sub loginfo { mylog('info', INFOCHAR, @_); }
250 sub logdebug { mylog('debug', DEBUGCHAR, @_); }
251 sub logwarn { mylog('warning', WARNCHAR, @_); }
252 sub logerr { mylog('error', ERRORCHAR, @_); }
253
254 sub logincoming {
255 my ($text, @contents) = @_;
256 my $last;
257
258 if (@contents) {
259 $last = $contents[-1];
260 } else {
261 $last = '';
262 }
263
264 mylog('info', INCOMINGCHAR, $text, $last);
265 }
266
267 sub log_headers {
268 my %headers = @_;
269
270 # For an explanation of the %headers structure, see the
271 # get_headers function below.
272 logincoming('From: ', @{$headers{'return-path'}});
273 logincoming('To: ', @{$headers{'to'}});
274 logincoming('Subject: ', @{$headers{'subject'}});
275 logincoming('Message-Id: ', @{$headers{'message-id'}});
276 }
277
278 \f
279 ###############################################################################
280 # Utility procedures
281
282 sub mkfolder {
283 my $mhfolder = shift;
284 my $folder;
285 my $target;
286 my $component;
287
288 $folder = $mh . '/' . $mhfolder;
289 $target = '';
290
291 if (not -d $folder) {
292 foreach $component (split('/', $folder)) {
293 if (defined($component) and length($component) >= 1) {
294 $target = $target . '/' . $component;
295 if (-d $target or mkdir($target)) {
296 next;
297 } else {
298 die("Failed to create +$mhfolder ($component)");
299 }
300 }
301 }
302 }
303 }
304
305 sub getfiles {
306 my $dir = shift;
307 my @result;
308
309 if (not opendir(DIR, $dir)) {
310 die("Failed opendir($dir)");
311 }
312
313 # Initialize $! to 0 (success) because stupid stupid Perl provides
314 # no way to distinguish an error from an empty directory; that
315 # means setting $! to 0 and testing it afterwards is the only way
316 # to detect an error. Real Programmers don't handle errors,
317 # right? >sigh<
318 $! = 0;
319 @result = grep {
320 ($_ ne '.' and $_ ne '..')
321 and $_ = "$MAILDIR/new/$_"
322 } readdir(DIR);
323
324 if ($! != 0) {
325 die("Failed readdir($dir)");
326 }
327
328 if (scalar(@result) == 0) {
329 exit(0);
330 }
331
332 STDOUT->autoflush(1);
333 print(scalar(@result), " messages...");
334
335 closedir(DIR);
336
337 return @result;
338 }
339
340 sub get_highest_msgnum {
341 my $mhfolder = shift;
342 my $dir;
343 my @list;
344 my $highest;
345 my $msgnum;
346
347 $dir = "$mh/$mhfolder";
348 if (not opendir(DIR, $dir)) {
349 die("Failed opendir($dir)");
350 }
351
352 $! = 0;
353 @list = readdir(DIR);
354
355 if ($! != 0) {
356 die("Failed readdir($dir)");
357 }
358
359 closedir(DIR);
360
361 $highest = 0;
362 foreach $msgnum (@list) {
363 # Look for integers.
364 if ($msgnum =~ /^[0-9]+$/) {
365 if ($msgnum > $highest) {
366 $highest = $msgnum;
367 }
368 }
369 }
370
371 return $highest;
372 }
373
374 sub store_message {
375 my $msg = shift;
376 my $mhfolder = shift;
377 my $msgnum;
378 my $try;
379 my $mhmsg;
380 my $status;
381
382 # We must do this even in -n mode because later steps fail without
383 # it. This should be harmless.
384 mkfolder($mhfolder);
385
386 # This loop is a modified version of the maildir delivery algorithm.
387 $msgnum = get_highest_msgnum($mhfolder);
388 for ($try = 0; ; $try++) {
389 $msgnum++;
390 $mhmsg = "$mh/$mhfolder/$msgnum";
391
392 if (not stat($mhmsg)) {
393 if ($!{ENOENT}) {
394 # Now we have a non-existent file, let's try to create
395 # it. We must create a zero-byte file first because a
396 # file my appear between our happy stat results and
397 # our later rename(2), which would clobber said file.
398 # So attempt to create a file with this name. If it
399 # succeeds, in just a bit here we'll knowingly clobber
400 # this file with the rename(2) call.
401
402 # Another way to do this is not to use rename(2), but
403 # use link(2) + unlink(2) instead. That's how the
404 # standard maildir algorithm does it. Each method has
405 # a disadvantage: the program may crash between the
406 # link(2) and unlink(2) calls. With the standard
407 # maildir algorithm, that means the message will end
408 # up duplicated. The advantage of creating an empty
409 # file followed by rename(2) is that an extra empty
410 # file is left behind as opposed to a duplicate
411 # message. This is more easily detected by the user.
412 if ($run) {
413 if (sysopen(MSG, "$mhmsg",
414 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
415 close(MSG);
416 last;
417 }
418 } else {
419 last;
420 }
421 }
422 }
423
424 # This algorithm is different from the maildir one; let's make
425 # 10 tries instead of 3.
426 if ($try == 9) {
427 die("Attempted filename $mhmsg exists.");
428 }
429
430 # This algorithm is different; i don't think we need to sleep.
431 #sleep(2);
432 }
433
434 if ($mhfolder ne 'SPAM') {
435 logsave("+$mhfolder");
436 }
437
438 if ($run) {
439 if (not rename($msg, $mhmsg)) {
440 die("Failed rename($msg, $mhmsg)");
441 }
442
443 # Mark each message as soon as we store it and bomb if that
444 # fails. While it is slow, it is not safe to store multiple
445 # messages and then have a failure before marking some (or
446 # all).
447 if ($mhfolder eq 'SPAM') {
448 push(@SPAM, $msgnum);
449 } else {
450 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
451 'unseen', '-add');
452 if (not WIFEXITED($status)) {
453 die("Failed to run mark");
454 } elsif (WEXITSTATUS($status) != 0) {
455 die("Failed to mark message unseen.");
456 }
457 }
458 }
459
460 return $msgnum;
461 }
462
463 # Parse a message file into a structure describing the headers. The
464 # structure is a hash of arrays. The hash keys are the names of the
465 # headers found in the message, made all lower-case. Each item in
466 # the hash is an array of header text. The array has one element
467 # per occurrence of the header. Most headers will only have a single
468 # element because they occur only once. The Received header is the
469 # most common header with multiple texts.
470 sub get_headers {
471 my $msg = shift;
472 my %headers;
473 my $current; # current header, used for unfolding lines
474 my $fieldname; # unmolested header name
475 my $contents; # contents of header
476
477 open(MSG, $msg);
478 while (<MSG>) {
479 chomp;
480 if (length == 0) {
481 last;
482 }
483
484 if (/^\s/) {
485 # folded header continuation
486
487 if (not defined($current)) {
488 print(STDERR "Malformed message, cannot parse headers.\n");
489 return ();
490 }
491
492 @{$headers{$current}}[-1] .= $_;
493 } else {
494 ($fieldname) = split(/:/);
495 $current = lc($fieldname);
496 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
497
498 if (defined($headers{$current})) {
499 # multiple occurence of same header
500 push(@{$headers{$current}}, $contents);
501 } else {
502 # first occurence of a header
503 $headers{$current} = [$contents];
504 }
505 }
506 }
507 close(MSG);
508
509 return %headers;
510 }
511
512 \f
513 ###############################################################################
514 # Filtering
515
516 sub find_mh_folder {
517 my $msg = shift;
518 my %headers;
519 my $filterref;
520 my @filter;
521 my $header;
522 my $contents;
523 my $pair;
524 my $match;
525 my $expression;
526 my $result;
527
528 %headers = get_headers($msg);
529 if (not %headers) {
530 return 'malformed';
531 }
532
533 log_headers(%headers);
534
535 # Walk the list of filters. This structure is documented in
536 # pod at the end of the program.
537 foreach $filterref (@FILTERS) {
538 @filter = @$filterref;
539 $header = shift(@filter);
540
541 # Handle filters using the magic TO header specially.
542 if ($header eq $MAGIC_TO_TOKEN) {
543 foreach $header (keys(%headers)) {
544 if ($header =~ /$MAGIC_TO_REGEX/i) {
545 foreach $contents (@{$headers{lc($header)}}) {
546 foreach $pair (@filter) {
547 ($match, $expression) = @$pair;
548 if ($contents =~ /$match/) {
549 return $expression;
550 }
551 }
552 }
553 }
554 }
555
556 # Now that it's been processed specially, skip normal handling.
557 next;
558 }
559
560 # Walk the list of message headers matching the filter's
561 # specified header.
562 foreach $contents (@{$headers{lc($header)}}) {
563 # Walk the filter's list of match/expression pairs.
564 foreach $pair (@filter) {
565 ($match, $expression) = @$pair;
566 if ($contents =~ /$match/i) {
567 if (eval "\$result = \"$expression\"") {
568 return $result;
569 }
570 }
571 }
572 }
573 }
574
575 return 'inbox';
576 }
577
578 sub filter_mail {
579 my @msglist = @_;
580 my $msgcount = scalar(@msglist);
581 my $msgcountlen = length($msgcount);
582 my @baton;
583 my $msg;
584 my $mhfolder;
585 my $spam = 0;
586 my $saved = 0;
587 my %FOLDERS = ('SPAM'=>1);
588
589 if (-f "$HOME/.mincspam") {
590 require "$HOME/.mincspam";
591 } else {
592 eval "sub spam_start_hook { return (); }";
593 eval "sub spam_stop_hook { }";
594 eval "sub spam_check { return 0; }";
595 }
596
597 @baton = spam_start_hook();
598
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: