=head1 NAME handler - generic email handler/redirector =head1 DESCRIPTION Generic plugin to reject, drop, redirect, or munge an email based on a numeric score passed as a transaction note e.g. by spam or virus filters. The intention is for this plugin to be able to implement most handling 'policies' in one place, allowing filtering plugins (e.g. spam/virus/etc.) to be simpler, pluggable, and cumulative. handler should appear in the qpsmtpd config/plugins file after the filter plugins it is assocated with, but before any QUEUE plugins. Note: redirection requires a recipients() mutator that is not available in stock Qpsmtpd. The one-line patch should be available from wherever you sourced this plugin. Note: if you want to invoke 'handler' multiple times (e.g. for both spam handling and virus handling), you can create symlinked versions and use the symlink names in the config/plugins file e.g. spamhandler note spam_score redirect_threshold 7 redirect_recipient spam vhandler note virus_score redirect_threshold 1 redirect_recipient virus =head1 CONFIG handler accepts the following parameters either via the config/plugins line or via a 'handler' (or your symlinked name, if applicable) config file (one parameter per line, name and value space-separated). =over 4 =item note The name of the transaction note containing the numeric score to be used to determine how we handle the mail. Default: spam_score. =item reject_threshold [-] The score at which the mail will be rejected outright (or a range within which the score must fall if max_threshold is specified). This should probably be pretty large e.g. for spamassassin, 15 or 20. Rejection is a terminal state, so rejected mail is never redirected or munged. Default: never reject. =item reject_note A transaction note containing the message to be issued with a rejection. The escape sequences %n and %v can be used as placeholders for the score transaction note and its value, respectively. The default message is: "%n too high - message denied." Default reject_note: none. =item drop_threshold [-] The score at which the mail will be dropped i.e. accepted, and then fed to /dev/null (or a range within which the score must fall if max_threshold is specified). Dropping the mail is a terminal state, so rejected mail is never redirected or munged. Default: never drop. =item redirect_threshold [-] The score above which the mail will be redirected instead of delivered to the specified recipients (or a range within which the score must fall if max_threshold is specified). Requires the 'redirect_recipient' parameter below. Default: none. =item redirect_recipient Email address to which mail scoring above the redirect_threshold (or within the reject_threshold range) is redirected (currently requires a one-line patch to Qpsmtpd::Transaction). If the address is a standard email address it replaces the current recipient list completely. Otherwise, if the redirect address is a bare username with no domain component then existing recipient domains are retained, and the username is munged as follows: =over 4 =item if the address is a bare username ending in a hyphen (e.g. 'spam-'), it is prefixed to existing recipient usernames (allowing per-user mailboxes within a spam account and a spam-default catchall) =item if the address is a bare username beginning with a hyphen (e.g. '-spam'), it is suffixed to existing recipient usernames (allowing per-user spam mailboxes) =item otherwise the bare username (e.g. 'spam' replaces existing recipient usernames (allowing per-domain spam mailboxes) =back Examples, spam to recipient gavin@openfusion.com.au: redirect_recipient spam@my.domain.com # -> spam@mydomain.com redirect_recipient spam # -> spam@openfusion.com.au redirect_recipient spam- # -> spam-gavin@openfusion.com.au redirect_recipient -spam # -> gavin-spam@openfusion.com.au =item munge_threshold [-] The score above which the mail becomes a candidate for munging in various ways (or a range within which the score must fall if max_threshold is specified). See the 'munge_subject' parameter below. Default: do not munge. =item munge_subject The tag (default: '***SPAM***') to prepend to the Subject header (if not already present) if the score exceeds munge_threshold above. =item per_recipient (only via config/plugins) Allow per-recipient 'handler' config files to be used. Note that because handler is a post_data plugin, it is required and assumed that all recipients are using the same config definitions (see e.g. the denysoft_multi_rcpt plugin). =back =head1 NOTES Might be other actions that could usefully be included here, such as adding to blacklists or greylists or tarpits (e.g. Spam Cannibal). Currently only tests transaction notes. Should probably extend this to connection notes to as well, to allow connection plugins to be handled too. =head1 AUTHOR Gavin Carr . =cut use Mail::Address; my $VERSION = 0.04; my %DEFAULTS = ( note => 'spam_score', munge_subject => '***SPAM***', reject_message => '%n too high - message denied', ); my ($plugin) = (__PACKAGE__ =~ m/([^:]+)$/); sub register { my ($self, $qp, %arg) = @_; $self->{_defaults} = { %DEFAULTS, %arg }; $self->register_hook($arg{per_recipient} ? "rcpt" : "mail", "load_config"); $self->register_hook("data_post", "check_score"); $self->register_hook("queue", "check_drop"); } sub load_config { my ($self, $transaction, $rcpt) = @_; # Setup only once (data plugin: must assume all recipients use the same config!) return DECLINED if $self->{_config}; # Setup config from defaults and handler config my $config_arg = $self->{_defaults}->{per_recipient} ? { rcpt => $rcpt } : {}; my @config = $self->qp->config($plugin, $config_arg); $self->{_config} = { %{$self->{_defaults}}, @config ? map { split /\s+/, $_, 2 } @config : () }; # Check redirect_recipient is set if redirect_threshold $self->log(LOGWARN, "$plugin: redirect_recipient is missing with redirect_threshold set") if $self->{_config}->{redirect_threshold} && ! $self->{_config}->{redirect_recipient}; # Check thresholds for max arguments for my $t (qw(reject_threshold drop_threshold redirect_threshold munge_threshold)) { if ($self->{_config}->{$t} && $self->{_config}->{$t} =~ m/[-,]/) { my ($min,$max) = split /[-,]/, $self->{_config}->{$t}, 2; $self->{_config}->{$t} = $min if $min && $min =~ m/\d+/; $self->{_config}->{"${t}_max"} = $max if $max && $max =~ m/\d+/; } } $self->log(LOGDEBUG, "$plugin config: " . join(', ', map { $_ . '=' . $self->{_config}->{$_} } sort keys %{$self->{_config}})); return (DECLINED); } sub check_score { my ($self, $transaction) = @_; my $score = $transaction->notes($self->{_config}->{note}) or return DECLINED; $self->log(LOGNOTICE, $self->{_config}->{note} . ": $score"); $transaction->header->add("X-\u$plugin-Score", $score); # Deny if reject threshold exceeded if (defined $self->{_config}->{reject_threshold} and $score >= $self->{_config}->{reject_threshold} and (! exists $self->{_config}->{reject_threshold_max} or $score < $self->{_config}->{reject_threshold_max})) { my $reject_message = $transaction->notes($self->{_config}->{reject_note}) if $self->{_config}->{reject_note}; $reject_message ||= $self->{_config}->{reject_message}; # Substitute %n and %v for score note name and value $reject_message =~ s/%n/$self->{_config}->{note}/g; $reject_message =~ s/%v/$score/g; return DENY, $reject_message; } if (defined $self->{_config}->{drop_threshold} and $score >= $self->{_config}->{drop_threshold} and (! exists $self->{_config}->{drop_threshold_max} or $score < $self->{_config}->{drop_threshold_max})) { $transaction->notes('handler_drop' => 1); return DECLINED; } # Redirect if redirect threshold exceeded $self->redirect($transaction) if defined $self->{_config}->{redirect_threshold} and $score >= $self->{_config}->{redirect_threshold} and (! exists $self->{_config}->{redirect_threshold_max} or $score < $self->{_config}->{redirect_threshold_max}); # Munge if munge threshold exceeded $self->munge($transaction) if defined $self->{_config}->{munge_threshold} and $score >= $self->{_config}->{munge_threshold} and (! exists $self->{_config}->{munge_threshold_max} or $score < $self->{_config}->{munge_threshold_max}); return DECLINED; } sub munge { my ($self, $transaction) = @_; my $tag = $self->{_config}->{munge_subject}; if ($tag) { my $subject = $transaction->header->get('Subject') || ''; $transaction->header->replace('Subject', "$tag $subject"); $transaction->header->add("X-\u$plugin-Munge", $tag); } return DECLINED; } sub redirect { my ($self, $transaction) = @_; $transaction->notes('handler_redirect') and return DECLINED; # Once only my $rcpt = $self->{_config}->{redirect_recipient} or return DECLINED; # Full email address - replace recipients list if ($rcpt =~ m'@') { my @rcpt = Mail::Address->parse($rcpt); unless (@rcpt) { $self->log(LOGERROR, sprintf("failed to parse any redirect_recipient addresses: '%s'", $rcpt)); return DECLINED; } # NB: requires non-standard recipients() mutator $transaction->recipients(@rcpt); $self->log(LOGNOTICE,"redirect: to " . join(',',map { $_->address } @rcpt)); $transaction->header->add("X-\u$plugin-Redirect", join(',',map { $_->address } @rcpt)); } # Recipient is username without domain else { for my $r ($transaction->recipients) { my $new = $rcpt; if (substr($rcpt,0,1) eq '-') { # Suffix username e.g. -spam $new = $r->user . $rcpt; } elsif (substr($rcpt,-1) eq '-') { # Prefix username e.g. spam- $new = $rcpt . $r->user; } $new .= '@' . $r->host if $r->host; my $old = $r->address; $r->address($new); $self->log(LOGNOTICE,sprintf("redirect: '%s' -> '%s'",$old,$new)); $transaction->header->add("X-\u$plugin-Redirect", $new); } } $transaction->notes(handler_redirect => $rcpt); $transaction->notes(qpstats_queue_replace => '$plugin:redirect'); return DECLINED; } sub check_drop { my ($self, $transaction) = @_; # If handler_drop set, fake a queue and OK if ($transaction->notes('handler_drop')) { my $msg_id = $transaction->header->get('Message-Id') || ''; $msg_id =~ s/[\r\n].*//s; # don't allow newlines in the Message-Id here return (OK, "Queued! $msg_id"); } return DECLINED; } # arch-tag: 09ce6583-fc2e-4b05-b8e4-d38df5810b92