]> diplodocus.org Git - minc/blob - minc
04a7d68409c06946b19fc2b1073382b10dd245c1
[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 =item `mhpath +`/.minc.context
164
165 minc uses this file for context (i.e. current folder) instead of
166 `mhpath +`/context. This allows you some amount of freedom to use mh
167 while minc is running. For example, you can changed folders without
168 causing a message to be stored in the wrong folder. Care must still
169 be taken, however, about the unseen sequence; if you change it
170 (whether via show, or mark, or anything else) while in the same folder
171 as minc, it is likely the new message minc stores will not end up
172 marked unseen.
173
174 =cut
175
176 our %FILTERS;
177 tie(%FILTERS, 'Tie::IxHash');
178 require "$HOME/.mincfilter";
179
180 if (-f "$HOME/.mincspam") {
181 require "$HOME/.mincspam";
182 } else {
183 sub is_spam {
184 return 0;
185 }
186 }
187
188 my $mh;
189 my $logfile;
190
191 $mh = `mhpath +`;
192 chomp($mh);
193
194 if ($run) {
195 $logfile = $mh . '/logs/minc.log';
196 } else {
197 $logfile = $mh . '/logs/dryrun.log';
198 }
199
200 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
201
202 \f
203 ###############################################################################
204 # Logging
205
206 # debuglevels for the logger
207 use constant LOG_DEBUGLEVEL => 'info';
208 use constant SCREEN_DEBUGLEVEL => 'debug';
209
210 # characters used to wrap around the id field in the log so I can more
211 # easily parse the msg flow
212 use constant DEBUGCHAR => '%';
213 use constant INFOCHAR => '=';
214 use constant INCOMINGCHAR => '<';
215 use constant SAVECHAR => '>';
216 use constant WARNCHAR => 'W';
217 use constant ERRORCHAR => 'E';
218
219 my $logger = new Log::Dispatch;
220 $logger->add(new Log::Dispatch::File (name=>'logfile',
221 filename=>$logfile,
222 min_level=>'info',
223 mode=>'append'));
224
225 # log alias, handles getting passed vars that are undef
226 sub mylog {
227 my $level = shift;
228 my $act = shift;
229 my $timestamp = strftime('%b %e %H:%M:%S', localtime);
230 my $msg;
231 foreach my $part (@_) {
232 if (defined($part)) {
233 $msg .= $part;
234 }
235 }
236 # no newlines in the log message, thanks
237 $msg =~ s/\n/ /gm;
238 $msg = sprintf("%s %s%s %s\n", $timestamp, $act, $act, $msg);
239 $logger->log(level=>$level, message=>$msg);
240
241 if ($act eq SAVECHAR) {
242 $logger->log(level=>$level, message=>"\n");
243 }
244 }
245
246 sub logincoming { mylog('info', INCOMINGCHAR, @_); }
247 sub logsave { mylog('notice', SAVECHAR, @_); }
248 sub loginfo { mylog('info', INFOCHAR, @_); }
249 sub logdebug { mylog('debug', DEBUGCHAR, @_); }
250 sub logwarn { mylog('warning', WARNCHAR, @_); }
251 sub logerr { mylog('error', ERRORCHAR, @_); }
252
253 sub log_headers {
254 my %headers = @_;
255
256 logincoming('From: ', $headers{'return-path'});
257 logincoming('To: ', $headers{'to'});
258 logincoming('Subject: ', $headers{'subject'});
259 logincoming('Message-Id: ', $headers{'message-id'});
260 }
261
262 \f
263 ###############################################################################
264 # Utility procedures
265
266 sub _errprint {
267 printf('%s:%s', __FILE__, __LINE__);
268
269 if (@_) {
270 print(': ');
271 foreach (@_) {
272 print;
273 }
274 }
275 }
276
277 sub err {
278 my $ex = shift;
279
280 _errprint(@_);
281 print(": $!\n");
282
283 exit($ex);
284 }
285
286 sub errx {
287 my $ex = shift;
288
289 _errprint(@_);
290 print("\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 unseen -add");
449 if (not WIFEXITED($status)) {
450 err(&EX_OSERR, "Failed to run mark");
451 } elsif (WEXITSTATUS($status) != 0) {
452 errx(&EX_SOFTWARE, "Failed to mark message unseen.");
453 }
454 }
455 }
456
457 if (not $FOLDERS{$mhfolder}) {
458 print("+$mhfolder\n");
459 $FOLDERS{$mhfolder} = 1;
460 }
461 }
462
463 sub get_headers {
464 my $msg = shift;
465 my %headers;
466 my $current; # current header, used for unfolding lines
467 my $fieldname; # unmolested header name
468
469 open(MSG, $msg);
470 while (<MSG>) {
471 chomp;
472 if (length == 0) {
473 last;
474 }
475
476 if (/^\s/) {
477 # folded header continuation
478
479 if (not defined($current)) {
480 print("Malformed message, cannot parse headers.\n");
481 return ();
482 }
483
484 $headers{$current} .= $_;
485 } else {
486 ($fieldname) = split(/:/);
487 $current = lc($fieldname);
488 (undef, $headers{$current}) = split(/^\Q$fieldname\E:\s*/);
489 }
490 }
491 close(MSG);
492
493 return %headers;
494 }
495
496 ###############################################################################
497 # Spam handling
498
499 sub kill_spam {
500 my @msglist = @_;
501 my @result;
502 my $msg;
503 my $i;
504
505 @result = ();
506
507 $i = 0;
508 foreach $msg (@msglist) {
509 printf('%sChecking for spam... %6d/%d',
510 "\r", ++$i, scalar(@msglist));
511 if (is_spam($msg)) {
512 print(" SPAM\n");
513 store_message($msg, 'SPAM');
514 } else {
515 push(@result, $msg);
516 }
517 }
518 print("\nDone: ", scalar(@result), " survivors\n");
519
520 return @result;
521 }
522
523 \f
524 ###############################################################################
525 # Filtering
526
527 sub find_mh_folder {
528 my $msg = shift;
529 my %headers;
530 my $header;
531 my $contents;
532 my $regex;
533 my $subst;
534 my $try;
535
536 %headers = get_headers($msg);
537 if (not %headers) {
538 return 'inbox';
539 }
540
541 log_headers(%headers);
542
543 foreach $header (keys(%FILTERS)) {
544 $contents = $headers{lc($header)};
545
546 if (defined($contents)) {
547 foreach $regex (keys(%{$FILTERS{$header}})) {
548 $subst = $FILTERS{$header}->{$regex};
549
550 $try = '$contents =~';
551 $try .= " s|$regex|$subst|is";
552 if (eval $try) {
553 return $contents;
554 }
555 }
556 }
557 }
558
559 return 'inbox';
560 }
561
562 sub filter_mail {
563 my @msglist = @_;
564 my $msg;
565 my $mhfolder;
566
567 foreach $msg (@msglist) {
568 $mhfolder = find_mh_folder($msg);
569 store_message($msg, $mhfolder);
570 }
571 }
572
573 \f
574 MAIN: {
575 my @msglist;
576
577 if ($dumpfilters) {
578 $Data::Dumper::Indent = 1;
579 print(Dumper(\%FILTERS));
580 exit(&EX_OK);
581 }
582
583 @msglist = getfiles("$MAILDIR/new");
584
585 if (not $filteronly) {
586 @msglist = kill_spam(@msglist);
587 }
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 For an example is_spam function, see
627 L<http:E<047>E<047>pretzelnet.orgE<047>cvsE<047>dotfilesE<047>.mincspam>
628
629 =head1 AUTHORS
630
631 Written by Eric Gillespie <epg@pretzelnet.org> with logging code
632 stolen from Adam Lazur <adam@lazur.org>.
633
634 Design by Eric Gillespie and Doug Porter <dsp@waterspout.com>.
635
636 =cut