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