# <@LICENSE> # Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to you under the Apache License, Version 2.0 # (the "License"); you may not use this file except in compliance with # the License. You may obtain a copy of the License at: # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # =head1 NAME Mail::SpamAssassin::SQLBasedAddrList - SpamAssassin SQL Based Auto Whitelist =head1 SYNOPSIS my $factory = Mail::SpamAssassin::SQLBasedAddrList->new() $spamtest->set_persistent_addr_list_factory ($factory); ... call into SpamAssassin classes... SpamAssassin will call: my $addrlist = $factory->new_checker($spamtest); $entry = $addrlist->get_addr_entry ($addr, $origip); ... =head1 DESCRIPTION A SQL based persistent address list implementation. See C for more information. Uses DBI::DBD module access to your favorite database (tested with MySQL, SQLite and PostgreSQL) to store user auto-whitelists. The default table structure looks like this: CREATE TABLE awl ( username VARCHAR NOT NULL, email VARCHAR NOT NULL, ip VARCHAR NOT NULL, count INT NOT NULL, totscore FLOAT NOT NULL, PRIMARY KEY (username, email, ip) ) You're table definition may change depending on which database driver you choose. There is a config option to override the table name. This module introduces several new config variables: user_awl_dsn user_awl_sql_username user_awl_sql_password user_awl_sql_table user_awl_sql_override_username see C for more information. =cut package Mail::SpamAssassin::SQLBasedAddrList; use strict; use warnings; use bytes; # Do this silliness to stop RPM from finding DBI as required BEGIN { require DBI; import DBI; } use Mail::SpamAssassin::PersistentAddrList; use Mail::SpamAssassin::Logger; use vars qw(@ISA); @ISA = qw(Mail::SpamAssassin::PersistentAddrList); =head2 new public class (Mail::SpamAssassin::SQLBasedAddrList) new () Description: This method creates a new instance of the SQLBasedAddrList factory and calls the parent's (PersistentAddrList) new method. =cut sub new { my ($proto) = @_; my $class = ref($proto) || $proto; my $self = $class->SUPER::new(@_); $self->{class} = $class; bless ($self, $class); $self; } =head2 new_checker public instance (Mail::SpamAssassin::SQLBasedAddrList) new_checker (\% $main) Description: This method is called to setup a new checker interface and return a blessed copy of itself. Here is where we setup the SQL database connection based on the config values. =cut sub new_checker { my ($self, $main) = @_; my $class = $self->{class}; if (!$main->{conf}->{user_awl_dsn} || !$main->{conf}->{user_awl_sql_table}) { dbg("auto-whitelist: sql-based invalid config"); return undef; } my $dsn = $main->{conf}->{user_awl_dsn}; my $dbuser = $main->{conf}->{user_awl_sql_username}; my $dbpass = $main->{conf}->{user_awl_sql_password}; my $dbh = DBI->connect($dsn, $dbuser, $dbpass, {'PrintError' => 0}); if(!$dbh) { dbg("auto-whitelist: sql-based unable to connect to database ($dsn) : " . DBI::errstr); return undef; } dbg("auto-whitelist: sql-based connected to $dsn"); $self = { 'main' => $main, 'dsn' => $dsn, 'dbh' => $dbh, 'tablename' => $main->{conf}->{user_awl_sql_table}, }; if ($main->{conf}->{user_awl_sql_override_username}) { $self->{_username} = $main->{conf}->{user_awl_sql_override_username}; } else { $self->{_username} = $main->{username}; # Need to make sure that a username is set, so just in case there is # no username set in main, set one here. unless ($self->{_username}) { $self->{_username} = "GLOBAL"; } } dbg("auto-whitelist: sql-based using username: ".$self->{_username}); return bless ($self, $class); } =head2 get_addr_entry public instance (\%) get_addr_entry (String $addr) Description: This method takes a given C<$addr> and splits it between the email address component and the ip component and performs a lookup in the database. If nothing is found in the database then a blank entry hash is created and returned, otherwise an entry containing the found information is returned. A key, C, is set to 1 if an entry already exists in the database, otherwise it is set to 0. =cut sub get_addr_entry { my ($self, $addr) = @_; my $entry = { addr => $addr, exists_p => 0, count => 0, totscore => 0, }; my ($email, $ip) = $self->_unpack_addr($addr); return $entry unless ($email && $ip); my $sql = "SELECT count, totscore FROM $self->{tablename} WHERE username = ? AND email = ? AND ip = ?"; my $sth = $self->{dbh}->prepare($sql); my $rc = $sth->execute($self->{_username}, $email, $ip); if (!$rc) { # there was an error, but try to go on my $err = $self->{dbh}->errstr; dbg("auto-whitelist: sql-based get_addr_entry: SQL error: $err"); $entry->{count} = 0; $entry->{totscore} = 0; } else { my $aryref = $sth->fetchrow_arrayref(); if (defined($aryref)) { # we got some data back $entry->{count} = $aryref->[0] || 0; $entry->{totscore} = $aryref->[1] || 0; $entry->{exists_p} = 1; dbg("auto-whitelist: sql-based get_addr_entry: found existing entry for $addr"); } else { dbg("auto-whitelist: sql-based get_addr_entry: no entry found for $addr"); } } $sth->finish(); dbg("auto-whitelist: sql-based $addr scores ".$entry->{count}.'/'.$entry->{totscore}); return $entry; } =head2 add_score public instance (\%) add_score (\% $entry, Integer $score) Description: This method adds a given C<$score> to a given C<$entry>. If the entry was marked as not existing in the database then an entry will be inserted, otherwise a simple update will be performed. NOTE: This code uses a self referential SQL call (ie set foo = foo + 1) which is supported by most modern database backends, but not everything calling itself a SQL database. =cut sub add_score { my($self, $entry, $score) = @_; return if (!$entry->{addr}); my ($email, $ip) = $self->_unpack_addr($entry->{addr}); $entry->{count} += 1; $entry->{totscore} += $score; return $entry unless ($email && $ip); if ($entry->{exists_p}) { # entry already exists, so just update my $sql = "UPDATE $self->{tablename} SET count = count + 1, totscore = totscore + ? WHERE username = ? AND email = ? AND ip = ?"; my $sth = $self->{dbh}->prepare($sql); my $rc = $sth->execute($score, $self->{_username}, $email, $ip); if (!$rc) { my $err = $self->{dbh}->errstr; dbg("auto-whitelist: sql-based add_score: SQL error: $err"); } else { dbg("auto-whitelist: sql-based add_score: new count: ". $entry->{count} .", new totscore: ".$entry->{totscore}." for ".$entry->{addr}); } $sth->finish(); } else { # no entry yet, so insert a new entry my $sql = "INSERT INTO $self->{tablename} (username,email,ip,count,totscore) VALUES (?,?,?,?,?)"; my $sth = $self->{dbh}->prepare($sql); my $rc = $sth->execute($self->{_username},$email,$ip,1,$score); if (!$rc) { my $err = $self->{dbh}->errstr; dbg("auto-whitelist: sql-based add_score: SQL error: $err"); } $entry->{exists_p} = 1; dbg("auto-whitelist: sql-based add_score: created new entry for ".$entry->{addr}." with totscore: $score"); $sth->finish(); } return $entry; } =head2 remove_entry public instance () remove_entry (\% $entry) Description: This method removes a given C<$entry> from the database. If the ip portion of the entry address is equal to "none" then remove any perl-IP entries for this address as well. =cut sub remove_entry { my ($self, $entry) = @_; my ($email, $ip) = $self->_unpack_addr($entry->{addr}); return unless ($email && $ip); my $sql = "DELETE FROM $self->{tablename} WHERE username = ? AND email = ?"; my @args = ($self->{_username}, $email); # when $ip is equal to none then attempt to delete all entries # associated with address if ($ip eq 'none') { dbg("auto-whitelist: sql-based remove_entry: removing all entries matching $email"); } else { $sql .= " AND ip = ?"; push(@args, $ip); dbg("auto-whitelist: sql-based remove_entry: removing single entry matching ".$entry->{addr}); } my $sth = $self->{dbh}->prepare($sql); my $rc = $sth->execute(@args); if (!$rc) { my $err = $self->{dbh}->errstr; dbg("auto-whitelist: sql-based remove_entry: SQL error: $err"); } else { # We might normally have a dbg saying we removed the address # but the common codepath already provides this in SpamAssassin.pm } $entry = undef; # slight cleanup since it is now gone } =head2 finish public instance () finish () Description: This method provides the necessary cleanup for the address list. =cut sub finish { my ($self) = @_; dbg("auto-whitelist: sql-based finish: disconnected from " . $self->{dsn}); $self->{dbh}->disconnect(); } =head2 _unpack_addr private instance (String, String) _unpack_addr(string $addr) Description: This method splits an autowhitelist address into it's two components, email and ip address. =cut sub _unpack_addr { my ($self, $addr) = @_; my ($email, $ip) = split(/\|ip=/, $addr); unless ($email && $ip) { dbg("auto-whitelist: sql-based _unpack_addr: unable to decode $addr"); } return ($email, $ip); } 1;