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