]>
diplodocus.org Git - minc/blob - minc
795187941ab16e0bf9d8ce8e5398a8105c64c996
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.
98 $help and pod2usage
(-exitstatus
=>0, -verbose
=>1);
99 @ARGV == 0 or pod2usage
();
109 How many columns the user's terminal can hold, used to print scan
110 lines for each processed message. Defaults to 80.
114 Where the configuration file (.minc) is found. Also,
115 $HOME/Maildir is used for the maildir if MAILDIR is not set.
119 Where mail is delivered.
125 use Env
qw(COLUMNS HOME MAILDIR);
130 die("HOME environment variable must be set.\n");
133 $MAILDIR = "$HOME/Maildir";
142 This file is Perl code (included via the 'require' directive) which
143 may define the FILTERS list, @start_hooks, @filter_hooks,
144 @post_store_hooks, and @stop_hooks.
146 =item `mhpath +`/.folders
148 B<minc> adds all folders it filters into to this file, which is used
149 by lukem's B<new(1)> (XXX need a link).
151 =item `mhpath +`/logs/minc.log
153 Where B<minc> logs what it does, unless in -n mode.
155 =item `mhpath +`/logs/dryrun.log
157 Where B<minc> logs what it would do; used in -n mode.
159 =item `mhpath +`/.minc.context
161 B<minc> uses this file for context (i.e. current folder) instead of
162 `mhpath +`/context. This allows you some amount of freedom to use mh
163 while B<minc> is running. For example, you can changed folders without
164 causing a message to be stored in the wrong folder. Care must still
165 be taken, however, about the unseen sequence; if you change it
166 (whether via show, or mark, or anything else) while in the same folder
167 as B<minc>, it is likely the new message B<minc> stores will not end up
175 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
176 our $SCAN_P_FOLDER = 0.1;
177 our $SCAN_P_MESSAGE = 0.0625;
178 our $SCAN_P_FROM = 0.175;
187 $logfile = $mh . '/logs/minc.log';
189 $logfile = $mh . '/logs/dryrun.log';
192 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
195 ###############################################################################
199 my $timestamp = strftime
('%b %e %H:%M:%S', localtime);
201 foreach my $part (@_) {
202 if (defined($part)) {
206 # no newlines in the log message, thanks
209 open(LOG
, ">>$logfile") or die("open(>>$logfile): $!");
210 print(LOG
"$timestamp $msg\n") or die("print(>>$logfile): $!");
211 close(LOG
) or die("close($logfile): $!");
215 my ($text, @contents) = @_;
219 $last = $contents[-1];
224 mylog
('<< ', $text, $last);
230 # For an explanation of the %headers structure, see the
231 # get_headers function below.
232 logheader
('From: ', @{$headers{'return-path'}});
233 logheader
('To: ', @{$headers{'to'}});
234 logheader
('Subject: ', @{$headers{'subject'}});
235 logheader
('Message-Id: ', @{$headers{'message-id'}});
239 ###############################################################################
248 foreach $component (split('/', $folder)) {
249 $target = join('/', $target, $component);
250 -d
$target or mkdir($target) or die("mkdir($target): $!");
258 if (not opendir(DIR
, $dir)) {
259 die("opendir($dir): $!");
262 # Initialize $! to 0 (success) because stupid stupid Perl provides
263 # no way to distinguish an error from an empty directory; that
264 # means setting $! to 0 and testing it afterwards is the only way
265 # to detect an error. Real Programmers don't handle errors,
268 @result = readdir(DIR
);
270 die("readdir($dir): $!");
277 STDOUT-
>autoflush(1);
278 print(@result - 2, " messages...");
285 sub get_highest_msgnum
{
286 my $mhfolder = shift;
292 $dir = "$mh/$mhfolder";
293 if (not opendir(DIR
, $dir)) {
294 die("opendir($dir): $!");
297 # Insert rant from getfiles here.
299 @list = readdir(DIR
);
302 die("readdir($dir): $!");
308 foreach $msgnum (@list) {
310 if ($msgnum =~ /^[0-9]+$/) {
311 if ($msgnum > $highest) {
322 my $mhfolder = shift;
328 # We must do this even in -n mode because later steps fail without
329 # it. This should be harmless.
332 # This loop is a modified version of the maildir delivery algorithm.
333 $msgnum = get_highest_msgnum
($mhfolder);
334 for ($try = 0; ; $try++) {
336 $mhmsg = "$mh/$mhfolder/$msgnum";
338 if (not stat($mhmsg)) {
340 # Now we have a non-existent file, let's try to create
341 # it. We must create a zero-byte file first because a
342 # file my appear between our happy stat results and
343 # our later rename(2), which would clobber said file.
344 # So attempt to create a file with this name. If it
345 # succeeds, in just a bit here we'll knowingly clobber
346 # this file with the rename(2) call.
348 # Another way to do this is not to use rename(2), but
349 # use link(2) + unlink(2) instead. That's how the
350 # standard maildir algorithm does it. Each method has
351 # a disadvantage: the program may crash between the
352 # link(2) and unlink(2) calls. With the standard
353 # maildir algorithm, that means the message will end
354 # up duplicated. The advantage of creating an empty
355 # file followed by rename(2) is that an extra empty
356 # file is left behind as opposed to a duplicate
357 # message. This is more easily detected by the user.
359 if (sysopen(MSG
, $mhmsg,
360 O_WRONLY
| O_EXCL
| O_CREAT
, 0600)) {
370 # This algorithm is different from the maildir one; let's make
371 # 10 tries instead of 3.
373 die("Attempted filename $mhmsg exists.");
376 # This algorithm is different; i don't think we need to sleep.
380 mylog
('+', $mhfolder);
383 if (not rename($msg, $mhmsg)) {
384 die("rename($msg, $mhmsg): $!");
387 # Mark each message as soon as we store it and bomb if that
388 # fails. While it is slow, it is not safe to store multiple
389 # messages and then have a failure before marking some (or
391 if ($mhfolder eq 'SPAM') {
392 push(@SPAM, $msgnum);
394 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
396 # XXX need to handle signalled and stopped, and print
397 # the exit code or signal number.
398 if (not WIFEXITED
($status)) {
399 die("Failed to run mark");
400 } elsif (WEXITSTATUS
($status) != 0) {
401 die("Failed to mark message unseen.");
409 # Parse a message file into a structure describing the headers. The
410 # structure is a hash of arrays. The hash keys are the names of the
411 # headers found in the message, made all lower-case. Each item in
412 # the hash is an array of header text. The array has one element
413 # per occurrence of the header. Most headers will only have a single
414 # element because they occur only once. The Received header is the
415 # most common header with multiple texts.
419 my $current; # current header, used for unfolding lines
420 my $fieldname; # unmolested header name
421 my $contents; # contents of header
423 open(MSG
, $msg) or die("open(MSG, $msg): $!");
431 # folded header continuation
433 if (not defined($current)) {
434 warn('Malformed message, cannot parse headers.');
438 @{$headers{$current}}[-1] .= $_;
440 ($fieldname) = split(/:/);
441 $current = lc($fieldname);
442 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
444 if (defined($headers{$current})) {
445 # multiple occurence of same header
446 push(@{$headers{$current}}, $contents);
448 # first occurence of a header
449 $headers{$current} = [$contents];
459 ###############################################################################
478 # Walk the list of filters. This structure is documented in
479 # pod at the end of the program.
480 foreach $filterref (@FILTERS) {
481 @filter = @$filterref;
482 $header = shift(@filter);
484 # Handle filters using the magic TO header specially.
485 if ($header eq $MAGIC_TO_TOKEN) {
486 foreach $header (keys(%headers)) {
487 if ($header =~ /$MAGIC_TO_REGEX/i) {
488 foreach $contents (@{$headers{lc($header)}}) {
489 foreach $pair (@filter) {
490 ($match, $expression) = @$pair;
491 if ($contents =~ /$match/i) {
492 if (eval "\$result = \"$expression\"") {
501 # Now that it's been processed specially, skip normal handling.
505 # Walk the list of message headers matching the filter's
507 foreach $contents (@{$headers{lc($header)}}) {
508 # Walk the filter's list of match/expression pairs.
509 foreach $pair (@filter) {
510 ($match, $expression) = @$pair;
511 if ($contents =~ /$match/i) {
512 if (eval "\$result = \"$expression\"") {
524 my $msgcount = @_ - 2; # don't count . and ..
525 my $len = length($msgcount);
534 if (-f
"$HOME/.minc") {
535 require "$HOME/.minc";
539 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
540 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
541 my $nF = int($COLUMNS * $SCAN_P_FROM);
542 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
545 for my $hook (@start_hooks) {
546 my ($handle, @baton) = $hook->();
547 if (defined($handle)) {
548 $batons{$handle} = [@baton];
553 ($msg eq '.' or $msg eq '..') and next;
555 my %headers = get_headers
($msg);
556 log_headers
(%headers);
559 for my $hook (@filter_hooks) {
560 my $result = $hook->(\
%batons, \
%headers, $msg);
561 defined($result) and ($mhfolder = $result);
564 defined($mhfolder) or ($mhfolder = find_mh_folder
($msg, %headers));
566 $msgnum = store_message
($msg, $mhfolder);
567 $folders{$mhfolder}++;
569 if ($mhfolder eq 'SPAM') {
573 my $from = [@{$headers{'from'}}]->[-1];
574 my $subject = [@{$headers{'subject'}}]->[-1];
575 for ($from, $subject) {
576 tr/\x00-\x1f\x80-\xff/ /;
579 print(' ' x
$COLUMNS);
580 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
581 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
582 # XXX shouldn't pop, as these are about to be
583 # passed to post_store_hooks
584 substr($from, 0, $nF),
585 substr($subject, 0, $ns));
588 for my $hook (@post_store_hooks) {
589 $hook->(\
%batons, \
%headers, $mhfolder, $msgnum);
593 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
594 $spam, $saved, $spam + $saved, $msgcount);
596 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
600 for my $hook (@stop_hooks) {
611 if (open($fh, "$mh/.folders")) {
616 } elsif (not $!{ENOENT
}) {
617 # For ENOENT, we go ahead and create it, else we error and
619 warn("open($mh/.folders): $!");
623 eval { ($fh, $fn) = tempfile
("$mh/.folders.XXXXX") };
629 print($fh "inbox\n");
630 for my $folder (sort(keys(%$folders))) {
631 $folder eq 'inbox' and next;
632 print($fh "$folder\n");
635 if (not close($fh)) {
636 warn("close($fn): $!");
637 unlink($fn) or warn("unlink($fn): $!");
641 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
649 require "$HOME/.minc";
650 $Data::Dumper
::Indent
= 1;
651 print(Dumper
(\
@FILTERS));
655 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
656 my %folders = filter_mail
(map { $_->[1] }
657 sort { $a->[0] <=> $b->[0] }
659 if (not ($st = stat($_))) {
666 $run and update_folders
(\
%folders);
668 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
674 =head1 THE FILTERS STRUCTURE
676 Each element of @FILTERS is a filter. A filter is itself an array.
677 The first element of a filter is a string, the name of the header this
678 filter acts upon. The header name is not case-sensitive. Each
679 subsequent element of a filter is a pair (i.e. a two-element array):
680 first, a regular expression B<minc> uses to determine whether this
681 filter matches or not, and second, an expression which B<minc>
682 evaluates to get the folder name.
684 B<minc> decides where to store a message by iterating over the
685 @FILTERS array. It tests each regexp of each filter against all
686 headers matching that filter's specified header. As soon as a match
687 is found, B<minc> evaluates the second part of the pair. This part
688 may contain positional parameters from the matched regexp ($1, $2,
689 etc.). The result of this expression is used as the folder name.
691 Multiple occurrences of the same header are preserved and tested
692 individually against the filters. That means, for example, that
693 filters can search all the Received headers for a certain string.
695 It is important to note that all the arrays of the @FILTERS structure
696 are traversed I<in order>. This means the filters can be arranged so
697 that some have priority over others. XXX: get Doug to write an
698 example of having the same header matched more than once.
700 Lastly, B<minc> supports a magic ' TO' header. Filters using this
701 header are matched against a collection of headers related to (and
702 including) the To. Which headers to use is determined by a regular
703 expression borrowed from procmail.
707 Filter hooks take a reference to a hash of batons, a reference to a
708 hash of headers, and the message filename as arguments. It returns
709 undef to decline filtering of this message (thus falling back to
710 subsequent filter hooks, and finally @FILTERS), or the name of the
711 folder to store this message into.
713 One of B<minc>'s global variables is available to the user-defined
714 hooks: $run. This boolean should be honored; hooks should only take
715 real action (i.e. removing or creating files, running external
716 programs, etc.) if $run is non-zero.
718 The baton hash is created simply from the start hooks; if the hook
719 returns at least one defined value, this value is used as the key and
720 all other return values are put into a list reference as the value.
721 This hash is then passed by reference to all filter, post-store, and
724 Post store hooks take a reference to a hash of batons, a reference to
725 a hash of headers, the folder this message was stored in, and its new
728 XXX: need more details about the hook process; for now read the code.
735 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
736 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
737 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
741 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
749 The first List-Id filter is a surprisingly effective one which creates
750 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
751 all Sourceforge lists to which i subscribe. Certainly there are lists
752 hosted at Sourceforge which do not work well with this, and this is
753 where it is important to remember that the first match is the one
754 uses. Simply put the more specific rules before this one.
756 The next List-Id example is simple. The swig example demonstrates
757 that the folder name does not have to use a portion of the matched
758 header; you can just hardcode the folder name.
760 The List-Post example is a nice one. Most ASF projects have their own
761 apache.org subdomain, with mailing lists hosted there. So, given a
762 list such as dev@httpd.apache.org, this filter will create the folder
763 name l/apache/httpd/dev.
765 XXX Need hook examples.
769 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
770 Gillespie and Doug Porter <dsp@waterspout.com>.
772 This program is free software; you can redistribute it and/or modify
773 it under the same terms as Perl itself.
778 # cperl-indent-level: 4
779 # perl-indent-level: 4
780 # indent-tabs-mode: nil
783 # vi: set tabstop=4 expandtab: