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