Author: hjp Date: Thu Jan 11 14:13:10 2007 New Revision: 705 Added: contrib/hjp/spamassassin_rcpt/Makefile contrib/hjp/spamassassin_rcpt/Makerules contrib/hjp/spamassassin_rcpt/qpsmtpd-plugin-spamassassin_rcpt.spec contrib/hjp/spamassassin_rcpt/spamassassin_rcpt
Log: Added spamassassin_rcpt (spamassassin with per-rcpt config) plugin. Added: contrib/hjp/spamassassin_rcpt/Makefile ============================================================================== --- (empty file) +++ contrib/hjp/spamassassin_rcpt/Makefile Thu Jan 11 14:13:10 2007 @@ -0,0 +1,13 @@ +NAME=spamassassin_rcpt +FILES = $(PKG).spec \ + Makefile \ + Makerules \ + $(NAME) \ + + +all: + +clean: + rm -f $(PKG).tar.gz *.tmp + +include Makerules Added: contrib/hjp/spamassassin_rcpt/Makerules ============================================================================== --- (empty file) +++ contrib/hjp/spamassassin_rcpt/Makerules Thu Jan 11 14:13:10 2007 @@ -0,0 +1,15 @@ +PKG = qpsmtpd-plugin-$(NAME) +CONTRIB_BASE=~/wrk/qpsmtpd/contrib/hjp +CONTRIB_FILES=$(patsubst %, $(CONTRIB_BASE)/$(NAME)/%, $(FILES)) + +contrib: $(CONTRIB_FILES) + +$(CONTRIB_BASE)/$(NAME)/%: % + cp -p $^ $@ + +rpm: $(PKG).tar.gz + rpm -ta --clean --sign --rmsource $^ + +$(PKG).tar.gz: $(FILES) + tar cfz $@ $^ + Added: contrib/hjp/spamassassin_rcpt/qpsmtpd-plugin-spamassassin_rcpt.spec ============================================================================== --- (empty file) +++ contrib/hjp/spamassassin_rcpt/qpsmtpd-plugin-spamassassin_rcpt.spec Thu Jan 11 14:13:10 2007 @@ -0,0 +1,59 @@ +Name: qpsmtpd-plugin-spamassassin_rcpt +Version: 238 +Release: 1 +Packager: [EMAIL PROTECTED] +Summary: SpamAssassin integration for qpsmtpd (with per-user thresholds) +License: distributable +Group: System Environment/Daemons +URL: http://smtpd.develooper.com/ +BuildRoot: %{_tmppath}/%{name}-root +Source0: %{name}.tar.gz +BuildArch: noarch +Requires: qpsmtpd-plugin-cf_wrapper >= 173 +Requires: qpsmtpd-plugin-address_notes + +%description +Plugin that checks if the mail is spam by using the "spamd" daemon +from the SpamAssassin package. F<http://www.spamassassin.org> + +This plugin differs from the one in the qpsmtpd package in that it +allows different configurations for different users. One message can be +rejected for one user and accepted for another. To achieve this it +relies on mechanisms implemented by the cf_wrapper and address_notes +plugins. + +%prep +%setup -q -c %{name} + +%build + +%clean +rm -rf $RPM_BUILD_ROOT + +%install +rm -rf $RPM_BUILD_ROOT +mkdir -p $RPM_BUILD_ROOT/usr/share/qpsmtpd/plugins +cp spamassassin_rcpt $RPM_BUILD_ROOT/usr/share/qpsmtpd/plugins + +[ -x /usr/lib/rpm/brp-compress ] && /usr/lib/rpm/brp-compress + + +%files +%defattr(-,root,root) +/usr/share/qpsmtpd/plugins/spamassassin_rcpt + +%changelog +* Fri Nov 24 2006 <[EMAIL PROTECTED]> 238-1 +- fixed suppression of multiple identical X-Spam-Status headers + +* Thu Nov 23 2006 <[EMAIL PROTECTED]> 237-1 +- made subject prefix configurable +- suppress multiple identical X-Spam-Status headers + +* Sat Sep 02 2006 <[EMAIL PROTECTED]> 214-1 +- updated documentation +- fixed bug where cf_wrapper_results wasn't set. + +* Sat Aug 05 2006 <[EMAIL PROTECTED]> 197-1 +- First RPM package. + Added: contrib/hjp/spamassassin_rcpt/spamassassin_rcpt ============================================================================== --- (empty file) +++ contrib/hjp/spamassassin_rcpt/spamassassin_rcpt Thu Jan 11 14:13:10 2007 @@ -0,0 +1,367 @@ +#!/usr/bin/perl +=head1 NAME + +spamassassin_rcpt - SpamAssassin integration for qpsmtpd with per-recipient config + +=head1 DESCRIPTION + +Plugin that checks if the mail is spam by using the "spamd" daemon +from the SpamAssassin package. F<http://www.spamassassin.org> +Unlike the spamassassin plugin in the core distribution, this plugin allows +per-recipient configuration. + +SpamAssassin 2.6 or newer is required. + +=head1 CONFIG + +Configured in the plugins file without any parameters, the +spamassassin plugin will add relevant headers from the spamd +(X-Spam-Status etc). + +The format goes like + + spamassassin option value [option value] + +Options being those listed below and the values being parameters to +the options. Confused yet? :-) + +=over 4 + +=item reject_threshold [threshold] + +Set the threshold over which the plugin will reject the mail. Some +mail servers are so useless that they ignore 55x responses not coming +after RCPT TO, so they might just keep retrying and retrying and +retrying until the mail expires from their queue. + +I like to configure this with 15 or 20 as the threshold. + +The default is to never reject mail based on the SpamAssassin score. + +=item munge_subject_threshold [threshold] + +Set the threshold over which we will prefix the subject with +'*****SPAM*****'. A messed up subject is easier to filter on than the +other headers for many people with not so clever mail clients. + +The default is to never munge the subject based on the SpamAssassin score. + +=item spamd_socket [/path/to/socket] + +Beginning with Mail::SpamAssassin 2.60, it is possible to use Unix +domain sockets for spamd. This is faster and more secure than using +a TCP connection. + +=item leave_old_headers [drop|rename|keep] + +Another mail server before might have checked this mail already and may have +added X-Spam-Status, X-Spam-Flag and X-Spam-Check-By lines. Normally you can +not trust such headers and should either rename them to X-Old-... (default, +parameter 'rename') or have them removed (parameter 'drop'). If you know +what you are doing, you can also leave them intact (parameter 'keep'). + +=item subject_prefix [string] + +The string to insert into the subject if +spamassassin_munge_subject_threshold is exceeded. This is currently a +global option, because for multi-recipient mails the subject is +rewritten at most once. + +=back + +With both of the first options the configuration line will look like the following + + spamasssasin reject_threshold 18 munge_subject_threshold 8 + +=head2 Per-Recipient Configuration + +The following address notes are recognized by this plugin + +=over + +=item spamassassin_reject_threshold [threshold] + +Overrides the reject_threshold for this recipient. If there are several +recipients and the mail should be rejected for some and accepted for the +others a temporary failure is generated and the cf_wrapper plugin is used +to weed out the recipients at the next delivery attempt. Ths may cause +some mail to be delayed. + +=item spamassassin_munge_subject_threshold [threshold] + +Overrides the munge_subject_threshold for this recipient. If there are several +recipients with different munge_subject_thresholds, the lowest one will be +used. It is not possible to pass on a mail to several recipients with different +munging. Either the subject is munged for all of them or none. + +=item spamassassin_user [username] + +The user to run as. This username will be passed to spamd and spamd will +read the configuration files in the user's home directory. This allows +the user e.g., so set different scores for some rules and to use their +bayesian filter database. It does not allow the use of custom rules. +See the spamd documentation for details. + +=back + +=head1 DEPENDENCIES + +This plugin depends on several other plugins: + +=over + +=item cf_wrapper + +The cf_wrapper plugin implements a framework for filters which hook into +data_post to reject messages for only some recipients (which SMTP isn't +designed for). + +=item address_notes + +The address_notes plugin extends the class Qpsmtpd::Address with a method +notes, which works similarly to connection and transaction notes. This +allows passing around notes on recipients. + +=item address_notes_aliases + +Finally you need a plugin to set those address notes which spamassassin_rcpt queries. +At the time of this writing the only one to do this is address_notes_aliases, +which in turn needs the aliases_check plugin to parse a config file. +Feel free to write one which gets the data from LDAP or an SQL database instead! + +=back + +In the plugins configuration file, the spamassassin_rcpt plugin needs to be after +the address_notes_aliases (or equivalent) plugin but before the cf_wrapper plugin. + +=head1 TODO + +Make the "subject munge string" configurable + +Implement autolearning. + +=head1 COPYRIGHT AND LICENSE + +Copyright (c) 2006 Peter J. Holzer <[EMAIL PROTECTED]>. + +This plugin is licensed under the same terms as the qpsmtpd package +itself. +Please see the LICENSE file included with qpsmtpd for details. + +=cut + + +use Qpsmtpd::DSN; +use Socket qw(:DEFAULT :crlf); +use IO::Handle; + +sub register { + my ($self, $qp, @args) = @_; + + $self->log(LOGERROR, "Bad parameters for the spamassassin plugin") + if @_ % 2; + + %{$self->{_args}} = @args; + +} + +sub hook_data_post { + my ($self, $transaction) = @_; + + $self->log(LOGDEBUG, "check_spam"); + return (DECLINED) if $transaction->body_size > 500_000; + + + for ($transaction->recipients()) { + $self->set_result($transaction, $_, \&check_spam2); + } + + return (DECLINED); +} + +sub set_result { + my ($self, $transaction, $rcpt, $test) = @_; + my $results = $transaction->notes('cf_wrapper_results'); + my $r = $rcpt->address; + my $rc = defined $results->{$r} + ? (ref $results->{$r} eq "ARRAY" + ? $results->{$r}[0] + : $results->{$r} + ) + : DECLINED; + if ($rc == DECLINED) { + my $msg; + ($rc, $msg) = $self->$test($transaction, $rcpt); + $self->log(LOGINFO, "setting result for $r to $rc"); + $results->{$r} = [$rc, $msg]; + } + $transaction->notes('cf_wrapper_results', $results); +} + +sub check_spam { + my ($self, $transaction, $username) = @_; + + my $cache = $transaction->notes('spamassassin_cache'); + unless ($cache) { + $cache = {}; + $transaction->notes('spamassassin_cache', $cache); + } + $self->log(LOGDEBUG, "check_spam"); + return @{ $cache->{$username} } if $cache->{$username}; + + my $remote = 'localhost'; + my $port = 783; + if ($port =~ /\D/) { $port = getservbyname($port, 'tcp') } + die "No port" unless $port; + my $iaddr = inet_aton($remote) or + $self->log(LOGERROR, "Could not resolve host: $remote") and return (); + my $paddr = sockaddr_in($port, $iaddr); + + my $proto = getprotobyname('tcp'); + if ($self->{_args}->{spamd_socket} and + $self->{_args}->{spamd_socket} =~ /^([\w\/.-]+)$/ ) { # connect to Unix Domain Socket + my $spamd_socket = $1; + + socket(SPAMD, PF_UNIX, SOCK_STREAM, 0) + or $self->log(LOGERROR, "Could not open socket: $!") and return (); + + $paddr = sockaddr_un($spamd_socket); + } + else { + socket(SPAMD, PF_INET, SOCK_STREAM, $proto) + or $self->log(LOGERROR, "Could not open socket: $!") and return (); + } + + connect(SPAMD, $paddr) + or $self->log(LOGERROR, "Could not connect to spamassassin daemon: $!") and return (); + $self->log(LOGDEBUG, "check_spam: connected to spamd"); + + SPAMD->autoflush(1); + + $transaction->body_resetpos; + + print SPAMD "SYMBOLS SPAMC/1.3" . CRLF; + print SPAMD "User: $username" . CRLF; + # Content-Length: + print SPAMD CRLF; + # or CHECK or REPORT or SYMBOLS + + print SPAMD "X-Envelope-From: ", $transaction->sender->format, CRLF + or $self->log(LOGWARN, "Could not print to spamd: $!"); + + print SPAMD join CRLF, split /\n/, $transaction->header->as_string + or $self->log(LOGWARN, "Could not print to spamd: $!"); + + print SPAMD CRLF + or $self->log(LOGWARN, "Could not print to spamd: $!"); + + while (my $line = $transaction->body_getline) { + chomp $line; + print SPAMD $line, CRLF + or $self->log(LOGWARN, "Could not print to spamd: $!"); + } + + print SPAMD CRLF; + shutdown(SPAMD, 1); + $self->log(LOGDEBUG, "check_spam: finished sending to spamd"); + my $line0 = <SPAMD>; # get the first protocol lines out + if ($line0) { + $self->log(LOGDEBUG, "check_spam: spamd: $line0"); + } + + my ($flag, $hits, $required); + while (<SPAMD>) { + $self->log(LOGDEBUG, "check_spam: spamd: $_"); + last unless m/\S/; + if (m{Spam: (True|False) ; (-?\d+\.\d) / (-?\d+\.\d)}) { + ($flag, $hits, $required) = ($1, $2, $3); + } + + } + my $tests = <SPAMD>; + $tests =~ s/\015//; # hack for outlook + $flag = $flag eq 'True' ? 'Yes' : 'No'; + $self->log(LOGDEBUG, "check_spam: finished reading from spamd"); + + $self->log(LOGNOTICE, "check_spam: $flag, hits=$hits, required=$required, " . + "tests=$tests"); + + $cache->{$username} = [ $flag, $hits, $required, $tests ]; + return ($flag, $hits, $required, $tests); +} + + +sub check_spam2 { + my ($self, $transaction, $rcpt) = @_; + $self->log(LOGDEBUG, "check_spam2: rcpt = $rcpt. rcpt is a " . ref($rcpt)); + my $username = $rcpt->notes('spamassassin_user') || getpwuid($>); + my ($flag, $hits, $required, $tests) = $self->check_spam($transaction, $username); + my $reject_threshold = $rcpt->notes('spamassassin_reject_threshold') || $self->{_args}->{reject_threshold} || 99; + $self->log(LOGDEBUG, "check_spam2: reject_threshold = $reject_threshold"); + if ($hits > $reject_threshold) { + return Qpsmtpd::DSN->sec_sender_unauthorized("spamassassin score $hits/$reject_threshold"); + } else { + my $munge_subject_threshold = $rcpt->notes('spamassassin_munge_subject_threshold') || $self->{_args}->{munge_subject_threshold} || 99; + $self->log(LOGDEBUG, "check_spam2: munge_subject_threshold = $munge_subject_threshold"); + $self->munge_headers($transaction, $flag, $hits, $required, $tests, $munge_subject_threshold); # XXX + return (DECLINED, "spamassassin score $hits/$reject_threshold"); + } + +} + +sub munge_headers { + my ($self, $transaction, $flag, $hits, $required, $tests, $munge_subject_threshold) = @_; + my $leave_old_headers = lc($self->{_args}->{leave_old_headers}) || 'rename'; + unless ($transaction->notes('spamassassin_cleaned_headers')) { + if ( $leave_old_headers eq 'rename' ) + { + foreach my $header ( $transaction->header->get('X-Spam-Check-By') ) + { + $transaction->header->add('X-Old-Spam-Check-By', $header, 0); + } + foreach my $header ( $transaction->header->get('X-Spam-Flag') ) + { + $transaction->header->add('X-Old-Spam-Flag', $header, 0); + } + + foreach my $header ( $transaction->header->get('X-Spam-Status') ) + { + $transaction->header->add('X-Old-Spam-Status', $header, 0); + } + } + + if ( $leave_old_headers eq 'drop' || $leave_old_headers eq 'rename' ) + { + $transaction->header->delete('X-Spam-Check-By'); + $transaction->header->delete('X-Spam-Flag'); + $transaction->header->delete('X-Spam-Status'); + } + + $transaction->header->add("X-Spam-Check-By", $self->qp->config('me'), 0); + + $transaction->notes('spamassassin_cleaned_headers', 1); + } + + if ($flag eq 'Yes' && !$transaction->notes('spamassassin_flag_yes')) { + $transaction->header->add('X-Spam-Flag', 'YES', 0); + $transaction->notes('spamassassin_flag_yes', 1); + } + if ($hits >= $munge_subject_threshold && !$transaction->notes('spamassassin_munged_subject')) { + my $subject = $transaction->header->get('Subject') || ''; + my $subject_prefix = $self->{_args}->{subject_prefix} || '*****SPAM*****'; + $transaction->header->replace('Subject', "$subject_prefix $subject"); + + $transaction->notes('spamassassin_munged_subject', 1); + } + my $spam_status = "$flag, hits=$hits required=$required\n\ttests=$tests"; + my $spam_status_seen = $transaction->notes('spamassassin_spam_status_seen'); + unless($spam_status_seen) { + $spam_status_seen = {}; + $transaction->notes('spamassassin_spam_status_seen', $spam_status_seen); + } + unless ($spam_status_seen->{$spam_status}) { + $transaction->header->add('X-Spam-Status', $spam_status, 0); + $spam_status_seen->{$spam_status} = 1; + } +} +# vim: sw=2 tw=0 expandtab
