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