#!/usr/bin/perl -w ############################################################################### # Purpose: automatically handles mailman administrative requests # Usage: run the script with --help option # Authors: Vadim Zeitlin # Daniel Veillard # License: BSD (http://opensource.org/licenses/bsd-license.php) # Created: 04.04.04 # Version: 0.03 alpha # RCS-Id: $Id$ ############################################################################### # TODO: # - automatically analyze the "reason" and act accordingly? # # 0.03: # read $HOME/.bounces for informations, add default filtering option # where messages are discarded based on preferences # use strict; use Getopt::Long (); use HTML::Entities (); use HTML::HeadParser (); use HTML::TokeParser (); use HTTP::Request::Common qw(GET POST); use HTTP::Cookies (); use LWP::UserAgent (); use LWP::Simple (); # uncomment this for tons of debugging messages #use LWP::Debug qw(+); ############################################################################### # Subroutines ############################################################################### sub ProcessMessages(); sub ListMessages(); sub FilterMessages(); # return the URL we're redirected to sub GetRedirectUrl($) { my $response = $_[0]; my $url = $response->header('Location') || return undef; # the Location URL is sometimes non-absolute which is not allowed, fix it local $URI::ABS_ALLOW_RELATIVE_SCHEME = 1; my $base = $response->base; $url = $HTTP::URI_CLASS->new($url, $base)->abs($base); return $url; } # go to the given tag, die if not found # # parameters: HTML::TokeParser object, the tag to go to # returns: the array reference returned by (successful) get_tag() sub GoToTag($$) { my ($htmlParser, $tag) = @_; my $token = $htmlParser->get_tag($tag); if ( !$token ) { die "No <$tag> tag found.\n"; } return $token; } # extract the list of the messages being held for approval from the mailman # administrative tasks HTML page # # parameters: the full HTML text of the page # returns: array of (msgno, from, subject, reason) arrays sub GetListOfHeldMessages($) { my $html = $_[0]; # look whether we have any messages to approve my $marker = "Posting Held for Approval"; my $pos = index $html, $marker; if ( $pos == -1 ) { # no messages at all return; } # and how many of them do we have? my $count; if ( substr($html, $pos + length $marker, 30) =~ /^ \(1 of (\d+)\)/ ) { $count = $1; } else { # mailman omits "1 of N" if there is only one message $count = 1; } # now extract the information about all messages my @msgs = (); my $htmlParser = HTML::TokeParser->new(\$html); # locate the beginning of the form GoToTag($htmlParser, "form"); # extract the message details: they're given in the first 3 table rows for ( 1..$count ) { GoToTag($htmlParser, "table"); GoToTag($htmlParser, "td"); GoToTag($htmlParser, "td"); my $from = $htmlParser->get_text("/td"); GoToTag($htmlParser, "td"); GoToTag($htmlParser, "td"); my $subject = $htmlParser->get_text("/td"); GoToTag($htmlParser, "td"); GoToTag($htmlParser, "td"); my $reason = $htmlParser->get_text("/td"); # the number of the message my $number = GoToTag($htmlParser, "input")->[1]{'name'}; GoToTag($htmlParser, "textarea"); GoToTag($htmlParser, "textarea"); my $headers = $htmlParser->get_text("/textarea"); GoToTag($htmlParser, "textarea"); my $content = $htmlParser->get_text("/textarea"); # TODO extend with more informations push @msgs, [$number, $from, $subject, $reason, $headers, $content]; } return @msgs; } ############################################################################### # Main program ############################################################################### # command line options parsing ############################################################################### my $VERSION_STRING = "0.02"; # boolean: if true, suppress messages my $quiet = 0; # resume from the last message retrieved? my $resume = 1; # the base admindb URL my $baseurl; # the list of messages to operate on my @messages; # the command to perform my $command; # the admin password my $password; # file to store cookies in my $cookie_file = ''; # the proxy to use my $proxy = ""; # booleans: version/help requested from cmd line? my $version = 0; my $help = 0; my $usage = < [ [message[, message...]]] where command is one of: list (default), accept, discard and is applied to the messages with the specified numbers (or all by default) held for approval at the given mailman URL. --help give the usage message showing the program options --version show the program version and exit --verbose give verbose informational messages (default) --quiet be silent, only error messages are given --pass=pass the mailing list administrative password --cookies=xxx file to use to store cookies in --proxy=url use the given proxy, if 'no' don't use proxy at all (even not the environment variable http_proxy which is used by default) EOF ; # main program start ############################################################################### # don't forget to modify the usage section above if you add more options! Getopt::Long::GetOptions ( 'verbose' => sub { $quiet = 0 }, 'quiet' => \$quiet, 'version' => \$version, 'help' => \$help, 'pass=s' => \$password, 'cookies=s' => \$cookie_file, 'proxy=s' => \$proxy, '<>' => sub { if ( $baseurl ) { if ( $command ) { push @messages, split(/,/, $_[0]) } else { $command = lc $_[0] } } else { $baseurl = $_[0] } } ) || die $usage; if ( $version || $help ) { if ( $version ) { print "mladmin version $VERSION_STRING, " . "written by Vadim Zeitlin \n"; } else { print $usage; } exit 0; } # # read the configuration options from the config file if any # my $conffile=$ENV{'HOME'}."/.bounces"; # # Open the configuration file and fills the %lists, %pass arrays # and reads the exclude patterns for @from, @subject, @header, @content # my %lists; my %pass; my @lists = (); my @pass = (); my @from = (); my @reason = (); my @subject = (); my @header = (); my @content = (); open(CONFIG, $conffile); while () { chomp; if ( $_ =~ /^#/ ) { } elsif ( $_ =~ /^\s*$/ ) { } elsif ( $_ =~ /^list/ ) { my ($tok, $name, $url, $passwd) = split(/\s+/, $_); $lists{$name} = $url; $pass{$name} = $passwd; print "Parsed informations for list $name\n" } elsif ( $_ =~ /^reject/ ) { my $value = $_; $value =~ s/^reject\s+(.*)/$1/; my $key = $value; $key =~ s/^(\w+)\s+.*/$1/; $value =~ s/^\w+\s+(.*)/$1/; if ( $key =~ 'from' ) { push @from, $value; } elsif ( $key =~ 'subject' ) { push @subject, $value; } elsif ( $key =~ 'header' ) { push @header, $value; } elsif ( $key =~ 'content' ) { push @content, $value; } elsif ( $key =~ 'reason' ) { push @reason, $value; } else { print "Unknown key $key value $value\n"; } } else { print "ignored $_\n"; } } close(CONFIG); my $nb_list = keys(%lists); if ( $nb_list != 0 ) { print "Read options for $nb_list lists\n"; } # # check we have enouth parameters # if ( !$baseurl and $nb_list == 0 ) { die "Required argument \"admindb_url\" is missing.\n$usage" } $command = "" if !defined $command; if ( $command ne "" && $command ne "list" && $command ne "discard" && $command ne "accept" && $command ne "filter") { die "Unknown command \"$command\": only \"list\", \"accept\" " . ", \"discard\" or \"filter\" allowed.\n" . "$usage" } my $ua; my $count; my @msgs; my $list; if ($nb_list == 0 ) { &process_list($baseurl, $password); } else { my $url; my $name; my $pw; if ($command eq "") { $command = "filter"; } while (($name,$url) = each %lists) { $pw = $pass{$name}; &process_list($url, $pw); } } # # Main subroutine to process a given list # arguments are the baseurl and the password # sub process_list { my $request; my $response; $baseurl = $_[0]; $password = $_[1]; # the short list name $list = $baseurl; $list =~ s@^.*admindb/@@; # we need to "login" to the service first: for this we need to get the cookie # which is set by the page we are redirected to when we initially try to # access the archive $ua = LWP::UserAgent->new; $ua->agent("mladmin/$VERSION_STRING"); if ( $proxy ) { $ua->proxy('http', $proxy); } elsif ( $proxy ne 'no' ) { # use http_proxy env var if set $ua->env_proxy(); } #else: don't use proxy at all # determine what to do with cookies my $cookie_jar; if ( $cookie_file ) { # store them $cookie_jar = HTTP::Cookies->new(file => $cookie_file, autosave => 1, ignore_discard => 1); } else { # don't store, use just during this session $cookie_jar = HTTP::Cookies->new(); } $ua->cookie_jar($cookie_jar); # unbuffer stdout as we want the progress messages to appear immediately select((select(STDOUT), $| = 1)[0]); # try to get the list of admin tasks directly if we use an existing cookies # file (otherwise wecan be sure that we need to login first) my $html; # the text of the admindb main page my $login; # do we need to login? if ( $cookie_file ) { $request = GET $baseurl; $response = $ua->simple_request($request); if ( !$response->is_success ) { die "Failed to retrieve the list of administrative tasks.\n" } $html = $response->content; $login = $html =~ "Administrative Authentication"; } else { $login = 1; } if ( $login ) { if ( !$password ) { my $hasReadKey = eval { require Term::ReadKey; }; if ( !$hasReadKey ) { warn "Please install Term::ReadKey to avoid echoing the password!\n"; } print "Administrative password for $list: "; if ( $hasReadKey ) { Term::ReadKey::ReadMode('noecho'); $password = Term::ReadKey::ReadLine(0); # 0 means normal read Term::ReadKey::ReadMode('restore'); } else { $password = } chomp $password; print "\n"; } print "Logging in to $list... " unless $quiet; $request = POST $baseurl, [ "adminpw" => $password ]; $response = $ua->simple_request($request); # although simple_request() adds the cookies for us we should # extract them from the response we get manually $cookie_jar->extract_cookies($response); while ( $response->is_redirect ) { my $url = GetRedirectUrl($response); # go to the new page if ( $url ) { $request = GET $url; $response = $ua->simple_request($request); } } if ( !$response->is_success ) { print "Failed!\n"; exit 2 } if ( $response->content =~ "Authentication failed" ) { print "wrong password\n"; exit 3 } print "ok.\n"; $html = $response->content; } #else: already authentificated # get the list of all postings held for approval @msgs = GetListOfHeldMessages($html); $count = $#msgs + 1; if ( !$count ) { warn "No held messages for the list $list.\n"; } # process the command given to us elsif ( $command eq "" || $command eq "list" ) { ListMessages } elsif ( $command eq "filter" ) { FilterMessages } else { # command == "discard" or "accept" ProcessMessages } } exit 0; ############################################################################### # More subroutines ############################################################################### # discard or accept the messages sub ProcessMessages() { my $all = $#messages == -1; print "Preparing to $command " . ($all ? "all " : "") . "messages " . ($all ? "" : join(',', @messages)) . "... " unless $quiet; # build the POST arguments string my %args; # the radiobox command index my $cmd = $command eq "discard" ? "3" : "1"; for ( $all ? 0..$#msgs : map(--$_, @messages) ) { $args{@{$msgs[$_]}[0]} = $cmd } my $request = POST $baseurl, \%args; my $response = $ua->simple_request($request); if ( !$response->is_success ) { warn "failed!\n"; } else { $count = ($all ? $#msgs : $#messages) + 1; my $sfx = $count == 1 ? "" : "s"; printf "$count message$sfx ${command}ed.\n" unless $quiet; } } # list the messages and optionally propose to discard them sub ListMessages() { # get the terminal size: use Term::Size if installed, fall back to # environment otherwise my $COLUMNS = 0; if ( eval { require Term::Size; } ) { $COLUMNS = &Term::Size::chars } $COLUMNS ||= $ENV{"COLUMNS"} || 80; print "$count message" . ($count == 1 ? "" : "s") . " held for approval for the list $list:\n" . "\n"; my ($widthFrom, $widthSubj, $widthReason) = ($COLUMNS / 3, $COLUMNS / 3, $COLUMNS - 2*($COLUMNS / 3) - 5); my $fmt = sprintf "%%-%d.%ds%%-%d.%ds%%-%d.%ds\n", $widthFrom + 1, $widthFrom, $widthSubj + 1, $widthSubj, $widthReason, $widthReason; printf " N " . $fmt, "From", "Subject", "Reason"; print '-' x $COLUMNS . "\n"; for (0..$#msgs) { my @msg = @{$msgs[$_]}; printf "%2d " . $fmt, $_ + 1, $msg[1], $msg[2], $msg[3] } print '-' x $COLUMNS . "\n"; # don't propose to discard messages if "--list" was explicitly specified if ( $command eq "" ) { print "Discard all messages (y/[n])? "; if ( <> =~ /^y/i ) { $command = "discard"; ProcessMessages } else { print "Nothing done.\n" unless $quiet } } } # Filter the messages, discarding the unwanted ones sub FilterMessages() { # build the POST arguments string my %args; my $req; my $res; my $discard_nr = 0; # the radiobox command index my $cmd = "3"; print "$count message" . ($count == 1 ? "" : "s") . " held for approval for the list $list:\n" . "\n"; for (0..$#msgs) { my @msg = @{$msgs[$_]}; my $frm = $msg[1]; my $sub = $msg[2]; my $rea = $msg[3]; my $head = $msg[4]; my $cont = $msg[5]; my $discard = 0; my $nr = $_; for (0..scalar(@from) - 1) { if ($discard == 1) { } elsif ( $frm =~ /$from[$_]/i ) { print "From: $frm\n"; print "Subject: $sub\n"; print "From $frm matches $from[$_] : discard it\n"; $discard = 1; } } if ($discard == 0) { for (0..scalar(@subject) - 1) { if ($discard == 1) { } elsif ( $sub =~ /$subject[$_]/i ) { print "From: $frm\n"; print "Subject: $sub\n"; print "Subject $sub matches $subject[$_] : discard it\n"; $discard = 1; } } } if ($discard == 0) { for (0..scalar(@reason) - 1) { if ($discard == 1) { } elsif ( $rea =~ /$reason[$_]/i ) { print "From: $frm\n"; print "Subject: $sub\n"; print "Reason $rea matches $reason[$_] : discard it\n"; $discard = 1; } } } if ($discard == 0) { for (0..scalar(@header) - 1) { if ($discard == 1) { } elsif ( $head =~ /$header[$_]/i ) { print "From: $frm\n"; print "Subject: $sub\n"; print "Headers matches $reason[$_] : discard it\n"; $discard = 1; } } } if ($discard == 0) { for (0..scalar(@content) - 1) { if ($discard == 1) { } elsif ( $cont =~ /$content[$_]/i ) { print "From: $frm\n"; print "Subject: $sub\n"; print "Content matches $content[$_] : discard it\n"; $discard = 1; } } } if ( $discard == 1 ) { $args{@{$msgs[$nr]}[0]} = $cmd; $discard_nr = $discard_nr + 1; } else { $args{@{$msgs[$nr]}[0]} = "0"; } } if ( $discard_nr != 0) { print "Discarding $discard_nr messages out of $count\n"; $req = POST $baseurl, \%args; $res = $ua->simple_request($req); if ( !$res->is_success ) { warn "failed!\n"; } else { printf "success\n"; } } } # History: # # 0.03: # ask whether to delete all messages # better support for terminals with width != 80 # # 0.02: # added "messages" argument and accept command # # 0.01: # initial barely functional release # vi: set ts=4 sw=4 et cin: