]>
diplodocus.org Git - minc/blob - minc
79a85e2bc1cb4fcdcc4e5604e86597a8e0cc8cc1
7 B<minc> - Incorporate mail from a maildir into mh folders.
15 B<minc> is a program for incorporating mail from a maildir to a mh
16 folder hierarchy. It takes mail from a maildir folder (not a maildir
17 folder hierarchy), optionally checks for spam with a user-defined
18 spam-checking function, and optionally filters mail into separate mh
21 The filtering is quite sophisticated, as it is done using real Perl
22 matching (m//) commands.
29 require 'sysexits.ph';
33 use Fcntl
qw(O_WRONLY O_EXCL O_CREAT);
38 use Log
::Dispatch
::File
;
39 use POSIX
qw(strftime WEXITSTATUS WIFEXITED);
41 # Autoflush STDOUT for the benefit of the status reporting in kill_spam().
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';
55 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
56 for testing the syntax of .mincfilter.
60 Filter only, then exit. This is useful after running B<minc -s>.
68 Dry run; do not actually incorporate the mail, but log and report to
69 stdout/stderr as normal.
73 Process SPAM only, then exit, leaving all non-spam messages in the
89 our $opt_s; # ;; # stupid cperl-mode
91 if (not getopts
('dfhns')) {
98 print("Sorry bub, no help.\n");
106 } elsif ($opt_s) { # ))){ # stupid cperl-mode
116 Where configuration files (.mincfilter) are found. Also,
117 $HOME/Maildir is used for the maildir if MAILDIR is not set.
121 Where mail is delivered.
127 use Env
qw(HOME MAILDIR);
130 die("HOME environment variable must be set.\n");
133 $MAILDIR = "$HOME/Maildir";
140 =item $HOME/.mincfilter
142 This file is Perl code (included via the 'require' directive) which is
143 expected to define the FILTERS list.
145 =item $HOME/.mincspam
147 If this file exists, B<minc> will include it with the expectation that
148 it will define a B<spam_check> function. This function takes a
149 message filename as an argument and returns 1 if the message is spam,
150 else 0. If this file does not exist, B<minc> will define a simple
151 function that always returns 0.
153 One of B<minc>'s global variables is available to the user-defined
154 B<spam_check> function: $run. This boolean should be honored;
155 B<spam_check> should only take real action (i.e. removing or creating
156 files, running external programs, etc.) if $run is non-zero.
158 This file may also declare two other functions: B<spam_start_hook> and
159 B<spam_stop_hook>. The former is passed no arguments and is expected
160 to return a list. This list is a "baton" that will also be passed to
161 B<spam_check> and B<spam_stop_hook>. It can hold anything
162 B<spam_check> will need to do its job, whether network sockets, pipes,
165 XXX: need more details about the spam-handling process; for now read
168 =item `mhpath +`/logs/minc.log
170 Where B<minc> logs what it does, unless in -n mode.
172 =item `mhpath +`/logs/dryrun.log
174 Where B<minc> logs what it would do; used in -n mode.
176 =item `mhpath +`/.minc.context
178 B<minc> uses this file for context (i.e. current folder) instead of
179 `mhpath +`/context. This allows you some amount of freedom to use mh
180 while B<minc> is running. For example, you can changed folders without
181 causing a message to be stored in the wrong folder. Care must still
182 be taken, however, about the unseen sequence; if you change it
183 (whether via show, or mark, or anything else) while in the same folder
184 as B<minc>, it is likely the new message B<minc> stores will not end up
192 require "$HOME/.mincfilter";
194 if (-f
"$HOME/.mincspam") {
195 require "$HOME/.mincspam";
197 eval "sub spam_start_hook { return (); }";
198 eval "sub spam_stop_hook { }";
199 eval "sub spam_check { return 0; }";
209 $logfile = $mh . '/logs/minc.log';
211 $logfile = $mh . '/logs/dryrun.log';
214 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
217 ###############################################################################
220 # debuglevels for the logger
221 use constant LOG_DEBUGLEVEL
=> 'info';
222 use constant SCREEN_DEBUGLEVEL
=> 'debug';
224 # characters used to wrap around the id field in the log so I can more
225 # easily parse the msg flow
226 use constant DEBUGCHAR
=> '%';
227 use constant INFOCHAR
=> '=';
228 use constant INCOMINGCHAR
=> '<';
229 use constant SAVECHAR
=> '>';
230 use constant WARNCHAR
=> 'W';
231 use constant ERRORCHAR
=> 'E';
233 my $logger = new Log
::Dispatch
;
234 $logger->add(new Log
::Dispatch
::File
(name
=>'logfile',
239 # log alias, handles getting passed vars that are undef
243 my $timestamp = strftime
('%b %e %H:%M:%S', localtime);
245 foreach my $part (@_) {
246 if (defined($part)) {
250 # no newlines in the log message, thanks
252 $msg = sprintf("%s %s%s %s\n", $timestamp, $act, $act, $msg);
253 $logger->log(level
=>$level, message
=>$msg);
255 if ($act eq SAVECHAR
) {
256 $logger->log(level
=>$level, message
=>"\n");
260 sub logsave
{ mylog
('notice', SAVECHAR
, @_); }
261 sub loginfo
{ mylog
('info', INFOCHAR
, @_); }
262 sub logdebug
{ mylog
('debug', DEBUGCHAR
, @_); }
263 sub logwarn
{ mylog
('warning', WARNCHAR
, @_); }
264 sub logerr
{ mylog
('error', ERRORCHAR
, @_); }
267 my ($text, @contents) = @_;
271 $last = $contents[-1];
276 mylog
('info', INCOMINGCHAR
, $text, $last);
282 # For an explanation of the %headers structure, see the
283 # get_headers function below.
284 logincoming
('From: ', @{$headers{'return-path'}});
285 logincoming
('To: ', @{$headers{'to'}});
286 logincoming
('Subject: ', @{$headers{'subject'}});
287 logincoming
('Message-Id: ', @{$headers{'message-id'}});
291 ###############################################################################
295 printf(STDERR
'%s:%s', __FILE__
, __LINE__
);
309 print(STDERR
": $!\n");
324 my $mhfolder = shift;
329 $folder = $mh . '/' . $mhfolder;
332 if (not -d
$folder) {
333 foreach $component (split('/', $folder)) {
334 if (defined($component) and length($component) >= 1) {
335 $target = $target . '/' . $component;
336 if (-d
$target or mkdir($target)) {
340 "Failed to create +$mhfolder ($component)");
351 if (not opendir(DIR
, $dir)) {
352 err
(&EX_OSERR
, "Failed opendir($dir)");
355 # Initialize $! to 0 (success) because stupid stupid Perl provides
356 # no way to distinguish an error from an empty directory; that
357 # means setting $! to 0 and testing it afterwards is the only way
358 # to detect an error. Real Programmers don't handle errors,
362 ($_ ne '.' and $_ ne '..')
363 and $_ = "$MAILDIR/new/$_"
367 err
(&EX_OSERR
, "Failed readdir($dir)");
375 sub get_highest_msgnum
{
376 my $mhfolder = shift;
382 $dir = "$mh/$mhfolder";
383 if (not opendir(DIR
, $dir)) {
384 err
(&EX_OSERR
, "Failed opendir($dir)");
388 @list = readdir(DIR
);
391 err
(&EX_OSERR
, "Failed readdir($dir)");
397 foreach $msgnum (@list) {
399 if ($msgnum =~ /^[0-9]+$/) {
400 if ($msgnum > $highest) {
409 # We want to print the name of each list that has new mail only once,
410 # so use this hash to store the lists that have already been printed.
411 # Start the list out with SPAM already in it, since we don't care when
412 # new messages are added to it.
413 my %FOLDERS = ('SPAM'=>1);
417 my $mhfolder = shift;
423 # We must do this even in -n mode because later steps fail without
424 # it. This should be harmless.
427 # This loop is a modified version of the maildir delivery algorithm.
428 $msgnum = get_highest_msgnum
($mhfolder);
429 for ($try = 0; ; $try++) {
431 $mhmsg = "$mh/$mhfolder/$msgnum";
433 if (not stat($mhmsg)) {
435 # Now we have a non-existent file, let's try to create
436 # it. We must create a zero-byte file first because a
437 # file my appear between our happy stat results and
438 # our later rename(2), which would clobber said file.
439 # So attempt to create a file with this name. If it
440 # succeeds, in just a bit here we'll knowingly clobber
441 # this file with the rename(2) call.
443 # Another way to do this is not to use rename(2), but
444 # use link(2) + unlink(2) instead. That's how the
445 # standard maildir algorithm does it. Each method has
446 # a disadvantage: the program may crash between the
447 # link(2) and unlink(2) calls. With the standard
448 # maildir algorithm, that means the message will end
449 # up duplicated. The advantage of creating an empty
450 # file followed by rename(2) is that an extra empty
451 # file is left behind as opposed to a duplicate
452 # message. This is more easiliy detected by the user.
454 if (sysopen(MSG
, "$mhmsg",
455 O_WRONLY
| O_EXCL
| O_CREAT
, 0600)) {
465 # This algorithm is different from the maildir one; let's make
466 # 10 tries instead of 3.
468 errx
(&EX_TEMPFAIL
, "Attempted filename $mhmsg exists.");
471 # This algorithm is different; i don't think we need to sleep.
475 if ($mhfolder ne 'SPAM') {
476 logsave
("+$mhfolder");
480 if (not rename($msg, $mhmsg)) {
481 err
(&EX_OSERR
, "Failed rename($msg, $mhmsg)");
484 # Mark each message as soon as we store it and bomb if that
485 # fails. While it is slow, it is not safe to store multiple
486 # messages and then have a failure before marking some (or
488 if ($mhfolder ne 'SPAM') {
489 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
491 if (not WIFEXITED
($status)) {
492 err
(&EX_OSERR
, "Failed to run mark");
493 } elsif (WEXITSTATUS
($status) != 0) {
494 errx
(&EX_SOFTWARE
, "Failed to mark message unseen.");
499 if (not $FOLDERS{$mhfolder}) {
500 print("+$mhfolder\n");
501 $FOLDERS{$mhfolder} = 1;
505 # Parse a message file into a structure describing the headers. The
506 # structure is a hash of arrays. The hash keys are the names of the
507 # headers found in the message, made all lower-case. Each item in
508 # the hash is an array of header text. The array has one element
509 # per occurrence of the header. Most headers will only have a single
510 # element because they occur only once. The Received header is the
511 # most common header with multiple texts.
515 my $current; # current header, used for unfolding lines
516 my $fieldname; # unmolested header name
517 my $contents; # contents of header
527 # folded header continuation
529 if (not defined($current)) {
530 print(STDERR
"Malformed message, cannot parse headers.\n");
534 @{$headers{$current}}[-1] .= $_;
536 ($fieldname) = split(/:/);
537 $current = lc($fieldname);
538 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
540 if (defined($headers{$current})) {
541 # multiple occurence of same header
542 push(@{$headers{$current}}, $contents);
544 # first occurence of a header
545 $headers{$current} = [$contents];
554 ###############################################################################
564 @baton = spam_start_hook
();
568 foreach $msg (@msglist) {
569 printf('%sChecking for spam... %6d/%d',
570 "\r", ++$i, scalar(@msglist));
571 if (spam_check
($msg, @baton)) {
573 store_message
($msg, 'SPAM');
578 print("\nDone: ", scalar(@result), " survivors\n");
580 spam_stop_hook
(@baton);
586 ###############################################################################
601 %headers = get_headers
($msg);
606 log_headers
(%headers);
608 # Walk the list of filters. This structure is documented in
609 # pod at the end of the program.
610 foreach $filterref (@FILTERS) {
611 @filter = @$filterref;
612 $header = shift(@filter);
614 # Handle filters using the magic TO header specially.
615 if ($header eq $MAGIC_TO_TOKEN) {
616 foreach $header (keys(%headers)) {
617 if ($header =~ /$MAGIC_TO_REGEX/i) {
618 foreach $contents (@{$headers{lc($header)}}) {
619 foreach $pair (@filter) {
620 ($match, $expression) = @$pair;
621 if ($contents =~ /$match/) {
629 # Now that it's been processed specially, skip normal handling.
633 # Walk the list of message headers matching the filter's
635 foreach $contents (@{$headers{lc($header)}}) {
636 # Walk the filter's list of match/expression pairs.
637 foreach $pair (@filter) {
638 ($match, $expression) = @$pair;
639 if ($contents =~ /$match/i) {
640 if (eval "\$result = \"$expression\"") {
656 foreach $msg (@msglist) {
657 $mhfolder = find_mh_folder
($msg);
658 store_message
($msg, $mhfolder);
667 $Data::Dumper
::Indent
= 1;
668 print(Dumper
(\
@FILTERS));
672 @msglist = getfiles
("$MAILDIR/new");
674 if (not $filteronly) {
675 @msglist = kill_spam
(@msglist);
679 filter_mail
(@msglist);
686 =head1 THE FILTERS STRUCTURE
688 The user's .mincfilter file must define the @FILTERS structure. This
689 structure is an array. Each element of @FILTERS is a filter. A
690 filter is itself an array. The first element of a filter is a string,
691 the name of the header this filter acts upon. The header name is not
692 case-sensitive. Each subsequent element of a filter is a pair (i.e. a
693 two-element array): first, a regular expression B<minc> uses to
694 determine whether this filter matches or not, and second, an
695 expression which B<minc> evaluates to get the folder name.
697 B<minc> decides where to store a message by iterating over the
698 @FILTERS array. It tests each regexp of each filter against all
699 headers matching that filter's specified header. As soon as a match
700 is found, B<minc> evaluates the second part of the pair. This part
701 may contain positional parameters from the matched regexp ($1, $2,
702 etc.). The result of this expression is used as the folder name.
704 Multiple occurrences of the same header are preserved and tested
705 individually against the filters. That means, for example, that
706 filters can search all the Received headers for a certain string.
708 It is important to note that all the arrays of the @FILTERS structure
709 are traversed I<in order>. This means the filters can be arranged so
710 that some have priority over others. XXX: get Doug to write an
711 example of having the same header matched more than once.
713 Lastly, B<minc> supports a magic ' TO' header. Filters using this
714 header are matched against a collection of headers related to (and
715 including) the To. Which headers to use is determined by a regular
716 expression borrowed from procmail.
723 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
724 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
725 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
729 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
737 The first List-Id filter is a surprisingly effective one which creates
738 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
739 all Sourceforge lists to which i subscribe. Certainly there are lists
740 hosted at Sourceforge which do not work well with this, and this is
741 where it is important to remember that the first match is the one
742 uses. Simply put the more specific rules before this one.
744 The next List-Id example is simple. The swig example demonstrates
745 that the folder name does not have to use a portion of the matched
746 header; you can just hardcode the folder name.
748 The List-Post example is a nice one. Most ASF projects have their own
749 apache.org subdomain, with mailing lists hosted there. So, given a
750 list such as dev@httpd.apache.org, this filter will create the folder
751 name l/apache/httpd/dev.
753 For an example B<spam_check> function, see
754 L<http:E<047>E<047>pretzelnet.orgE<047>cvsE<047>dotfilesE<047>.mincspam>
758 Written by Eric Gillespie <epg@pretzelnet.org> with logging code
759 stolen from Adam Lazur <adam@lazur.org>.
761 Design by Eric Gillespie and Doug Porter <dsp@waterspout.com>.
766 # cperl-indent-level: 4
767 # perl-indent-level: 4
768 # indent-tabs-mode: nil
771 # vi: set tabstop=4 expandtab: