]> diplodocus.org Git - minc/blob - minc
a48e412f30be75aa5a36dca29d7e8e9cd54c4a18
[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 my @parts;
366 my @result;
367 my $done = 0;
368 for my $part (split(' ', $1)) {
369 if (not $done) {
370 my ($st, $en) = split('-', $part);
371 if ((defined($en) and ($msgnum >= $st and $msgnum <= $en))
372 or $msgnum == $st) {
373 # It's already there.
374 $done = 1;
375 }
376 if (defined($en)) {
377 if ($st - 1 == $msgnum) {
378 $part = "$msgnum-$en";
379 $done = 1;
380 } elsif ($en + 1 == $msgnum) {
381 $part = "$st-$msgnum";
382 $done = 1;
383 }
384 } else {
385 if ($part - 1 == $msgnum) {
386 $part = "$msgnum-$part";
387 $done = 1;
388 } elsif ($part + 1 == $msgnum) {
389 $part = "$part-$msgnum";
390 $done = 1;
391 }
392 }
393 }
394 push(@result, $part);
395 }
396 if (not $done) {
397 push(@result, $msgnum);
398 }
399 print($fh "$seq: ", join(' ', @result), "\n");
400 $marked = 1;
401 } else {
402 print($fh "$_\n");
403 }
404 }
405 $marked or print($fh "$seq: $msgnum\n");
406 close($fh) or die("close(>$fn): $!");
407 }
408
409 sub store_message {
410 my $msg = shift;
411 my $mhfolder = shift;
412 my $msgnum;
413 my $try;
414 my $mhmsg;
415 my $status;
416
417 # We must do this even in -n mode because later steps fail without
418 # it. This should be harmless.
419 mkfolder($mhfolder);
420
421 # This loop is a modified version of the maildir delivery algorithm.
422 $msgnum = get_highest_msgnum($mhfolder);
423 for ($try = 0; ; $try++) {
424 $msgnum++;
425 $mhmsg = "$mh/$mhfolder/$msgnum";
426
427 if (not stat($mhmsg)) {
428 if ($!{ENOENT}) {
429 # Now we have a non-existent file, let's try to create
430 # it. We must create a zero-byte file first because a
431 # file my appear between our happy stat results and
432 # our later rename(2), which would clobber said file.
433 # So attempt to create a file with this name. If it
434 # succeeds, in just a bit here we'll knowingly clobber
435 # this file with the rename(2) call.
436
437 # Another way to do this is not to use rename(2), but
438 # use link(2) + unlink(2) instead. That's how the
439 # standard maildir algorithm does it. Each method has
440 # a disadvantage: the program may crash between the
441 # link(2) and unlink(2) calls. With the standard
442 # maildir algorithm, that means the message will end
443 # up duplicated. The advantage of creating an empty
444 # file followed by rename(2) is that an extra empty
445 # file is left behind as opposed to a duplicate
446 # message. This is more easily detected by the user.
447 if ($run) {
448 if (sysopen(MSG, $mhmsg,
449 O_WRONLY | O_EXCL | O_CREAT, 0600)) {
450 close(MSG);
451 last;
452 }
453 } else {
454 last;
455 }
456 }
457 }
458
459 # This algorithm is different from the maildir one; let's make
460 # 10 tries instead of 3.
461 if ($try == 9) {
462 die("Attempted filename $mhmsg exists.");
463 }
464
465 # This algorithm is different; i don't think we need to sleep.
466 #sleep(2);
467 }
468
469 mylog('+', $mhfolder);
470
471 if ($run) {
472 if (not rename($msg, $mhmsg)) {
473 die("rename($msg, $mhmsg): $!");
474 }
475
476 # Mark each message as soon as we store it and bomb if that
477 # fails. While it is slow, it is not safe to store multiple
478 # messages and then have a failure before marking some (or
479 # all).
480 if ($mhfolder eq 'SPAM') {
481 push(@SPAM, $msgnum);
482 } else {
483 mark($mhfolder, $msgnum, 'unseen');
484 }
485 }
486
487 return $msgnum;
488 }
489
490 # Parse a message file into a structure describing the headers. The
491 # structure is a hash of arrays. The hash keys are the names of the
492 # headers found in the message, made all lower-case. Each item in
493 # the hash is an array of header text. The array has one element
494 # per occurrence of the header. Most headers will only have a single
495 # element because they occur only once. The Received header is the
496 # most common header with multiple texts.
497 sub get_headers {
498 my $msg = shift;
499 my %headers;
500 my $current; # current header, used for unfolding lines
501 my $fieldname; # unmolested header name
502 my $contents; # contents of header
503
504 open(MSG, $msg) or die("open(MSG, $msg): $!");
505 while (<MSG>) {
506 chomp;
507 if (length == 0) {
508 last;
509 }
510
511 if (/^\s/) {
512 # folded header continuation
513
514 if (not defined($current)) {
515 warn('Malformed message, cannot parse headers.');
516 next;
517 }
518
519 @{$headers{$current}}[-1] .= $_;
520 } else {
521 ($fieldname) = split(/:/);
522 $current = lc($fieldname);
523 (undef, $contents) = split(/^\Q$fieldname\E:\s*/);
524
525 if (defined($headers{$current})) {
526 # multiple occurence of same header
527 push(@{$headers{$current}}, $contents);
528 } else {
529 # first occurence of a header
530 $headers{$current} = [$contents];
531 }
532 }
533 }
534 close(MSG);
535
536 return %headers;
537 }
538
539 \f
540 ###############################################################################
541 # Filtering
542
543 sub find_mh_folder {
544 my $msg = shift;
545 my %headers = @_;
546 my $filterref;
547 my @filter;
548 my $header;
549 my $contents;
550 my $pair;
551 my $regexp;
552 my $expression;
553 my $result;
554
555 if (not %headers) {
556 return 'malformed';
557 }
558
559 # Walk the list of filters. This structure is documented in
560 # pod at the end of the program.
561 foreach $filterref (@FILTERS) {
562 @filter = @$filterref;
563 $header = shift(@filter);
564
565 # Handle filters using the magic TO header specially.
566 if ($header eq $MAGIC_TO_TOKEN) {
567 foreach $header (keys(%headers)) {
568 if ($header =~ /$MAGIC_TO_REGEX/i) {
569 foreach $contents (@{$headers{lc($header)}}) {
570 foreach $pair (@filter) {
571 ($regexp, $expression) = @$pair;
572 if ($contents =~ $regexp) {
573 if (eval "\$result = \"$expression\"") {
574 return $result;
575 }
576 }
577 }
578 }
579 }
580 }
581
582 # Now that it's been processed specially, skip normal handling.
583 next;
584 }
585
586 # Walk the list of message headers matching the filter's
587 # specified header.
588 foreach $contents (@{$headers{lc($header)}}) {
589 # Walk the filter's list of regexp/expression pairs.
590 foreach $pair (@filter) {
591 ($regexp, $expression) = @$pair;
592 if ($contents =~ $regexp) {
593 if (eval "\$result = \"$expression\"") {
594 return $result;
595 }
596 }
597 }
598 }
599 }
600
601 return 'inbox';
602 }
603
604 sub filter_mail {
605 my $msgcount = @_ - 2; # don't count . and ..
606 my $len = length($msgcount);
607 my @baton;
608 my $msg;
609 my $mhfolder;
610 my $spam = 0;
611 my $saved = 0;
612 my $msgnum;
613 my %folders;
614
615 if (-f "$HOME/.minc") {
616 require "$HOME/.minc";
617 }
618
619 # XXX lame names
620 my $nf = int($COLUMNS * $SCAN_P_FOLDER);
621 my $nm = int($COLUMNS * $SCAN_P_MESSAGE);
622 my $nF = int($COLUMNS * $SCAN_P_FROM);
623 my $ns = $COLUMNS - $nf - $nm - $nF - 3;
624
625 my %batons;
626 for my $hook (@start_hooks) {
627 my ($handle, @baton) = $hook->();
628 if (defined($handle)) {
629 $batons{$handle} = [@baton];
630 }
631 }
632
633 for $msg (@_) {
634 ($msg eq '.' or $msg eq '..') and next;
635
636 my %headers = get_headers($msg);
637 log_headers(%headers);
638
639 undef($mhfolder);
640 for my $hook (@filter_hooks) {
641 my $result = $hook->(\%batons, \%headers, $msg);
642 defined($result) and ($mhfolder = $result);
643 }
644
645 defined($mhfolder) or ($mhfolder = find_mh_folder($msg, %headers));
646
647 $msgnum = store_message($msg, $mhfolder);
648 $folders{$mhfolder}++;
649
650 if ($mhfolder eq 'SPAM') {
651 $spam++;
652 } else {
653 $saved++;
654 my $from = '';
655 my $subject = '';
656 # Sometimes these headers are missing...
657 eval { $from = [@{$headers{'from'}}]->[-1] };
658 eval { $subject = [@{$headers{'subject'}}]->[-1] };
659 for ($from, $subject) {
660 tr/\x00-\x1f\x80-\xff/ /;
661 }
662 print("\r");
663 print(' ' x $COLUMNS);
664 printf("\r\%-${nf}s \%${nm}d \%-${nF}s \%s\n",
665 substr($mhfolder, 0, $nf), substr($msgnum, 0, $nm),
666 substr($from, 0, $nF),
667 substr($subject, 0, $ns));
668 }
669
670 for my $hook (@post_store_hooks) {
671 $hook->(\%batons, \%headers, $mhfolder, $msgnum);
672 }
673
674 print("\r");
675 printf(" \%${len}d SPAM \%${len}d saved \%${len}d/%1d",
676 $spam, $saved, $spam + $saved, $msgcount);
677
678 defined($maxmsgs) and ($spam + $saved < $maxmsgs or last);
679 }
680 print("\n");
681
682 for my $hook (@stop_hooks) {
683 $hook->(\%batons);
684 }
685
686 return %folders;
687 }
688
689 sub build_dot_folders {
690 my $folders = shift;
691 my $fh = shift;
692 my $fn;
693
694 if (defined($fh)) {
695 while (<$fh>) {
696 chomp;
697 $folders->{$_}++;
698 }
699 }
700
701 eval { ($fh, $fn) = tempfile("$mh/.folders.XXXXX") };
702 if ($@) {
703 warn("$@");
704 return;
705 }
706
707 for my $folder (sort { $folder_sorter->($a,$b) } keys(%$folders)) {
708 print($fh "$folder\n");
709 }
710
711 if (not close($fh)) {
712 warn("close($fn): $!");
713 unlink($fn) or warn("unlink($fn): $!");
714 return;
715 }
716
717 rename($fn, "$mh/.folders") or warn("rename($fn, $mh/.folders): $!");
718 }
719
720 sub create_dot_folders {
721 if (-f "$HOME/.minc") {
722 require "$HOME/.minc";
723 }
724
725 my %folders;
726 open(my $fh, '-|', 'folders', '-fast', '-recur')
727 or die("open(folders|): $!");
728 build_dot_folders(\%folders, $fh);
729 return 0;
730 }
731
732 sub update_dot_folders {
733 my $folders = shift;
734 my $fh;
735
736 if (not open($fh, "$mh/.folders") and not $!{ENOENT}) {
737 # For ENOENT, we go ahead and create it, else we error and
738 # don't clobber it.
739 warn("open($mh/.folders): $!");
740 return;
741 }
742
743 build_dot_folders($folders, $fh);
744 }
745
746 \f
747 MAIN: {
748 my $st;
749
750 if ($dumpfilters) {
751 require "$HOME/.minc";
752 $Data::Dumper::Indent = 1;
753 print(Dumper(\@FILTERS));
754 exit;
755 }
756
757 $rebuild_dot_folders and exit(create_dot_folders);
758
759 chdir("$MAILDIR/new") or die("chdir($MAILDIR/new): $!");
760 my %folders = filter_mail(map { $_->[1] }
761 sort { $a->[0] <=> $b->[0] }
762 map {
763 if (not ($st = stat($_))) {
764 die("stat($_): $!");
765 }
766 [$st->mtime, $_]
767 }
768 getfiles('.'));
769
770 $run and update_dot_folders(\%folders);
771
772 @SPAM and (exec('scan', '+SPAM', @SPAM) or die);
773 }
774
775 \f
776 __END__
777
778 =head1 THE FILTERS STRUCTURE
779
780 Each element of @FILTERS is a filter. A filter is itself an array.
781 The first element of a filter is a string, the name of the header this
782 filter acts upon. The header name is not case-sensitive. Each
783 subsequent element of a filter is a pair (i.e. a two-element array):
784 first, a regular expression B<minc> uses to determine whether this
785 filter matches or not, and second, an expression which B<minc>
786 evaluates to get the folder name.
787
788 B<minc> decides where to store a message by iterating over the
789 @FILTERS array. It tests each regexp of each filter against all
790 headers matching that filter's specified header. As soon as a match
791 is found, B<minc> evaluates the second part of the pair. This part
792 may contain positional parameters from the matched regexp ($1, $2,
793 etc.). The result of this expression is used as the folder name.
794
795 Multiple occurrences of the same header are preserved and tested
796 individually against the filters. That means, for example, that
797 filters can search all the Received headers for a certain string.
798
799 It is important to note that all the arrays of the @FILTERS structure
800 are traversed I<in order>. This means the filters can be arranged so
801 that some have priority over others. XXX: get Doug to write an
802 example of having the same header matched more than once.
803
804 Lastly, B<minc> supports a magic ' TO' header. Filters using this
805 header are matched against a collection of headers related to (and
806 including) the To. Which headers to use is determined by a regular
807 expression borrowed from procmail.
808
809 =head1 HOOKS
810
811 Filter hooks take a reference to a hash of batons, a reference to a
812 hash of headers, and the message filename as arguments. It returns
813 undef to decline filtering of this message (thus falling back to
814 subsequent filter hooks, and finally @FILTERS), or the name of the
815 folder to store this message into.
816
817 One of B<minc>'s global variables is available to the user-defined
818 hooks: $run. This boolean should be honored; hooks should only take
819 real action (i.e. removing or creating files, running external
820 programs, etc.) if $run is non-zero.
821
822 The baton hash is created simply from the start hooks; if the hook
823 returns at least one defined value, this value is used as the key and
824 all other return values are put into a list reference as the value.
825 This hash is then passed by reference to all filter, post-store, and
826 stop hooks.
827
828 Post store hooks take a reference to a hash of batons, a reference to
829 a hash of headers, the folder this message was stored in, and its new
830 message number.
831
832 XXX: need more details about the hook process; for now read the code.
833
834 =head1 EXAMPLES
835
836 @FILTERS = (
837
838 ['List-Id',
839 ['<([[:graph:]]+)-([[:graph:]]+)\.lists\.sourceforge\.net>', 'l/$1/$2'],
840 ['<([[:graph:]]+?)(-list)?\.freedesktop\.org>', 'l/freedesktop/$1'],
841 ['<swig\.cs\.uchicago\.edu>', 'l/swig'],
842 ],
843
844 ['List-Post',
845 ['<mailto:([[:graph:]]+)+@([[:graph:]]+)\.apache\.org>', 'l/apache/$2/$1'],
846 ],
847
848 ['To',
849 ['root', 'root'],
850 ],
851 );
852
853 The first List-Id filter is a surprisingly effective one which creates
854 appropriate folder names (such as l/htdig/updates and l/rox/devel) for
855 all Sourceforge lists to which i subscribe. Certainly there are lists
856 hosted at Sourceforge which do not work well with this, and this is
857 where it is important to remember that the first match is the one
858 uses. Simply put the more specific rules before this one.
859
860 The next List-Id example is simple. The swig example demonstrates
861 that the folder name does not have to use a portion of the matched
862 header; you can just hardcode the folder name.
863
864 The List-Post example is a nice one. Most ASF projects have their own
865 apache.org subdomain, with mailing lists hosted there. So, given a
866 list such as dev@httpd.apache.org, this filter will create the folder
867 name l/apache/httpd/dev.
868
869 XXX Need hook examples.
870
871 =head1 AUTHORS
872
873 Written by Eric Gillespie <epg@pretzelnet.org>. Design by Eric
874 Gillespie and Doug Porter <dsp@waterspout.com>.
875
876 This program is free software; you can redistribute it and/or modify
877 it under the same terms as Perl itself.
878
879 =cut
880
881 # Local variables:
882 # cperl-indent-level: 4
883 # perl-indent-level: 4
884 # indent-tabs-mode: nil
885 # End:
886
887 # vi: set tabstop=4 expandtab: