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