]>
diplodocus.org Git - minc/blob - minc
34f50ef23535aa964acfb09b3d967e813dfb978a
7 B<minc> - incorporate mail from a maildir into mh folders
11 B<minc> [B<-m> I<MAX>] [B<-n>] [B<-p>]
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.
24 The filtering is quite sophisticated, as it is done using real Perl
25 matching (m//) commands.
34 use Fcntl
qw(O_WRONLY O_EXCL O_CREAT);
36 use File
::Temp
qw(tempfile);
38 use Getopt
::Long
qw(:config gnu_getopt no_ignore_case);
39 use POSIX
qw(strftime WEXITSTATUS WIFEXITED);
44 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
45 # against headers matching this regex (taken from procmail).
46 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
47 my $MAGIC_TO_TOKEN = ' TO';
49 # List of SPAM message numbers, scanned at the end so the user can
50 # check for false positives.
59 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
60 for testing the syntax of .mincfilter.
68 Stop processing after I<MAX> messages.
72 Dry run; do not actually incorporate the mail, but log and report to
73 stdout/stderr as normal.
77 Print the filename of each message before checking it for spam. This
78 can be handy if a particular message is giving the spam checker a
96 'p' => \
$printfilenames,
98 $help and pod2usage
(-exitstatus
=>0, -verbose
=>1);
99 @ARGV == 0 or pod2usage
();
109 Where configuration files (.mincfilter) are found. Also,
110 $HOME/Maildir is used for the maildir if MAILDIR is not set.
114 Where mail is delivered.
120 use Env
qw(COLUMNS HOME MAILDIR);
125 die("HOME environment variable must be set.\n");
128 $MAILDIR = "$HOME/Maildir";
135 =item $HOME/.mincfilter
137 This file is Perl code (included via the 'require' directive) which is
138 expected to define the FILTERS list.
140 =item $HOME/.mincspam
142 If this file exists, B<minc> will include it with the expectation that
143 it will define a B<spam_check> function. This function takes a
144 message filename as an argument and returns 1 if the message is spam,
145 else 0. If this file does not exist, B<minc> will define a simple
146 function that always returns 0.
148 One of B<minc>'s global variables is available to the user-defined
149 B<spam_check> function: $run. This boolean should be honored;
150 B<spam_check> should only take real action (i.e. removing or creating
151 files, running external programs, etc.) if $run is non-zero.
153 This file may also declare two other functions: B<spam_start_hook> and
154 B<spam_stop_hook>. The former is passed no arguments and is expected
155 to return a list. This list is a "baton" that will also be passed to
156 B<spam_check> and B<spam_stop_hook>. It can hold anything
157 B<spam_check> will need to do its job, whether network sockets, pipes,
160 XXX: need more details about the spam-handling process; for now read
163 =item `mhpath +`/logs/minc.log
165 Where B<minc> logs what it does, unless in -n mode.
167 =item `mhpath +`/logs/dryrun.log
169 Where B<minc> logs what it would do; used in -n mode.
171 =item `mhpath +`/.minc.context
173 B<minc> uses this file for context (i.e. current folder) instead of
174 `mhpath +`/context. This allows you some amount of freedom to use mh
175 while B<minc> is running. For example, you can changed folders without
176 causing a message to be stored in the wrong folder. Care must still
177 be taken, however, about the unseen sequence; if you change it
178 (whether via show, or mark, or anything else) while in the same folder
179 as B<minc>, it is likely the new message B<minc> stores will not end up
187 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
188 our $SCAN_P_FOLDER = 0.1;
189 our $SCAN_P_MESSAGE = 0.0625;
190 our $SCAN_P_FROM = 0.175;
199 $logfile = $mh . '/logs/minc.log';
201 $logfile = $mh . '/logs/dryrun.log';
204 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
207 ###############################################################################
211 my $timestamp = strftime
('%b %e %H:%M:%S', localtime);
213 foreach my $part (@_) {
214 if (defined($part)) {
218 # no newlines in the log message, thanks
221 open(LOG
, ">>$logfile") or die("open(>>$logfile): $!");
222 print(LOG
"$timestamp $msg\n") or die("print(>>$logfile): $!");
223 close(LOG
) or die("close($logfile): $!");
227 my ($text, @contents) = @_;
231 $last = $contents[-1];
236 mylog
('<< ', $text, $last);
242 # For an explanation of the %headers structure, see the
243 # get_headers function below.
244 logheader
('From: ', @{$headers{'return-path'}});
245 logheader
('To: ', @{$headers{'to'}});
246 logheader
('Subject: ', @{$headers{'subject'}});
247 logheader
('Message-Id: ', @{$headers{'message-id'}});
251 ###############################################################################
260 foreach $component (split('/', $folder)) {
261 $target = join('/', $target, $component);
262 -d
$target or mkdir($target) or die("mkdir($target): $!");
270 if (not opendir(DIR
, $dir)) {
271 die("opendir($dir): $!");
274 # Initialize $! to 0 (success) because stupid stupid Perl provides
275 # no way to distinguish an error from an empty directory; that
276 # means setting $! to 0 and testing it afterwards is the only way
277 # to detect an error. Real Programmers don't handle errors,
280 @result = readdir(DIR
);
282 die("readdir($dir): $!");
289 STDOUT-
>autoflush(1);
290 print(@result - 2, " messages...");
297 sub get_highest_msgnum
{
298 my $mhfolder = shift;
304 $dir = "$mh/$mhfolder";
305 if (not opendir(DIR
, $dir)) {
306 die("opendir($dir): $!");
309 # Insert rant from getfiles here.
311 @list = readdir(DIR
);
314 die("readdir($dir): $!");
320 foreach $msgnum (@list) {
322 if ($msgnum =~ /^[0-9]+$/) {
323 if ($msgnum > $highest) {
334 my $mhfolder = shift;
340 # We must do this even in -n mode because later steps fail without
341 # it. This should be harmless.
344 # This loop is a modified version of the maildir delivery algorithm.
345 $msgnum = get_highest_msgnum
($mhfolder);
346 for ($try = 0; ; $try++) {
348 $mhmsg = "$mh/$mhfolder/$msgnum";
350 if (not stat($mhmsg)) {
352 # Now we have a non-existent file, let's try to create
353 # it. We must create a zero-byte file first because a
354 # file my appear between our happy stat results and
355 # our later rename(2), which would clobber said file.
356 # So attempt to create a file with this name. If it
357 # succeeds, in just a bit here we'll knowingly clobber
358 # this file with the rename(2) call.
360 # Another way to do this is not to use rename(2), but
361 # use link(2) + unlink(2) instead. That's how the
362 # standard maildir algorithm does it. Each method has
363 # a disadvantage: the program may crash between the
364 # link(2) and unlink(2) calls. With the standard
365 # maildir algorithm, that means the message will end
366 # up duplicated. The advantage of creating an empty
367 # file followed by rename(2) is that an extra empty
368 # file is left behind as opposed to a duplicate
369 # message. This is more easily detected by the user.
371 if (sysopen(MSG
, $mhmsg,
372 O_WRONLY
| O_EXCL
| O_CREAT
, 0600)) {
382 # This algorithm is different from the maildir one; let's make
383 # 10 tries instead of 3.
385 die("Attempted filename $mhmsg exists.");
388 # This algorithm is different; i don't think we need to sleep.
392 if ($mhfolder ne 'SPAM') {
393 mylog
('+', $mhfolder);
397 if (not rename($msg, $mhmsg)) {
398 die("rename($msg, $mhmsg): $!");
401 # Mark each message as soon as we store it and bomb if that
402 # fails. While it is slow, it is not safe to store multiple
403 # messages and then have a failure before marking some (or
405 if ($mhfolder eq 'SPAM') {
406 push(@SPAM, $msgnum);
408 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
410 # XXX need to handle signalled and stopped, and print
411 # the exit code or signal number.
412 if (not WIFEXITED
($status)) {
413 die("Failed to run mark");
414 } elsif (WEXITSTATUS
($status) != 0) {
415 die("Failed to mark message unseen.");
423 # Parse a message file into a structure describing the headers. The
424 # structure is a hash of arrays. The hash keys are the names of the
425 # headers found in the message, made all lower-case. Each item in
426 # the hash is an array of header text. The array has one element
427 # per occurrence of the header. Most headers will only have a single
428 # element because they occur only once. The Received header is the
429 # most common header with multiple texts.
433 my $current; # current header, used for unfolding lines
434 my $fieldname; # unmolested header name
435 my $contents; # contents of header
437 open(MSG
, $msg) or die("open(MSG, $msg): $!");
445 # folded header continuation
447 if (not defined($current)) {
448 warn('Malformed message, cannot parse headers.');
452 @{$headers{$current}}[-1] .= $_;
454 ($fieldname) = split(/:/);
455 $current = lc($fieldname);
456 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
458 if (defined($headers{$current})) {
459 # multiple occurence of same header
460 push(@{$headers{$current}}, $contents);
462 # first occurence of a header
463 $headers{$current} = [$contents];
473 ###############################################################################
492 # Walk the list of filters. This structure is documented in
493 # pod at the end of the program.
494 foreach $filterref (@FILTERS) {
495 @filter = @$filterref;
496 $header = shift(@filter);
498 # Handle filters using the magic TO header specially.
499 if ($header eq $MAGIC_TO_TOKEN) {
500 foreach $header (keys(%headers)) {
501 if ($header =~ /$MAGIC_TO_REGEX/i) {
502 foreach $contents (@{$headers{lc($header)}}) {
503 foreach $pair (@filter) {
504 ($match, $expression) = @$pair;
505 if ($contents =~ /$match/) {
513 # Now that it's been processed specially, skip normal handling.
517 # Walk the list of message headers matching the filter's
519 foreach $contents (@{$headers{lc($header)}}) {
520 # Walk the filter's list of match/expression pairs.
521 foreach $pair (@filter) {
522 ($match, $expression) = @$pair;
523 if ($contents =~ /$match/i) {
524 if (eval "\$result = \"$expression\"") {
536 my $msgcount = @_ - 2; # don't count . and ..
537 my $len = length($msgcount);
546 if (-f
"$HOME/.minc") {
547 require "$HOME/.minc";
551 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
552 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
553 my $nF = int($COLUMNS * $SCAN_P_FROM);
554 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
557 for my $hook (@start_hooks) {
558 my ($handle, @baton) = $hook->();
559 if (defined($handle)) {
560 $batons{$handle} = [@baton];
565 ($msg eq '.' or $msg eq '..') and next;
567 if ($printfilenames) {
571 my %headers = get_headers
($msg);
572 log_headers
(%headers);
575 for my $hook (@filter_hooks) {
576 my $result = $hook->(\
%batons, \
%headers, $msg);
577 defined($result) and ($mhfolder = $result);
580 defined($mhfolder) or ($mhfolder = find_mh_folder
($msg, %headers));
582 $msgnum = store_message
($msg, $mhfolder);
583 $folders{$mhfolder}++;
585 if ($mhfolder eq 'SPAM') {
589 my $from = [@{$headers{'from'}}]->[-1];
590 my $subject = [@{$headers{'subject'}}]->[-1];
591 for ($from, $subject) {
592 tr/\x00-\x1f\x80-\xff/ /;
595 print(' ' x
$COLUMNS);
596 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
597 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
598 # XXX shouldn't pop, as these are about to be
599 # passed to post_store_hooks
600 substr($from, 0, $nF),
601 substr($subject, 0, $ns));
604 for my $hook (@post_store_hooks) {
605 $hook->(\
%batons, \
%headers, $mhfolder, $msgnum);
609 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
610 $spam, $saved, $spam + $saved, $msgcount);
612 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
616 for my $hook (@stop_hooks) {
627 if (not open($fh, "$mh/.folders")) {
628 warn("open($mh/.folders): $!");
637 eval { ($fh, $fn) = tempfile
("$mh/.folders.XXXXX") };
643 print($fh "inbox\n");
644 for my $folder (sort(keys(%$folders))) {
645 $folder eq 'inbox' and next;
646 print($fh "$folder\n");
649 if (not close($fh)) {
650 warn("close($fn): $!");
651 unlink($fn) or warn("unlink($fn): $!");
655 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
663 require "$HOME/.minc";
664 $Data::Dumper
::Indent
= 1;
665 print(Dumper
(\
@FILTERS));
669 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
670 my %folders = filter_mail
(map { $_->[1] }
671 sort { $a->[0] <=> $b->[0] }
673 if (not ($st = stat($_))) {
680 update_folders
(\
%folders);
682 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
688 =head1 THE FILTERS STRUCTURE
690 The user's .mincfilter file must define the @FILTERS structure. This
691 structure is an array. Each element of @FILTERS is a filter. A
692 filter is itself an array. The first element of a filter is a string,
693 the name of the header this filter acts upon. The header name is not
694 case-sensitive. Each subsequent element of a filter is a pair (i.e. a
695 two-element array): first, a regular expression B<minc> uses to
696 determine whether this filter matches or not, and second, an
697 expression which B<minc> evaluates to get the folder name.
699 B<minc> decides where to store a message by iterating over the
700 @FILTERS array. It tests each regexp of each filter against all
701 headers matching that filter's specified header. As soon as a match
702 is found, B<minc> evaluates the second part of the pair. This part
703 may contain positional parameters from the matched regexp ($1, $2,
704 etc.). The result of this expression is used as the folder name.
706 Multiple occurrences of the same header are preserved and tested
707 individually against the filters. That means, for example, that
708 filters can search all the Received headers for a certain string.
710 It is important to note that all the arrays of the @FILTERS structure
711 are traversed I<in order>. This means the filters can be arranged so
712 that some have priority over others. XXX: get Doug to write an
713 example of having the same header matched more than once.
715 Lastly, B<minc> supports a magic ' TO' header. Filters using this
716 header are matched against a collection of headers related to (and
717 including) the To. Which headers to use is determined by a regular
718 expression borrowed from procmail.
725 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
726 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
727 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
731 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
739 The first List-Id filter is a surprisingly effective one which creates
740 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
741 all Sourceforge lists to which i subscribe. Certainly there are lists
742 hosted at Sourceforge which do not work well with this, and this is
743 where it is important to remember that the first match is the one
744 uses. Simply put the more specific rules before this one.
746 The next List-Id example is simple. The swig example demonstrates
747 that the folder name does not have to use a portion of the matched
748 header; you can just hardcode the folder name.
750 The List-Post example is a nice one. Most ASF projects have their own
751 apache.org subdomain, with mailing lists hosted there. So, given a
752 list such as dev@httpd.apache.org, this filter will create the folder
753 name l/apache/httpd/dev.
755 For an example B<spam_check> function, see
756 L<http://pretzelnet.org/cvs/dotfiles/.mincspam>
760 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
761 Gillespie and Doug Porter <dsp@waterspout.com>.
763 This program is free software; you can redistribute it and/or modify
764 it under the same terms as Perl itself.
769 # cperl-indent-level: 4
770 # perl-indent-level: 4
771 # indent-tabs-mode: nil
774 # vi: set tabstop=4 expandtab: