]>
diplodocus.org Git - minc/blob - minc
8ac7af24cfceba99f8e3a5d113647ff7fc58ed7a
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
42 use Fcntl
qw(O_WRONLY O_EXCL O_CREAT);
44 use File
::Temp
qw(tempfile);
46 use Getopt
::Long
qw(:config gnu_getopt no_ignore_case);
47 use POSIX
qw(strftime WEXITSTATUS WIFEXITED);
52 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
53 # against headers matching this regex (taken from procmail).
54 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
55 my $MAGIC_TO_TOKEN = ' TO';
57 # List of SPAM message numbers, scanned at the end so the user can
58 # check for false positives.
67 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
68 for testing the syntax of .minc.
76 Stop processing after I<MAX> messages.
80 Dry run; do not actually incorporate the mail, but log and report to
81 stdout/stderr as normal.
85 Rebuild `mhpath +`/.folders from scratch, processing no mail.
95 my $rebuild_dot_folders;
102 'r' => \
$rebuild_dot_folders,
104 $help and pod2usage
(-exitstatus
=>0, -verbose
=>1);
105 @ARGV == 0 or pod2usage
();
115 How many columns the user's terminal can hold, used to print scan
116 lines for each processed message. Defaults to 80.
120 Where the configuration file (.minc) is found. Also,
121 $HOME/Maildir is used for the maildir if MAILDIR is not set.
125 Where mail is delivered.
131 use Env
qw(COLUMNS HOME MAILDIR);
136 die("HOME environment variable must be set.\n");
139 $MAILDIR = "$HOME/Maildir";
148 This file is Perl code (included via the 'require' directive) which
149 may define the FILTERS list, @start_hooks, @filter_hooks,
150 @post_store_hooks, and @stop_hooks.
152 =item `mhpath +`/.folders
154 B<minc> adds all folders it filters into to this file, which is used
155 by lukem's B<new(1)> (XXX need a link).
157 =item `mhpath +`/logs/minc.log
159 Where B<minc> logs what it does, unless in -n mode.
161 =item `mhpath +`/.minc.context
163 B<minc> uses this file for context (i.e. current folder) instead of
164 `mhpath +`/context. This allows you some amount of freedom to use mh
165 while B<minc> is running. For example, you can changed folders without
166 causing a message to be stored in the wrong folder. Care must still
167 be taken, however, about the unseen sequence; if you change it
168 (whether via show, or mark, or anything else) while in the same folder
169 as B<minc>, it is likely the new message B<minc> stores will not end up
177 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
178 our $SCAN_P_FOLDER = 0.1;
179 our $SCAN_P_MESSAGE = 0.0625;
180 our $SCAN_P_FROM = 0.175;
181 our @folder_sort_list = (qr/^inbox$/);
182 our $folder_sorter = sub { sort_by_list
(@_, @folder_sort_list) };
189 my $logfile = $mh . '/logs/minc.log';
191 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
194 ###############################################################################
200 my $timestamp = strftime
('%b %e %H:%M:%S', localtime);
202 foreach my $part (@_) {
203 if (defined($part)) {
207 # no newlines in the log message, thanks
210 open(LOG
, ">>$logfile") or die("open(>>$logfile): $!");
211 print(LOG
"$timestamp $msg\n") or die("print(>>$logfile): $!");
212 close(LOG
) or die("close($logfile): $!");
216 my ($text, @contents) = @_;
220 $last = $contents[-1];
225 mylog
('<< ', $text, $last);
231 # For an explanation of the %headers structure, see the
232 # get_headers function below.
233 logheader
('From: ', @{$headers{'return-path'}});
234 logheader
('To: ', @{$headers{'to'}});
235 logheader
('Subject: ', @{$headers{'subject'}});
236 logheader
('Message-Id: ', @{$headers{'message-id'}});
240 ###############################################################################
269 foreach $component (split('/', $folder)) {
270 $target = join('/', $target, $component);
271 -d
$target or mkdir($target) or die("mkdir($target): $!");
279 if (not opendir(DIR
, $dir)) {
280 die("opendir($dir): $!");
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,
289 @result = readdir(DIR
);
291 die("readdir($dir): $!");
298 STDOUT-
>autoflush(1);
299 print(@result - 2, " messages...");
307 sub get_highest_msgnum
{
308 my $mhfolder = shift;
314 if (defined($msgnum_cache{$mhfolder})) {
315 return $msgnum_cache{$mhfolder}++;
318 $dir = "$mh/$mhfolder";
319 if (not opendir(DIR
, $dir)) {
320 die("opendir($dir): $!");
323 # Insert rant from getfiles here.
325 @list = readdir(DIR
);
328 die("readdir($dir): $!");
334 foreach $msgnum (@list) {
336 if ($msgnum =~ /^[0-9]+$/) {
337 if ($msgnum > $highest) {
343 return $msgnum_cache{$mhfolder} = $highest;
350 my $fn = "$mh/$folder/.mh_sequences";
354 if (not open($fh, $fn)) {
355 $!{ENOENT
} or die("open($fn): $!");
362 open($fh, '>', $fn) or die("open(>$fn): $!");
363 for $_ (@sequences) {
365 print($fh "$_ $msgnum\n");
371 $marked or print($fh "$seq: $msgnum\n");
372 close($fh) or die("close(>$fn): $!");
377 my $mhfolder = shift;
383 # We must do this even in -n mode because later steps fail without
384 # it. This should be harmless.
387 # This loop is a modified version of the maildir delivery algorithm.
388 $msgnum = get_highest_msgnum
($mhfolder);
389 for ($try = 0; ; $try++) {
391 $mhmsg = "$mh/$mhfolder/$msgnum";
393 if (not stat($mhmsg)) {
395 # Now we have a non-existent file, let's try to create
396 # it. We must create a zero-byte file first because a
397 # file my appear between our happy stat results and
398 # our later rename(2), which would clobber said file.
399 # So attempt to create a file with this name. If it
400 # succeeds, in just a bit here we'll knowingly clobber
401 # this file with the rename(2) call.
403 # Another way to do this is not to use rename(2), but
404 # use link(2) + unlink(2) instead. That's how the
405 # standard maildir algorithm does it. Each method has
406 # a disadvantage: the program may crash between the
407 # link(2) and unlink(2) calls. With the standard
408 # maildir algorithm, that means the message will end
409 # up duplicated. The advantage of creating an empty
410 # file followed by rename(2) is that an extra empty
411 # file is left behind as opposed to a duplicate
412 # message. This is more easily detected by the user.
414 if (sysopen(MSG
, $mhmsg,
415 O_WRONLY
| O_EXCL
| O_CREAT
, 0600)) {
425 # This algorithm is different from the maildir one; let's make
426 # 10 tries instead of 3.
428 die("Attempted filename $mhmsg exists.");
431 # This algorithm is different; i don't think we need to sleep.
435 mylog
('+', $mhfolder);
438 if (not rename($msg, $mhmsg)) {
439 die("rename($msg, $mhmsg): $!");
442 # Mark each message as soon as we store it and bomb if that
443 # fails. While it is slow, it is not safe to store multiple
444 # messages and then have a failure before marking some (or
446 if ($mhfolder eq 'SPAM') {
447 push(@SPAM, $msgnum);
449 mark
($mhfolder, $msgnum, 'unseen');
456 # Parse a message file into a structure describing the headers. The
457 # structure is a hash of arrays. The hash keys are the names of the
458 # headers found in the message, made all lower-case. Each item in
459 # the hash is an array of header text. The array has one element
460 # per occurrence of the header. Most headers will only have a single
461 # element because they occur only once. The Received header is the
462 # most common header with multiple texts.
466 my $current; # current header, used for unfolding lines
467 my $fieldname; # unmolested header name
468 my $contents; # contents of header
470 open(MSG
, $msg) or die("open(MSG, $msg): $!");
478 # folded header continuation
480 if (not defined($current)) {
481 warn('Malformed message, cannot parse headers.');
485 @{$headers{$current}}[-1] .= $_;
487 ($fieldname) = split(/:/);
488 $current = lc($fieldname);
489 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
491 if (defined($headers{$current})) {
492 # multiple occurence of same header
493 push(@{$headers{$current}}, $contents);
495 # first occurence of a header
496 $headers{$current} = [$contents];
506 ###############################################################################
525 # Walk the list of filters. This structure is documented in
526 # pod at the end of the program.
527 foreach $filterref (@FILTERS) {
528 @filter = @$filterref;
529 $header = shift(@filter);
531 # Handle filters using the magic TO header specially.
532 if ($header eq $MAGIC_TO_TOKEN) {
533 foreach $header (keys(%headers)) {
534 if ($header =~ /$MAGIC_TO_REGEX/i) {
535 foreach $contents (@{$headers{lc($header)}}) {
536 foreach $pair (@filter) {
537 ($regexp, $expression) = @$pair;
538 if ($contents =~ $regexp) {
539 if (eval "\$result = \"$expression\"") {
548 # Now that it's been processed specially, skip normal handling.
552 # Walk the list of message headers matching the filter's
554 foreach $contents (@{$headers{lc($header)}}) {
555 # Walk the filter's list of regexp/expression pairs.
556 foreach $pair (@filter) {
557 ($regexp, $expression) = @$pair;
558 if ($contents =~ $regexp) {
559 if (eval "\$result = \"$expression\"") {
571 my $msgcount = @_ - 2; # don't count . and ..
572 my $len = length($msgcount);
581 if (-f
"$HOME/.minc") {
582 require "$HOME/.minc";
586 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
587 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
588 my $nF = int($COLUMNS * $SCAN_P_FROM);
589 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
592 for my $hook (@start_hooks) {
593 my ($handle, @baton) = $hook->();
594 if (defined($handle)) {
595 $batons{$handle} = [@baton];
600 ($msg eq '.' or $msg eq '..') and next;
602 my %headers = get_headers
($msg);
603 log_headers
(%headers);
606 for my $hook (@filter_hooks) {
607 my $result = $hook->(\
%batons, \
%headers, $msg);
608 defined($result) and ($mhfolder = $result);
611 defined($mhfolder) or ($mhfolder = find_mh_folder
($msg, %headers));
613 $msgnum = store_message
($msg, $mhfolder);
614 $folders{$mhfolder}++;
616 if ($mhfolder eq 'SPAM') {
622 # Sometimes these headers are missing...
623 eval { $from = [@{$headers{'from'}}]->[-1] };
624 eval { $subject = [@{$headers{'subject'}}]->[-1] };
625 for ($from, $subject) {
626 tr/\x00-\x1f\x80-\xff/ /;
629 print(' ' x
$COLUMNS);
630 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
631 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
632 substr($from, 0, $nF),
633 substr($subject, 0, $ns));
636 for my $hook (@post_store_hooks) {
637 $hook->(\
%batons, \
%headers, $mhfolder, $msgnum);
641 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
642 $spam, $saved, $spam + $saved, $msgcount);
644 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
648 for my $hook (@stop_hooks) {
655 sub build_dot_folders
{
667 eval { ($fh, $fn) = tempfile
("$mh/.folders.XXXXX") };
673 for my $folder (sort { $folder_sorter->($a,$b) } keys(%$folders)) {
674 print($fh "$folder\n");
677 if (not close($fh)) {
678 warn("close($fn): $!");
679 unlink($fn) or warn("unlink($fn): $!");
683 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
686 sub create_dot_folders
{
687 if (-f
"$HOME/.minc") {
688 require "$HOME/.minc";
692 open(my $fh, '-|', 'folders', '-fast', '-recur')
693 or die("open(folders|): $!");
694 build_dot_folders
(\
%folders, $fh);
698 sub update_dot_folders
{
702 if (not open($fh, "$mh/.folders") and not $!{ENOENT
}) {
703 # For ENOENT, we go ahead and create it, else we error and
705 warn("open($mh/.folders): $!");
709 build_dot_folders
($folders, $fh);
717 require "$HOME/.minc";
718 $Data::Dumper
::Indent
= 1;
719 print(Dumper
(\
@FILTERS));
723 $rebuild_dot_folders and exit(create_dot_folders
);
725 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
726 my %folders = filter_mail
(map { $_->[1] }
727 sort { $a->[0] <=> $b->[0] }
729 if (not ($st = stat($_))) {
736 $run and update_dot_folders
(\
%folders);
738 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
744 =head1 THE FILTERS STRUCTURE
746 Each element of @FILTERS is a filter. A filter is itself an array.
747 The first element of a filter is a string, the name of the header this
748 filter acts upon. The header name is not case-sensitive. Each
749 subsequent element of a filter is a pair (i.e. a two-element array):
750 first, a regular expression B<minc> uses to determine whether this
751 filter matches or not, and second, an expression which B<minc>
752 evaluates to get the folder name.
754 B<minc> decides where to store a message by iterating over the
755 @FILTERS array. It tests each regexp of each filter against all
756 headers matching that filter's specified header. As soon as a match
757 is found, B<minc> evaluates the second part of the pair. This part
758 may contain positional parameters from the matched regexp ($1, $2,
759 etc.). The result of this expression is used as the folder name.
761 Multiple occurrences of the same header are preserved and tested
762 individually against the filters. That means, for example, that
763 filters can search all the Received headers for a certain string.
765 It is important to note that all the arrays of the @FILTERS structure
766 are traversed I<in order>. This means the filters can be arranged so
767 that some have priority over others. XXX: get Doug to write an
768 example of having the same header matched more than once.
770 Lastly, B<minc> supports a magic ' TO' header. Filters using this
771 header are matched against a collection of headers related to (and
772 including) the To. Which headers to use is determined by a regular
773 expression borrowed from procmail.
777 Filter hooks take a reference to a hash of batons, a reference to a
778 hash of headers, and the message filename as arguments. It returns
779 undef to decline filtering of this message (thus falling back to
780 subsequent filter hooks, and finally @FILTERS), or the name of the
781 folder to store this message into.
783 One of B<minc>'s global variables is available to the user-defined
784 hooks: $run. This boolean should be honored; hooks should only take
785 real action (i.e. removing or creating files, running external
786 programs, etc.) if $run is non-zero.
788 The baton hash is created simply from the start hooks; if the hook
789 returns at least one defined value, this value is used as the key and
790 all other return values are put into a list reference as the value.
791 This hash is then passed by reference to all filter, post-store, and
794 Post store hooks take a reference to a hash of batons, a reference to
795 a hash of headers, the folder this message was stored in, and its new
798 XXX: need more details about the hook process; for now read the code.
805 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
806 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
807 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
811 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
819 The first List-Id filter is a surprisingly effective one which creates
820 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
821 all Sourceforge lists to which i subscribe. Certainly there are lists
822 hosted at Sourceforge which do not work well with this, and this is
823 where it is important to remember that the first match is the one
824 uses. Simply put the more specific rules before this one.
826 The next List-Id example is simple. The swig example demonstrates
827 that the folder name does not have to use a portion of the matched
828 header; you can just hardcode the folder name.
830 The List-Post example is a nice one. Most ASF projects have their own
831 apache.org subdomain, with mailing lists hosted there. So, given a
832 list such as dev@httpd.apache.org, this filter will create the folder
833 name l/apache/httpd/dev.
835 XXX Need hook examples.
839 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
840 Gillespie and Doug Porter <dsp@waterspout.com>.
842 This program is free software; you can redistribute it and/or modify
843 it under the same terms as Perl itself.
848 # cperl-indent-level: 4
849 # perl-indent-level: 4
850 # indent-tabs-mode: nil
853 # vi: set tabstop=4 expandtab: