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