]> diplodocus.org Git - minc/blob - minc
(store_message): Fix usage of the system function so that the shell
[minc] / minc
1 #! /usr/local/bin/perl
2
3 # $Id$
4
5 =head1 NAME
6
7 B<minc> - Incorporate mail from a maildir into mh folders.
8
9 =head1 SYNOPSIS
10
11 B<minc> [-B<dhns>]
12
13 =head1 DESCRIPTION
14
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
19 folders.
20
21 The filtering is quite sophisticated, as it is done using real Perl
22 matching (m//) commands.
23
24 =cut
25
26 use strict;
27 use warnings;
28
29 require 'sysexits.ph';
30
31 use Data::Dumper;
32 use Errno;
33 use Fcntl qw(O_WRONLY O_EXCL O_CREAT);
34 use File::Basename;
35 use Getopt::Std;
36 use Log::Dispatch;
37 use Log::Dispatch::File;
38 use POSIX qw(strftime WEXITSTATUS WIFEXITED);
39
40 =head1 OPTIONS
41
42 =over 4
43
44 =item B<-d>
45
46 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
47 for testing the syntax of .mincfilter.
48
49 =item B<-f>
50
51 Filter only, then exit. This is useful after running B<minc -s>.
52
53 =item B<-h>
54
55 Show help.
56
57 =item B<-n>
58
59 Dry run; do not actually incorporate the mail, but log and report to
60 stdout/stderr as normal.
61
62 =item B<-s>
63
64 Process SPAM only, then exit, leaving all non-spam messages in the
65 maildir.
66
67 =back
68
69 =cut
70
71 my $dumpfilters = 0;
72 my $filteronly = 0;
73 our $run = 1;
74 my $spamonly = 0;
75
76 our $opt_d;
77 our $opt_f;
78 our $opt_h;
79 our $opt_n;
80 our $opt_s; # ;; # stupid cperl-mode
81
82 if (not getopts('dfhns')) {
83 exit(&EX_USAGE);
84 }
85
86 if ($opt_d) {
87 $dumpfilters = 1;
88 } elsif ($opt_h) {
89 print("Sorry bub, no help.\n");
90 exit(&EX_OK);
91 } elsif ($opt_n) {
92 $run = 0;
93 }
94
95 if ($opt_f) {
96 $filteronly = 1;
97 } elsif ($opt_s) { # ))){ # stupid cperl-mode
98 $spamonly = 1;
99 }
100
101 =head1 ENVIRONMENT
102
103 =over 4
104
105 =item HOME
106
107 Where configuration files (.mincfilter) are found. Also,
108 $HOME/Maildir is used for the maildir if MAILDIR is not set.
109
110 =item MAILDIR
111
112 Where mail is delivered.
113
114 =back
115
116 =cut
117
118 use Env qw(HOME MAILDIR);
119
120 if (not $HOME) {
121 die("HOME environment variable must be set.\n");
122 }
123 if (not $MAILDIR) {
124 $MAILDIR = "$HOME/Maildir";
125 }
126
127 =head1 FILES
128
129 =over 4
130
131 =item $HOME/.mincfilter
132
133 This file is Perl code (included via the 'require' directive) which is
134 expected to define the FILTERS list.
135
136 =item $HOME/.mincspam
137
138 If this file exists, B<minc> will include it with the expectation that it
139 will define a B<is_spam> function. This function takes a message
140 filename as an argument and returns 1 if the message is spam, else 0.
141 If this file does not exist, B<minc> will define a simple function that
142 always returns 0.
143
144 One of B<minc>'s global variables is available to the user-defined
145 is_spam function: $run. This boolean should be honored; is_spam
146 should only take real action (i.e. removing or creating files, running
147 external programs, etc.) if $run is non-zero.
148
149 XXX: need more details about the spam-handling process; for now read
150 the code.
151
152 =item `mhpath +`/logs/minc.log
153
154 Where B<minc> logs what it does, unless in -n mode.
155
156 =item `mhpath +`/logs/dryrun.log
157
158 Where B<minc> logs what it would do; used in -n mode.
159
160 =item `mhpath +`/.minc.context
161
162 B<minc> uses this file for context (i.e. current folder) instead of
163 `mhpath +`/context. This allows you some amount of freedom to use mh
164 while B<minc> is running. For example, you can changed folders without
165 causing a message to be stored in the wrong folder. Care must still
166 be taken, however, about the unseen sequence; if you change it
167 (whether via show, or mark, or anything else) while in the same folder
168 as B<minc>, it is likely the new message B<minc> stores will not end up
169 marked unseen.
170
171 =back
172
173 =cut
174
175 our @FILTERS;
176 require "$HOME/.mincfilter2";
177
178 if (-f "$HOME/.mincspam") {
179 require "$HOME/.mincspam";
180 } else {
181 sub is_spam {
182 return 0;
183 }
184 }
185
186 my $mh;
187 my $logfile;
188
189 $mh = `mhpath +`;
190 chomp($mh);
191
192 if ($run) {
193 $logfile = $mh . '/logs/minc.log';
194 } else {
195 $logfile = $mh . '/logs/dryrun.log';
196 }
197
198 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
199
200 \f
201 ###############################################################################
202 # Logging
203
204 # debuglevels for the logger
205 use constant LOG_DEBUGLEVEL => 'info';
206 use constant SCREEN_DEBUGLEVEL => 'debug';
207
208 # characters used to wrap around the id field in the log so I can more
209 # easily parse the msg flow
210 use constant DEBUGCHAR => '%';
211 use constant INFOCHAR => '=';
212 use constant INCOMINGCHAR => '<';
213 use constant SAVECHAR => '>';
214 use constant WARNCHAR => 'W';
215 use constant ERRORCHAR => 'E';
216
217 my $logger = new Log::Dispatch;
218 $logger->add(new Log::Dispatch::File (name=>'logfile',
219 filename=>$logfile,
220 min_level=>'info',
221 mode=>'append'));
222
223 # log alias, handles getting passed vars that are undef
224 sub mylog {
225 my $level = shift;
226 my $act = shift;
227 my $timestamp = strftime('%b %e %H:%M:%S', localtime);
228 my $msg;
229 foreach my $part (@_) {
230 if (defined($part)) {
231 $msg .= $part;
232 }
233 }
234 # no newlines in the log message, thanks
235 $msg =~ s/\n/ /gm;
236 $msg = sprintf("%s %s%s %s\n", $timestamp, $act, $act, $msg);
237 $logger->log(level=>$level, message=>$msg);
238
239 if ($act eq SAVECHAR) {
240 $logger->log(level=>$level, message=>"\n");
241 }
242 }
243
244 sub logincoming { mylog('info', INCOMINGCHAR, @_); }
245 sub logsave { mylog('notice', SAVECHAR, @_); }
246 sub loginfo { mylog('info', INFOCHAR, @_); }
247 sub logdebug { mylog('debug', DEBUGCHAR, @_); }
248 sub logwarn { mylog('warning', WARNCHAR, @_); }
249 sub logerr { mylog('error', ERRORCHAR, @_); }
250
251 sub log_headers {
252 my %headers = @_;
253
254 # For an explanation of the %headers structure, see the
255 # get_headers function below.
256 logincoming('From: ', @{$headers{'return-path'}}[-1]);
257 logincoming('To: ', @{$headers{'to'}}[-1]);
258 logincoming('Subject: ', @{$headers{'subject'}}[-1]);
259 logincoming('Message-Id: ', @{$headers{'message-id'}}[-1]);
260 }
261
262 \f
263 ###############################################################################
264 # Utility procedures
265
266 sub _errprint {
267 printf(STDERR '%s:%s', __FILE__, __LINE__);
268
269 if (@_) {
270 print(STDERR ': ');
271 foreach (@_) {
272 print(STDERR);
273 }
274 }
275 }
276
277 sub err {
278 my $ex = shift;
279
280 _errprint(@_);
281 print(STDERR ": $!\n");
282
283 exit($ex);
284 }
285
286 sub errx {
287 my $ex = shift;
288
289 _errprint(@_);
290 print(STDERR "\n");
291
292 exit($ex);
293 }
294
295 sub mkfolder {
296 my $mhfolder = shift;
297 my $folder;
298 my $target;
299 my $component;
300
301 $folder = $mh . '/' . $mhfolder;
302 $target = '';
303
304 if (not -d $folder) {
305 foreach $component (split('/', $folder)) {
306 if (defined($component) and length($component) >= 1) {
307 $target = $target . '/' . $component;
308 if (-d $target or mkdir($target)) {
309 next;
310 } else {
311 err(&EX_OSERR,
312 "Failed to create +$mhfolder ($component)");
313 }
314 }
315 }
316 }
317 }
318
319 sub getfiles {
320 my $dir = shift;
321 my @result;
322
323 if (not opendir(DIR, $dir)) {
324 err(&EX_OSERR, "Failed opendir($dir)");
325 }
326
327 # Initialize $! to 0 (success) because stupid stupid Perl provides
328 # no way to distinguish an error from an empty directory; that
329 # means setting $! to 0 and testing it afterwards is the only way
330 # to detect an error. Real Programmers don't handle errors,
331 # right? >sigh<
332 $! = 0;
333 @result = grep {
334 ($_ ne '.' and $_ ne '..')
335 and $_ = "$MAILDIR/new/$_"
336 } readdir(DIR);
337
338 if ($! != 0) {
339 err(&EX_OSERR, "Failed readdir($dir)");
340 }
341
342 closedir(DIR);
343
344 return @result;
345 }
346
347 sub get_highest_msgnum {
348 my $mhfolder = shift;
349 my $dir;
350 my @list;
351 my $highest;
352 my $msgnum;
353
354 $dir = "$mh/$mhfolder";
355 if (not opendir(DIR, $dir)) {
356 err(&EX_OSERR, "Failed opendir($dir)");
357 }
358
359 $! = 0;
360 @list = readdir(DIR);
361
362 if ($! != 0) {
363 err(&EX_OSERR, "Failed readdir($dir)");
364 }
365
366 closedir(DIR);
367
368 $highest = 0;
369 foreach $msgnum (@list) {
370 # Look for integers.
371 if ($msgnum =~ /^[0-9]+$/) {
372 if ($msgnum > $highest) {
373 $highest = $msgnum;
374 }
375 }
376 }
377
378 return $highest;
379 }
380
381 # We want to print the name of each list that has new mail only once,
382 # so use this hash to store the lists that have already been printed.
383 # Start the list out with SPAM already in it, since we don't care when
384 # new messages are added to it.
385 my %FOLDERS = ('SPAM'=>1);
386
387 sub store_message {
388 my $msg = shift;
389 my $mhfolder = shift;
390 my $msgnum;
391 my $try;
392 my $mhmsg;
393 my $status;
394
395 # We must do this even in -n mode because later steps fail without
396 # it. This should be harmless.
397 mkfolder($mhfolder);
398
399 # This loop is a modified version of the maildir delivery algorithm.
400 $msgnum = get_highest_msgnum($mhfolder);
401 for ($try = 0; ; $try++) {
402 $msgnum++;
403 $mhmsg = "$mh/$mhfolder/$msgnum";
404
405 if (not stat($mhmsg)) {
406 if ($!{ENOENT}) {
407 # Now we have a non-existent file, let's try to create
408 # it. We must create a zero-byte file first because a
409 # file my appear between our happy stat results and
410 # our later rename(2), which would clobber said file.
411 # So attempt to create a file with this name. If it
412 # succeeds, in just a bit here we'll knowingly clobber
413 # this file with the rename(2) call.
414 if ($run) {
415 if (sysopen(MSG, "$mhmsg",
416 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
417 close(MSG);
418 last;
419 }
420 } else {
421 last;
422 }
423 }
424 }
425
426 # This algorithm is different from the maildir one; let's make
427 # 10 tries instead of 3.
428 if ($try == 9) {
429 errx(&EX_TEMPFAIL, "Attempted filename $mhmsg exists.");
430 }
431
432 # This algorithm is different; i don't think we need to sleep.
433 #sleep(2);
434 }
435
436 logsave("+$mhfolder");
437
438 if ($run) {
439 if (not rename($msg, $mhmsg)) {
440 err(&EX_OSERR, "Failed rename($msg, $mhmsg)");
441 }
442
443 # Mark each message as soon as we store it and bomb if that
444 # fails. While it is slow, it is not safe to store multiple
445 # messages and then have a failure before marking some (or
446 # all).
447 if ($mhfolder ne 'SPAM') {
448 $status = system('mark', "+$mhfolder", "$msgnum", '-sequence',
449 'unseen', '-add');
450 if (not WIFEXITED($status)) {
451 err(&EX_OSERR, "Failed to run mark");
452 } elsif (WEXITSTATUS($status) != 0) {
453 errx(&EX_SOFTWARE, "Failed to mark message unseen.");
454 }
455 }
456 }
457
458 if (not $FOLDERS{$mhfolder}) {
459 print("+$mhfolder\n");
460 $FOLDERS{$mhfolder} = 1;
461 }
462 }
463
464 # Parse a message file into a structure describing the headers. The
465 # structure is a hash of arrays. The hash keys are the names of the
466 # headers found in the message, made all lower-case. Each item in
467 # the hash is an array of header text. The array has one element
468 # per occurrence of the header. Most headers will only have a single
469 # element because they occur only once. The Received header is the
470 # most common header with multiple texts.
471 sub get_headers {
472 my $msg = shift;
473 my %headers;
474 my $current; # current header, used for unfolding lines
475 my $fieldname; # unmolested header name
476 my $contents; # contents of header
477
478 open(MSG, $msg);
479 while (<MSG>) {
480 chomp;
481 if (length == 0) {
482 last;
483 }
484
485 if (/^\s/) {
486 # folded header continuation
487
488 if (not defined($current)) {
489 print(STDERR "Malformed message, cannot parse headers.\n");
490 return ();
491 }
492
493 @{$headers{$current}}[-1] .= $_;
494 } else {
495 ($fieldname) = split(/:/);
496 $current = lc($fieldname);
497 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
498
499 if (defined($headers{$current})) {
500 # multiple occurence of same header
501 push(@{$headers{$current}}, $contents);
502 } else {
503 # first occurence of a header
504 $headers{$current} = [$contents];
505 }
506 }
507 }
508 close(MSG);
509
510 return %headers;
511 }
512
513 ###############################################################################
514 # Spam handling
515
516 sub kill_spam {
517 my @msglist = @_;
518 my @result;
519 my $msg;
520 my $i;
521
522 @result = ();
523
524 $i = 0;
525 foreach $msg (@msglist) {
526 printf('%sChecking for spam... %6d/%d',
527 "\r", ++$i, scalar(@msglist));
528 if (is_spam($msg)) {
529 print(" SPAM\n");
530 store_message($msg, 'SPAM');
531 } else {
532 push(@result, $msg);
533 }
534 }
535 print("\nDone: ", scalar(@result), " survivors\n");
536
537 return @result;
538 }
539
540 \f
541 ###############################################################################
542 # Filtering
543
544 sub find_mh_folder {
545 my $msg = shift;
546 my %headers;
547 my $filterref;
548 my @filter;
549 my $header;
550 my $contents;
551 my $pair;
552 my $match;
553 my $expression;
554 my $result;
555
556 %headers = get_headers($msg);
557 if (not %headers) {
558 return 'malformed';
559 }
560
561 log_headers(%headers);
562
563 # Walk the list of filters. This structure is documented in
564 # pod at the end of the program.
565 foreach $filterref (@FILTERS) {
566 @filter = @$filterref;
567 $header = shift(@filter);
568
569 # Walk the list of message headers matching the filter's
570 # specified header.
571 foreach $contents (@{$headers{lc($header)}}) {
572 # Walk the filter's list of match/expression pairs.
573 foreach $pair (@filter) {
574 ($match, $expression) = @$pair;
575 if ($contents =~ /$match/) {
576 if (eval "\$result = \"$expression\"") {
577 return $result;
578 }
579 }
580 }
581 }
582 }
583
584 return 'inbox';
585 }
586
587 sub filter_mail {
588 my @msglist = @_;
589 my $msg;
590 my $mhfolder;
591
592 foreach $msg (@msglist) {
593 $mhfolder = find_mh_folder($msg);
594 store_message($msg, $mhfolder);
595 }
596 }
597
598 \f
599 MAIN: {
600 my @msglist;
601
602 if ($dumpfilters) {
603 $Data::Dumper::Indent = 1;
604 print(Dumper(\@FILTERS));
605 exit(&EX_OK);
606 }
607
608 @msglist = getfiles("$MAILDIR/new");
609
610 if (not $filteronly) {
611 @msglist = kill_spam(@msglist);
612 }
613
614 if (not $spamonly) {
615 filter_mail(@msglist);
616 }
617 }
618
619 \f
620 __END__
621
622 =head1 THE FILTERS STRUCTURE
623
624 The user's .mincfilter file must define the @FILTERS structure. This
625 structure is an array. Each element of @FILTERS is a filter. A
626 filter is itself an array. The first element of a filter is a string,
627 the name of the header this filter acts upon. The header name is not
628 case-sensitive. Each subsequent element of a filter is a pair (i.e. a
629 two-element array): first, a regular expression B<minc> uses to
630 determine whether this filter matches or not, and second, an
631 expression which B<minc> evaluates to get the folder name.
632
633 B<minc> decides where to store a message by iterating over the
634 @FILTERS array. It tests each regexp of each filter against all
635 headers matching that filter's specified header. As soon as a match
636 is found, B<minc> evaluates the second part of the pair. This part
637 may contain positional parameters from the matched regexp ($1, $2,
638 etc.). The result of this expression is used as the folder name.
639
640 Multiple occurrences of the same header are preserved and tested
641 individually against the filters. That means, for example, that
642 filters can search all the Received headers for a certain string.
643
644 It is important to note that all the arrays of the @FILTERS structure
645 are traversed I<in order>. This means the filters can be arranged so
646 that some have priority over others. XXX: get Doug to write an
647 example of having the same header matched more than once.
648
649 =head1 EXAMPLES
650
651 @FILTERS = (
652
653 ['List-Id',
654 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
655 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
656 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
657 ],
658
659 ['List-Post',
660 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
661 ],
662
663 ['To',
664 ['root', 'root'],
665 ],
666 );
667
668 The first List-Id filter is a surprisingly effective one which creates
669 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
670 all Sourceforge lists to which i subscribe. Certainly there are lists
671 hosted at Sourceforge which do not work well with this, and this is
672 where it is important to remember that the first match is the one
673 uses. Simply put the more specific rules before this one.
674
675 The next List-Id example is simple. The swig example demonstrates
676 that the folder name does not have to use a portion of the matched
677 header; you can just hardcode the folder name.
678
679 The List-Post example is a nice one. Most ASF projects have their own
680 apache.org subdomain, with mailing lists hosted there. So, given a
681 list such as dev@httpd.apache.org, this filter will create the folder
682 name l/apache/httpd/dev.
683
684 For an example is_spam function, see
685 L<http:E<047>E<047>pretzelnet.orgE<047>cvsE<047>dotfilesE<047>.mincspam>
686
687 =head1 AUTHORS
688
689 Written by Eric Gillespie <epg@pretzelnet.org> with logging code
690 stolen from Adam Lazur <adam@lazur.org>.
691
692 Design by Eric Gillespie and Doug Porter <dsp@waterspout.com>.
693
694 =cut
695
696 # Local variables:
697 # cperl-indent-level: 4
698 # perl-indent-level: 4
699 # indent-tabs-mode: nil
700 # End:
701
702 # vi: set tabstop=4 expandtab: