]> diplodocus.org Git - minc/blob - minc
8ac7af24cfceba99f8e3a5d113647ff7fc58ed7a
[minc] / minc
1 #! /usr/bin/env 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<-m> I<MAX>] [B<-n>]
12
13 B<minc> B<-d>
14
15 B<minc> B<-r>
16
17 =head1 DESCRIPTION
18
19 B<minc> incorporates mail from a maildir to a mh folder hierarchy. It
20 takes mail from a maildir folder (not a maildir folder hierarchy),
21 running each message through regular expression based filter and hook
22 functions to determine in which folder to store it or whether it's
23 spam. Post-processing hooks may be applied to each message.
24
25 As it processes each message, B<minc> prints a line for each message
26 similar to B<inc(1)> and B<scan(1)>. This line includes the folder
27 and message number in which the message was stored, the last 'From'
28 header, and the last 'Subject' header. These fields are truncated to
29 fit in the user's terminal (see COLUMNS in B<ENVIRONMENT> below) in
30 the following proportions: folder (0.1), message number (0.0625), from
31 header (0.175). Any of these may be overridden with $SCAN_P_FOLDER,
32 $SCAN_P_MESSAGE, or $SCAN_P_FROM. The subject always fills out the
33 rest of the line.
34
35 =cut
36
37 use strict;
38 use warnings;
39
40 use Data::Dumper;
41 use Errno;
42 use Fcntl qw(O_WRONLY O_EXCL O_CREAT);
43 use FileHandle;
44 use File::Temp qw(tempfile);
45 use File::stat;
46 use Getopt::Long qw(:config gnu_getopt no_ignore_case);
47 use POSIX qw(strftime WEXITSTATUS WIFEXITED);
48 use Pod::Usage;
49
50 our $VERSION = 1;
51
52 # If a filter set's header is $MAGIC_TO_TOKEN, that set is compared
53 # against headers matching this regex (taken from procmail).
54 my $MAGIC_TO_REGEX = '^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To)';
55 my $MAGIC_TO_TOKEN = ' TO';
56
57 # List of SPAM message numbers, scanned at the end so the user can
58 # check for false positives.
59 my @SPAM;
60
61 =head1 OPTIONS
62
63 =over 4
64
65 =item B<-d>
66
67 Dump (using Data::Dumper) the FILTERS list and exit. This is useful
68 for testing the syntax of .minc.
69
70 =item B<-h>
71
72 Show help.
73
74 =item B<-m> I<MAX>
75
76 Stop processing after I<MAX> messages.
77
78 =item B<-n>
79
80 Dry run; do not actually incorporate the mail, but log and report to
81 stdout/stderr as normal.
82
83 =item B<-r>
84
85 Rebuild `mhpath +`/.folders from scratch, processing no mail.
86
87 =back
88
89 =cut
90
91 my $dumpfilters;
92 my $help;
93 my $maxmsgs;
94 my $norun;
95 my $rebuild_dot_folders;
96
97 GetOptions(
98 'd' => \$dumpfilters,
99 'h|help' => \$help,
100 'm=i' => \$maxmsgs,
101 'n' => \$norun,
102 'r' => \$rebuild_dot_folders,
103 ) or pod2usage();
104 $help and pod2usage(-exitstatus=>0, -verbose=>1);
105 @ARGV == 0 or pod2usage();
106
107 our $run = !$norun;
108
109 =head1 ENVIRONMENT
110
111 =over 4
112
113 =item COLUMNS
114
115 How many columns the user's terminal can hold, used to print scan
116 lines for each processed message. Defaults to 80.
117
118 =item HOME
119
120 Where the configuration file (.minc) is found. Also,
121 $HOME/Maildir is used for the maildir if MAILDIR is not set.
122
123 =item MAILDIR
124
125 Where mail is delivered.
126
127 =back
128
129 =cut
130
131 use Env qw(COLUMNS HOME MAILDIR);
132
133 $COLUMNS ||= 80;
134
135 if (not $HOME) {
136 die("HOME environment variable must be set.\n");
137 }
138 if (not $MAILDIR) {
139 $MAILDIR = "$HOME/Maildir";
140 }
141
142 =head1 FILES
143
144 =over 4
145
146 =item $HOME/.minc
147
148 This file is Perl code (included via the 'require' directive) which
149 may define the FILTERS list, @start_hooks, @filter_hooks,
150 @post_store_hooks, and @stop_hooks.
151
152 =item `mhpath +`/.folders
153
154 B<minc> adds all folders it filters into to this file, which is used
155 by lukem's B<new(1)> (XXX need a link).
156
157 =item `mhpath +`/logs/minc.log
158
159 Where B<minc> logs what it does, unless in -n mode.
160
161 =item `mhpath +`/.minc.context
162
163 B<minc> uses this file for context (i.e. current folder) instead of
164 `mhpath +`/context. This allows you some amount of freedom to use mh
165 while B<minc> is running. For example, you can changed folders without
166 causing a message to be stored in the wrong folder. Care must still
167 be taken, however, about the unseen sequence; if you change it
168 (whether via show, or mark, or anything else) while in the same folder
169 as B<minc>, it is likely the new message B<minc> stores will not end up
170 marked unseen.
171
172 =back
173
174 =cut
175
176 our @FILTERS;
177 our (@start_hooks, @stop_hooks, @filter_hooks, @post_store_hooks);
178 our $SCAN_P_FOLDER = 0.1;
179 our $SCAN_P_MESSAGE = 0.0625;
180 our $SCAN_P_FROM = 0.175;
181 our @folder_sort_list = (qr/^inbox$/);
182 our $folder_sorter = sub { sort_by_list(@_, @folder_sort_list) };
183
184 my $mh;
185
186 $mh = `mhpath +`;
187 chomp($mh);
188
189 my $logfile = $mh . '/logs/minc.log';
190
191 $ENV{"MHCONTEXT"} = $mh . '/.minc.context';
192
193 \f
194 ###############################################################################
195 # Logging
196
197 sub mylog {
198 $run or return;
199
200 my $timestamp = strftime('%b %e %H:%M:%S', localtime);
201 my $msg;
202 foreach my $part (@_) {
203 if (defined($part)) {
204 $msg .= $part;
205 }
206 }
207 # no newlines in the log message, thanks
208 $msg =~ s/\n/ /gm;
209
210 open(LOG, ">>$logfile") or die("open(>>$logfile): $!");
211 print(LOG "$timestamp $msg\n") or die("print(>>$logfile): $!");
212 close(LOG) or die("close($logfile): $!");
213 }
214
215 sub logheader {
216 my ($text, @contents) = @_;
217 my $last;
218
219 if (@contents) {
220 $last = $contents[-1];
221 } else {
222 $last = '';
223 }
224
225 mylog('<< ', $text, $last);
226 }
227
228 sub log_headers {
229 my %headers = @_;
230
231 # For an explanation of the %headers structure, see the
232 # get_headers function below.
233 logheader('From: ', @{$headers{'return-path'}});
234 logheader('To: ', @{$headers{'to'}});
235 logheader('Subject: ', @{$headers{'subject'}});
236 logheader('Message-Id: ', @{$headers{'message-id'}});
237 }
238
239 \f
240 ###############################################################################
241 # Utility procedures
242
243 sub sort_by_list {
244 my $a = shift;
245 my $b = shift;
246
247 for my $i (@_) {
248 my $am = $a =~ $i;
249 my $bm = $b =~ $i;
250 if ($am) {
251 if ($bm) {
252 last;
253 }
254 return -1;
255 } elsif ($bm) {
256 return 1;
257 }
258 }
259
260 return $a cmp $b;
261 }
262
263 sub mkfolder {
264 my $folder = shift;
265 my $target;
266 my $component;
267
268 $target = $mh;
269 foreach $component (split('/', $folder)) {
270 $target = join('/', $target, $component);
271 -d $target or mkdir($target) or die("mkdir($target): $!");
272 }
273 }
274
275 sub getfiles {
276 my $dir = shift;
277 my @result;
278
279 if (not opendir(DIR, $dir)) {
280 die("opendir($dir): $!");
281 }
282
283 # Initialize $! to 0 (success) because stupid stupid Perl provides
284 # no way to distinguish an error from an empty directory; that
285 # means setting $! to 0 and testing it afterwards is the only way
286 # to detect an error. Real Programmers don't handle errors,
287 # right? >sigh<
288 $! = 0;
289 @result = readdir(DIR);
290 if ($! != 0) {
291 die("readdir($dir): $!");
292 }
293
294 if (@result <= 2) {
295 exit(0);
296 }
297
298 STDOUT->autoflush(1);
299 print(@result - 2, " messages...");
300
301 closedir(DIR);
302
303 return @result;
304 }
305
306 my %msgnum_cache;
307 sub get_highest_msgnum {
308 my $mhfolder = shift;
309 my $dir;
310 my @list;
311 my $highest;
312 my $msgnum;
313
314 if (defined($msgnum_cache{$mhfolder})) {
315 return $msgnum_cache{$mhfolder}++;
316 }
317
318 $dir = "$mh/$mhfolder";
319 if (not opendir(DIR, $dir)) {
320 die("opendir($dir): $!");
321 }
322
323 # Insert rant from getfiles here.
324 $! = 0;
325 @list = readdir(DIR);
326
327 if ($! != 0) {
328 die("readdir($dir): $!");
329 }
330
331 closedir(DIR);
332
333 $highest = 0;
334 foreach $msgnum (@list) {
335 # Look for integers.
336 if ($msgnum =~ /^[0-9]+$/) {
337 if ($msgnum > $highest) {
338 $highest = $msgnum;
339 }
340 }
341 }
342
343 return $msgnum_cache{$mhfolder} = $highest;
344 }
345
346 sub mark {
347 my $folder = shift;
348 my $msgnum = shift;
349 my $seq = shift;
350 my $fn = "$mh/$folder/.mh_sequences";
351 my $fh;
352 my @sequences;
353
354 if (not open($fh, $fn)) {
355 $!{ENOENT} or die("open($fn): $!");
356 } else {
357 @sequences = <$fh>;
358 chomp(@sequences);
359 }
360
361 my $marked = 0;
362 open($fh, '>', $fn) or die("open(>$fn): $!");
363 for $_ (@sequences) {
364 if (/^$seq: /) {
365 print($fh "$_ $msgnum\n");
366 $marked = 1;
367 } else {
368 print($fh "$_\n");
369 }
370 }
371 $marked or print($fh "$seq: $msgnum\n");
372 close($fh) or die("close(>$fn): $!");
373 }
374
375 sub store_message {
376 my $msg = shift;
377 my $mhfolder = shift;
378 my $msgnum;
379 my $try;
380 my $mhmsg;
381 my $status;
382
383 # We must do this even in -n mode because later steps fail without
384 # it. This should be harmless.
385 mkfolder($mhfolder);
386
387 # This loop is a modified version of the maildir delivery algorithm.
388 $msgnum = get_highest_msgnum($mhfolder);
389 for ($try = 0; ; $try++) {
390 $msgnum++;
391 $mhmsg = "$mh/$mhfolder/$msgnum";
392
393 if (not stat($mhmsg)) {
394 if ($!{ENOENT}) {
395 # Now we have a non-existent file, let's try to create
396 # it. We must create a zero-byte file first because a
397 # file my appear between our happy stat results and
398 # our later rename(2), which would clobber said file.
399 # So attempt to create a file with this name. If it
400 # succeeds, in just a bit here we'll knowingly clobber
401 # this file with the rename(2) call.
402
403 # Another way to do this is not to use rename(2), but
404 # use link(2) + unlink(2) instead. That's how the
405 # standard maildir algorithm does it. Each method has
406 # a disadvantage: the program may crash between the
407 # link(2) and unlink(2) calls. With the standard
408 # maildir algorithm, that means the message will end
409 # up duplicated. The advantage of creating an empty
410 # file followed by rename(2) is that an extra empty
411 # file is left behind as opposed to a duplicate
412 # message. This is more easily detected by the user.
413 if ($run) {
414 if (sysopen(MSG, $mhmsg,
415 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
416 close(MSG);
417 last;
418 }
419 } else {
420 last;
421 }
422 }
423 }
424
425 # This algorithm is different from the maildir one; let's make
426 # 10 tries instead of 3.
427 if ($try == 9) {
428 die("Attempted filename $mhmsg exists.");
429 }
430
431 # This algorithm is different; i don't think we need to sleep.
432 #sleep(2);
433 }
434
435 mylog('+', $mhfolder);
436
437 if ($run) {
438 if (not rename($msg, $mhmsg)) {
439 die("rename($msg, $mhmsg): $!");
440 }
441
442 # Mark each message as soon as we store it and bomb if that
443 # fails. While it is slow, it is not safe to store multiple
444 # messages and then have a failure before marking some (or
445 # all).
446 if ($mhfolder eq 'SPAM') {
447 push(@SPAM, $msgnum);
448 } else {
449 mark($mhfolder, $msgnum, 'unseen');
450 }
451 }
452
453 return $msgnum;
454 }
455
456 # Parse a message file into a structure describing the headers. The
457 # structure is a hash of arrays. The hash keys are the names of the
458 # headers found in the message, made all lower-case. Each item in
459 # the hash is an array of header text. The array has one element
460 # per occurrence of the header. Most headers will only have a single
461 # element because they occur only once. The Received header is the
462 # most common header with multiple texts.
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 my $contents; # contents of header
469
470 open(MSG, $msg) or die("open(MSG, $msg): $!");
471 while (<MSG>) {
472 chomp;
473 if (length == 0) {
474 last;
475 }
476
477 if (/^\s/) {
478 # folded header continuation
479
480 if (not defined($current)) {
481 warn('Malformed message, cannot parse headers.');
482 next;
483 }
484
485 @{$headers{$current}}[-1] .= $_;
486 } else {
487 ($fieldname) = split(/:/);
488 $current = lc($fieldname);
489 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
490
491 if (defined($headers{$current})) {
492 # multiple occurence of same header
493 push(@{$headers{$current}}, $contents);
494 } else {
495 # first occurence of a header
496 $headers{$current} = [$contents];
497 }
498 }
499 }
500 close(MSG);
501
502 return %headers;
503 }
504
505 \f
506 ###############################################################################
507 # Filtering
508
509 sub find_mh_folder {
510 my $msg = shift;
511 my %headers = @_;
512 my $filterref;
513 my @filter;
514 my $header;
515 my $contents;
516 my $pair;
517 my $regexp;
518 my $expression;
519 my $result;
520
521 if (not %headers) {
522 return 'malformed';
523 }
524
525 # Walk the list of filters. This structure is documented in
526 # pod at the end of the program.
527 foreach $filterref (@FILTERS) {
528 @filter = @$filterref;
529 $header = shift(@filter);
530
531 # Handle filters using the magic TO header specially.
532 if ($header eq $MAGIC_TO_TOKEN) {
533 foreach $header (keys(%headers)) {
534 if ($header =~ /$MAGIC_TO_REGEX/i) {
535 foreach $contents (@{$headers{lc($header)}}) {
536 foreach $pair (@filter) {
537 ($regexp, $expression) = @$pair;
538 if ($contents =~ $regexp) {
539 if (eval "\$result = \"$expression\"") {
540 return $result;
541 }
542 }
543 }
544 }
545 }
546 }
547
548 # Now that it's been processed specially, skip normal handling.
549 next;
550 }
551
552 # Walk the list of message headers matching the filter's
553 # specified header.
554 foreach $contents (@{$headers{lc($header)}}) {
555 # Walk the filter's list of regexp/expression pairs.
556 foreach $pair (@filter) {
557 ($regexp, $expression) = @$pair;
558 if ($contents =~ $regexp) {
559 if (eval "\$result = \"$expression\"") {
560 return $result;
561 }
562 }
563 }
564 }
565 }
566
567 return 'inbox';
568 }
569
570 sub filter_mail {
571 my $msgcount = @_ - 2; # don't count . and ..
572 my $len = length($msgcount);
573 my @baton;
574 my $msg;
575 my $mhfolder;
576 my $spam = 0;
577 my $saved = 0;
578 my $msgnum;
579 my %folders;
580
581 if (-f "$HOME/.minc") {
582 require "$HOME/.minc";
583 }
584
585 # XXX lame names
586 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
587 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
588 my $nF = int($COLUMNS * $SCAN_P_FROM);
589 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
590
591 my %batons;
592 for my $hook (@start_hooks) {
593 my ($handle, @baton) = $hook->();
594 if (defined($handle)) {
595 $batons{$handle} = [@baton];
596 }
597 }
598
599 for $msg (@_) {
600 ($msg eq '.' or $msg eq '..') and next;
601
602 my %headers = get_headers($msg);
603 log_headers(%headers);
604
605 undef($mhfolder);
606 for my $hook (@filter_hooks) {
607 my $result = $hook->(\%batons, \%headers, $msg);
608 defined($result) and ($mhfolder = $result);
609 }
610
611 defined($mhfolder) or ($mhfolder = find_mh_folder($msg, %headers));
612
613 $msgnum = store_message($msg, $mhfolder);
614 $folders{$mhfolder}++;
615
616 if ($mhfolder eq 'SPAM') {
617 $spam++;
618 } else {
619 $saved++;
620 my $from = '';
621 my $subject = '';
622 # Sometimes these headers are missing...
623 eval { $from = [@{$headers{'from'}}]->[-1] };
624 eval { $subject = [@{$headers{'subject'}}]->[-1] };
625 for ($from, $subject) {
626 tr/\x00-\x1f\x80-\xff/ /;
627 }
628 print("\r");
629 print(' ' x $COLUMNS);
630 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
631 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
632 substr($from, 0, $nF),
633 substr($subject, 0, $ns));
634 }
635
636 for my $hook (@post_store_hooks) {
637 $hook->(\%batons, \%headers, $mhfolder, $msgnum);
638 }
639
640 print("\r");
641 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
642 $spam, $saved, $spam + $saved, $msgcount);
643
644 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
645 }
646 print("\n");
647
648 for my $hook (@stop_hooks) {
649 $hook->(\%batons);
650 }
651
652 return %folders;
653 }
654
655 sub build_dot_folders {
656 my $folders = shift;
657 my $fh = shift;
658 my $fn;
659
660 if (defined($fh)) {
661 while (<$fh>) {
662 chomp;
663 $folders->{$_}++;
664 }
665 }
666
667 eval { ($fh, $fn) = tempfile("$mh/.folders.XXXXX") };
668 if ($@) {
669 warn("$@");
670 return;
671 }
672
673 for my $folder (sort { $folder_sorter->($a,$b) } keys(%$folders)) {
674 print($fh "$folder\n");
675 }
676
677 if (not close($fh)) {
678 warn("close($fn): $!");
679 unlink($fn) or warn("unlink($fn): $!");
680 return;
681 }
682
683 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
684 }
685
686 sub create_dot_folders {
687 if (-f "$HOME/.minc") {
688 require "$HOME/.minc";
689 }
690
691 my %folders;
692 open(my $fh, '-|', 'folders', '-fast', '-recur')
693 or die("open(folders|): $!");
694 build_dot_folders(\%folders, $fh);
695 return 0;
696 }
697
698 sub update_dot_folders {
699 my $folders = shift;
700 my $fh;
701
702 if (not open($fh, "$mh/.folders") and not $!{ENOENT}) {
703 # For ENOENT, we go ahead and create it, else we error and
704 # don't clobber it.
705 warn("open($mh/.folders): $!");
706 return;
707 }
708
709 build_dot_folders($folders, $fh);
710 }
711
712 \f
713 MAIN: {
714 my $st;
715
716 if ($dumpfilters) {
717 require "$HOME/.minc";
718 $Data::Dumper::Indent = 1;
719 print(Dumper(\@FILTERS));
720 exit;
721 }
722
723 $rebuild_dot_folders and exit(create_dot_folders);
724
725 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
726 my %folders = filter_mail(map { $_->[1] }
727 sort { $a->[0] <=> $b->[0] }
728 map {
729 if (not ($st = stat($_))) {
730 die("stat($_): $!");
731 }
732 [$st->mtime, $_]
733 }
734 getfiles('.'));
735
736 $run and update_dot_folders(\%folders);
737
738 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
739 }
740
741 \f
742 __END__
743
744 =head1 THE FILTERS STRUCTURE
745
746 Each element of @FILTERS is a filter. A filter is itself an array.
747 The first element of a filter is a string, the name of the header this
748 filter acts upon. The header name is not case-sensitive. Each
749 subsequent element of a filter is a pair (i.e. a two-element array):
750 first, a regular expression B<minc> uses to determine whether this
751 filter matches or not, and second, an expression which B<minc>
752 evaluates to get the folder name.
753
754 B<minc> decides where to store a message by iterating over the
755 @FILTERS array. It tests each regexp of each filter against all
756 headers matching that filter's specified header. As soon as a match
757 is found, B<minc> evaluates the second part of the pair. This part
758 may contain positional parameters from the matched regexp ($1, $2,
759 etc.). The result of this expression is used as the folder name.
760
761 Multiple occurrences of the same header are preserved and tested
762 individually against the filters. That means, for example, that
763 filters can search all the Received headers for a certain string.
764
765 It is important to note that all the arrays of the @FILTERS structure
766 are traversed I<in order>. This means the filters can be arranged so
767 that some have priority over others. XXX: get Doug to write an
768 example of having the same header matched more than once.
769
770 Lastly, B<minc> supports a magic ' TO' header. Filters using this
771 header are matched against a collection of headers related to (and
772 including) the To. Which headers to use is determined by a regular
773 expression borrowed from procmail.
774
775 =head1 HOOKS
776
777 Filter hooks take a reference to a hash of batons, a reference to a
778 hash of headers, and the message filename as arguments. It returns
779 undef to decline filtering of this message (thus falling back to
780 subsequent filter hooks, and finally @FILTERS), or the name of the
781 folder to store this message into.
782
783 One of B<minc>'s global variables is available to the user-defined
784 hooks: $run. This boolean should be honored; hooks should only take
785 real action (i.e. removing or creating files, running external
786 programs, etc.) if $run is non-zero.
787
788 The baton hash is created simply from the start hooks; if the hook
789 returns at least one defined value, this value is used as the key and
790 all other return values are put into a list reference as the value.
791 This hash is then passed by reference to all filter, post-store, and
792 stop hooks.
793
794 Post store hooks take a reference to a hash of batons, a reference to
795 a hash of headers, the folder this message was stored in, and its new
796 message number.
797
798 XXX: need more details about the hook process; for now read the code.
799
800 =head1 EXAMPLES
801
802 @FILTERS = (
803
804 ['List-Id',
805 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
806 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
807 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
808 ],
809
810 ['List-Post',
811 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
812 ],
813
814 ['To',
815 ['root', 'root'],
816 ],
817 );
818
819 The first List-Id filter is a surprisingly effective one which creates
820 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
821 all Sourceforge lists to which i subscribe. Certainly there are lists
822 hosted at Sourceforge which do not work well with this, and this is
823 where it is important to remember that the first match is the one
824 uses. Simply put the more specific rules before this one.
825
826 The next List-Id example is simple. The swig example demonstrates
827 that the folder name does not have to use a portion of the matched
828 header; you can just hardcode the folder name.
829
830 The List-Post example is a nice one. Most ASF projects have their own
831 apache.org subdomain, with mailing lists hosted there. So, given a
832 list such as dev@httpd.apache.org, this filter will create the folder
833 name l/apache/httpd/dev.
834
835 XXX Need hook examples.
836
837 =head1 AUTHORS
838
839 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
840 Gillespie and Doug Porter <dsp@waterspout.com>.
841
842 This program is free software; you can redistribute it and/or modify
843 it under the same terms as Perl itself.
844
845 =cut
846
847 # Local variables:
848 # cperl-indent-level: 4
849 # perl-indent-level: 4
850 # indent-tabs-mode: nil
851 # End:
852
853 # vi: set tabstop=4 expandtab: