]>
diplodocus.org Git - minc/blob - minc
f8d54dd2d1735868f8a6a04d112f3a0689ca7753
7 B<minc> - incorporate mail from a maildir into mh folders
11 B<minc> [B<-m> I<MAX>] [B<-n>]
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 running each message through regular expression based filter and hook
22 functions to determine in which folder to store it or whether it's
23 spam. Post-processing hooks may be applied to each message.
25 As it processes each message, B<minc> prints a line for each message
26 similar to B<inc(1)> and B<scan(1)>. This line includes the folder
27 and message number in which the message was stored, the last 'From'
28 header, and the last 'Subject' header. These fields are truncated to
29 fit in the user's terminal (see COLUMNS in B<ENVIRONMENT> below) in
30 the following proportions: folder (0.1), message number (0.0625), from
31 header (0.175). Any of these may be overridden with $SCAN_P_FOLDER,
32 $SCAN_P_MESSAGE, or $SCAN_P_FROM. The subject always fills out the
40 $SIG{'PIPE'} = 'IGNORE';
44 use Fcntl
qw(O_WRONLY O_EXCL O_CREAT);
46 use File
::Temp
qw(tempfile);
48 use Getopt
::Long
qw(:config gnu_getopt no_ignore_case);
49 use POSIX
qw(strftime WEXITSTATUS WIFEXITED);
54 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
55 # against headers matching this regex (taken from procmail).
56 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
57 my $MAGIC_TO_TOKEN = ' TO';
59 # List of SPAM message numbers, scanned at the end so the user can
60 # check for false positives.
69 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
70 for testing the syntax of .minc.
78 Stop processing after I<MAX> messages.
82 Dry run; do not actually incorporate the mail, but log and report to
83 stdout/stderr as normal.
87 Rebuild `mhpath +`/.folders from scratch, processing no mail.
97 my $rebuild_dot_folders;
100 'd' => \
$dumpfilters,
104 'r' => \
$rebuild_dot_folders,
106 $help and pod2usage
(-exitstatus
=>0, -verbose
=>1);
107 @ARGV == 0 or pod2usage
();
117 How many columns the user's terminal can hold, used to print scan
118 lines for each processed message. Defaults to 80.
122 Where the configuration file (.minc) is found. Also,
123 $HOME/Maildir is used for the maildir if MAILDIR is not set.
127 Where mail is delivered.
133 use Env
qw(COLUMNS HOME MAILDIR);
138 die("HOME environment variable must be set.\n");
141 $MAILDIR = "$HOME/Maildir";
150 This file is Perl code (included via the 'require' directive) which
151 may define the FILTERS list, @start_hooks, @filter_hooks,
152 @post_store_hooks, and @stop_hooks.
154 =item `mhpath +`/.folders
156 B<minc> adds all folders it filters into to this file, which is used
157 by lukem's B<new(1)> (XXX need a link).
159 =item `mhpath +`/logs/minc.log
161 Where B<minc> logs what it does, unless in -n mode.
163 =item `mhpath +`/.minc.context
165 B<minc> uses this file for context (i.e. current folder) instead of
166 `mhpath +`/context. This allows you some amount of freedom to use mh
167 while B<minc> is running. For example, you can changed folders without
168 causing a message to be stored in the wrong folder. Care must still
169 be taken, however, about the unseen sequence; if you change it
170 (whether via show, or mark, or anything else) while in the same folder
171 as B<minc>, it is likely the new message B<minc> stores will not end up
179 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
180 our $SCAN_P_FOLDER = 0.1;
181 our $SCAN_P_MESSAGE = 0.0625;
182 our $SCAN_P_FROM = 0.175;
183 our @folder_sort_list = (qr/^inbox$/);
184 our $folder_sorter = sub { sort_by_list
(@_, @folder_sort_list) };
191 my $logfile = $mh . '/logs/minc.log';
193 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
196 ###############################################################################
202 my $timestamp = strftime
('%b %e %H:%M:%S', localtime);
204 foreach my $part (@_) {
205 if (defined($part)) {
209 # no newlines in the log message, thanks
212 open(LOG
, ">>$logfile") or die("open(>>$logfile): $!");
213 print(LOG
"$timestamp $msg\n") or die("print(>>$logfile): $!");
214 close(LOG
) or die("close($logfile): $!");
218 my ($text, @contents) = @_;
222 $last = $contents[-1];
227 mylog
('<< ', $text, $last);
233 # For an explanation of the %headers structure, see the
234 # get_headers function below.
235 logheader
('From: ', @{$headers{'return-path'}});
236 logheader
('To: ', @{$headers{'to'}});
237 logheader
('Subject: ', @{$headers{'subject'}});
238 logheader
('Message-Id: ', @{$headers{'message-id'}});
242 ###############################################################################
271 foreach $component (split('/', $folder)) {
272 $target = join('/', $target, $component);
273 -d
$target or mkdir($target) or die("mkdir($target): $!");
281 if (not opendir(DIR
, $dir)) {
282 die("opendir($dir): $!");
285 # Initialize $! to 0 (success) because stupid stupid Perl provides
286 # no way to distinguish an error from an empty directory; that
287 # means setting $! to 0 and testing it afterwards is the only way
288 # to detect an error. Real Programmers don't handle errors,
291 @result = readdir(DIR
);
293 die("readdir($dir): $!");
300 STDOUT-
>autoflush(1);
301 print(@result - 2, " messages...");
309 sub get_highest_msgnum
{
310 my $mhfolder = shift;
316 if (defined($msgnum_cache{$mhfolder})) {
317 return $msgnum_cache{$mhfolder}++;
320 $dir = "$mh/$mhfolder";
321 if (not opendir(DIR
, $dir)) {
322 die("opendir($dir): $!");
325 # Insert rant from getfiles here.
327 @list = readdir(DIR
);
330 die("readdir($dir): $!");
336 foreach $msgnum (@list) {
338 if ($msgnum =~ /^[0-9]+$/) {
339 if ($msgnum > $highest) {
345 $msgnum_cache{$mhfolder} = $highest;
346 return $msgnum_cache{$mhfolder}++;
353 my $fn = "$mh/$folder/.mh_sequences";
357 if (not open($fh, $fn)) {
358 $!{ENOENT
} or die("open($fn): $!");
365 open($fh, '>', $fn) or die("open(>$fn): $!");
366 for $_ (@sequences) {
371 for my $part (split(' ', $1)) {
373 my ($st, $en) = split('-', $part);
374 if ((defined($en) and ($msgnum >= $st and $msgnum <= $en))
376 # It's already there.
380 if ($st - 1 == $msgnum) {
381 $part = "$msgnum-$en";
383 } elsif ($en + 1 == $msgnum) {
384 $part = "$st-$msgnum";
388 if ($part - 1 == $msgnum) {
389 $part = "$msgnum-$part";
391 } elsif ($part + 1 == $msgnum) {
392 $part = "$part-$msgnum";
397 push(@result, $part);
400 push(@result, $msgnum);
402 print($fh "$seq: ", join(' ', @result), "\n");
408 $marked or print($fh "$seq: $msgnum\n");
409 close($fh) or die("close(>$fn): $!");
414 my $mhfolder = shift;
420 # We must do this even in -n mode because later steps fail without
421 # it. This should be harmless.
424 # This loop is a modified version of the maildir delivery algorithm.
425 $msgnum = get_highest_msgnum
($mhfolder);
426 for ($try = 0; ; $try++) {
428 $mhmsg = "$mh/$mhfolder/$msgnum";
430 if (not stat($mhmsg)) {
432 # Now we have a non-existent file, let's try to create
433 # it. We must create a zero-byte file first because a
434 # file my appear between our happy stat results and
435 # our later rename(2), which would clobber said file.
436 # So attempt to create a file with this name. If it
437 # succeeds, in just a bit here we'll knowingly clobber
438 # this file with the rename(2) call.
440 # Another way to do this is not to use rename(2), but
441 # use link(2) + unlink(2) instead. That's how the
442 # standard maildir algorithm does it. Each method has
443 # a disadvantage: the program may crash between the
444 # link(2) and unlink(2) calls. With the standard
445 # maildir algorithm, that means the message will end
446 # up duplicated. The advantage of creating an empty
447 # file followed by rename(2) is that an extra empty
448 # file is left behind as opposed to a duplicate
449 # message. This is more easily detected by the user.
451 if (sysopen(MSG
, $mhmsg,
452 O_WRONLY
| O_EXCL
| O_CREAT
, 0600)) {
462 # This algorithm is different from the maildir one; let's make
463 # 10 tries instead of 3.
465 die("Attempted filename $mhmsg exists.");
468 # This algorithm is different; i don't think we need to sleep.
472 mylog
('+', $mhfolder);
475 if (not rename($msg, $mhmsg)) {
476 die("rename($msg, $mhmsg): $!");
479 # Mark each message as soon as we store it and bomb if that
480 # fails. While it is slow, it is not safe to store multiple
481 # messages and then have a failure before marking some (or
483 if ($mhfolder eq 'SPAM') {
484 push(@SPAM, $msgnum);
486 mark
($mhfolder, $msgnum, 'unseen');
493 # Parse a message file into a structure describing the headers. The
494 # structure is a hash of arrays. The hash keys are the names of the
495 # headers found in the message, made all lower-case. Each item in
496 # the hash is an array of header text. The array has one element
497 # per occurrence of the header. Most headers will only have a single
498 # element because they occur only once. The Received header is the
499 # most common header with multiple texts.
503 my $current; # current header, used for unfolding lines
504 my $fieldname; # unmolested header name
505 my $contents; # contents of header
507 open(MSG
, $msg) or die("open(MSG, $msg): $!");
515 # folded header continuation
517 if (not defined($current)) {
518 warn('Malformed message, cannot parse headers.');
522 @{$headers{$current}}[-1] .= $_;
524 ($fieldname) = split(/:/);
525 $current = lc($fieldname);
526 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
528 if (defined($headers{$current})) {
529 # multiple occurence of same header
530 push(@{$headers{$current}}, $contents);
532 # first occurence of a header
533 $headers{$current} = [$contents];
543 ###############################################################################
562 # Walk the list of filters. This structure is documented in
563 # pod at the end of the program.
564 foreach $filterref (@FILTERS) {
565 @filter = @$filterref;
566 $header = shift(@filter);
568 # Handle filters using the magic TO header specially.
569 if ($header eq $MAGIC_TO_TOKEN) {
570 foreach $header (keys(%headers)) {
571 if ($header =~ /$MAGIC_TO_REGEX/i) {
572 foreach $contents (@{$headers{lc($header)}}) {
573 foreach $pair (@filter) {
574 ($regexp, $expression) = @$pair;
575 if ($contents =~ $regexp) {
576 if (eval "\$result = \"$expression\"") {
585 # Now that it's been processed specially, skip normal handling.
589 # Walk the list of message headers matching the filter's
591 foreach $contents (@{$headers{lc($header)}}) {
592 # Walk the filter's list of regexp/expression pairs.
593 foreach $pair (@filter) {
594 ($regexp, $expression) = @$pair;
595 if ($contents =~ $regexp) {
596 if (eval "\$result = \"$expression\"") {
609 my $msgcount = @_ - 2; # don't count . and ..
610 my $len = length($msgcount);
619 if (-f
"$HOME/.minc") {
620 require "$HOME/.minc";
624 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
625 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
626 my $nF = int($COLUMNS * $SCAN_P_FROM);
627 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
630 for my $hook (@start_hooks) {
631 my ($handle, @baton) = $hook->();
632 if (defined($handle)) {
633 $batons{$handle} = [@baton];
638 ($msg eq '.' or $msg eq '..') and next;
640 my %headers = get_headers
($msg);
641 log_headers
(%headers);
644 for my $hook (@filter_hooks) {
645 my $result = $hook->(\
%batons, \
%headers, $msg);
646 defined($result) and ($mhfolder = $result);
649 defined($mhfolder) or ($mhfolder = find_mh_folder
($msg, %headers));
651 $msgnum = store_message
($msg, $mhfolder);
652 $folders{$mhfolder}++;
654 if ($mhfolder eq 'SPAM') {
660 # Sometimes these headers are missing...
661 eval { $from = [@{$headers{'from'}}]->[-1] };
662 eval { $subject = [@{$headers{'subject'}}]->[-1] };
663 for ($from, $subject) {
664 tr/\x00-\x1f\x80-\xff/ /;
666 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
667 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
668 substr($from, 0, $nF),
669 substr($subject, 0, $ns));
672 for my $hook (@post_store_hooks) {
673 $hook->(\
%batons, \
%headers, $mhfolder, $msgnum);
676 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
677 $spam, $saved, $spam + $saved, $msgcount);
679 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
683 for my $hook (@stop_hooks) {
690 sub build_dot_folders
{
702 eval { ($fh, $fn) = tempfile
("$mh/.folders.XXXXX") };
708 for my $folder (sort { $folder_sorter->($a,$b) } keys(%$folders)) {
709 print($fh "$folder\n");
712 if (not close($fh)) {
713 warn("close($fn): $!");
714 unlink($fn) or warn("unlink($fn): $!");
718 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
721 sub create_dot_folders
{
722 if (-f
"$HOME/.minc") {
723 require "$HOME/.minc";
727 open(my $fh, '-|', 'folders', '-fast', '-recur')
728 or die("open(folders|): $!");
729 build_dot_folders
(\
%folders, $fh);
733 sub update_dot_folders
{
737 if (not open($fh, "$mh/.folders") and not $!{ENOENT
}) {
738 # For ENOENT, we go ahead and create it, else we error and
740 warn("open($mh/.folders): $!");
744 build_dot_folders
($folders, $fh);
747 # XXX Could use some unification with getfiles...
749 my $dir = "$MAILDIR/spam";
751 if (not chdir($dir)) {
752 print(STDERR
"skipping maildir spam: chdir($dir): $!\n");
756 if (not opendir(DIR
, '.')) {
757 print(STDERR
"skipping maildir spam: opendir($dir): $!\n");
762 my @spams = readdir(DIR
);
764 print(STDERR
"skipping maildir spam: readdir($dir): $!\n");
770 for my $msg (@spams) {
771 ($msg eq '.' or $msg eq '..') and next;
772 store_message
($msg, 'SPAM');
781 require "$HOME/.minc";
782 $Data::Dumper
::Indent
= 1;
783 print(Dumper
(\
@FILTERS));
787 $rebuild_dot_folders and exit(create_dot_folders
);
789 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
790 my %folders = filter_mail
(map { $_->[1] }
791 sort { $a->[0] <=> $b->[0] }
793 if (not ($st = stat($_))) {
800 $run and %folders and update_dot_folders
(\
%folders);
804 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
810 =head1 THE FILTERS STRUCTURE
812 Each element of @FILTERS is a filter. A filter is itself an array.
813 The first element of a filter is a string, the name of the header this
814 filter acts upon. The header name is not case-sensitive. Each
815 subsequent element of a filter is a pair (i.e. a two-element array):
816 first, a regular expression B<minc> uses to determine whether this
817 filter matches or not, and second, an expression which B<minc>
818 evaluates to get the folder name.
820 B<minc> decides where to store a message by iterating over the
821 @FILTERS array. It tests each regexp of each filter against all
822 headers matching that filter's specified header. As soon as a match
823 is found, B<minc> evaluates the second part of the pair. This part
824 may contain positional parameters from the matched regexp ($1, $2,
825 etc.). The result of this expression is used as the folder name.
827 Multiple occurrences of the same header are preserved and tested
828 individually against the filters. That means, for example, that
829 filters can search all the Received headers for a certain string.
831 It is important to note that all the arrays of the @FILTERS structure
832 are traversed I<in order>. This means the filters can be arranged so
833 that some have priority over others. XXX: get Doug to write an
834 example of having the same header matched more than once.
836 Lastly, B<minc> supports a magic ' TO' header. Filters using this
837 header are matched against a collection of headers related to (and
838 including) the To. Which headers to use is determined by a regular
839 expression borrowed from procmail.
843 Filter hooks take a reference to a hash of batons, a reference to a
844 hash of headers, and the message filename as arguments. It returns
845 undef to decline filtering of this message (thus falling back to
846 subsequent filter hooks, and finally @FILTERS), or the name of the
847 folder to store this message into.
849 One of B<minc>'s global variables is available to the user-defined
850 hooks: $run. This boolean should be honored; hooks should only take
851 real action (i.e. removing or creating files, running external
852 programs, etc.) if $run is non-zero.
854 The baton hash is created simply from the start hooks; if the hook
855 returns at least one defined value, this value is used as the key and
856 all other return values are put into a list reference as the value.
857 This hash is then passed by reference to all filter, post-store, and
860 Post store hooks take a reference to a hash of batons, a reference to
861 a hash of headers, the folder this message was stored in, and its new
864 XXX: need more details about the hook process; for now read the code.
871 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
872 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
873 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
877 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
885 The first List-Id filter is a surprisingly effective one which creates
886 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
887 all Sourceforge lists to which i subscribe. Certainly there are lists
888 hosted at Sourceforge which do not work well with this, and this is
889 where it is important to remember that the first match is the one
890 uses. Simply put the more specific rules before this one.
892 The next List-Id example is simple. The swig example demonstrates
893 that the folder name does not have to use a portion of the matched
894 header; you can just hardcode the folder name.
896 The List-Post example is a nice one. Most ASF projects have their own
897 apache.org subdomain, with mailing lists hosted there. So, given a
898 list such as dev@httpd.apache.org, this filter will create the folder
899 name l/apache/httpd/dev.
901 XXX Need hook examples.
905 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
906 Gillespie and Doug Porter <dsp@waterspout.com>.
908 This program is free software; you can redistribute it and/or modify
909 it under the same terms as Perl itself.
914 # cperl-indent-level: 4
915 # perl-indent-level: 4
916 # indent-tabs-mode: nil
919 # vi: set tabstop=4 expandtab: