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