| 1 | #!/usr/bin/perl -w |
|---|
| 2 | # |
|---|
| 3 | # pl-janitor -- |
|---|
| 4 | # |
|---|
| 5 | # Do periodic maintenance of Project Lancelot lists |
|---|
| 6 | # |
|---|
| 7 | # Copyright (C) 2006 Anselm Lingnau <anselm@anselms.net>. |
|---|
| 8 | # |
|---|
| 9 | # See the end of the in-file documentation for licensing details. |
|---|
| 10 | |
|---|
| 11 | =head1 NAME |
|---|
| 12 | |
|---|
| 13 | pl-janitor -- do periodic maintenance of Project Lancelot lists |
|---|
| 14 | |
|---|
| 15 | =head1 SYNOPSIS |
|---|
| 16 | |
|---|
| 17 | pl-janitor [-dv?] list@domain|DIRECTORY ... |
|---|
| 18 | |
|---|
| 19 | =head1 DESCRIPTION |
|---|
| 20 | |
|---|
| 21 | The B<pl-janitor> program is supposed to be run by B<cron> and does |
|---|
| 22 | most of the work of automatic bounce processing. It checks whether a |
|---|
| 23 | subscribing address has had excessive bounces and blocks it from receiving |
|---|
| 24 | further messages if this is the case. It also takes care of sending out |
|---|
| 25 | appropriate notifications to the address in question and finally unsubscribes |
|---|
| 26 | it from the list if enough time has elapsed without a reaction. |
|---|
| 27 | |
|---|
| 28 | =head1 SEE ALSO |
|---|
| 29 | |
|---|
| 30 | pl-bouncedb(1), pl-incoming(1) |
|---|
| 31 | |
|---|
| 32 | =head1 AUTHOR |
|---|
| 33 | |
|---|
| 34 | Anselm Lingnau <anselm@anselms.net> |
|---|
| 35 | |
|---|
| 36 | =head1 COPYRIGHT AND LICENSE |
|---|
| 37 | |
|---|
| 38 | Copyright 2006 by Anselm Lingnau. This program is free software; you |
|---|
| 39 | may redistribute it and/or modify it under the terms of the GNU |
|---|
| 40 | General Public License as published by the Free Software Foundation; |
|---|
| 41 | either version 2 of the License, or (at your option) any later version. |
|---|
| 42 | |
|---|
| 43 | This program is distributed in the hope that it will be useful, but |
|---|
| 44 | WITHOUT ANY WARRANTY; without even the implied warranty of |
|---|
| 45 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
|---|
| 46 | General Public License for more details. |
|---|
| 47 | |
|---|
| 48 | You should have received a copy of the GNU General Public License |
|---|
| 49 | along with this program; if not, refer to |
|---|
| 50 | <URL:http://www.gnu.org/copyleft/gpl.html>. You may also obtain it by |
|---|
| 51 | writing to the Free Software Foundation, Inc., 59 Temple Place - Suite |
|---|
| 52 | 330, Boston, MA 02111-1307, USA. |
|---|
| 53 | |
|---|
| 54 | =cut |
|---|
| 55 | |
|---|
| 56 | use strict; |
|---|
| 57 | |
|---|
| 58 | use Config; |
|---|
| 59 | use File::Basename; |
|---|
| 60 | use Time::Piece; |
|---|
| 61 | |
|---|
| 62 | use vars qw($g_base_dir); |
|---|
| 63 | BEGIN { |
|---|
| 64 | use FindBin qw($Bin); |
|---|
| 65 | $g_base_dir = dirname($Bin); |
|---|
| 66 | } |
|---|
| 67 | use lib "$g_base_dir/lib"; |
|---|
| 68 | use lib "$g_base_dir/lib/perl5/$Config{version}"; |
|---|
| 69 | use lib "$g_base_dir/lib/perl5/site_perl/$Config{version}"; |
|---|
| 70 | |
|---|
| 71 | use Lancelot::GetOpt qw/GetOptions/; |
|---|
| 72 | use Pod::Usage; |
|---|
| 73 | |
|---|
| 74 | use Lancelot::DB; |
|---|
| 75 | use Lancelot::Log qw/log log_and_die/; |
|---|
| 76 | use Lancelot::Send; |
|---|
| 77 | use Lancelot::Template; |
|---|
| 78 | |
|---|
| 79 | use Lancelot::Module::process_bounce; |
|---|
| 80 | use Lancelot::Module::post_digest; |
|---|
| 81 | |
|---|
| 82 | sub send_bounce_message { |
|---|
| 83 | my ($db, $listaddr, $template, $address, $keys) = @_; |
|---|
| 84 | my ($fromlocal, $fromdomain) = split /\@/, $listaddr, 2; |
|---|
| 85 | my ($rlocal, $rdomain) = split /\@/, $address, 2; |
|---|
| 86 | my ($delim) = $db->get_config("mail.delimiter"); |
|---|
| 87 | log_and_die "crit", "mail.delimiter not set" unless $delim; |
|---|
| 88 | my (%keys) = (%$keys, "verp-recipient" => $address, |
|---|
| 89 | "verp-from" => "$fromlocal${delim}bounce-notify-$rlocal=$rdomain\@$fromdomain", |
|---|
| 90 | "reenable-address" => "$fromlocal${delim}bounce-reenable-$rlocal=$rdomain\@$fromdomain"); |
|---|
| 91 | log "debug", "send bounce warning to $address"; |
|---|
| 92 | Lancelot::Send::sendtemplate($template, $db, undef, \%keys); |
|---|
| 93 | # If this is the bounce-unsub message the log entry will be deleted at once |
|---|
| 94 | $db->log_bounce_warning($address); |
|---|
| 95 | } |
|---|
| 96 | |
|---|
| 97 | register Lancelot::Template "bounce-message", "en", <<END; |
|---|
| 98 | From: <<list.name>> Management <<<owner-address>>> |
|---|
| 99 | To: <<verp-recipient>> |
|---|
| 100 | Subject: Bounce notification for <<list.name>> |
|---|
| 101 | |
|---|
| 102 | I\'m sorry to have to tell you that several messages to the |
|---|
| 103 | |
|---|
| 104 | <<verp-recipient>> |
|---|
| 105 | |
|---|
| 106 | address, which subscribes to the <<list.name>> list, have |
|---|
| 107 | been bounced back to the list management software. The address in |
|---|
| 108 | question has been disabled temporarily and will not be receiving |
|---|
| 109 | further messages from the list for the time being. |
|---|
| 110 | |
|---|
| 111 | To re-enable the address for message reception, send a (possibly |
|---|
| 112 | empty) message to |
|---|
| 113 | |
|---|
| 114 | <<reenable-address>> |
|---|
| 115 | |
|---|
| 116 | within the next <<interval>> days. If the address is not re-enabled |
|---|
| 117 | during this period, it will be unsubscribed permanently, and you |
|---|
| 118 | will have to resubscribe to the list to receive messages again. |
|---|
| 119 | |
|---|
| 120 | If you have any queries, please feel free to contact <<list.ownername|the list owner>> |
|---|
| 121 | at <<owner-address>>. |
|---|
| 122 | |
|---|
| 123 | The <<list.name>> Management Team |
|---|
| 124 | |
|---|
| 125 | END |
|---|
| 126 | |
|---|
| 127 | register Lancelot::Template "bounce-unsub-message", "en", <<END; |
|---|
| 128 | From: <<list.name>> Management <<<owner-address>>> |
|---|
| 129 | To: <<verp-recipient>> |
|---|
| 130 | Subject: Unsubscription notification for <<list.name>> |
|---|
| 131 | |
|---|
| 132 | I\'m sorry to have to tell you that the |
|---|
| 133 | |
|---|
| 134 | <<verp-recipient>> |
|---|
| 135 | |
|---|
| 136 | address, which used to subscribe to the <<list.name>> list, has |
|---|
| 137 | been automatically unsubscribed from the list because it caused an excessive |
|---|
| 138 | number of bounces (message transmission errors). |
|---|
| 139 | |
|---|
| 140 | If this has happened in error, please accept our apologies for the |
|---|
| 141 | inconvenience. You may resubscribe immediately by sending an empty message |
|---|
| 142 | to |
|---|
| 143 | |
|---|
| 144 | <<sub-address>> |
|---|
| 145 | |
|---|
| 146 | Also feel free to contact <<list.ownername|the list owner>> at |
|---|
| 147 | <<owner-address>> to vent your anger :^| In fact, we |
|---|
| 148 | hope that you will do so, so we can figure out if anything went wrong at |
|---|
| 149 | our end. |
|---|
| 150 | |
|---|
| 151 | The <<list.name>> Management Team |
|---|
| 152 | |
|---|
| 153 | END |
|---|
| 154 | |
|---|
| 155 | my (%opts) = ( notreally => 0, bounces => 1, digest => 1 ); |
|---|
| 156 | |
|---|
| 157 | GetOptions(\%opts, -argvmin => 1, "no list address or directory specified", |
|---|
| 158 | "notreally|n?", "bounces|b?", "digest|d?", |
|---|
| 159 | "threshold:"); |
|---|
| 160 | |
|---|
| 161 | # Replace directory name arguments by the names of Project Lancelot |
|---|
| 162 | # lists immediately below them. This contains some special support for |
|---|
| 163 | # Our-ISP, where lists are in directories called $HOME/domains/DOMAIN/LIST; |
|---|
| 164 | # you need to use $HOME/domains/DOMAIN as an argument to pl-janitor in this |
|---|
| 165 | # case. |
|---|
| 166 | |
|---|
| 167 | log "info", "starting run at " . scalar(localtime); |
|---|
| 168 | |
|---|
| 169 | my (@lists) = (); |
|---|
| 170 | foreach my $arg (@ARGV) { |
|---|
| 171 | if (-d $arg) { |
|---|
| 172 | log "debug", "arg=$arg " . join ",", glob "$arg/*\@*"; |
|---|
| 173 | foreach (grep { -f File::Spec->join($_, "list.db") } glob "$arg/*") { |
|---|
| 174 | my $list = basename $_; |
|---|
| 175 | $list .= '@' . basename dirname $_ if $list !~ /\@/; |
|---|
| 176 | push @lists, $list; |
|---|
| 177 | } |
|---|
| 178 | } else { |
|---|
| 179 | push @lists, $arg; |
|---|
| 180 | } |
|---|
| 181 | } |
|---|
| 182 | log "debug", "lists=@lists"; |
|---|
| 183 | |
|---|
| 184 | # Go through the lists and do your thing |
|---|
| 185 | |
|---|
| 186 | foreach my $listaddr (@lists) { |
|---|
| 187 | my $db = new Lancelot::DB $listaddr, { nodefault => 1 }; |
|---|
| 188 | |
|---|
| 189 | if (!defined $db) { |
|---|
| 190 | log "err", "list $listaddr cannot be found"; |
|---|
| 191 | warn "$0: list $listaddr cannot be found\n"; |
|---|
| 192 | next; |
|---|
| 193 | } |
|---|
| 194 | |
|---|
| 195 | process_bounces($listaddr, $db) if $opts{bounces}; |
|---|
| 196 | process_digest($listaddr, $db) if $opts{digest}; |
|---|
| 197 | |
|---|
| 198 | # TODO: Weed out overdue confirmations |
|---|
| 199 | } |
|---|
| 200 | |
|---|
| 201 | sub process_bounces { |
|---|
| 202 | my ($listaddr, $db) = @_; |
|---|
| 203 | |
|---|
| 204 | log "debug", "processing bounces for $listaddr"; |
|---|
| 205 | |
|---|
| 206 | # Get bounce parameters for this list |
|---|
| 207 | |
|---|
| 208 | my $bkt_count = $db->get_config("bounce.bucketcount"); |
|---|
| 209 | my $bkt_size = $db->get_config("bounce.bucketsize"); |
|---|
| 210 | my $b_threshold = $opts{'threshold'} || $db->get_config("bounce.threshold"); |
|---|
| 211 | my $b_warnings = $db->get_config("bounce.warnings"); |
|---|
| 212 | my $b_wperiod = $db->get_config("bounce.warnperiod"); |
|---|
| 213 | log_and_die "crit", "bounce hander parameter(s) missing from configuration" |
|---|
| 214 | unless $bkt_count && $bkt_size && $b_threshold |
|---|
| 215 | && $b_warnings && $b_wperiod; |
|---|
| 216 | |
|---|
| 217 | # Check whether any new addresses need to be marked BOUNCING |
|---|
| 218 | |
|---|
| 219 | my $cutoff = time - $bkt_count * $bkt_size; |
|---|
| 220 | foreach my $address ($db->get_bounce_addresses("*", $cutoff)) { |
|---|
| 221 | my $addr_opts = $db->get_address_options($address); |
|---|
| 222 | my $t = Lancelot::Module::process_bounce::total_score($db, $address, |
|---|
| 223 | $bkt_count, |
|---|
| 224 | $bkt_size); |
|---|
| 225 | log "debug", "total score for $address is $t (vs. $b_threshold)"; |
|---|
| 226 | if ($t > $b_threshold && $addr_opts->{status} eq "SUBSCRIBED") { |
|---|
| 227 | # Address is officially bouncing for the first time |
|---|
| 228 | log "info", "mark $address as BOUNCING"; |
|---|
| 229 | unless ($opts{notreally}) { |
|---|
| 230 | $db->set_address_options($address, { status => "BOUNCING" }); |
|---|
| 231 | send_bounce_message($db, $listaddr, "bounce-message", $address, |
|---|
| 232 | {interval => $b_warnings*$b_wperiod/86400}); |
|---|
| 233 | } |
|---|
| 234 | } |
|---|
| 235 | } |
|---|
| 236 | |
|---|
| 237 | # Check whether any BOUNCING addresses need unsubscribed or a new reminder |
|---|
| 238 | |
|---|
| 239 | foreach my $address ($db->get_addresses("*", { bouncing => 1 })) { |
|---|
| 240 | log "debug", "check bouncing address $address"; |
|---|
| 241 | my ($count, $last) = $db->last_bounce_warning($address); |
|---|
| 242 | log "debug","count=$count last=$last (".localtime($last)->datetime.")"; |
|---|
| 243 | next if $last > time - $b_wperiod; |
|---|
| 244 | if ($count < $b_warnings) { |
|---|
| 245 | # Send another reminder |
|---|
| 246 | log "info", sprintf "send a bounce reminder to $address (%d left)", |
|---|
| 247 | $b_warnings - $count; |
|---|
| 248 | send_bounce_message($db, $listaddr, "bounce-message", $address, |
|---|
| 249 | {interval => ($b_warnings-$count)*$b_wperiod/86400}) |
|---|
| 250 | unless $opts{notreally}; |
|---|
| 251 | } else { |
|---|
| 252 | # That's it -- they will be unsubscribed |
|---|
| 253 | log "info", "unsubscribe $address due to excessive bounces"; |
|---|
| 254 | unless ($opts{notreally}) { |
|---|
| 255 | send_bounce_message($db, $listaddr, "bounce-unsub-message", |
|---|
| 256 | $address, {}); |
|---|
| 257 | $db->set_address_options($address, { status => "DELETE" }); |
|---|
| 258 | $db->clear_bounce_warnings($address); |
|---|
| 259 | $db->clear_bounces($address); |
|---|
| 260 | } |
|---|
| 261 | } |
|---|
| 262 | } |
|---|
| 263 | } |
|---|
| 264 | |
|---|
| 265 | sub process_digest { |
|---|
| 266 | my ($listaddr, $db) = @_; |
|---|
| 267 | |
|---|
| 268 | # See whether we ought to send out a digest based on the time of the |
|---|
| 269 | # last digest and the maximum delay. |
|---|
| 270 | |
|---|
| 271 | my $digest_period = $db->get_config("digest.maxperiod"); |
|---|
| 272 | if ($digest_period =~ s/([hdw])$//) { |
|---|
| 273 | my $unit = $1; |
|---|
| 274 | my %factor = ( h => 3600, d => 24*3600, w => 7*24*3600 ); |
|---|
| 275 | $digest_period *= $factor{$unit}; |
|---|
| 276 | } |
|---|
| 277 | my $digest_last = $db->get_parameter("digest_last"); |
|---|
| 278 | my $now = localtime; |
|---|
| 279 | log "debug", "time=" . $now->epoch . " digest_last=$digest_last digest_period=$digest_period"; |
|---|
| 280 | |
|---|
| 281 | if ($now->epoch - $digest_last > $digest_period) { |
|---|
| 282 | my $from = $db->get_parameter("digest_first" || 1); |
|---|
| 283 | my $to = $db->get_parameter("message") - 1; |
|---|
| 284 | |
|---|
| 285 | if ($from <= $to) { |
|---|
| 286 | log "debug", "sending out overdue digest (messages $from-$to)"; |
|---|
| 287 | unless ($opts{notreally}) { |
|---|
| 288 | $db->set_parameter("digest_first", $to + 1); |
|---|
| 289 | Lancelot::Module::post_digest::send_digest($db, $from, $to); |
|---|
| 290 | } |
|---|
| 291 | } else { |
|---|
| 292 | log "debug" , "digest would be overdue but nothing was posted"; |
|---|
| 293 | } |
|---|
| 294 | } |
|---|
| 295 | } |
|---|