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