]> diplodocus.org Git - minc/blob - minc
8bf698b60eddba014095352ef26d1934c149372c
[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<dmnp>]
12
13 =head1 DESCRIPTION
14
15 B<minc> incorporates mail from a maildir to a mh folder hierarchy. It
16 takes mail from a maildir folder (not a maildir folder hierarchy),
17 optionally checks for spam with a user-defined spam-checking function,
18 and optionally filters mail into separate mh folders.
19
20 The filtering is quite sophisticated, as it is done using real Perl
21 matching (m//) commands.
22
23 =cut
24
25 use strict;
26 use warnings;
27
28 use Data::Dumper;
29 use Errno;
30 use Fcntl qw(O_WRONLY O_EXCL O_CREAT);
31 use FileHandle;
32 use File::stat;
33 use Getopt::Std; $Getopt::Std::STANDARD_HELP_VERSION = 1;
34 use POSIX qw(strftime WEXITSTATUS WIFEXITED);
35
36 our $VERSION = 1;
37
38 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
39 # against headers matching this regex (taken from procmail).
40 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
41 my $MAGIC_TO_TOKEN = ' TO';
42
43 # List of SPAM message numbers, scanned at the end so the user can
44 # check for false positives.
45 my @SPAM;
46
47 =head1 OPTIONS
48
49 =over 4
50
51 =item B<-d>
52
53 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
54 for testing the syntax of .mincfilter.
55
56 =item B<--help>
57
58 Show help.
59
60 =item B<-m> I<MAX>
61
62 Stop processing after I<MAX> messages.
63
64 =item B<-n>
65
66 Dry run; do not actually incorporate the mail, but log and report to
67 stdout/stderr as normal.
68
69 =item B<-p>
70
71 Print the filename of each message before checking it for spam. This
72 can be handy if a particular message is giving the spam checker a
73 problem.
74
75 =back
76
77 =cut
78
79 my $dumpfilters = 0;
80 my $maxmsgs;
81 our $run = 1;
82 my $printfilenames = 0;
83
84 my %opts;
85
86 if (not getopts('dm:np', \%opts)) {
87 exit(2);
88 }
89
90 if ($opts{'d'}) {
91 $dumpfilters = 1;
92 } elsif ($opts{'n'}) {
93 $run = 0;
94 }
95
96 if (defined($opts{'m'})) {
97 $maxmsgs = $opts{'m'};
98 }
99
100 if ($opts{'p'}) {
101 $printfilenames = 1;
102 }
103
104 =head1 ENVIRONMENT
105
106 =over 4
107
108 =item HOME
109
110 Where configuration files (.mincfilter) are found. Also,
111 $HOME/Maildir is used for the maildir if MAILDIR is not set.
112
113 =item MAILDIR
114
115 Where mail is delivered.
116
117 =back
118
119 =cut
120
121 use Env qw(HOME MAILDIR);
122
123 if (not $HOME) {
124 die("HOME environment variable must be set.\n");
125 }
126 if (not $MAILDIR) {
127 $MAILDIR = "$HOME/Maildir";
128 }
129
130 =head1 FILES
131
132 =over 4
133
134 =item $HOME/.mincfilter
135
136 This file is Perl code (included via the 'require' directive) which is
137 expected to define the FILTERS list.
138
139 =item $HOME/.mincspam
140
141 If this file exists, B<minc> will include it with the expectation that
142 it will define a B<spam_check> function. This function takes a
143 message filename as an argument and returns 1 if the message is spam,
144 else 0. If this file does not exist, B<minc> will define a simple
145 function that always returns 0.
146
147 One of B<minc>'s global variables is available to the user-defined
148 B<spam_check> function: $run. This boolean should be honored;
149 B<spam_check> should only take real action (i.e. removing or creating
150 files, running external programs, etc.) if $run is non-zero.
151
152 This file may also declare two other functions: B<spam_start_hook> and
153 B<spam_stop_hook>. The former is passed no arguments and is expected
154 to return a list. This list is a "baton" that will also be passed to
155 B<spam_check> and B<spam_stop_hook>. It can hold anything
156 B<spam_check> will need to do its job, whether network sockets, pipes,
157 or whatever.
158
159 XXX: need more details about the spam-handling process; for now read
160 the code.
161
162 =item `mhpath +`/logs/minc.log
163
164 Where B<minc> logs what it does, unless in -n mode.
165
166 =item `mhpath +`/logs/dryrun.log
167
168 Where B<minc> logs what it would do; used in -n mode.
169
170 =item `mhpath +`/.minc.context
171
172 B<minc> uses this file for context (i.e. current folder) instead of
173 `mhpath +`/context. This allows you some amount of freedom to use mh
174 while B<minc> is running. For example, you can changed folders without
175 causing a message to be stored in the wrong folder. Care must still
176 be taken, however, about the unseen sequence; if you change it
177 (whether via show, or mark, or anything else) while in the same folder
178 as B<minc>, it is likely the new message B<minc> stores will not end up
179 marked unseen.
180
181 =back
182
183 =cut
184
185 our @FILTERS;
186 require "$HOME/.mincfilter";
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 $mhfolder = shift;
252 my $folder;
253 my $target;
254 my $component;
255
256 $folder = $mh . '/' . $mhfolder;
257 $target = '';
258
259 if (not -d $folder) {
260 foreach $component (split('/', $folder)) {
261 if (defined($component) and length($component) >= 1) {
262 $target = $target . '/' . $component;
263 if (-d $target or mkdir($target)) {
264 next;
265 } else {
266 die("Failed to create +$mhfolder ($component)");
267 }
268 }
269 }
270 }
271 }
272
273 sub getfiles {
274 my $dir = shift;
275 my @result;
276
277 if (not opendir(DIR, $dir)) {
278 die("Failed opendir($dir)");
279 }
280
281 # Initialize $! to 0 (success) because stupid stupid Perl provides
282 # no way to distinguish an error from an empty directory; that
283 # means setting $! to 0 and testing it afterwards is the only way
284 # to detect an error. Real Programmers don't handle errors,
285 # right? >sigh<
286 $! = 0;
287 @result = grep {
288 ($_ ne '.' and $_ ne '..')
289 and $_ = "$MAILDIR/new/$_"
290 } readdir(DIR);
291
292 if ($! != 0) {
293 die("Failed readdir($dir)");
294 }
295
296 if (scalar(@result) == 0) {
297 exit(0);
298 }
299
300 STDOUT->autoflush(1);
301 print(scalar(@result), " messages...");
302
303 closedir(DIR);
304
305 return @result;
306 }
307
308 sub get_highest_msgnum {
309 my $mhfolder = shift;
310 my $dir;
311 my @list;
312 my $highest;
313 my $msgnum;
314
315 $dir = "$mh/$mhfolder";
316 if (not opendir(DIR, $dir)) {
317 die("Failed opendir($dir)");
318 }
319
320 $! = 0;
321 @list = readdir(DIR);
322
323 if ($! != 0) {
324 die("Failed readdir($dir)");
325 }
326
327 closedir(DIR);
328
329 $highest = 0;
330 foreach $msgnum (@list) {
331 # Look for integers.
332 if ($msgnum =~ /^[0-9]+$/) {
333 if ($msgnum > $highest) {
334 $highest = $msgnum;
335 }
336 }
337 }
338
339 return $highest;
340 }
341
342 sub store_message {
343 my $msg = shift;
344 my $mhfolder = shift;
345 my $msgnum;
346 my $try;
347 my $mhmsg;
348 my $status;
349
350 # We must do this even in -n mode because later steps fail without
351 # it. This should be harmless.
352 mkfolder($mhfolder);
353
354 # This loop is a modified version of the maildir delivery algorithm.
355 $msgnum = get_highest_msgnum($mhfolder);
356 for ($try = 0; ; $try++) {
357 $msgnum++;
358 $mhmsg = "$mh/$mhfolder/$msgnum";
359
360 if (not stat($mhmsg)) {
361 if ($!{ENOENT}) {
362 # Now we have a non-existent file, let's try to create
363 # it. We must create a zero-byte file first because a
364 # file my appear between our happy stat results and
365 # our later rename(2), which would clobber said file.
366 # So attempt to create a file with this name. If it
367 # succeeds, in just a bit here we'll knowingly clobber
368 # this file with the rename(2) call.
369
370 # Another way to do this is not to use rename(2), but
371 # use link(2) + unlink(2) instead. That's how the
372 # standard maildir algorithm does it. Each method has
373 # a disadvantage: the program may crash between the
374 # link(2) and unlink(2) calls. With the standard
375 # maildir algorithm, that means the message will end
376 # up duplicated. The advantage of creating an empty
377 # file followed by rename(2) is that an extra empty
378 # file is left behind as opposed to a duplicate
379 # message. This is more easily detected by the user.
380 if ($run) {
381 if (sysopen(MSG, "$mhmsg",
382 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
383 close(MSG);
384 last;
385 }
386 } else {
387 last;
388 }
389 }
390 }
391
392 # This algorithm is different from the maildir one; let's make
393 # 10 tries instead of 3.
394 if ($try == 9) {
395 die("Attempted filename $mhmsg exists.");
396 }
397
398 # This algorithm is different; i don't think we need to sleep.
399 #sleep(2);
400 }
401
402 if ($mhfolder ne 'SPAM') {
403 mylog('+', $mhfolder);
404 }
405
406 if ($run) {
407 if (not rename($msg, $mhmsg)) {
408 die("Failed rename($msg, $mhmsg)");
409 }
410
411 # Mark each message as soon as we store it and bomb if that
412 # fails. While it is slow, it is not safe to store multiple
413 # messages and then have a failure before marking some (or
414 # all).
415 if ($mhfolder eq 'SPAM') {
416 push(@SPAM, $msgnum);
417 } else {
418 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
419 'unseen', '-add');
420 if (not WIFEXITED($status)) {
421 die("Failed to run mark");
422 } elsif (WEXITSTATUS($status) != 0) {
423 die("Failed to mark message unseen.");
424 }
425 }
426 }
427
428 return $msgnum;
429 }
430
431 # Parse a message file into a structure describing the headers. The
432 # structure is a hash of arrays. The hash keys are the names of the
433 # headers found in the message, made all lower-case. Each item in
434 # the hash is an array of header text. The array has one element
435 # per occurrence of the header. Most headers will only have a single
436 # element because they occur only once. The Received header is the
437 # most common header with multiple texts.
438 sub get_headers {
439 my $msg = shift;
440 my %headers;
441 my $current; # current header, used for unfolding lines
442 my $fieldname; # unmolested header name
443 my $contents; # contents of header
444
445 open(MSG, $msg);
446 while (<MSG>) {
447 chomp;
448 if (length == 0) {
449 last;
450 }
451
452 if (/^\s/) {
453 # folded header continuation
454
455 if (not defined($current)) {
456 print(STDERR "Malformed message, cannot parse headers.\n");
457 return ();
458 }
459
460 @{$headers{$current}}[-1] .= $_;
461 } else {
462 ($fieldname) = split(/:/);
463 $current = lc($fieldname);
464 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
465
466 if (defined($headers{$current})) {
467 # multiple occurence of same header
468 push(@{$headers{$current}}, $contents);
469 } else {
470 # first occurence of a header
471 $headers{$current} = [$contents];
472 }
473 }
474 }
475 close(MSG);
476
477 return %headers;
478 }
479
480 \f
481 ###############################################################################
482 # Filtering
483
484 sub find_mh_folder {
485 my $msg = shift;
486 my %headers;
487 my $filterref;
488 my @filter;
489 my $header;
490 my $contents;
491 my $pair;
492 my $match;
493 my $expression;
494 my $result;
495
496 %headers = get_headers($msg);
497 if (not %headers) {
498 return 'malformed';
499 }
500
501 log_headers(%headers);
502
503 # Walk the list of filters. This structure is documented in
504 # pod at the end of the program.
505 foreach $filterref (@FILTERS) {
506 @filter = @$filterref;
507 $header = shift(@filter);
508
509 # Handle filters using the magic TO header specially.
510 if ($header eq $MAGIC_TO_TOKEN) {
511 foreach $header (keys(%headers)) {
512 if ($header =~ /$MAGIC_TO_REGEX/i) {
513 foreach $contents (@{$headers{lc($header)}}) {
514 foreach $pair (@filter) {
515 ($match, $expression) = @$pair;
516 if ($contents =~ /$match/) {
517 return $expression;
518 }
519 }
520 }
521 }
522 }
523
524 # Now that it's been processed specially, skip normal handling.
525 next;
526 }
527
528 # Walk the list of message headers matching the filter's
529 # specified header.
530 foreach $contents (@{$headers{lc($header)}}) {
531 # Walk the filter's list of match/expression pairs.
532 foreach $pair (@filter) {
533 ($match, $expression) = @$pair;
534 if ($contents =~ /$match/i) {
535 if (eval "\$result = \"$expression\"") {
536 return $result;
537 }
538 }
539 }
540 }
541 }
542
543 return 'inbox';
544 }
545
546 sub filter_mail {
547 my @msglist = @_;
548 my $msgcount = scalar(@msglist);
549 my $msgcountlen = length($msgcount);
550 my @baton;
551 my $msg;
552 my $mhfolder;
553 my $spam = 0;
554 my $saved = 0;
555 my %FOLDERS = ('SPAM'=>1);
556
557 if (-f "$HOME/.mincspam") {
558 require "$HOME/.mincspam";
559 } else {
560 eval "sub spam_start_hook { return (); }";
561 eval "sub spam_stop_hook { }";
562 eval "sub spam_check { return 0; }";
563 }
564
565 @baton = spam_start_hook();
566
567 foreach $msg (@msglist) {
568 if (spam_check($msg, @baton)) {
569 $mhfolder = 'SPAM';
570 $spam = scalar(@SPAM)+ 1;
571 } else {
572 $mhfolder = find_mh_folder($msg);
573 $saved++;
574 }
575
576 store_message($msg, $mhfolder);
577
578 print("\r");
579 if (not $FOLDERS{$mhfolder}) {
580 print(' ' x $msgcountlen);
581 print(" \r+$mhfolder\n");
582 $FOLDERS{$mhfolder} = 1;
583 }
584
585 printf('%6d SPAM %6d saved %6d/%1d',
586 $spam, $saved, $spam + $saved, $msgcount);
587
588 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
589 }
590 print("\n");
591
592 spam_stop_hook(@baton);
593 }
594
595 \f
596 MAIN: {
597 my @msglist;
598 my $st;
599
600 if ($dumpfilters) {
601 $Data::Dumper::Indent = 1;
602 print(Dumper(\@FILTERS));
603 exit;
604 }
605
606 @msglist = (
607 map { $_->[1] }
608 sort { $a->[0] <=> $b->[0] }
609 map {
610 if (not ($st = stat($_))) {
611 die("stat($_): $!");
612 }
613 [$st->mtime, $_]
614 }
615 getfiles("$MAILDIR/new"));
616
617 filter_mail(@msglist);
618
619 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
620 }
621
622 \f
623 __END__
624
625 =head1 THE FILTERS STRUCTURE
626
627 The user's .mincfilter file must define the @FILTERS structure. This
628 structure is an array. Each element of @FILTERS is a filter. A
629 filter is itself an array. The first element of a filter is a string,
630 the name of the header this filter acts upon. The header name is not
631 case-sensitive. Each subsequent element of a filter is a pair (i.e. a
632 two-element array): first, a regular expression B<minc> uses to
633 determine whether this filter matches or not, and second, an
634 expression which B<minc> evaluates to get the folder name.
635
636 B<minc> decides where to store a message by iterating over the
637 @FILTERS array. It tests each regexp of each filter against all
638 headers matching that filter's specified header. As soon as a match
639 is found, B<minc> evaluates the second part of the pair. This part
640 may contain positional parameters from the matched regexp ($1, $2,
641 etc.). The result of this expression is used as the folder name.
642
643 Multiple occurrences of the same header are preserved and tested
644 individually against the filters. That means, for example, that
645 filters can search all the Received headers for a certain string.
646
647 It is important to note that all the arrays of the @FILTERS structure
648 are traversed I<in order>. This means the filters can be arranged so
649 that some have priority over others. XXX: get Doug to write an
650 example of having the same header matched more than once.
651
652 Lastly, B<minc> supports a magic ' TO' header. Filters using this
653 header are matched against a collection of headers related to (and
654 including) the To. Which headers to use is determined by a regular
655 expression borrowed from procmail.
656
657 =head1 EXAMPLES
658
659 @FILTERS = (
660
661 ['List-Id',
662 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
663 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
664 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
665 ],
666
667 ['List-Post',
668 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
669 ],
670
671 ['To',
672 ['root', 'root'],
673 ],
674 );
675
676 The first List-Id filter is a surprisingly effective one which creates
677 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
678 all Sourceforge lists to which i subscribe. Certainly there are lists
679 hosted at Sourceforge which do not work well with this, and this is
680 where it is important to remember that the first match is the one
681 uses. Simply put the more specific rules before this one.
682
683 The next List-Id example is simple. The swig example demonstrates
684 that the folder name does not have to use a portion of the matched
685 header; you can just hardcode the folder name.
686
687 The List-Post example is a nice one. Most ASF projects have their own
688 apache.org subdomain, with mailing lists hosted there. So, given a
689 list such as dev@httpd.apache.org, this filter will create the folder
690 name l/apache/httpd/dev.
691
692 For an example B<spam_check> function, see
693 L<http:E<047>E<047>pretzelnet.orgE<047>cvsE<047>dotfilesE<047>.mincspam>
694
695 =head1 AUTHORS
696
697 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
698 Gillespie and Doug Porter <dsp@waterspout.com>.
699
700 This program is free software; you can redistribute it and/or modify
701 it under the same terms as Perl itself.
702
703 =cut
704
705 # Local variables:
706 # cperl-indent-level: 4
707 # perl-indent-level: 4
708 # indent-tabs-mode: nil
709 # End:
710
711 # vi: set tabstop=4 expandtab: