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