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