]> diplodocus.org Git - minc/blob - minc
80c88f4c7c3fef312214cf308e5fd38be064cadf
[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 scalar(@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 $mhfolder = shift;
250 my $folder;
251 my $target;
252 my $component;
253
254 $folder = $mh . '/' . $mhfolder;
255 $target = '';
256
257 if (not -d $folder) {
258 foreach $component (split('/', $folder)) {
259 if (defined($component) and length($component) >= 1) {
260 $target = $target . '/' . $component;
261 if (-d $target or mkdir($target)) {
262 next;
263 } else {
264 die("Failed to create +$mhfolder ($component)");
265 }
266 }
267 }
268 }
269 }
270
271 sub getfiles {
272 my $dir = shift;
273 my @result;
274
275 if (not opendir(DIR, $dir)) {
276 die("Failed opendir($dir)");
277 }
278
279 # Initialize $! to 0 (success) because stupid stupid Perl provides
280 # no way to distinguish an error from an empty directory; that
281 # means setting $! to 0 and testing it afterwards is the only way
282 # to detect an error. Real Programmers don't handle errors,
283 # right? >sigh<
284 $! = 0;
285 @result = grep {
286 ($_ ne '.' and $_ ne '..')
287 and $_ = "$MAILDIR/new/$_"
288 } readdir(DIR);
289
290 if ($! != 0) {
291 die("Failed readdir($dir)");
292 }
293
294 if (scalar(@result) == 0) {
295 exit(0);
296 }
297
298 STDOUT->autoflush(1);
299 print(scalar(@result), " messages...");
300
301 closedir(DIR);
302
303 return @result;
304 }
305
306 sub get_highest_msgnum {
307 my $mhfolder = shift;
308 my $dir;
309 my @list;
310 my $highest;
311 my $msgnum;
312
313 $dir = "$mh/$mhfolder";
314 if (not opendir(DIR, $dir)) {
315 die("Failed opendir($dir)");
316 }
317
318 $! = 0;
319 @list = readdir(DIR);
320
321 if ($! != 0) {
322 die("Failed readdir($dir)");
323 }
324
325 closedir(DIR);
326
327 $highest = 0;
328 foreach $msgnum (@list) {
329 # Look for integers.
330 if ($msgnum =~ /^[0-9]+$/) {
331 if ($msgnum > $highest) {
332 $highest = $msgnum;
333 }
334 }
335 }
336
337 return $highest;
338 }
339
340 sub store_message {
341 my $msg = shift;
342 my $mhfolder = shift;
343 my $msgnum;
344 my $try;
345 my $mhmsg;
346 my $status;
347
348 # We must do this even in -n mode because later steps fail without
349 # it. This should be harmless.
350 mkfolder($mhfolder);
351
352 # This loop is a modified version of the maildir delivery algorithm.
353 $msgnum = get_highest_msgnum($mhfolder);
354 for ($try = 0; ; $try++) {
355 $msgnum++;
356 $mhmsg = "$mh/$mhfolder/$msgnum";
357
358 if (not stat($mhmsg)) {
359 if ($!{ENOENT}) {
360 # Now we have a non-existent file, let's try to create
361 # it. We must create a zero-byte file first because a
362 # file my appear between our happy stat results and
363 # our later rename(2), which would clobber said file.
364 # So attempt to create a file with this name. If it
365 # succeeds, in just a bit here we'll knowingly clobber
366 # this file with the rename(2) call.
367
368 # Another way to do this is not to use rename(2), but
369 # use link(2) + unlink(2) instead. That's how the
370 # standard maildir algorithm does it. Each method has
371 # a disadvantage: the program may crash between the
372 # link(2) and unlink(2) calls. With the standard
373 # maildir algorithm, that means the message will end
374 # up duplicated. The advantage of creating an empty
375 # file followed by rename(2) is that an extra empty
376 # file is left behind as opposed to a duplicate
377 # message. This is more easily detected by the user.
378 if ($run) {
379 if (sysopen(MSG, "$mhmsg",
380 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
381 close(MSG);
382 last;
383 }
384 } else {
385 last;
386 }
387 }
388 }
389
390 # This algorithm is different from the maildir one; let's make
391 # 10 tries instead of 3.
392 if ($try == 9) {
393 die("Attempted filename $mhmsg exists.");
394 }
395
396 # This algorithm is different; i don't think we need to sleep.
397 #sleep(2);
398 }
399
400 if ($mhfolder ne 'SPAM') {
401 mylog('+', $mhfolder);
402 }
403
404 if ($run) {
405 if (not rename($msg, $mhmsg)) {
406 die("Failed rename($msg, $mhmsg)");
407 }
408
409 # Mark each message as soon as we store it and bomb if that
410 # fails. While it is slow, it is not safe to store multiple
411 # messages and then have a failure before marking some (or
412 # all).
413 if ($mhfolder eq 'SPAM') {
414 push(@SPAM, $msgnum);
415 } else {
416 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
417 'unseen', '-add');
418 if (not WIFEXITED($status)) {
419 die("Failed to run mark");
420 } elsif (WEXITSTATUS($status) != 0) {
421 die("Failed to mark message unseen.");
422 }
423 }
424 }
425
426 return $msgnum;
427 }
428
429 # Parse a message file into a structure describing the headers. The
430 # structure is a hash of arrays. The hash keys are the names of the
431 # headers found in the message, made all lower-case. Each item in
432 # the hash is an array of header text. The array has one element
433 # per occurrence of the header. Most headers will only have a single
434 # element because they occur only once. The Received header is the
435 # most common header with multiple texts.
436 sub get_headers {
437 my $msg = shift;
438 my %headers;
439 my $current; # current header, used for unfolding lines
440 my $fieldname; # unmolested header name
441 my $contents; # contents of header
442
443 open(MSG, $msg);
444 while (<MSG>) {
445 chomp;
446 if (length == 0) {
447 last;
448 }
449
450 if (/^\s/) {
451 # folded header continuation
452
453 if (not defined($current)) {
454 print(STDERR "Malformed message, cannot parse headers.\n");
455 return ();
456 }
457
458 @{$headers{$current}}[-1] .= $_;
459 } else {
460 ($fieldname) = split(/:/);
461 $current = lc($fieldname);
462 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
463
464 if (defined($headers{$current})) {
465 # multiple occurence of same header
466 push(@{$headers{$current}}, $contents);
467 } else {
468 # first occurence of a header
469 $headers{$current} = [$contents];
470 }
471 }
472 }
473 close(MSG);
474
475 return %headers;
476 }
477
478 \f
479 ###############################################################################
480 # Filtering
481
482 sub find_mh_folder {
483 my $msg = shift;
484 my %headers;
485 my $filterref;
486 my @filter;
487 my $header;
488 my $contents;
489 my $pair;
490 my $match;
491 my $expression;
492 my $result;
493
494 %headers = get_headers($msg);
495 if (not %headers) {
496 return 'malformed';
497 }
498
499 log_headers(%headers);
500
501 # Walk the list of filters. This structure is documented in
502 # pod at the end of the program.
503 foreach $filterref (@FILTERS) {
504 @filter = @$filterref;
505 $header = shift(@filter);
506
507 # Handle filters using the magic TO header specially.
508 if ($header eq $MAGIC_TO_TOKEN) {
509 foreach $header (keys(%headers)) {
510 if ($header =~ /$MAGIC_TO_REGEX/i) {
511 foreach $contents (@{$headers{lc($header)}}) {
512 foreach $pair (@filter) {
513 ($match, $expression) = @$pair;
514 if ($contents =~ /$match/) {
515 return $expression;
516 }
517 }
518 }
519 }
520 }
521
522 # Now that it's been processed specially, skip normal handling.
523 next;
524 }
525
526 # Walk the list of message headers matching the filter's
527 # specified header.
528 foreach $contents (@{$headers{lc($header)}}) {
529 # Walk the filter's list of match/expression pairs.
530 foreach $pair (@filter) {
531 ($match, $expression) = @$pair;
532 if ($contents =~ /$match/i) {
533 if (eval "\$result = \"$expression\"") {
534 return $result;
535 }
536 }
537 }
538 }
539 }
540
541 return 'inbox';
542 }
543
544 sub filter_mail {
545 my @msglist = @_;
546 my $msgcount = scalar(@msglist);
547 my $msgcountlen = length($msgcount);
548 my @baton;
549 my $msg;
550 my $mhfolder;
551 my $spam = 0;
552 my $saved = 0;
553 my %FOLDERS = ('SPAM'=>1);
554
555 if (-f "$HOME/.mincspam") {
556 require "$HOME/.mincspam";
557 } else {
558 eval "sub spam_start_hook { return (); }";
559 eval "sub spam_stop_hook { }";
560 eval "sub spam_check { return 0; }";
561 }
562
563 @baton = spam_start_hook();
564
565 foreach $msg (@msglist) {
566 if (spam_check($msg, @baton)) {
567 $mhfolder = 'SPAM';
568 $spam = scalar(@SPAM)+ 1;
569 } else {
570 $mhfolder = find_mh_folder($msg);
571 $saved++;
572 }
573
574 store_message($msg, $mhfolder);
575
576 print("\r");
577 if (not $FOLDERS{$mhfolder}) {
578 print(' ' x $msgcountlen);
579 print(" \r+$mhfolder\n");
580 $FOLDERS{$mhfolder} = 1;
581 }
582
583 printf('%6d SPAM %6d saved %6d/%1d',
584 $spam, $saved, $spam + $saved, $msgcount);
585
586 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
587 }
588 print("\n");
589
590 spam_stop_hook(@baton);
591 }
592
593 \f
594 MAIN: {
595 my @msglist;
596 my $st;
597
598 if ($dumpfilters) {
599 $Data::Dumper::Indent = 1;
600 print(Dumper(\@FILTERS));
601 exit;
602 }
603
604 @msglist = (
605 map { $_->[1] }
606 sort { $a->[0] <=> $b->[0] }
607 map {
608 if (not ($st = stat($_))) {
609 die("stat($_): $!");
610 }
611 [$st->mtime, $_]
612 }
613 getfiles("$MAILDIR/new"));
614
615 filter_mail(@msglist);
616
617 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
618 }
619
620 \f
621 __END__
622
623 =head1 THE FILTERS STRUCTURE
624
625 The user's .mincfilter file must define the @FILTERS structure. This
626 structure is an array. Each element of @FILTERS is a filter. A
627 filter is itself an array. The first element of a filter is a string,
628 the name of the header this filter acts upon. The header name is not
629 case-sensitive. Each subsequent element of a filter is a pair (i.e. a
630 two-element array): first, a regular expression B<minc> uses to
631 determine whether this filter matches or not, and second, an
632 expression which B<minc> evaluates to get the folder name.
633
634 B<minc> decides where to store a message by iterating over the
635 @FILTERS array. It tests each regexp of each filter against all
636 headers matching that filter's specified header. As soon as a match
637 is found, B<minc> evaluates the second part of the pair. This part
638 may contain positional parameters from the matched regexp ($1, $2,
639 etc.). The result of this expression is used as the folder name.
640
641 Multiple occurrences of the same header are preserved and tested
642 individually against the filters. That means, for example, that
643 filters can search all the Received headers for a certain string.
644
645 It is important to note that all the arrays of the @FILTERS structure
646 are traversed I<in order>. This means the filters can be arranged so
647 that some have priority over others. XXX: get Doug to write an
648 example of having the same header matched more than once.
649
650 Lastly, B<minc> supports a magic ' TO' header. Filters using this
651 header are matched against a collection of headers related to (and
652 including) the To. Which headers to use is determined by a regular
653 expression borrowed from procmail.
654
655 =head1 EXAMPLES
656
657 @FILTERS = (
658
659 ['List-Id',
660 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
661 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
662 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
663 ],
664
665 ['List-Post',
666 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
667 ],
668
669 ['To',
670 ['root', 'root'],
671 ],
672 );
673
674 The first List-Id filter is a surprisingly effective one which creates
675 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
676 all Sourceforge lists to which i subscribe. Certainly there are lists
677 hosted at Sourceforge which do not work well with this, and this is
678 where it is important to remember that the first match is the one
679 uses. Simply put the more specific rules before this one.
680
681 The next List-Id example is simple. The swig example demonstrates
682 that the folder name does not have to use a portion of the matched
683 header; you can just hardcode the folder name.
684
685 The List-Post example is a nice one. Most ASF projects have their own
686 apache.org subdomain, with mailing lists hosted there. So, given a
687 list such as dev@httpd.apache.org, this filter will create the folder
688 name l/apache/httpd/dev.
689
690 For an example B<spam_check> function, see
691 L<http:E<047>E<047>pretzelnet.orgE<047>cvsE<047>dotfilesE<047>.mincspam>
692
693 =head1 AUTHORS
694
695 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
696 Gillespie and Doug Porter <dsp@waterspout.com>.
697
698 This program is free software; you can redistribute it and/or modify
699 it under the same terms as Perl itself.
700
701 =cut
702
703 # Local variables:
704 # cperl-indent-level: 4
705 # perl-indent-level: 4
706 # indent-tabs-mode: nil
707 # End:
708
709 # vi: set tabstop=4 expandtab: