qpsmtpd/plugins/spamassassin

472 lines
15 KiB
Plaintext
Raw Normal View History

#!perl -w
=head1 NAME
spamassassin - SpamAssassin integration for qpsmtpd
=head1 DESCRIPTION
Plugin that checks if the mail is spam by using the "spamd" daemon
from the SpamAssassin package. F<http://www.spamassassin.org>
SpamAssassin 2.6 or newer is required.
Stores the results in a note named spamassassin (for other plugins). The note
is a hashref with whatever fields are defined in your spamassassin config.
These are the common ones: score,required,autolearn,tests,version
=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? :-) It looks like this in practice:
spamassassin reject 7 leave_old_headers keep
=over 4
=item reject [threshold]
Set the threshold where 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.
Depending on your spamassassin configuration a reasonable setting is
typically somewhere between 12 to 20.
The default is to never reject mail based on the SpamAssassin score.
=item munge_subject_threshold [threshold]
Set the threshold where the plugin will prefix the subject with the
value of C<subject_prefix>. A modified subject is easier to filter on
than the other headers for many people with not so clever mail
clients. You might want to make another plugin that does this on a
per user basis.
The default is to never munge the subject based on the SpamAssassin score.
=item subject_prefix [prefix]
What to prefix the subject with if the message is detected as spam
(i.e. if score is greater than C<munge_subject_threshold>. Defaults to
C<*** SPAM ***>
=item spamd_socket [/path/to/socket|spamd.host:port]
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, but if you run spamd on a remote machine, you need to
use 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 spamd_user [username]
The username to pass to spamd, if different from the user qpsmtpd runs as.
=item relayclient skip
What special treatment is offered to connection with relay permission? Relay
permissions are granted when the connecting IP is listed in the relayclients
file and/or when the user has authenticated. The only valid option at present
is 'skip', which skips SA scoring.
If SpamAssasin has certain network tests enabled, users may get elevated spam
scores because their dynamic IP space is properly listed on DUL blocking lists.
If the user is authenticated or coming from a trusted IP, odds are we don't
want to be reject their messages. Especially when running qpsmtpd on port 587.
=back
With both of the first options the configuration line will look like the following
spamasssasin reject 18 munge_subject_threshold 8
=head1 MULTIPLE RECIPIENT BEHAVIOR
This plugin supports per-user SpamAssassin preferences. When per-user SA prefs
are enabled (by setting spamd_user = vpopmail), the message recipient is used
as the spamd username. If SpamAssassin has per-user preferences enabled, it
will consult the users spam preferences when scoring the message.
When a message has multiple recipients, we do not change the spamd username.
The message is still scored by SA, but per-user preferences are not
consulted. To aid in debugging, messages with multiple recipents will
have an X-Spam-User header inserted. Admins and savvy users can look for
that header to confirm the reason their personal prefs were not consulted.
To get per-user SA prefs to work for messages with multiple recipients, the
LDA should be configured to check for the presence of the X-Spam-User header.
If the X-Spam-User header is present, the LDA should submit the message to
spamd for re-processing with the recipients address.
=head1 TODO
Make the "subject munge string" configurable
=head1 CHANGES
2012.04.02 - Matt Simerson
* refactored for ease of maintenance
* added support for per-user SpamAssassin preferences
* updated get_spam_results so that score=N.N works (as well as hits=N.N)
* rewrote the X-Spam-* header additions so that SA generated headers are
not discarded. Admin can alter SA headers with add_header in their SA
config. Subverting their changes there is unexpected. Making them read
code to figure out why is an unnecessary hurdle.
* added assemble_message, so we can calc content size which spamd wants
=cut
use strict;
use warnings;
use Qpsmtpd::Constants;
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 };
# backwards compatibility with previous config syntax
if ( ! defined $self->{_args}{reject} && defined $self->{_args}{reject_threshold} ) {
$self->{_args}{reject} = $self->{_args}{reject_threshold};
};
$self->register_hook('data_post', 'check_spam_reject');
$self->register_hook('data_post', 'check_spam_munge_subject');
}
sub hook_data_post {
my ($self, $transaction) = @_;
return (DECLINED) if $self->is_immune();
if ( $transaction->data_size > 500_000 ) {
$self->log(LOGINFO, "skip: too large (".$transaction->data_size.")");
return (DECLINED);
};
my $SPAMD = $self->connect_to_spamd() or return (DECLINED);
my $username = $self->select_spamd_username( $transaction );
my $message = $self->assemble_message($transaction);
my $length = length $message;
$self->print_to_spamd( $SPAMD, $message, $length, $username );
shutdown($SPAMD, 1); # close our side of the socket (tell spamd we're done)
my $headers = $self->parse_spamd_response( $SPAMD ) or return (DECLINED);
$self->insert_spam_headers( $transaction, $headers, $username );
return (DECLINED);
};
sub select_spamd_username {
my ($self, $transaction) = @_;
my $username = $self->{_args}{spamd_user} || getpwuid($>);
my $recipient_count = scalar $transaction->recipients;
if ( $recipient_count > 1 ) {
$self->log(LOGDEBUG, "Message has $recipient_count recipients");
return $username;
};
if ( $username eq 'vpopmail' ) {
# use the recipients email address as username. This enables per-user SA prefs
$username = ($transaction->recipients)[0]->address;
}
else {
$self->log(LOGDEBUG, "skipping per-user SA prefs");
};
return $username;
};
sub parse_spamd_response {
my ( $self, $SPAMD ) = @_;
my $line0 = <$SPAMD>; # get the first protocol line
if ( $line0 !~ /EX_OK/ ) {
$self->log(LOGERROR, "invalid response from spamd: $line0");
return;
};
my (%new_headers, $last_header);
while (<$SPAMD>) {
s/[\r\n]//g;
if ( m/^(X-Spam-.*?): (.*)?/ ) {
$new_headers{$1} = $2 || '';
$last_header = $1;
next;
}
if ( $last_header && m/^(\s+.*)/ ) { # a folded line, append to last
$new_headers{$last_header} .= CRLF . "\t" . $1;
next;
}
$last_header = undef;
}
close $SPAMD;
$self->log(LOGDEBUG, "finished reading from spamd");
return scalar keys %new_headers ? \%new_headers : undef;
};
sub insert_spam_headers {
my ( $self, $transaction, $new_headers, $username ) = @_;
my $recipient_count = scalar $transaction->recipients;
$self->_cleanup_spam_header($transaction, 'X-Spam-User'); # always clean up
if ( $recipient_count > 1 ) { # add for multiple recipients
$transaction->header->add('X-Spam-User', $username . ", $recipient_count recipients", 0);
};
foreach my $name ( keys %$new_headers ) {
next if $name eq 'X-Spam-Prev-Subject'; # might exist if SA rewrote subject
if ( $name eq 'X-Spam-Report' ) {
next; # Mail::Header mangles this prefolded header
# $self->log(LOGDEBUG, $new_headers->{$name} );
};
if ( $name eq 'X-Spam-Status' ) {
$self->parse_spam_header( $new_headers->{$name} );
};
$new_headers->{$name} =~ s/\015//; # hack for outlook
$self->_cleanup_spam_header($transaction, $name);
$transaction->header->add($name, $new_headers->{$name}, 0);
};
}
sub assemble_message {
my ($self, $transaction) = @_;
$transaction->body_resetpos;
my $message = "X-Envelope-From: "
. $transaction->sender->format . "\n"
. $transaction->header->as_string . "\n\n";
while (my $line = $transaction->body_getline) { $message .= $line; };
$message = join(CRLF, split/\n/, $message);
return $message . CRLF;
};
sub connect_to_spamd {
my $self = shift;
my $socket = $self->{_args}{spamd_socket};
my $SPAMD;
if ( $socket && $socket =~ /\// ) { # file path
$SPAMD = $self->connect_to_spamd_socket( $socket );
}
else {
$SPAMD = $self->connect_to_spamd_tcpip( $socket );
};
return if ! $SPAMD;
$SPAMD->autoflush(1);
return $SPAMD;
};
sub connect_to_spamd_socket {
my ( $self, $socket ) = @_;
if ( ! $socket || $socket !~ /^([\w\/.-]+)$/ ) { # Unix Domain Socket
$self->log(LOGERROR, "not a valid path");
return;
};
socket(my $SPAMD, PF_UNIX, SOCK_STREAM, 0) or do {
$self->log(LOGERROR, "Could not open socket: $!");
return;
};
my $paddr = sockaddr_un( $socket );
connect($SPAMD, $paddr) or do {
$self->log(LOGERROR, "Could not connect to spamd socket: $!");
return;
};
$self->log(LOGDEBUG, "connected to spamd");
return $SPAMD;
};
sub connect_to_spamd_tcpip {
my ( $self, $socket ) = @_;
my $remote = 'localhost';
my $port = 783;
if (defined $socket && $socket =~ /^([\w.-]+):(\d+)$/) {
$remote = $1;
$port = $2;
}
if ($port =~ /\D/) { $port = getservbyname($port, 'tcp') };
if ( ! $port ) {
$self->log(LOGERROR, "No spamd port, check your spamd_socket config.");
return;
};
my $iaddr = inet_aton($remote) or do {
$self->log(LOGERROR, "Could not resolve host: $remote");
return;
};
my $paddr = sockaddr_in($port, $iaddr);
my $proto = getprotobyname('tcp');
socket(my $SPAMD, PF_INET, SOCK_STREAM, $proto) or do {
$self->log(LOGERROR, "Could not open socket: $!");
return;
};
connect($SPAMD, $paddr) or do {
$self->log(LOGERROR, "Could not connect to spamd: $!");
return;
};
$self->log(LOGDEBUG, "connected to spamd");
return $SPAMD;
};
sub print_to_spamd {
my ( $self, $SPAMD, $message, $length, $username ) = @_;
print $SPAMD "HEADERS SPAMC/1.4" . CRLF;
print $SPAMD "Content-length: $length" . CRLF;
print $SPAMD "User: $username" . CRLF;
print $SPAMD CRLF;
print $SPAMD $message or $self->log(LOGWARN, "Could not print to spamd: $!");
$self->log(LOGDEBUG, "check_spam: finished sending to spamd");
};
sub check_spam_reject {
my ($self, $transaction) = @_;
my $sa_results = $self->get_spam_results($transaction) or do {
$self->log(LOGNOTICE, "skip: no spamassassin results");
return DECLINED;
};
my $score = $sa_results->{score} or do {
$self->log(LOGERROR, "skip: error getting spamassassin score");
return DECLINED;
};
my $ham_or_spam = $sa_results->{is_spam} eq 'Yes' ? 'Spam' : 'Ham';
my $reject = $self->{_args}{reject} or do {
$self->log(LOGERROR, "skip: reject not set ($ham_or_spam, $score)");
return DECLINED;
};
if ( $score < $reject ) {
$self->log(LOGINFO, "pass, $ham_or_spam, $score < $reject");
return DECLINED;
};
# default of media_unsupported is DENY, so just change the message
$self->log(LOGINFO, "deny, $ham_or_spam, $score > $reject");
return Qpsmtpd::DSN->media_unsupported("spam score exceeded threshold");
}
sub check_spam_munge_subject {
my ($self, $transaction) = @_;
my $qp_num = $self->{_args}{munge_subject_threshold};
my $sa = $self->get_spam_results($transaction) or return DECLINED;
my $required = $sa->{required} || $qp_num or do {
$self->log(LOGDEBUG, "skipping munge, no user or qpsmtpd pref set");
return DECLINED;
};
return DECLINED unless $sa->{score} > $required;
my $subject_prefix = $self->qp->config('subject_prefix') || '*** SPAM ***';
my $subject = $transaction->header->get('Subject') || '';
$transaction->header->replace('Subject', "$subject_prefix $subject");
return DECLINED;
}
sub get_spam_results {
my ($self, $transaction) = @_;
if ( defined $transaction->notes('spamassassin') ) {
return $transaction->notes('spamassassin');
};
my $header = $transaction->header->get('X-Spam-Status') or return;
my $r = $self->parse_spam_header( $header );
$self->log(LOGDEBUG, "$r->{is_spam}, $r->{score}");
$transaction->notes('spamassassin', $r);
return $r;
}
sub parse_spam_header {
my ($self, $string) = @_;
# the X-Spam-Score header contents vary based on the settings in
# the spamassassin *.cf files. Rather than parse via regexp, split
# on the consistent whitespace and = delimiters. More reliable and
# likely faster.
my @parts = split(/\s+/, $string);
my $is_spam = shift @parts;
chomp @parts;
chop $is_spam; # remove trailing ,
my %r;
foreach ( @parts ) {
my ($key,$val) = split(/=/, $_);
$r{$key} = $val;
}
$r{is_spam} = $is_spam;
# backwards compatibility for SA versions < 3
if ( defined $r{hits} && ! defined $r{score} ) {
$r{score} = delete $r{hits};
};
return \%r;
};
sub _cleanup_spam_header {
my ($self, $transaction, $header_name) = @_;
my $action = 'rename';
if ( $self->{_args}->{leave_old_headers} ) {
$action = lc($self->{_args}->{leave_old_headers});
};
return unless $action eq 'drop' || $action eq 'rename';
my $old_header_name = $header_name;
$old_header_name = ($old_header_name =~ s/^X-//) ? "X-Old-$old_header_name" : "Old-$old_header_name";
for my $header ( $transaction->header->get($header_name) ) {
$transaction->header->add($old_header_name, $header) if $action eq 'rename';
$transaction->header->delete($header_name);
}
}