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