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