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