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