Server IP : 85.214.239.14 / Your IP : 3.145.88.111 Web Server : Apache/2.4.62 (Debian) System : Linux h2886529.stratoserver.net 4.9.0 #1 SMP Tue Jan 9 19:45:01 MSK 2024 x86_64 User : www-data ( 33) PHP Version : 7.4.18 Disable Function : pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare, MySQL : OFF | cURL : OFF | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /proc/self/root/proc/2/root/proc/3/cwd/proc/2/root/usr/share/perl5/ |
Upload File : |
package Amavis; require 5.005; # need qr operator and \z in regexp require 5.008; # need basic Unicode support require 5.008001; # need utf8::is_utf8() use strict; use re 'taint'; BEGIN { use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION); $VERSION = '2.412'; } use Errno qw(ENOENT EACCES EAGAIN ESRCH EBADF EINVAL); use POSIX qw(locale_h); use Fcntl qw(:flock F_GETFL F_SETFL FD_CLOEXEC); use IO::Handle; use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL); use IO::Socket::UNIX; use Time::HiRes (); # body digest, either MD5 or SHA-1 (or perhaps SHA-256) use Digest::MD5; use Digest::SHA; use Net::Server 0.87; # need Net::Server::PreForkSimple::done use Net::Server::Daemonize qw(set_uid set_gid); use MIME::Base64; use Amavis::Conf qw(:platform :sa :confvars c cr ca $macro_tests_sanity_limit); use Amavis::Custom; use Amavis::Expand qw(expand tokenize); use Amavis::In::Connection; use Amavis::In::Message; use Amavis::In::Message::PerRecip; use Amavis::JSON; use Amavis::Log qw(open_log close_log collect_log_stats); use Amavis::Lookup qw(lookup lookup2); use Amavis::Lookup::IP qw(lookup_ip_acl normalize_ip_addr); use Amavis::Lookup::Label; use Amavis::Lookup::RE; use Amavis::Notify qw(delivery_status_notification delivery_short_report build_mime_entity defanged_mime_entity expand_variables); use Amavis::Out; use Amavis::Out::EditHeader; use Amavis::ProcControl qw(exit_status_str proc_status_ok cloexec run_command collect_results); use Amavis::rfc2821_2822_Tools; use Amavis::Timing qw(section_time get_time_so_far get_rusage rusage_report); use Amavis::UnmangleSender qw(oldest_public_ip_addr_from_received first_received_from); use Amavis::Unpackers::MIME qw(mime_decode); use Amavis::Unpackers::NewFilename; use Amavis::Unpackers::Part; use Amavis::Unpackers::Validity qw(check_header_validity check_for_banned_names); use Amavis::Util qw(untaint untaint_inplace min max minmax unique_list unique_ref ll do_log do_log_safe update_current_log_level dump_captured_log log_capture_enabled am_id sanitize_str debug_oneshot proto_decode truncate_utf_8 is_valid_utf_8 safe_decode_mime safe_encode safe_encode_utf8 safe_encode_utf8_inplace safe_decode safe_decode_utf8 safe_decode_latin1 clear_idn_cache idn_to_utf8 idn_to_ascii mail_addr_idn_to_ascii mail_addr_decode orcpt_encode orcpt_decode format_time_interval add_entropy stir_random generate_mail_id make_password prolong_timer get_deadline waiting_for_client switch_to_my_time switch_to_client_time snmp_counters_init snmp_count dynamic_destination ccat_split ccat_maj cmp_ccat cmp_ccat_maj setting_by_given_contents_category_all setting_by_given_contents_category); use vars qw( $extra_code_sql_base $extra_code_sql_log $extra_code_sql_quar $extra_code_sql_lookup $extra_code_ldap $extra_code_antivirus $extra_code_antispam $extra_code_antispam_sa); use vars qw(%modules_basic %got_signals); use vars qw($user_id_sql $user_policy_id_sql $wb_listed_sql); use vars qw($implicit_maps_inserted $maps_have_been_labeled); use vars qw($db_env $snmp_db $zmq_obj @zmq_sockets); use vars qw(%builtins); # macros in customizable notification messages use vars qw($last_task_completed_at); use vars qw($child_invocation_count $child_task_count); use vars qw($child_init_hook_was_called); # $child_invocation_count # counts child re-use from 1 to max_requests # $child_task_count # counts check_mail_begin_task (and check_mail) calls; # this often runs in sync with $child_invocation_count, # but with SMTP or LMTP input there may be more than one # message passed during a single SMTP session use vars qw(@config_files); # configuration files provided by -c or defaulted use vars qw($MSGINFO $report_ref); use vars qw($av_output @virusname @detecting_scanners @av_scanners_results $banned_filename_any $banned_filename_all @bad_headers); # Amavis::In::AMPDP, Amavis::In::SMTP and In::Courier objects use vars qw($ampdp_in_obj $smtp_in_obj $courier_in_obj); use vars qw($sql_dataset_conn_lookups); # Amavis::Out::SQL::Connection object use vars qw($sql_dataset_conn_storage); # Amavis::Out::SQL::Connection object use vars qw($sql_storage); # Amavis::Out::SQL::Log object use vars qw($sql_lookups $sql_wblist); # Amavis::Lookup::SQL objects use vars qw($ldap_connection); # Amavis::LDAP::Connection object use vars qw($ldap_lookups); # Amavis::Lookup::LDAP object use vars qw($redis_storage); # Amavis::Redis object: penpals & repu use vars qw($dns_resolver); # a reusable Net::DNS::Resolver object use vars qw($warm_restart); # 1: warm (reload), 0: cold start (restart) use vars qw(@public_networks_maps); sub new { my $class = shift; # make Amavis a subclass of Net::Server::whatever @ISA = !$daemonize && $max_servers==1 ? 'Net::Server' # facilitates debugging : defined $min_servers ? 'Net::Server::PreFork' : 'Net::Server::PreForkSimple'; # $class->SUPER::new(@_); # available since Net::Server 0.91 bless { server => $_[0] }, $class; # works with all versions } sub macro_rusage { my($msginfo,$recip_index,$name,$arg) = @_; my($rusage_self, $rusage_children) = get_rusage(); !$rusage_self || !$rusage_children || !defined($rusage_self->{$arg}) ? '' : $rusage_self->{$arg} + $rusage_children->{$arg}; } # implements macros: T, and SA lookalikes: TESTS, TESTSSCORES # sub macro_tests { my($msginfo,$recip_index,$name,$sep) = @_; my(@s); my $per_recip_data = $msginfo->per_recip_data; if (defined $recip_index) { # return info on one particular recipient my $r; $r = $per_recip_data->[$recip_index] if $recip_index >= 0; if (defined $r) { my $spam_tests = $r->spam_tests; @s = split(/,/, join(',',map($$_,@$spam_tests))) if $spam_tests; } } else { my(%all_spam_tests); for my $r (@$per_recip_data) { my $spam_tests = $r->spam_tests; if ($spam_tests) { $all_spam_tests{$_} = 1 for split(/,/,join(',',map($$_,@$spam_tests))); } } @s = sort keys %all_spam_tests; } if ($macro_tests_sanity_limit && @s > $macro_tests_sanity_limit) { $#s = $macro_tests_sanity_limit-1; push(@s,"...") } @s = map { my($tn,$ts) = split(/=/,$_,2); $tn } @s if $name eq 'TESTS'; if ($name eq 'T' || !defined($sep)) { \@s } else { join($sep,@s) } }; # implements macros: c, and SA lookalikes: SCORE(pad), STARS(*) # sub macro_score { my($msginfo,$recip_index,$name,$arg) = @_; my $per_recip_data = $msginfo->per_recip_data; my($result, $sl_min, $sl_max, $w); $w = ''; if ($name eq 'SCORE' && defined($arg) && $arg=~/^(0+| +)\z/) { $w = length($arg)+4; $w = $arg=~/^0/ ? "0$w" : "$w"; # SA style padding } my $fmt = "%$w.3f"; my $fmts = "%+$w.3f"; # padding, sign if (defined $recip_index) { # return info on one particular recipient my $r; $r = $per_recip_data->[$recip_index] if $recip_index >= 0; $sl_min = $sl_max = $r->spam_level if defined $r; } else { ($sl_min,$sl_max) = minmax(map($_->spam_level, @$per_recip_data)); } if ($name eq 'STARS') { my $slc = $arg ne '' ? $arg : c('sa_spam_level_char'); $result = !defined $slc || $slc eq '' || !defined $sl_min || $sl_min<1 ? '' : $slc x min(50, int $sl_min); } elsif (!defined $sl_min) { $result = '-'; # } elsif ($name eq 'SCORE' || abs($sl_min-$sl_max) < 0.1) { } elsif (abs($sl_min-$sl_max) < 0.1) { # users expect a single value, or not worth reporting a small difference $result = sprintf($fmt,$sl_min); $result =~ s/\.?0*\z//; # trim fraction } else { # format SA score as min..max $sl_min = sprintf($fmt,$sl_min); $sl_min =~ s/\.?0*\z//; $sl_max = sprintf($fmt,$sl_max); $sl_max =~ s/\.?0*\z//; $result = $sl_min . '..' . $sl_max; } $result; }; # implements macro 'header_field', providing a requested header field # from a message; attempts decoding UTF-8 to logical characters # unless a macro name is 'header_field_octets'; non-decodable UTF-8 # is left unchanged as octets # sub macro_header_field { my($msginfo,$name,$header_field_name,$limit,$hf_index) = @_; undef $hf_index if $hf_index !~ /^[+-]?\d+\z/; # defaults to last my $s = $msginfo->get_header_field_body($header_field_name, $hf_index); return undef if !defined($s); # unfold, trim, protect any leftover CR and LF chomp($s); $s=~s/\n(?=[ \t])//gs; $s=~s/^[ \t]+//; $s=~s/[ \t\n]+\z//; if ($header_field_name =~ /^(?:Message-ID|Resent-Message-ID|In-Reply-To|References)\z/i) { $s = join(' ',parse_message_id($s)) if $s ne ''; # strip CFWS } if ($name ne 'header_field_octets' && $s =~ tr/\x00-\x7F//c && is_valid_utf_8($s)) { eval { $s = safe_decode_utf8($s, 1|8); 1 } } if (defined($limit) && $limit !~ /^\s+\z/ && $limit > 5 && length($s) > $limit) { substr($s,$limit-5) = ''; $s .= '[...]'; } $s =~ s{ ( [\r\n] ) }{ sprintf('\\x{%02X}',ord($1)) }xgse; $s; }; sub dkim_test { my($name,$which) = @_; my $w = lc $which; my $sigs_ref = $MSGINFO->dkim_signatures_valid; $sigs_ref = [] if !$sigs_ref; $w eq 'any' || $w eq '' ? (!@$sigs_ref ? undef : scalar(@$sigs_ref)) : $w eq 'author' ? $MSGINFO->dkim_author_sig : $w eq 'sender' ? $MSGINFO->dkim_sender_sig : $w eq 'thirdparty'? $MSGINFO->dkim_thirdparty_sig : $w eq 'envsender' ? $MSGINFO->dkim_envsender_sig : $w eq 'identity' ? join(',', map($_->identity, @$sigs_ref)) : $w eq 'selector' ? join(',', map($_->selector, @$sigs_ref)) : $w eq 'domain' ? join(',', map($_->domain, @$sigs_ref)) : $w eq 'sig_sd' ? join(',', unique_list(map($_->selector.':'.$_->domain, @$sigs_ref))) : $w eq 'newsig_sd' ? join(',', unique_list(map($_->selector.':'.$_->domain, @{$MSGINFO->dkim_signatures_new||[]}))) : dkim_acceptable_signing_domain($MSGINFO,$which); } sub dkim_acceptable_signing_domain($@) { my($msginfo,@acceptable_sdid) = @_; my $matches = 0; my $sigs_ref = $msginfo->dkim_signatures_valid; if ($sigs_ref && @$sigs_ref) { for my $sig (@$sigs_ref) { my $sdid_ace = idn_to_ascii($sig->domain); for (@acceptable_sdid) { my $ad = !defined $_ ? '' : $_; local($1); $ad = $1 if $ad =~ /\@([^\@]*)\z/; # compatibility with pre-2.6.5 if ($ad eq '') { # checking for author domain signature $matches = 1 if $msginfo->dkim_author_sig; } elsif ($ad =~ /^\.(.*)\z/s) { # domain itself or its subdomain my $d = idn_to_ascii($1); if ($sdid_ace eq $d || $sdid_ace =~ /\.\Q$d\E\z/s) { $matches = 1; last; } } else { if ($sdid_ace eq idn_to_ascii($ad)) { $matches = 1; last } } } last if $matches; } } $matches; }; # initialize the %builtins, which is an associative array of built-in macros # to be used in notification message expansion and log templates # sub init_builtin_macros() { # A key (macro name) used to be a single character, but can now be a longer # string, typically a name containing letters, numbers and '_' or '-'. # Upper case letters may (as a mnemonic) suggest the value is an array, # lower case may suggest the value is a scalar string - but this is only # a convention and not enforced. All-uppercase multicharacter names are # intended as SpamAssassin-lookalike macros, although there is nothing # special about them and can be called like other macros. # # A value may be a reference to a subroutine which will be called later at # a time of macro expansion. This way we can provide a method for obtaining # information which is not yet available at the time of initialization, such # as AV scanner results, or provide a lazy evaluation for more expensive # calculations. Subroutine will be called in scalar context, its first # argument is a macro name (a string), remaining arguments (strings, if any) # are arguments of a macro call as specified in the call. The subroutine may # return a scalar string (or undef), or an array reference. # # for SpamAssassin-lookalike macros semantics see Mail::SpamAssassin::Conf %builtins = ( '.' => undef, p => sub {c('policy_bank_path')}, # mail reception timestamp (e.g. start of an SMTP transaction): DATE => sub {rfc2822_timestamp($MSGINFO->rx_time)}, d => sub {rfc2822_timestamp($MSGINFO->rx_time)}, # RFC 5322 local time U => sub {iso8601_utc_timestamp($MSGINFO->rx_time)}, # iso8601 UTC u => sub {sprintf("%010d",int($MSGINFO->rx_time))},# s since Unix epoch,UTC # equivalent, but with more descriptive macro names: date_unix_utc => sub {sprintf("%010d",int($MSGINFO->rx_time))}, date_iso8601_utc => sub {iso8601_utc_timestamp($MSGINFO->rx_time)}, date_iso8601_local => sub {iso8601_timestamp($MSGINFO->rx_time)}, date_rfc2822_local => sub {rfc2822_timestamp($MSGINFO->rx_time)}, week_iso8601 => sub {iso8601_week($MSGINFO->rx_time)}, weekday => sub {iso8601_weekday($MSGINFO->rx_time)}, y => sub {sprintf("%.0f", 1000*get_time_so_far())}, # elapsed time in ms h => sub { $MSGINFO->smtputf8 ? safe_decode_utf8(idn_to_utf8(c('myhostname'))) : idn_to_ascii(c('myhostname')) }, HOSTNAME => sub {safe_decode_utf8(idn_to_utf8(c('myhostname')))}, l => sub {$MSGINFO->originating ? 1 : undef}, # our client (mynets/roaming) s => sub {$MSGINFO->sender_smtp}, # orig. unmodified env. sender addr in <> S => sub {$MSGINFO->sender_smtp}, # kept for compatibility, avoid! o => sub { # best attempt at determining true sender (origin) of the virus, sanitize_str($MSGINFO->sender_source) }, # normally same as %s R => sub {$MSGINFO->recips}, # original message recipients list D => sub {my($y,$n,$f)=delivery_short_report($MSGINFO); $y}, #succ. delivrd O => sub {my($y,$n,$f)=delivery_short_report($MSGINFO); $n}, #failed recips N => sub {my($y,$n,$f)=delivery_short_report($MSGINFO); $f}, #short dsn actions_performed => sub {join(',',@{$MSGINFO->actions_performed||[]})}, Q => sub {$MSGINFO->queue_id}, # MTA queue ID of the message if known m => sub {my $m_id = $MSGINFO->get_header_field_body('message-id'); defined $m_id ? (parse_message_id($m_id))[0] : undef }, r => sub {my $m_id = $MSGINFO->get_header_field_body('resent-message-id'); defined $m_id ? (parse_message_id($m_id))[0] : undef }, j => sub {macro_header_field($MSGINFO,'header','Subject')}, log_domains => sub { my %domains; # $domains{'ORIG'} = 1 if $MSGINFO->originating; for my $r (@{$MSGINFO->per_recip_data}) { if (!$r->recip_is_local) { $domains{'EXT'} = 1; } else { my($localpart,$domain) = split_address($r->recip_addr); $domain =~ s/^\@//; $domains{lc($domain)} = 1; } } join(',', sort {$a cmp $b} keys %domains); }, rfc2822_sender => sub {my $s = $MSGINFO->rfc2822_sender; !defined($s) ? undef : qquote_rfc2821_local($s) }, rfc2822_from => sub {my $f = $MSGINFO->rfc2822_from; !defined($f) ? undef : qquote_rfc2821_local(ref $f ? @$f : $f)}, rfc2822_resent_sender => sub {my $rs = $MSGINFO->rfc2822_resent_sender; !defined($rs) ? undef : qquote_rfc2821_local(grep(defined $_, @$rs))}, rfc2822_resent_from => sub {my $rf = $MSGINFO->rfc2822_resent_from; !defined($rf) ? undef : qquote_rfc2821_local(grep(defined $_, @$rf))}, header_field_octets => sub {macro_header_field($MSGINFO,@_)}, # as octets header_field => sub {macro_header_field($MSGINFO,@_)}, # as characters HEADER => sub {macro_header_field($MSGINFO,@_)}, useragent => # argument: 'name' or 'body', or empty to return entire field sub { my($macro_name,$which_part) = @_; my($head,$body); $body = macro_header_field($MSGINFO,'header', $head='User-Agent'); $body = macro_header_field($MSGINFO,'header', $head='X-Mailer') if !defined $body; !defined($body) ? undef : lc($which_part) eq 'name' ? $head : lc($which_part) eq 'body' ? $body : "$head: $body"; }, ccat => sub { # somewhat expensive! #** my($name,$attr,$which) = @_; $attr = lc $attr; # name | major | minor | <empty> # | is_blocking | is_nonblocking # | is_blocked_by_nonmain $which = lc $which; # main | blocking | auto my $result = ''; my $blocking_ccat = $MSGINFO->blocking_ccat; if ($attr eq 'is_blocking') { $result = defined($blocking_ccat) ? 1 : ''; } elsif ($attr eq 'is_nonblocking') { $result = !defined($blocking_ccat) ? 1 : ''; } elsif ($attr eq 'is_blocked_by_nonmain') { if (defined($blocking_ccat)) { my $aref = $MSGINFO->contents_category; $result = 1 if ref($aref) && @$aref > 0 && $blocking_ccat ne $aref->[0]; } } elsif ($attr eq 'name') { $result = $which eq 'main' ? $MSGINFO->setting_by_main_contents_category(\%ccat_display_names) : $which eq 'blocking' ? $MSGINFO->setting_by_blocking_contents_category( \%ccat_display_names) : $MSGINFO->setting_by_contents_category( \%ccat_display_names); } else { # attr = major, minor, or anything else returns a pair my($maj,$min) = ccat_split( ($which eq 'blocking' || $which ne 'main' && defined $blocking_ccat) ? $blocking_ccat : $MSGINFO->contents_category); $result = $attr eq 'major' ? $maj : $attr eq 'minor' ? sprintf('%d',$min) : sprintf('(%d,%d)',$maj,$min); } $result; }, ccat_maj => # deprecated, use [:ccat|major] sub { my $blocking_ccat = $MSGINFO->blocking_ccat; (ccat_split(defined $blocking_ccat ? $blocking_ccat : $MSGINFO->contents_category))[0]; }, ccat_min => # deprecated, use [:ccat|minor] sub { my $blocking_ccat = $MSGINFO->blocking_ccat; (ccat_split(defined $blocking_ccat ? $blocking_ccat : $MSGINFO->contents_category))[1]; }, ccat_name => # deprecated, use [:ccat|name] sub { $MSGINFO->setting_by_contents_category(\%ccat_display_names) }, dsn_notify => sub { return 'NEVER' if $MSGINFO->sender eq ''; my(%merged); for my $r (@{$MSGINFO->per_recip_data}) { my $dn = $r->dsn_notify; for ($dn ? @$dn : ('FAILURE')) { $merged{uc($_)} = 1 } } uc(join(',', sort keys %merged)); }, attachment_password => sub { my $password = $MSGINFO->attachment_password; # already have it? if (!defined $password) { # make one, and store it for later $password = make_password(c('attachment_password'), $MSGINFO); $MSGINFO->attachment_password($password); } $password; }, b => sub {$MSGINFO->body_digest}, # original message body digest, hex enc body_digest => sub { # original message body digest, raw bytes (binary!) my $bd = $MSGINFO->body_digest; # hex digits, high nybble first !defined $bd ? '' : pack('H*',$bd); }, n => sub {$MSGINFO->log_id}, # amavis internal task id (in log and nanny) i => sub {$MSGINFO->mail_id}, # long-term unique mail id on this system secret_id => sub {$MSGINFO->secret_id}, # mail_id's counterpart, base64url mail_id => sub {$MSGINFO->mail_id}, # synonym for %i, base64url (RFC 4648) parent_mail_id => sub {$MSGINFO->parent_mail_id}, log_id => sub {$MSGINFO->log_id}, # synonym for %n MAILID => sub {$MSGINFO->mail_id}, # synonym for %i (no equivalent in SA) LOGID => sub {$MSGINFO->log_id}, # synonym for %n (no equivalent in SA) P => sub {$MSGINFO->partition_tag}, # SQL partition tag partition_tag => sub {$MSGINFO->partition_tag}, # synonym for %P q => sub { my $q = $MSGINFO->quarantined_to; $q && [map { my $m=$_; $m=~s{^\Q$QUARANTINEDIR\E/}{}; $m } @$q]; }, # list of quarantine mailboxes v => sub { !defined $av_output ? undef # anti-virus scanner output : [split(/[ \t]*\r?\n/, $av_output)]}, V => sub { my $vn = $MSGINFO->virusnames; # unique virus names $vn && unique_ref($vn) }, W => sub { my($name,@args) = @_; # detecting scanners & their virus names # with no args: return a list of av scanners detecting a virus return \@detecting_scanners if !@args; # otherwise provide a per-scanner report of virus names found join('; ', map { my($av, $status, @virus_names) = @$_; my $scanner_name = $av && $av->[0]; for ($scanner_name) { # aliasing to $_ if (!/^[^:" \t]+\z/) { tr/"/'/; $_ = '"'.$_.'"' } } $scanner_name . ':' . (!$status ? '-' : '['.join(',',@virus_names).']'); } @av_scanners_results); }, F => sub { my $b; # first banned part name with a comment from a rule regexp for my $r (@{$MSGINFO->per_recip_data}) { $b = $r->banning_reason_short; last if defined $b; } $b }, banning_rule_key => sub { # regexp of a matching banning rules yielding a true rhs result unique_ref(map { my $v = $_->banning_rule_key; !defined($v) ? () : @$v } @{$MSGINFO->per_recip_data}); }, banning_rule_comment => sub { # just a comment (or a whole regexp if it contains no comments) # from matching banning regexp rules yielding a true rhs result unique_ref(map { my $v = $_->banning_rule_comment; !defined($v) ? () : @$v } @{$MSGINFO->per_recip_data}); }, banning_rule_rhs => sub { # right-hand-side of those matching banning rules yielding true # (a r.h.s. of a rule can be a string, is treated as a boolean, # but often it is just an implicit 0 or 1) unique_ref(map { my $v = $_->banning_rule_rhs; !defined($v) ? () : @$v } @{$MSGINFO->per_recip_data}); }, banned_parts => sub { # list of banned parts with their full paths my $b = unique_ref(map(@{$_->banned_parts}, grep(defined($_->banned_parts),@{$MSGINFO->per_recip_data}))); my $b_chopped = @$b > 2; @$b = (@$b[0,1],'...') if $b_chopped; s/[ \t]{6,}/ ... /g for @$b; $b }, banned_parts_as_attr => sub { # list of banned parts with their full paths my $b = unique_ref(map(@{$_->banned_parts_as_attr}, grep(defined($_->banned_parts_as_attr), @{$MSGINFO->per_recip_data}))); my $b_chopped = @$b > 2; @$b = (@$b[0,1],'...') if $b_chopped; s/[ \t]{6,}/ ... /g for @$b; $b }, X => sub {\@bad_headers}, H => sub {[map(split(/\n/,$_), @{$MSGINFO->orig_header})]}, # arry of lines A => sub {[split(/\r?\n/, $MSGINFO->spam_summary)]}, # SA report text SUMMARY => sub {$MSGINFO->spam_summary}, REPORT => sub {sanitize_str($MSGINFO->spam_report,1)}, #contains any octet TESTSSCORES => sub {macro_tests($MSGINFO,undef,@_)}, # tests with scores TESTS => sub {macro_tests($MSGINFO,undef,@_)}, # tests without scores z => sub {$MSGINFO->msg_size}, #mail size as defined by RFC 1870, or approx ip_trace_all => sub { # all IP addresses in the Received trace, top-down my $trace = $MSGINFO->trace; return if !$trace; [ map(defined $_ ? sanitize_str($_) : 'x', map($_->{ip}, @$trace)) ]; }, ip_trace_public => sub { # all public IP addresses in the Received trace my $ip_trace = $MSGINFO->ip_addr_trace_public; return if !$ip_trace; [ map(defined $_ ? sanitize_str($_) : 'x', @$ip_trace) ]; }, ip_proto_trace_all => sub { # from a Received trace # protocol type from the WITH clause and an IP address my $trace_ref = $MSGINFO->trace; return if !$trace_ref; my(@trace) = @$trace_ref; shift(@trace); # chop off the last hop (MTA -> amavisd) [ map(sanitize_str( (!$_->{with} ? '' : $_->{with}.'://') . (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip} : '['.$_->{ip}.']:'.$_->{port})),@trace)]; }, ip_proto_trace_public => sub { # from a Received trace # protocol type from the WITH clause and an IP address my $trace_ref = $MSGINFO->trace; return if !$trace_ref; my(@trace) = @$trace_ref; shift(@trace); # chop off the last hop (MTA -> amavisd) [ map(sanitize_str( (!$_->{with} ? '' : $_->{with}.'://') . (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip} : '['.$_->{ip}.']:'.$_->{port}) ), grep($_->{public}, @trace)) ]; }, protocol => # "WITH protocol type" as seen by amavisd (the last hop) sub { my $c = $MSGINFO->conn_obj; !$c ? '' : $c->appl_proto }, t => sub { # first (oldest) entry in the Received trace sanitize_str(first_received_from($MSGINFO)) }, e => sub { # first (oldest) valid public IP in the Received trace, # same as the last entry in ip_trace_public sanitize_str(oldest_public_ip_addr_from_received($MSGINFO)) }, a => sub { $MSGINFO->client_addr }, # original SMTP session client IP addr client_addr => sub { $MSGINFO->client_addr }, # synonym with 'a' client_port => sub { $MSGINFO->client_port }, client_addr_port => sub { # original SMTP session client IP addr & port no. my($a,$p) = ($MSGINFO->client_addr, $MSGINFO->client_port); !defined $a || $a eq '' ? undef : ('[' . $a . ']' . ($p ? ":$p" : '')); }, g => sub { # original SMTP session client DNS name sanitize_str($MSGINFO->client_name) }, client_helo => sub { # original SMTP session EHLO/HELO name sanitize_str($MSGINFO->client_helo) }, client_protocol => sub { $MSGINFO->client_proto }, # XFORWARD PROTO, AM.PDP remote_mta => sub { unique_ref(map($_->recip_remote_mta, @{$MSGINFO->per_recip_data})) }, smtp_response => sub { unique_ref(map($_->recip_smtp_response, @{$MSGINFO->per_recip_data})) }, remote_mta_smtp_response => sub { unique_ref(map($_->recip_remote_mta_smtp_response, @{$MSGINFO->per_recip_data})) }, REMOTEHOSTADDR => # where the request came from sub { my $c = $MSGINFO->conn_obj; !$c ? '' : $c->client_ip }, REMOTEHOSTNAME => sub { my $c = $MSGINFO->conn_obj; my $ip = !$c ? '' : $c->client_ip; $ip ne '' ? "[$ip]" : 'localhost' }, AUTOLEARN => sub {$MSGINFO->supplementary_info('AUTOLEARN')}, ADDEDHEADERHAM => sub {$MSGINFO->supplementary_info('ADDEDHEADERHAM')}, ADDEDHEADERSPAM => sub {$MSGINFO->supplementary_info('ADDEDHEADERSPAM')}, SUBJPREFIX => sub {$MSGINFO->supplementary_info('SUBJPREFIX')}, supplementary_info => # additional information from SA and other scanners sub { my($name,$key,$fmt)=@_; my $info = $MSGINFO->supplementary_info($key); $info eq '' ? '' : $fmt eq '' ? $info : sprintf($fmt,$info); }, rusage => sub { macro_rusage($MSGINFO,undef,@_) }, # resource usage REQD => sub { my $tag2_level; for (@{$MSGINFO->per_recip_data}) { # get minimal tag2_level my $tag2_l = lookup2(0, $_->recip_addr, ca('spam_tag2_level_maps')); $tag2_level = $tag2_l if defined($tag2_l) && (!defined($tag2_level) || $tag2_l < $tag2_level); } !defined($tag2_level) ? '-' : 0+sprintf("%.3f",$tag2_level); }, '1'=> sub { # above tag level and not bypassed for any recipient? grep($_->is_in_contents_category(CC_CLEAN,1), @{$MSGINFO->per_recip_data}) ? 'Y' : '0' }, '2'=> sub { # above tag2 level and not bypassed for any recipient? grep($_->is_in_contents_category(CC_SPAMMY), @{$MSGINFO->per_recip_data}) ? 'Y' : '0' }, YESNO => sub { my($arg_spam, $arg_ham) = @_; # like %2, but gives: Yes/No grep($_->is_in_contents_category(CC_SPAMMY), @{$MSGINFO->per_recip_data}) ? (defined $arg_spam ? $arg_spam : 'Yes') : (defined $arg_ham ? $arg_ham : 'No') }, YESNOCAPS => sub { my($arg_spam, $arg_ham) = @_; # like %2, but gives: YES/NO grep($_->is_in_contents_category(CC_SPAMMY), @{$MSGINFO->per_recip_data}) ? (defined $arg_spam ? $arg_spam : 'YES') : (defined $arg_ham ? $arg_ham : 'NO') }, 'k'=> sub { # above kill level and not bypassed for any recipient? grep($_->is_in_contents_category(CC_SPAM), @{$MSGINFO->per_recip_data}) ? 'Y' : '0' }, score_boost => 0, # legacy c => sub {macro_score($MSGINFO,undef,@_)}, # info on all recipients SCORE => sub {macro_score($MSGINFO,undef,@_)}, # info on all recipients STARS => sub {macro_score($MSGINFO,undef,@_)}, # info on all recipients dkim => \&dkim_test, tls_in => sub {$MSGINFO->tls_cipher}, # currently only shows ciphers in use report_format => undef, # notification message format, supplied elsewhere feedback_type => undef, # (ARF) feedback type or empty, supplied elsewhere wrap => sub {my($name,$width,$prefix,$indent,$str) = @_; wrap_string($str,$width,$prefix,$indent)}, lc => sub {my $name=shift; lc(join('',@_))}, # to lowercase uc => sub {my $name=shift; uc(join('',@_))}, # to uppercase substr => sub {my($name,$s,$ofs,$len) = @_; defined $len ? substr($s,$ofs,$len) : substr($s,$ofs)}, index => sub {my($name,$s,$substr,$pos) = @_; index($s, $substr, defined $pos ? $pos : 0)}, len => sub {my($name,$s) = @_; length($s)}, incr => sub {my($name,$v,@rest) = @_; if (!@rest) { $v++ } else { $v += $_ for @rest }; "$v"}, decr => sub {my($name,$v,@rest) = @_; if (!@rest) { $v-- } else { $v -= $_ for @rest }; "$v"}, min => sub {my($name,@args) = @_; min(map(/^\s*\z/?undef:$_, @args))}, max => sub {my($name,@args) = @_; max(map(/^\s*\z/?undef:$_, @args))}, sprintf=> sub {my($name,$fmt,@args) = @_; sprintf($fmt,@args)}, join => sub {my($name,$sep,@args) = @_; join($sep,@args)}, limit => sub {my($name,$lim,$s) = @_; $lim < 6 || length($s) <= $lim ? $s : substr($s,0,$lim-5).'[...]' }, dquote => sub {my $nm=shift; join('', map { my $s=$_; $s=~s{"}{""}g; '"'.$s.'"' } @_)}, uquote => sub {my $nm=shift; join('', map { my $s=$_; $s=~s{[ \t]+}{_}g; $s } @_)}, rot13 => sub {my($name,$s) = @_; # obfuscation (Caesar cipher) $s=~tr/a-zA-Z/n-za-mN-ZA-M/; $s }, hexenc => sub {my $nm=shift; join('', map(unpack('H*',$_), @_))}, b64encode => sub {my $nm=shift; join(' ', map(encode_base64($_,''),@_))}, b64enc => sub {my $nm=shift; # preferred over b64encode join('', map { my $s=encode_base64($_,''); $s=~s/=+\z//; $s } @_)}, b64urlenc => sub {my $nm=shift; join('', map { my $s=encode_base64($_,''); $s=~s/=+\z//; $s=~tr{+/}{-_}; $s } @_)}, mail_addr_decode => sub {my($nm,$addr) = @_; mail_addr_decode($addr,0)}, mail_addr_decode_octets => sub {my($nm,$addr) = @_; mail_addr_decode($addr,1)}, mime_decode => sub { # convert RFC 2047 encoded-words or UTF-8 octets to logical characters, # truncate to $max_len characters if limit is provded my($nm,$str,$max_len,$both_if_diff) = @_; return '' if !defined $str || $str eq ''; my $chars = safe_decode_mime($str); # octets to logical characters if (!defined $max_len || $max_len <= 0) { # no size limit return $chars if !$both_if_diff; $chars .= ' (raw: ' . $str . ')' if $chars ne $str; } else { # truncate characters string at $max_len substr($chars,$max_len) = '' if length($chars) > $max_len; return $chars if !$both_if_diff; # only compare the visible part my $octets = safe_encode_utf8($chars); substr($str,length($octets)) = '' if length($str) > length($octets); $chars .= ' (raw: ' . $str . ')' if $str ne $chars; } $chars; }, mime2utf8 => sub { # convert RFC 2047 encoded-words or UTF-8 to UTF-8 octets, # truncate to $max_len characters if limit is provded my($nm,$str,$max_len,$both_if_diff) = @_; return '' if !defined $str || $str eq ''; my $chars = safe_decode_mime($str); # to logical characters my $octets = safe_encode_utf8($chars); # to bytes, UTF-8 encoded $octets = truncate_utf_8($octets,$max_len); return $octets if !$both_if_diff; # only compare the visible part if (defined $max_len && $max_len > 0 && length($str) > $max_len) { substr($str,$max_len) = ''; } $str = $octets . ' (raw: ' . $str . ')' if $octets ne $str; $str; }, report_json => sub { return if !$report_ref; # ugly globals structured_report_update_time($report_ref); my $macro_name = shift; if (!@_) { # all fields, no filtering return Amavis::JSON::encode($report_ref); # as a string of characters } else { # filtering by field names my @keys = @_ == 1 ? split(' ',$_[0]) : @_; # whitespace-separated? my(@negated_keys) = map(/^!(.*)\z/s ? $1 : (), @keys); my %filtered; if (@negated_keys) { # take all but negated fields %filtered = %$report_ref; delete @filtered{@negated_keys}; } else { # take only listed fields %filtered = map(exists $report_ref->{$_} ? ($_,$report_ref->{$_}) : (), @keys); } return Amavis::JSON::encode(\%filtered); # as a string of characters } }, # macros f, T, C, B will be defined for each notification as appropriate # (representing From:, To:, Cc:, and Bcc: respectively) # remaining free letters: wxEGIJKLMYZ ); } # initialize %local_delivery_aliases # sub init_local_delivery_aliases() { # The %local_delivery_aliases maps local virtual 'localpart' to a mailbox # (e.g. to a quarantine filename or a directory). Used by method 'local:', # i.e. in mail_to_local_mailbox(), for direct local quarantining. # The hash value may be a ref to a pair of fixed strings, or a subroutine ref # (which must return a pair of strings (a list, not a list ref)) which makes # possible lazy evaluation when some part of the pair is not known before # the final delivery time. The first string in a pair must be either: # - empty or undef, which will disable saving the message, # - a filename, indicating a Unix-style mailbox, # - a directory name, indicating a maildir-style mailbox, # in which case the second string may provide a suggested file name. # %Amavis::Conf::local_delivery_aliases = ( 'virus-quarantine' => sub { ($QUARANTINEDIR, undef) }, 'banned-quarantine' => sub { ($QUARANTINEDIR, undef) }, 'unchecked-quarantine' => sub { ($QUARANTINEDIR, undef) }, 'spam-quarantine' => sub { ($QUARANTINEDIR, undef) }, 'bad-header-quarantine' => sub { ($QUARANTINEDIR, undef) }, 'clean-quarantine' => sub { ($QUARANTINEDIR, undef) }, 'other-quarantine' => sub { ($QUARANTINEDIR, undef) }, 'archive-quarantine' => sub { ($QUARANTINEDIR, undef) }, # some more examples: 'archive-files' => sub { ("$QUARANTINEDIR", undef) }, 'archive-mbox' => sub { ("$QUARANTINEDIR/archive.mbox", undef) }, 'recip-quarantine' => sub { ("$QUARANTINEDIR/recip-archive.mbox",undef) }, 'sender-quarantine' => sub { my $s = $MSGINFO->sender; substr($s,100) = '...' if length($s) > 100+3; $s =~ tr/a-zA-Z0-9@._+-/=/c; $s =~ s/\@/_at_/g; untaint_inplace($s) if $s =~ /^(?:[a-zA-Z0-9%=._+-]+)\z/; # untaint ($QUARANTINEDIR, "sender-$s-%m.gz"); # suggested file name }, # 'recip-quarantine2' => sub { # my(@fnames); # my $myfield = # Amavis::Lookup::SQLfield->new($sql_lookups,'some_field_name','S'); # for my $r (@{$MSGINFO->recips}) { # my $field_value = lookup(0,$r,$myfield); # my $fname = $field_value; # or perhaps: my $fname = $r; # local($1); $fname =~ s/[^a-zA-Z0-9._\@]/=/g; $fname =~ s/\@/%/g; # untaint_inplace($fname) if $fname =~ /^([a-zA-Z0-9._=%]+)\z/; # $fname =~ s/%/%%/g; # protect % # do_log(3, "Recipient: %s, field: %s, fname: %s", # $r, $field_value, $fname); # push(@fnames, $fname); # } # # ???what file name to choose if there is more than one recipient??? # ( $QUARANTINEDIR, "sender-$fnames[0]-%i-%n.gz" ); # suggested file name # }, ); } # tokenize templates (input to macro expansion), after dropping privileges # sub init_tokenize_templates() { my(@templ_names) = qw(log_templ log_recip_templ notify_sender_templ notify_virus_recips_templ notify_virus_sender_templ notify_virus_admin_templ notify_spam_sender_templ notify_spam_admin_templ notify_release_templ notify_report_templ notify_autoresp_templ); for my $bank_name (keys %policy_bank) { for my $n (@templ_names) { # tokenize templates to speed up macro expansion my $s = $policy_bank{$bank_name}{$n}; $s = $$s if ref($s) eq 'SCALAR'; if (defined $s) { # encode log templates to UTF-8, leave the rest as character strings safe_encode_utf8_inplace($s) if $n eq 'log_templ' || $n eq 'log_recip_templ'; $policy_bank{$bank_name}{$n} = tokenize(\$s); } } } } # pre-parse IP lookup tables to speed up lookups, after dropping privileges # sub init_preparse_ip_lookups() { for my $bank_name (keys %policy_bank) { my $r = $policy_bank{$bank_name}{'inet_acl'}; if (ref($r) eq 'ARRAY') { # should be a ref to an IP lookup table $policy_bank{$bank_name}{'inet_acl'} = Amavis::Lookup::IP->new(@$r); } $r = $policy_bank{$bank_name}{'ip_repu_ignore_maps'}; # listref of tables if (ref($r) eq 'ARRAY') { # should be an array, test just to make sure for my $table (@$r) { # replace plain lists with pre-parsed objects $table = Amavis::Lookup::IP->new(@$table) if ref($table) eq 'ARRAY'; } } $r = $policy_bank{$bank_name}{'client_ipaddr_policy'}; # listref of pairs if (ref($r) eq 'ARRAY') { # should be an array, test just to make sure my $odd = 1; for my $table (@$r) { # replace plain lists with pre-parsed objects $table = Amavis::Lookup::IP->new(@$table) if $odd && ref($table) eq 'ARRAY'; $odd = !$odd; } } } } # initialize some remaining global variables in a master process; # invoked after chroot and after privileges have been dropped, before forking # sub after_chroot_init() { $child_invocation_count = $child_task_count = 0; %modules_basic = %INC; # helps to track missing modules in chroot do_log(5,"after_chroot_init: EUID: %s (%s); EGID: %s (%s)", $>,$<, $),$( ); my(@msg); my $euid = $>; # effective UID $> = 0; # try to become root POSIX::setuid(0) if $> != 0; # and try some more if ($euid == 0) { @msg = ('Running as EUID 0 (root), ABORTING!', 'Please start as non-root, e.g. by su(1) or using option -u user,', 'or configure the $daemon_user setting.'); } elsif ($> == 0) { # succeeded? panic! @msg = ("It is possible to change EUID from $euid to root, ABORTING!", 'Please start as non-root, e.g. by su(1) or using option -u user,', 'or configure the $daemon_user setting.'); } elsif ($daemon_chroot_dir eq '') { # A quick check on vulnerability/protection of a config file # (non-exhaustive: doesn't test for symlink tricks and higher directories). # The config file has already been executed by now, so it may be # too late to feel sorry now, but better late then never. my(@actual_c_f) = Amavis::Conf::get_config_files_read(); do_log(2,"config files read: %s", join(", ",@actual_c_f)); for my $config_file (@actual_c_f) { local($1); # IO::Handle::_open_mode_string can taint $1 if mode is '+<' my $fh = IO::File->new; my $errn = stat($config_file) ? 0 : 0+$!; if ($errn) { # not accessible, don't bother to test further } elsif ($i_know_what_i_am_doing{no_conf_file_writable_check}) { # skip checking } elsif ($fh->open($config_file,O_RDWR)) { push(@msg, "Config file \"$config_file\" is writable, ". "UID $<, EUID $>, EGID $)" ); $fh->close; # close, ignoring status } elsif (rename($config_file, $config_file.'.moved')) { my $m = 'appears writable (unconfirmed)'; my $errn_cf_orig = stat($config_file) ? 0 : 0+$!; my $errn_cf_movd = stat($config_file.'.moved') ? 0 : 0+$!; if ($errn_cf_orig==ENOENT && $errn_cf_movd!=ENOENT) { # try to rename back, ignoring status rename($config_file.'.moved', $config_file); $m = 'is writable (confirmed)'; } push(@msg, "Directory of a config file \"$config_file\" $m, ". "UID $<, EUID $>, EGID $)" ); } last if @msg; } } if (@msg) { do_log(-3,"FATAL: %s",$_) for @msg; print STDERR (map("$_\n", @msg)); die "SECURITY PROBLEM, ABORTING"; exit 1; # just in case } init_tokenize_templates(); init_preparse_ip_lookups(); # report versions of some (more interesting) modules for my $m ('Amavis::Conf', sort map { my $s = $_; $s =~ s/\.pm\z//; $s =~ s{/}{::}g; $s } grep(/\.pm\z/, keys %INC)) { next if !grep($_ eq $m, qw(Amavis::Conf Archive::Tar Archive::Zip Compress::Zlib Compress::Raw::Zlib Convert::TNEF Convert::UUlib File::LibMagic MIME::Entity MIME::Parser MIME::Tools Mail::Header Mail::Internet Digest::MD5 Digest::SHA Digest::SHA1 Crypt::OpenSSL::RSA Authen::SASL Authen::SASL::XS Authen::SASL::Cyrus Authen::SASL::Perl Encode Scalar::Util Time::HiRes File::Temp Unix::Syslog Unix::Getrusage Socket Socket6 IO::Socket::INET6 IO::Socket::IP IO::Socket::SSL Net::Server NetAddr::IP Net::DNS Net::LibIDN Net::LibIDN2 Net::SSLeay Net::Patricia Net::LDAP Mail::SpamAssassin Mail::DKIM::Verifier Mail::DKIM::Signer Mail::ClamAV Mail::SPF Mail::SPF::Query URI Razor2::Client::Version DBI DBD::mysql DBD::Pg DBD::SQLite BerkeleyDB DB_File ZMQ ZMQ::LibZMQ2 ZMQ::LibZMQ3 ZeroMQ SAVI Anomy::Sanitizer)); do_log(1, "Module %-19s %s", $m, eval{$m->VERSION} || '?'); } do_log(1,"SQL base code %s loaded", $extra_code_sql_base ?'':" NOT"); do_log(1,"SQL::Log code %s loaded", $extra_code_sql_log ?'':" NOT"); do_log(1,"SQL::Quarantine %s loaded", $extra_code_sql_quar ?'':" NOT"); do_log(1,"Lookup::SQL code %s loaded", $extra_code_sql_lookup ?'':" NOT"); do_log(1,"Lookup::LDAP code %s loaded", $extra_code_ldap ?'':" NOT"); # store policy names into 'policy_bank_name' fields, if not explicitly set for my $name (keys %policy_bank) { if (ref($policy_bank{$name}) eq 'HASH' && !exists($policy_bank{$name}{'policy_bank_name'})) { $policy_bank{$name}{'policy_bank_name'} = $name; $policy_bank{$name}{'policy_bank_path'} = $name; } } }; # overlay the current policy bank by settings from the # $policy_bank{$policy_bank_name}, or load the default policy bank (empty name) # sub load_policy_bank($;$) { my($policy_bank_name, $msginfo) = @_; if (!defined $policy_bank_name) { # silently ignore } elsif (!exists $policy_bank{$policy_bank_name}) { do_log(5,'policy bank "%s" does not exist, ignored', $policy_bank_name); } elsif ($policy_bank_name eq '') { # special case %current_policy_bank = %{$policy_bank{$policy_bank_name}}; # copy base update_current_log_level(); do_log(4,'loaded base policy bank'); } elsif ($policy_bank_name eq c('policy_bank_name')) { do_log(5,'policy bank "%s" just loaded, ignored', $policy_bank_name); } else { # compatibility: policy bank MYNETS implicitly pre-sets 'originating' flag $current_policy_bank{'originating'} = 1 if $policy_bank_name eq 'MYNETS'; my $cpbp = c('policy_bank_path'); # currently loaded bank my $new_bank_ref = $policy_bank{$policy_bank_name}; my $do_log5 = ll(5); for my $k (keys %$new_bank_ref) { if ($k eq 'ACTION') { if (ref $new_bank_ref->{$k} eq 'CODE') { do_log(5,'invoking user ACTION on loading a policy bank %s', $policy_bank_name); eval { # $msginfo may be undef when a policy bank load takes place early &{$new_bank_ref->{$k}}($msginfo,$policy_bank_name); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,'failed ACTION on loading a policy bank %s: %s', $policy_bank_name, $eval_stat); }; } } elsif (!exists $current_policy_bank{$k}) { do_log(-1,'loading policy bank "%s": unknown field "%s"', $policy_bank_name,$k); } elsif (ref($new_bank_ref->{$k}) ne 'HASH' || ref($current_policy_bank{$k}) ne 'HASH') { $current_policy_bank{$k} = $new_bank_ref->{$k}; # do_log(5,'loading policy bank %s, curr{%s} replaced by %s', # $policy_bank_name, $k, $current_policy_bank{$k}) if $do_log5; } else { # new hash to be merged into or replacing an existing hash if ($new_bank_ref->{$k}{REPLACE}) { # replace the entire hash $current_policy_bank{$k} = { %{$new_bank_ref->{$k}} }; # copy of new do_log(5,'loading policy bank %s, curr{%s} hash replaced', $policy_bank_name, $k) if $do_log5; } else { # merge field-by-field, old fields missing in new are retained $current_policy_bank{$k} = { %{$current_policy_bank{$k}} }; # copy while (my($key,$val) = each %{$new_bank_ref->{$k}}) { do_log(5,'loading policy bank %s, curr{%s}{%s} = %s, %s', $policy_bank_name, $k, $key, $val, !exists($current_policy_bank{$k}{$key}) ? 'new' : 'replaces '.$current_policy_bank{$k}{$key} ) if $do_log5; $current_policy_bank{$k}{$key} = $val; } } delete $current_policy_bank{$k}{REPLACE}; } } $current_policy_bank{'policy_bank_path'} = ($cpbp eq '' ? '' : $cpbp.'/') . $policy_bank_name; ll(3) && do_log(3,'loaded policy bank "%s"%s', $policy_bank_name, $cpbp eq '' ? '' : " over \"$cpbp\""); # update global settings which may have changed update_current_log_level(); $msginfo->originating(c('originating')) if $msginfo; } } # systemd notifier # sub sd_notify($@) { # my($unset_environment, @messages) = @_; my $unset_environment = shift; my $result; # undef=failure, 0=nothing to do, 1=success my $socket_name = $ENV{NOTIFY_SOCKET}; if (!@_) { # no messages $result = 0; } elsif (!defined $socket_name || $socket_name eq '') { $result = 0; ll(2) && do_log(2, "sd_notify (no socket): %s", join("\n",@_)); } elsif ($socket_name !~ m{^[/@].}s) { # must be an absolute path or an abstract socket do_log(0, "sd_notify: NOTIFY_SOCKET env.var '%s' must be ". "an absolute path or an abstract socket", $socket_name); $! = EINVAL; } else { ll(1) && do_log(1, "sd_notify (%s): %s", $socket_name, join("\n",@_)); $socket_name =~ s{^\@}{\x{00}}s; # abstract socket (Linux specific) eval { my $sock = IO::Socket::UNIX->new(Type => SOCK_DGRAM); $sock or die "Can't create a socket object of type AF_LOCAL: $!"; # should also send credentials, e.g. using IO::Handle::Record module # FreeBSD: struct cmsgcred; send a SCM_CREDS message # OpenBSD: struct sockpeercred; SO_PASSCRED # Linux: struct ucred; send a SCM_CREDENTIALS msg; SO_PEERCRED; unix(7) $sock->connect( pack_sockaddr_un(untaint($socket_name)) ) or die "Can't connect to NOTIFY_SOCKET $socket_name: $!"; defined $sock->send(join("\n",@_), MSG_NOSIGNAL) or die "Error sending to NOTIFY_SOCKET $socket_name: $!"; $sock->close or die "Error closing NOTIFY_SOCKET: $!"; $result = 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1, "sd_notify: %s", $eval_stat); }; } undef $ENV{NOTIFY_SOCKET} if $unset_environment; $result; } sub sd_notifyf($$;@) { my($unset_environment, $message, @args) = @_; sd_notify($unset_environment, @args ? sprintf($message,@args) : $message); } ### Net::Server hook ### Occurs in the parent (master) process after (possibly) opening a log file, ### creating pid file, reopening STDIN/STDOUT to /dev/null and daemonizing; ### but before binding to sockets # sub post_configure_hook { if ($warm_restart) { sd_notify(0, "STATUS=Preparing to re-bind sockets."); } elsif (!$daemonize) { sd_notify(0, "STATUS=Preparing to bind sockets."); } else { sd_notify(0, "MAINPID=$$","STATUS=Daemonized, preparing to bind sockets."); } # umask(0007); # affects protection of Unix sockets created by Net::Server } sub set_sockets_access() { if (defined $unix_socket_mode && $unix_socket_mode ne '') { for my $s (@listen_sockets) { local($1); if ($s =~ m{^(/.+)\|unix\z}si) { my $path = $1; chmod($unix_socket_mode,$path) or do_log(-1, "Error setting mode 0%03o on a socket %s: %s", $unix_socket_mode, $path, $!); } } } } ### Net::Server hook ### Occurs in the parent (master) process after binding to sockets, ### but before chrooting and dropping privileges # sub post_bind_hook { umask(0027); # restore our preferred umask set_sockets_access() if defined $warm_restart && !$warm_restart; sd_notify(0, "STATUS=Sockets bound, checking user and group."); } ### Net::Server hook ### This hook occurs in the parent (master) process after chroot, ### after change of user, and change of group has occurred. ### It allows for preparation before forking and looping begins. # sub pre_loop_hook { my $self = $_[0]; local $SIG{CHLD} = 'DEFAULT'; # do_log(5, "entered pre_loop_hook"); eval { sd_notify(0, "STATUS=The rest of pre-fork init, finding helper programs."); after_chroot_init(); # the rest of the top-level initialization # this needs to be done after chroot, otherwise paths will be wrong find_external_programs([split(/:/,$path,-1)]); # path, decoders, scanners # do some sanity checking my $name = $TEMPBASE; $name = "$daemon_chroot_dir $name" if $daemon_chroot_dir ne ''; my $errn = stat($TEMPBASE) ? 0 : 0+$!; if ($errn==ENOENT) { die "No TEMPBASE directory: $name" } elsif ($errn) { die "TEMPBASE directory inaccessible, $!: $name" } elsif (!-d _) { die "TEMPBASE is not a directory: $name" } elsif (!-w _) { die "TEMPBASE directory is not writable: $name" } if ($enable_db) { my $name = $db_home; $name = "$daemon_chroot_dir $name" if $daemon_chroot_dir ne ''; $errn = stat($db_home) ? 0 : 0+$!; if ($errn == ENOENT) { die "Please create an empty directory $name to hold a database". " (config variable \$db_home)\n" } elsif ($errn) { die "db_home $name inaccessible: $!" } elsif (!-d _) { die "db_home $name is not a directory" } elsif (!-w _) { die "db_home $name directory is not writable" } Amavis::DB::init(1, !$warm_restart); } if (!defined($sql_quarantine_chunksize_max)) { die "Variable \$sql_quarantine_chunksize_max is undefined\n"; } elsif ($sql_quarantine_chunksize_max < 1024) { die "Setting of \$sql_quarantine_chunksize_max is too small: ". "$sql_quarantine_chunksize_max bytes, it would be inefficient\n"; } elsif ($sql_quarantine_chunksize_max > 1024*1024) { do_log(-1, "Setting of %s is quite large: %d KiB, it unnecessarily ". "wastes memory", '$sql_quarantine_chunksize_max', $sql_quarantine_chunksize_max/1024); } if ($QUARANTINEDIR ne '') { my $name = $QUARANTINEDIR; $name = "$daemon_chroot_dir $name" if $daemon_chroot_dir ne ''; $errn = stat($QUARANTINEDIR) ? 0 : 0+$!; if ($errn == ENOENT) { } # ok elsif ($errn) { die "QUARANTINEDIR $name inaccessible: $!" } # elsif (-d _ && !-w _){ die "QUARANTINEDIR directory $name not writable"} } $spamcontrol_obj->init_pre_fork if $spamcontrol_obj; my(@modules_extra) = grep(!exists $modules_basic{$_}, keys %INC); if (@modules_extra) { do_log(1, "extra modules loaded after daemonizing/chrooting: %s", join(", ", sort @modules_extra)); %modules_basic = %INC; } if (!grep { my $v = $policy_bank{$_}{'enable_dkim_verification'}; defined(!ref $v ? $v : $$v) } keys %policy_bank) { do_log(0,'DKIM signature verification disabled, corresponding features '. 'not available. If not intentional, consider enabling it by setting: '. '$enable_dkim_verification to 1, or explicitly disable it by setting '. 'it to 0 to mute this warning.'); } # systemd, Type=notify sd_notify(0, "READY=1", "STATUS=Initialization done."); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; my $msg = "TROUBLE in pre_loop_hook: $eval_stat"; do_log(-2,"%s",$msg); sd_notify(0, "STOPPING=1", "STATUS=$msg"); die("Suicide (" . am_id() . ") " . $msg . "\n"); }; 1; } # (!)_DIE: Unable to create sub named "" at /usr/local/sbin/amavisd line 9947. # The line 9947 was in sub write_to_log_hook: local $SIG{CHLD} = 'DEFAULT'; # perl #60360: local $SIG{FOO} = sub {...}; sets signal handler to SIG_DFL # # http://www.perlmonks.org/?node_id=721692 # # non-atomic, clears to SIG_DFL, then sets: local $SIG{ALRM} = sub {...}; # use Sub::ScopeFinalizer qw( scope_finalizer ); # my $sentry = local_sassign $SIG{ALRM}, \&alarm_handler; # sub local_sassign { # my $r = \($_[0]); # my $sentry = scope_finalizer { $$r = $_[0] } { args => [ $$r ] }; # $$r = $_[1]; return $sentry; # } # or use: # use POSIX qw(:signal_h) ; # my $sigset = POSIX::SigSet->new ; # my $blockset = POSIX::SigSet->new( SIGALRM ) ; # sigprocmask(SIG_BLOCK, $blockset, $sigset ); # local $SIG{ALRM} = sub .... ; # sigprocmask(SIG_SETMASK, $sigset ); ### log routine Net::Server hook ### (Sys::Syslog MUST NOT be specified as a value of 'log_file'!) # # Redirect Net::Server logging to use Amavis' do_log(). # The main reason is that Net::Server uses Sys::Syslog # (and has two bugs in doing it, at least the Net-Server-0.82), # and Amavis users are accustomed to Unix::Syslog. # sub write_to_log_hook { my($self,$level,$msg) = @_; my $prop = $self->{server}; local $SIG{CHLD} = 'DEFAULT'; $level = 0 if $level < 0; $level = 4 if $level > 4; # my $ll = (-2,-1,0,1,3)[$level]; # 0=err, 1=warn, 2=notice, 3=info, 4=debug my $ll = (-1, 0,1,3,4)[$level]; # 0=err, 1=warn, 2=notice, 3=info, 4=debug chomp($msg); # just call Amavis' traditional logging ll($ll) && do_log($ll, "Net::Server: %s", $msg); 1; } ### user customizable Net::Server hook (Net::Server 0.88 or later), ### This hook occurs in the master process at the top of run_n_children ### which is called each time the server goes to start more child processes. # sub run_n_children_hook { # do_log(5, "entered run_n_children_hook"); sd_notify(0, "STATUS=Starting child process(es), ready for work."); Amavis::AV::sophos_savi_reload() if $extra_code_antivirus && Amavis::AV::sophos_savi_stale(); add_entropy(Time::HiRes::gettimeofday); } ### compatibility with patched Net::Server by SAVI patch (Net::Server <= 0.87) # sub parent_fork_hook { my $self = $_[0]; $self->run_n_children_hook } ### user customizable Net::Server hook, ### run by every child process during its startup # sub child_init_hook { my $self = $_[0]; local $SIG{CHLD} = 'DEFAULT'; $child_init_hook_was_called = 1; do_log(5, "entered child_init_hook"); $my_pid = $$; $0 = c('myprogram_name') . ' (virgin child)'; # DB::enable_profile(sprintf("/tmp/nytprof-amavis-%s-%d.out", # $my_pid, int rand 1000000)) if $profiling; stir_random(); log_capture_enabled(1) if $enable_log_capture; # reset log counters inherited from a master process collect_log_stats(); # my(@signames) = qw(HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV # SYS PIPE ALRM TERM URG TSTP CONT TTIN TTOU IO # XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2); # my $h = sub { my $s = $_[0]; $got_signals{$s}++; # local($SIG{$s})='IGNORE'; kill($my_pid,$s) }; # @SIG{@signames} = ($h) x @signames; my $inherited_entropy; eval { # if (defined $daemon_user && $daemon_user ne '' && ($> == 0 || $< == 0)) { # # last resort, in case Net::Server didn't do it # do_log(2, "child_init_hook: dropping privileges, user=%s, group=%s", # $daemon_user,$daemon_group); # drop_priv($daemon_user,$daemon_group); # } undef $db_env; undef $snmp_db; # just in case Amavis::Timing::init(); snmp_counters_init(); close_log(); open_log(); # reopen syslog or log file to get per-process fd if ($enable_zmq && @zmq_sockets) { do_log(5, "child_init_hook: zmq socket: %s", join(', ',@zmq_sockets)); $zmq_obj = Amavis::ZMQ->new(@zmq_sockets); if ($zmq_obj) { sleep 1; # a crude way to avoid a "slow joiner" syndrome #*** $zmq_obj->register_proc(0,1,''); } } if ($enable_db) { # Berkeley DB handles should not be shared across process forks, # each forked child should acquire its own Berkeley DB handles $db_env = Amavis::DB->new; # get access to a bdb environment $snmp_db = Amavis::DB::SNMP->new($db_env); $snmp_db->register_proc(0,1,'') if $snmp_db; # alive and idle my $var_ref = $snmp_db->read_snmp_variables('entropy'); $inherited_entropy = $var_ref->[0] if $var_ref && @$var_ref; } # Prepare permanent SQL dataset connection objects, does not connect yet! # $sql_dataset_conn_lookups and $sql_dataset_conn_storage may be the # same dataset (one connection used), or they may be separate objects, # which will make separate connections to (same or distinct) datasets, # possibly using different SQL engine types or servers if ($extra_code_sql_lookup && @lookup_sql_dsn) { $sql_dataset_conn_lookups = Amavis::Out::SQL::Connection->new(@lookup_sql_dsn); } if ($extra_code_sql_log && @storage_sql_dsn) { if (!$sql_dataset_conn_lookups || @storage_sql_dsn != @lookup_sql_dsn || grep($storage_sql_dsn[$_] ne $lookup_sql_dsn[$_], (0..$#storage_sql_dsn)) ) { # DSN differs or no SQL lookups, storage needs its own connection $sql_dataset_conn_storage = Amavis::Out::SQL::Connection->new(@storage_sql_dsn); if ($sql_dataset_conn_lookups) { do_log(2,"storage and lookups will use separate connections to SQL"); } else { do_log(5,"only storage connections to SQL, no lookups"); } } else { # same dataset, use the same database connection object $sql_dataset_conn_storage = $sql_dataset_conn_lookups; do_log(2,"storage and lookups will use the same connection to SQL"); } } # create storage/lookup objs to hold DBI handles and 'prepared' statements $sql_storage = Amavis::Out::SQL::Log->new($sql_dataset_conn_storage) if $sql_dataset_conn_storage; $sql_lookups = Amavis::Lookup::SQL->new($sql_dataset_conn_lookups, 'sel_policy') if $sql_dataset_conn_lookups; $sql_wblist = Amavis::Lookup::SQL->new($sql_dataset_conn_lookups, 'sel_wblist') if $sql_dataset_conn_lookups; if (@storage_redis_dsn) { $redis_storage = Amavis::Redis->new(@storage_redis_dsn); } $spamcontrol_obj->init_child if $spamcontrol_obj; # Amavis::Util::dump_subs(); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-2, "TROUBLE in child_init_hook: %s", $eval_stat); die "Suicide in child_init_hook: $eval_stat\n"; }; add_entropy($inherited_entropy, Time::HiRes::gettimeofday, rand()); Amavis::Timing::go_idle('vir'); # DB::disable_profile() if $profiling; } ### user customizable Net::Server hook # sub post_accept_hook { my $self = $_[0]; local $SIG{CHLD} = 'DEFAULT'; # do_log(5, "entered post_accept_hook"); DB::enable_profile(sprintf("/tmp/nytprof-amavis-%s-%d.out", $my_pid, int rand 1000000)) if $profiling; if (!$child_init_hook_was_called) { # this can happen with base Net::Server (not PreFork nor PreForkSiple) do_log(5, "post_accept_hook: invoking child_init_hook which was skipped"); $self->child_init_hook; } $child_invocation_count++; $0 = sprintf("%s (ch%d-accept)", c('myprogram_name'), $child_invocation_count); Amavis::Util::am_id(undef); Amavis::Timing::go_busy('hi '); # establish initial time right after 'accept' Amavis::Timing::init(); snmp_counters_init(); $zmq_obj->register_proc(1,1,'A') if $zmq_obj; # enter 'accept' state $snmp_db->register_proc(1,1,'A') if $snmp_db; if ($child_invocation_count % 13 == 0) # every now and then { clear_idn_cache(); clear_query_keys_cache() } load_policy_bank(''); # start with a builtin baseline policy bank } # load policy banks according to my socket (destination), # then check for allowed access from the peer (client/source) # sub access_is_allowed($;$$$$) { my($unix_socket_path, $src_addr, $src_port, $dst_addr, $dst_port) = @_; my(@bank_names); if (defined $unix_socket_path) { push(@bank_names, $interface_policy{"SOCK"}); push(@bank_names, $interface_policy{$unix_socket_path}); } elsif (defined $dst_addr && defined $dst_port) { $dst_addr = '['.lc($dst_addr).']' if $dst_addr =~ /:[0-9a-f]*:/i; # IPv6? push(@bank_names, $interface_policy{$dst_port}); push(@bank_names, $interface_policy{"$dst_addr:$dst_port"}); } load_policy_bank($_) for @bank_names; # note that the new policy bank may have replaced the inet_acl access table if (defined $unix_socket_path) { # always permit access - unix sockets are immune to this check } elsif (defined $src_addr) { my($permit,$fullkey,$err) = lookup_ip_acl($src_addr, Amavis::Lookup::Label->new('inet_acl'), ca('inet_acl')); if ($err) { do_log(-1, "DENIED ACCESS due to INVALID PEER IP ADDRESS %s: %s", $src_addr, $err); return 0; } elsif (!$permit) { do_log(-1, "DENIED ACCESS from IP %s, policy bank '%s'%s", $src_addr, c('policy_bank_path'), !defined $fullkey ? '' : ", blocked by rule $fullkey"); return 0; } } 1; } ### user customizable Net::Server hook, load a by-interface policy bank; ### if this hook returns 1 the request is processed ### if this hook returns 0 the request is denied # sub allow_deny_hook { my $self = $_[0]; local($1,$2,$3,$4); # Perl bug: $1 and $2 come tainted from Net::Server ! local $SIG{CHLD} = 'DEFAULT'; # do_log(5, "entered allow_deny_hook"); my $prop = $self->{server}; my $sock = $prop->{client}; my $is_ux = $sock && $sock->UNIVERSAL::can('NS_proto') && $sock->NS_proto eq 'UNIX'; if ($is_ux) { my $unix_socket_path = $sock->hostpath; $unix_socket_path = 'UNKNOWN' if !defined $unix_socket_path; return access_is_allowed($unix_socket_path); } else { return access_is_allowed(undef, $prop->{peeraddr}, $prop->{peerport}, $prop->{sockaddr}, $prop->{sockport}); } } ### The heart of the program ### user customizable Net::Server hook # sub process_request { my $self = $_[0]; local $SIG{CHLD} = 'DEFAULT'; # do_log(5, "entered process_request"); local($1,$2,$3,$4); # Perl bug: $1 and $2 come tainted from Net::Server ! my $prop = $self->{server}; my $sock = $prop->{client}; ll(3) && do_log(3, "process_request: fileno sock=%s, STDIN=%s, STDOUT=%s", fileno($sock), fileno(STDIN), fileno(STDOUT)); # Net::Server 0.91 dups a socket to STDIN and STDOUT, which we do not want; # it also forgets to close STDIN & STDOUT afterwards, so session remains # open (smtp QUIT does not work), fixed in 0.92; # Net::Server 0.92 introduced option no_client_stdout, but it # breaks Net::Server::get_client_info by setting it, so we can't use it; # On NetBSD closing fh STDIN (on fd0) somehow leaves fd0 still assigned to # a socket (Net::Server 0.91) and cannot be closed even by a POSIX::close # Let's just leave STDIN and STDOUT as they are, which works for versions # of Net::Server 0.90 and older, is wasteful with 0.91 and 0.92, and is # fine with 0.93. if (ref($sock) !~ /^(?:IO::Socket::SSL|Net::Server::Proto::SSL)\z/) { # binmode not implemented in IO::Socket::SSL and returns false binmode($sock) or die "Can't set socket $sock to binmode: $!"; } local $SIG{ALRM} = sub { die "timed out\n" }; # do not modify the sig text! my $eval_stat; eval { # if ($] < 5.006) # Perl older than 5.6.0 did not set FD_CLOEXEC on sockets # { cloexec($_,1,$_) for @{$prop->{sock}} } switch_to_my_time('new request'); # timer init if ($extra_code_ldap && !$ldap_lookups) { # make LDAP lookup object $ldap_connection = Amavis::LDAP::Connection->new($default_ldap); $ldap_lookups = Amavis::Lookup::LDAP->new($default_ldap,$ldap_connection) if $ldap_connection; } if ($ldap_lookups && $lookup_maps_imply_sql_and_ldap && !$implicit_maps_inserted) { # make LDAP field lookup objects with incorporated field names # fieldtype: B=boolean, N=numeric, S=string, L=list # B-, N-, S-, L- returns undef if field does not exist # B0: boolean, nonexistent field treated as false, # B1: boolean, nonexistent field treated as true my $lf = sub{Amavis::Lookup::LDAPattr->new($ldap_lookups,@_)}; unshift(@Amavis::Conf::local_domains_maps, $lf->('amavisLocal', 'B1')); unshift(@Amavis::Conf::virus_lovers_maps, $lf->('amavisVirusLover', 'B-')); unshift(@Amavis::Conf::spam_lovers_maps, $lf->('amavisSpamLover', 'B-')); unshift(@Amavis::Conf::unchecked_lovers_maps, $lf->('amavisUncheckedLover', 'B-')); unshift(@Amavis::Conf::banned_files_lovers_maps, $lf->('amavisBannedFilesLover', 'B-')); unshift(@Amavis::Conf::bad_header_lovers_maps, $lf->('amavisBadHeaderLover', 'B-')); unshift(@Amavis::Conf::bypass_virus_checks_maps, $lf->('amavisBypassVirusChecks', 'B-')); unshift(@Amavis::Conf::bypass_spam_checks_maps, $lf->('amavisBypassSpamChecks', 'B-')); unshift(@Amavis::Conf::bypass_banned_checks_maps,$lf->('amavisBypassBannedChecks', 'B-')); unshift(@Amavis::Conf::bypass_header_checks_maps,$lf->('amavisBypassHeaderChecks', 'B-')); unshift(@Amavis::Conf::spam_tag_level_maps, $lf->('amavisSpamTagLevel', 'N-')); unshift(@Amavis::Conf::spam_tag2_level_maps, $lf->('amavisSpamTag2Level', 'N-')); unshift(@Amavis::Conf::spam_tag3_level_maps, $lf->('amavisSpamTag3Level', 'N-')); unshift(@Amavis::Conf::spam_kill_level_maps, $lf->('amavisSpamKillLevel', 'N-')); unshift(@Amavis::Conf::spam_dsn_cutoff_level_maps,$lf->('amavisSpamDsnCutoffLevel','N-')); unshift(@Amavis::Conf::spam_quarantine_cutoff_level_maps,$lf->('amavisSpamQuarantineCutoffLevel','N-')); unshift(@Amavis::Conf::spam_subject_tag_maps, $lf->('amavisSpamSubjectTag', 'S-')); unshift(@Amavis::Conf::spam_subject_tag2_maps, $lf->('amavisSpamSubjectTag2', 'S-')); unshift(@Amavis::Conf::spam_subject_tag3_maps, $lf->('amavisSpamSubjectTag3', 'S-')); unshift(@Amavis::Conf::virus_quarantine_to_maps, $lf->('amavisVirusQuarantineTo', 'S-')); unshift(@Amavis::Conf::spam_quarantine_to_maps, $lf->('amavisSpamQuarantineTo', 'S-')); unshift(@Amavis::Conf::banned_quarantine_to_maps, $lf->('amavisBannedQuarantineTo','S-')); unshift(@Amavis::Conf::unchecked_quarantine_to_maps, $lf->('amavisUncheckedQuarantineTo','S-')); unshift(@Amavis::Conf::bad_header_quarantine_to_maps, $lf->('amavisBadHeaderQuarantineTo', 'S-')); unshift(@Amavis::Conf::clean_quarantine_to_maps, $lf->('amavisCleanQuarantineTo', 'S-')); unshift(@Amavis::Conf::archive_quarantine_to_maps, $lf->('amavisArchiveQuarantineTo', 'S-')); unshift(@Amavis::Conf::message_size_limit_maps, $lf->('amavisMessageSizeLimit', 'N-')); unshift(@Amavis::Conf::addr_extension_virus_maps, $lf->('amavisAddrExtensionVirus', 'S-')); unshift(@Amavis::Conf::addr_extension_spam_maps, $lf->('amavisAddrExtensionSpam', 'S-')); unshift(@Amavis::Conf::addr_extension_banned_maps, $lf->('amavisAddrExtensionBanned','S-')); unshift(@Amavis::Conf::addr_extension_bad_header_maps, $lf->('amavisAddrExtensionBadHeader','S-')); unshift(@Amavis::Conf::warnvirusrecip_maps, $lf->('amavisWarnVirusRecip', 'B-')); unshift(@Amavis::Conf::warnbannedrecip_maps, $lf->('amavisWarnBannedRecip', 'B-')); unshift(@Amavis::Conf::warnbadhrecip_maps, $lf->('amavisWarnBadHeaderRecip', 'B-')); unshift(@Amavis::Conf::newvirus_admin_maps, $lf->('amavisNewVirusAdmin', 'S-')); unshift(@Amavis::Conf::virus_admin_maps, $lf->('amavisVirusAdmin', 'S-')); unshift(@Amavis::Conf::spam_admin_maps, $lf->('amavisSpamAdmin', 'S-')); unshift(@Amavis::Conf::banned_admin_maps, $lf->('amavisBannedAdmin', 'S-')); unshift(@Amavis::Conf::bad_header_admin_maps, $lf->('amavisBadHeaderAdmin', 'S-')); unshift(@Amavis::Conf::banned_filename_maps, $lf->('amavisBannedRuleNames', 'S-')); unshift(@Amavis::Conf::disclaimer_options_bysender_maps, $lf->('amavisDisclaimerOptions', 'S-')); unshift(@Amavis::Conf::forward_method_maps, $lf->('amavisForwardMethod', 'S-')); unshift(@Amavis::Conf::sa_userconf_maps, $lf->('amavisSaUserConf', 'S-')); unshift(@Amavis::Conf::sa_username_maps, $lf->('amavisSaUserName', 'S-')); section_time('ldap-prepare'); } if ($sql_lookups && $lookup_maps_imply_sql_and_ldap && !$implicit_maps_inserted) { # make SQL field lookup objects with incorporated field names # fieldtype: B=boolean, N=numeric, S=string, # B-, N-, S- returns undef if field does not exist # B0: boolean, nonexistent field treated as false, # B1: boolean, nonexistent field treated as true my $nf = sub{Amavis::Lookup::SQLfield->new($sql_lookups,@_)}; # shorthand $user_id_sql = $nf->('id', 'S-'); $user_policy_id_sql = $nf->('policy_id', 'S-'); unshift(@Amavis::Conf::local_domains_maps, $nf->('local', 'B1')); unshift(@Amavis::Conf::virus_lovers_maps, $nf->('virus_lover', 'B-')); unshift(@Amavis::Conf::spam_lovers_maps, $nf->('spam_lover', 'B-')); unshift(@Amavis::Conf::unchecked_lovers_maps, $nf->('unchecked_lover', 'B-')); unshift(@Amavis::Conf::banned_files_lovers_maps, $nf->('banned_files_lover', 'B-')); unshift(@Amavis::Conf::bad_header_lovers_maps, $nf->('bad_header_lover', 'B-')); unshift(@Amavis::Conf::bypass_virus_checks_maps, $nf->('bypass_virus_checks', 'B-')); unshift(@Amavis::Conf::bypass_spam_checks_maps, $nf->('bypass_spam_checks', 'B-')); unshift(@Amavis::Conf::bypass_banned_checks_maps, $nf->('bypass_banned_checks', 'B-')); unshift(@Amavis::Conf::bypass_header_checks_maps, $nf->('bypass_header_checks', 'B-')); unshift(@Amavis::Conf::spam_tag_level_maps, $nf->('spam_tag_level', 'N-')); unshift(@Amavis::Conf::spam_tag2_level_maps, $nf->('spam_tag2_level', 'N-')); unshift(@Amavis::Conf::spam_tag3_level_maps, $nf->('spam_tag3_level', 'N-')); unshift(@Amavis::Conf::spam_kill_level_maps, $nf->('spam_kill_level', 'N-')); unshift(@Amavis::Conf::spam_dsn_cutoff_level_maps,$nf->('spam_dsn_cutoff_level','N-')); unshift(@Amavis::Conf::spam_quarantine_cutoff_level_maps,$nf->('spam_quarantine_cutoff_level','N-')); unshift(@Amavis::Conf::spam_subject_tag_maps, $nf->('spam_subject_tag', 'S-')); unshift(@Amavis::Conf::spam_subject_tag2_maps, $nf->('spam_subject_tag2', 'S-')); unshift(@Amavis::Conf::spam_subject_tag3_maps, $nf->('spam_subject_tag3', 'S-')); unshift(@Amavis::Conf::virus_quarantine_to_maps, $nf->('virus_quarantine_to', 'S-')); unshift(@Amavis::Conf::spam_quarantine_to_maps, $nf->('spam_quarantine_to', 'S-')); unshift(@Amavis::Conf::banned_quarantine_to_maps, $nf->('banned_quarantine_to', 'S-')); unshift(@Amavis::Conf::unchecked_quarantine_to_maps, $nf->('unchecked_quarantine_to', 'S-')); unshift(@Amavis::Conf::bad_header_quarantine_to_maps, $nf->('bad_header_quarantine_to','S-')); unshift(@Amavis::Conf::clean_quarantine_to_maps, $nf->('clean_quarantine_to', 'S-')); unshift(@Amavis::Conf::archive_quarantine_to_maps,$nf->('archive_quarantine_to','S-')); unshift(@Amavis::Conf::message_size_limit_maps, $nf->('message_size_limit', 'N-')); unshift(@Amavis::Conf::addr_extension_virus_maps, $nf->('addr_extension_virus', 'S-')); unshift(@Amavis::Conf::addr_extension_spam_maps, $nf->('addr_extension_spam', 'S-')); unshift(@Amavis::Conf::addr_extension_banned_maps,$nf->('addr_extension_banned','S-')); unshift(@Amavis::Conf::addr_extension_bad_header_maps,$nf->('addr_extension_bad_header','S-')); unshift(@Amavis::Conf::warnvirusrecip_maps, $nf->('warnvirusrecip', 'B-')); unshift(@Amavis::Conf::warnbannedrecip_maps, $nf->('warnbannedrecip', 'B-')); unshift(@Amavis::Conf::warnbadhrecip_maps, $nf->('warnbadhrecip', 'B-')); unshift(@Amavis::Conf::newvirus_admin_maps, $nf->('newvirus_admin', 'S-')); unshift(@Amavis::Conf::virus_admin_maps, $nf->('virus_admin', 'S-')); unshift(@Amavis::Conf::spam_admin_maps, $nf->('spam_admin', 'S-')); unshift(@Amavis::Conf::banned_admin_maps, $nf->('banned_admin', 'S-')); unshift(@Amavis::Conf::bad_header_admin_maps, $nf->('bad_header_admin', 'S-')); unshift(@Amavis::Conf::banned_filename_maps, $nf->('banned_rulenames', 'S-')); unshift(@Amavis::Conf::disclaimer_options_bysender_maps, $nf->('disclaimer_options', 'S-')); unshift(@Amavis::Conf::forward_method_maps, $nf->('forward_method', 'S-')); unshift(@Amavis::Conf::sa_userconf_maps, $nf->('sa_userconf', 'S-')); unshift(@Amavis::Conf::sa_username_maps, $nf->('sa_username', 'S-')); section_time('sql-prepare'); } $implicit_maps_inserted = 1; if (!$maps_have_been_labeled) { Amavis::Conf::label_default_maps(); $maps_have_been_labeled = 1 } my $ns_proto = $sock->NS_proto; # Net::Server::Proto submodules my $conn = Amavis::In::Connection->new; # keeps info about connection $conn->socket_proto($ns_proto); my $suggested_protocol = c('protocol'); # suggested by the policy bank $suggested_protocol = '' if !defined $suggested_protocol; do_log(5,"process_request: suggested_protocol=\"%s\" on a %s socket", $suggested_protocol, $ns_proto); $zmq_obj->register_proc(2,0,'b') if $zmq_obj; # begin protocol # $snmp_db->register_proc(2,0,'b') if $snmp_db; if ($ns_proto eq 'UNIX') { my $path = $sock->hostpath; $conn->socket_path($path); # how to test: $ socat stdio unix-connect:/var/amavis/amavisd.sock,crnl } else { # TCP, UDP, UNIXDGRAM, SSLEAY, SSL (Net::Server::Proto modules) my $sock_addr = $prop->{sockaddr}; my $peer_addr = $prop->{peeraddr}; if ($sock_addr eq $peer_addr) { # common, small optimization $peer_addr = $sock_addr = normalize_ip_addr($sock_addr); } else { $sock_addr = normalize_ip_addr($sock_addr); $peer_addr = normalize_ip_addr($peer_addr); } # untaint IP addresses and port numbers, just in case $conn->socket_port(untaint($prop->{sockport})); $conn->client_port(untaint($prop->{peerport})); $conn->socket_ip(untaint($sock_addr)); $conn->client_ip(untaint($peer_addr)); } if ($suggested_protocol eq 'SMTP' || $suggested_protocol eq 'LMTP' || ($suggested_protocol eq '' && $ns_proto =~ /^(?:TCP|SSLEAY|SSL)\z/)) { require Amavis::In::SMTP; $smtp_in_obj = Amavis::In::SMTP->new if !$smtp_in_obj; $smtp_in_obj->process_smtp_request( $sock, ($suggested_protocol eq 'LMTP'?1:0), $conn, \&check_mail); } elsif ($suggested_protocol eq 'AM.PDP') { # amavis policy delegation protocol (e.g. new milter or amavisd-release) require Amavis::In::AMPDP; $ampdp_in_obj = Amavis::In::AMPDP->new if !$ampdp_in_obj; $ampdp_in_obj->process_policy_request($sock, $conn, \&check_mail, 0); } elsif ($suggested_protocol eq 'COURIER') { die "unavailable support for protocol: $suggested_protocol"; } elsif ($suggested_protocol eq 'QMQPqq') { die "unavailable support for protocol: $suggested_protocol"; } elsif ($suggested_protocol eq 'TCP-LOOKUP') { #postfix maps, experimental process_tcp_lookup_request($sock, $conn); do_log(2, "%s", Amavis::Timing::report()); # report elapsed times # } elsif ($suggested_protocol eq 'AM.CL') { # # defaults to old amavis helper program protocol # $ampdp_in_obj = Amavis::In::AMPDP->new if !$ampdp_in_obj; # $ampdp_in_obj->process_policy_request($sock, $conn, \&check_mail, 1); } elsif ($suggested_protocol eq '') { die "protocol not specified, $ns_proto"; } else { die "unsupported protocol: $suggested_protocol, $ns_proto"; } require Amavis::Out::SMTP::Session; Amavis::Out::SMTP::Session::rundown_stale_sessions(0); 1; } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" }; alarm(0); # stop the timer if (defined $eval_stat) { chomp $eval_stat; my $timed_out = $eval_stat =~ /^timed out\b/; if ($timed_out) { my $msg = "Requesting process rundown, task exceeded allowed time"; $msg .= " during waiting for input from client" if waiting_for_client(); do_log(-1, $msg); } else { do_log(-2, "TROUBLE in process_request: %s", $eval_stat); $smtp_in_obj->preserve_evidence(1) if $smtp_in_obj; do_log(-1, "Requesting process rundown after fatal error"); } undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj; $self->done(1); } elsif (defined $max_requests && $max_requests > 0 && $child_task_count >= $max_requests) { # in case of multiple-transaction protocols (e.g. SMTP, LMTP) # we do not like to keep running indefinitely at the mercy of MTA do_log(2, "Requesting process rundown after %d tasks (and %s sessions)", $child_task_count, $child_invocation_count); undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj; $self->done(1); } elsif ($extra_code_antivirus && Amavis::AV::sophos_savi_stale() ) { do_log(0, "Requesting process rundown due to stale Sophos virus data"); undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj; $self->done(1); } my(@modules_extra) = grep(!exists $modules_basic{$_}, keys %INC); # do_log(2, "modules loaded: %s", join(", ", sort keys %modules_basic)); if (@modules_extra) { do_log(1, "extra modules loaded: %s", join(", ", sort @modules_extra)); %modules_basic = %INC; } ll(5) && do_log(5, 'exiting process_request'); } sub child_goes_idle($) { my $where = $_[0]; do_log(5, 'child_goes_idle (%s)', $where); my(@disconnected_what); $sql_dataset_conn_storage && eval { $sql_dataset_conn_storage->disconnect_from_sql && push(@disconnected_what,'SQL-storage'); }; $sql_dataset_conn_lookups && eval { # $sql_dataset_conn_lookups possibly the same as $sql_dataset_conn_storage, # attempting to disconnect twice does no harm $sql_dataset_conn_lookups->disconnect_from_sql && push(@disconnected_what,'SQL-lookup'); }; $ldap_connection && eval { $ldap_connection->disconnect_from_ldap && push(@disconnected_what,'LDAP'); }; do_log(5, 'child_goes_idle: disconnected %s (%s)', !@disconnected_what ? 'none' : join(', ',@disconnected_what), $where); } ### After processing of a request, but before client connection has been closed ### user customizable Net::Server hook # sub post_process_request_hook { my $self = $_[0]; my $prop = $self->{server}; my $sock = $prop->{client}; local $SIG{CHLD} = 'DEFAULT'; # do_log(5, "entered post_process_request_hook"); alarm(0); # stop the timer child_goes_idle('post_process_request') if !$database_sessions_persistent; debug_oneshot(0); $0 = sprintf("%s (ch%d-avail)", c('myprogram_name'), $child_invocation_count); $zmq_obj->register_proc(1,0,'') if $zmq_obj; # alive and idle again $snmp_db->register_proc(1,0,'') if $snmp_db; Amavis::Timing::go_idle('bye'); if (ll(3)) { my $load_report = Amavis::Timing::report_load(); do_log(3,$load_report) if defined $load_report; } dump_captured_log(1, c('enable_log_capture_dump')); # workaround: Net::Server 0.91 forgets to disconnect session if (Net::Server->VERSION == 0.91) { close STDIN; close STDOUT } # DB::disable_profile() if $profiling; DB::finish_profile() if $profiling; } ### Child is about to be terminated ### user customizable Net::Server hook # sub child_finish_hook { my $self = $_[0]; local $SIG{CHLD} = 'DEFAULT'; # do_log_safe(5, "entered child_finish_hook"); # for my $m (sort map { s/\.pm\z//; s[/][::]g; $_ } grep(/\.pm\z/, keys %INC)){ # do_log(0, "Module %-19s %s", $m, $m->VERSION || '?') # if grep($m=~/^$_/, qw(Mail::ClamAV Mail::SpamAssassin Razor2 Net::DNS)); # } child_goes_idle('child finishing'); $spamcontrol_obj->rundown_child if $spamcontrol_obj; $0 = sprintf("%s (ch%d-finish)", c('myprogram_name'), $child_invocation_count); do_log_safe(5,"child_finish_hook: invoking DESTROY methods"); undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj; undef $sql_storage; undef $sql_wblist; undef $sql_lookups; undef $sql_dataset_conn_lookups; undef $sql_dataset_conn_storage; undef $ldap_lookups; undef $ldap_connection; undef $redis_storage; # unregister our process if ($zmq_obj) { eval { $zmq_obj->register_proc(0,0,undef); 1; } or do_log_safe(-1, "child_finish_hook: ZMQ unregistering failed: %s",$@); } if ($snmp_db) { eval { $snmp_db->register_proc(0,0,undef); 1; } or do_log_safe(-1, "child_finish_hook: DB unregistering failed: %s",$@); } undef $snmp_db; undef $db_env; undef $zmq_obj; log_capture_enabled(0); } ### user customizable Net::Server hook, ### hook occurs in the main process before the server begins shutting down # sub pre_server_close_hook { sd_notify(0, "STOPPING=1", "STATUS=Server rundown, notifying child processes."); } ### user customizable Net::Server hook, ### hook occurs in the main process after child proceses have been shut down # sub post_child_cleanup_hook { sd_notify(0, "STATUS=Child processes have been stopped."); } ### user customizable Net::Server hook, ### hook occurs in the main process if a server has received a HUP signal. ### It occurs just before restarting the server via exec. # sub restart_close_hook { sd_notify(0, "RELOADING=1", "STATUS=Reloading server, about to re-exec the program."); } ### user customizable Net::Server hook, ### hook occurs in the main process if a server has been restarted via the HUP ### signal and re-exec'd. It occurs just before reopening to the filenos of ### the sockets that were already opened. # sub restart_open_hook { sd_notify(0, "STATUS=Warm restart, re-binding sockets."); } sub END { # runs before exiting the module local($@,$!); # do_log_safe(5,"at the END handler: invoking DESTROY methods"); undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj; undef $sql_storage; undef $sql_wblist; undef $sql_lookups; undef $sql_dataset_conn_lookups; undef $sql_dataset_conn_storage; undef $ldap_lookups; undef $ldap_connection; undef $redis_storage; # unregister our process if ($zmq_obj) { eval { $zmq_obj->register_proc(0,0,undef); 1; } or do_log_safe(-1, "Amavis::END: ZMQ unregistering failed: %s", $@); } if ($snmp_db) { eval { $snmp_db->register_proc(0,0,undef); 1; } or do_log_safe(-1, "Amavis::END: DB unregistering failed: %s", $@); } undef $snmp_db; undef $db_env; undef $zmq_obj; log_capture_enabled(0); } # implements Postfix TCP lookup server, see tcp_table(5) man page; experimental # sub process_tcp_lookup_request($$) { my($sock, $conn) = @_; local($/) = "\012"; # set line terminator to LF (regardless of platform) my $req_cnt; my $ln; for ($! = 0; defined($ln=$sock->getline); $! = 0) { $req_cnt++; my $level = 0; local($1); my($resp_code, $resp_msg) = (400, 'INTERNAL ERROR'); if ($ln =~ /^get (.*?)\015?\012\z/si) { my $key = proto_decode($1); my $sl = lookup2(0,$key, ca('spam_lovers_maps')); $resp_code = 200; $level = 2; $resp_msg = $sl ? "OK Recipient <$key> IS spam lover" : "DUNNO Recipient <$key> is NOT spam lover"; } elsif ($ln =~ /^put ([^ ]*) (.*?)\015?\012\z/si) { $resp_code = 500; $resp_msg = 'request not implemented: ' . $ln; } else { $resp_code = 500; $resp_msg = 'illegal request: ' . $ln; } do_log($level, "tcp_lookup(%s): %s %s", $req_cnt,$resp_code,$resp_msg); $sock->printf("%03d %s\012", $resp_code, tcp_lookup_encode($resp_msg)) or die "Can't write to tcp_lookup socket: $!"; } defined $ln || $! == 0 or die "Error reading from socket: $!"; do_log(0, "tcp_lookup: RUNDOWN after %d requests", $req_cnt); } sub tcp_lookup_encode($) { my $str = $_[0]; local($1); $str =~ s/([^\041-\044\046-\176])/sprintf("%%%02x",ord($1))/gse; $str; } sub check_mail_begin_task() { # The check_mail_begin_task (and check_mail) may be called several times # per child lifetime and/or per-SMTP session. The variable $child_task_count # is mainly used by AV-scanner interfaces, e.g. to initialize when invoked # for the first time during child process lifetime $child_task_count++; do_log(4, "check_mail_begin_task: task_count=%d", $child_task_count); # comment out to retain SQL/LDAP cache entries for the whole child lifetime: $sql_wblist->clear_cache if $sql_wblist; $sql_lookups->clear_cache if $sql_lookups; $ldap_lookups->clear_cache if $ldap_lookups; # reset certain global variables for each task undef $av_output; @detecting_scanners = (); @av_scanners_results = (); @virusname = (); @bad_headers = (); $banned_filename_any = $banned_filename_all = 0; undef $MSGINFO; undef $report_ref; } # create a mail_id unique to a database and save preliminary info to SQL; # if SQL is not enabled, just call a plain generate_mail_id() once # sub generate_unique_mail_id($) { my $msginfo = $_[0]; my($mail_id,$secret_id); for (my $attempt = 5; ;) { # sanity limit on retries ($mail_id,$secret_id) = generate_mail_id(); $msginfo->secret_id($secret_id); $secret_id = 'X' x length($secret_id); # can't hurt to wipe out $msginfo->mail_id($mail_id); # assign a long-term unique id to the msg my $is_unique = 1; # don't bother to save info on incoming messages - saves Redis storage # while still offering necessary data for a penpals function if ($redis_storage && $msginfo->originating) { # attempt to save a message placeholder to Redis, ensuring it is unique eval { $redis_storage->save_info_preliminary($msginfo) or ($is_unique=0); 1; } or do { chomp $@; do_log(-1, 'storing preliminary info to redis failed: %s', $@); }; } if ($is_unique && $sql_storage) { # attempt to save a message placeholder to SQL, ensuring it is unique $sql_storage->save_info_preliminary($msginfo) or ($is_unique=0); } last if $is_unique; if (--$attempt <= 0) { do_log(-2,'too many retries on storing preliminary, info not saved'); last; } else { snmp_count('GenMailIdRetries'); do_log(2,'retrying storing preliminary, %d attempts remain', $attempt); sleep(int(1+rand(3))); add_entropy(Time::HiRes::gettimeofday, $attempt); } } $mail_id; } sub extract_info_from_received_trace($) { my($msginfo) = @_; my(@trace); for (my $j=0; ; $j++) { # walk through Received header fields, top-down my $r = $msginfo->get_header_field_body('received',$j); last if !defined $r; my $fields_ref = parse_received($r); my $ip = fish_out_ip_from_received($r,$fields_ref); # possibly undef $ip = normalize_ip_addr($ip) if defined $ip; push(@trace, { ip => $ip, %$fields_ref }); } \@trace; } # Collects some information derived from the envelope and the message, # do some common lookups, storing the information into a $msginfo object # to make commonly used information quickly and readily available to the # rest of the program, e.g. avoiding a need for repeated lookups or parsing # of the same attribute # sub collect_some_info($) { my $msginfo = $_[0]; my $partition_tag = c('partition_tag'); $partition_tag = &$partition_tag($msginfo) if ref $partition_tag eq 'CODE'; $partition_tag = 0 if !defined $partition_tag; $msginfo->partition_tag($partition_tag); my $sender = $msginfo->sender; $msginfo->sender_source($sender); # obtain RFC 5322 From and Sender from the mail header section, parsed/clean my $rfc2822_sender = $msginfo->get_header_field_body('sender'); my $rfc2822_from_field = $msginfo->get_header_field_body('from'); my(@rfc2822_from); # RFC 5322 (ex RFC 2822) allows multiple author's addr local($1); if (defined $rfc2822_sender) { my(@sender_parsed) = map(unquote_rfc2821_local($_), parse_address_list($rfc2822_sender)); $rfc2822_sender = !@sender_parsed ? '' : $sender_parsed[0]; # none or one $msginfo->rfc2822_sender($rfc2822_sender); } if (defined $rfc2822_from_field) { @rfc2822_from = map(unquote_rfc2821_local($_), parse_address_list($rfc2822_from_field)); # rfc2822_from is a ref to a list when there are multiple author addresses! $msginfo->rfc2822_from(!@rfc2822_from ? undef : @rfc2822_from < 2 ? $rfc2822_from[0] : \@rfc2822_from); } my $rfc2822_to = $msginfo->get_header_field_body('to'); if (defined $rfc2822_to) { my(@to_parsed) = map(unquote_rfc2821_local($_), parse_address_list($rfc2822_to)); $msginfo->rfc2822_to(@to_parsed<2 ? $to_parsed[0] : \@to_parsed); } my $rfc2822_cc = $msginfo->get_header_field_body('cc'); if (defined $rfc2822_cc) { my(@cc_parsed) = map(unquote_rfc2821_local($_), parse_address_list($rfc2822_cc)); $msginfo->rfc2822_cc(@cc_parsed<2 ? $cc_parsed[0] : \@cc_parsed); } my(@rfc2822_resent_from, @rfc2822_resent_sender); if (defined $msginfo->get_header_field2('resent-from') || defined $msginfo->get_header_field2('resent-sender')) { # triage # Each Resent block should have exactly one Resent-From, and none or one # Resent-Sender address. A HACK: undef in each list is used to separate # addresses obtained from different resent blocks, for the benefit of # those interested in traversing them block by block (e.g. when choosing # a DKIM signing key). The RFC 5322 section 3.6.6 says: All of the resent # fields corresponding to a particular resending of the message SHOULD be # grouped together. my(@r_from, @r_sender); local($1); for (my $j = 0; ; $j++) { # traverse header section by fields, top-down my($f_i,$f) = $msginfo->get_header_field2(undef,$j); if ( @r_from && ( !defined($f) || # end of a header section $f !~ /^Resent-/si || # presumably end of a resent block $f =~ /^Resent-From\s*:/si || # another Resent-From encountered $f =~ /^Resent-Sender\s*:/si && @r_sender # another Resent-Sender ) ) { # end of a current resent block # a hack: undef in a list is used to separate addresses # from different resent blocks push(@rfc2822_resent_from, undef, @r_from); @r_from = (); push(@rfc2822_resent_sender, undef, @r_sender); @r_sender = (); } last if !defined $f; if ($f =~ /^Resent-From\s*:(.*)\z/si) { push(@r_from, map(unquote_rfc2821_local($_), parse_address_list($1))); } elsif ($f =~ /^Resent-Sender\s*:(.*)\z/si) { # multiple Resent-Sender in a block are illegal, store them all anyway push(@r_sender,map(unquote_rfc2821_local($_), parse_address_list($1))); } } if (@r_from || @r_sender) { # any leftovers not forming a resent block? push(@rfc2822_resent_from, undef, @r_from); push(@rfc2822_resent_sender, undef, @r_sender); } shift(@rfc2822_resent_from) if @rfc2822_resent_from; # remove undef shift(@rfc2822_resent_sender) if @rfc2822_resent_sender; # remove undef # rfc2822_resent_from and rfc2822_resent_sender are listrefs (or undef) $msginfo->rfc2822_resent_from(\@rfc2822_resent_from) if @rfc2822_resent_from; $msginfo->rfc2822_resent_sender(\@rfc2822_resent_sender) if @rfc2822_resent_sender; } my $refs_in_reply_to = $msginfo->get_header_field_body('in-reply-to'); my $refs_references = $msginfo->get_header_field_body('references'); my(@refs) = grep(defined $_, $refs_in_reply_to, $refs_references); @refs = parse_message_id(join(' ',@refs)) if @refs; do_log(4, 'references: %s', join(', ',@refs)) if @refs; $msginfo->references(\@refs); my $mail_size = $msginfo->msg_size; # use corrected ESMTP size if avail. if (!defined($mail_size) || $mail_size <= 0) { # not yet known? $mail_size = $msginfo->orig_header_size + $msginfo->orig_body_size; $msginfo->msg_size($mail_size); # store back do_log(4,"message size unknown, size set to %d", $mail_size); } my $trace_ref = extract_info_from_received_trace($msginfo); my $cl_ip = $msginfo->client_addr; if (defined $cl_ip) { my $last_hop = $trace_ref->[0]; my $last_hop_ip = $last_hop && $last_hop->{ip}; if (!defined $last_hop_ip || lc($cl_ip) ne lc($last_hop_ip)) { # milter? do_log(5,"prepending client's IP address to trace: %s", $cl_ip); unshift(@$trace_ref, { ip => $msginfo->client_addr, port => $msginfo->client_port, with => $msginfo->client_proto, }); } elsif ($last_hop->{ip} && !$last_hop->{port}) { # add a missing information, not available in a Received trace $last_hop->{port} = $msginfo->client_port; } } { # add the last hop (ours, currently underway) to the trace my $conn = $msginfo->conn_obj; # the connection between MTA and amavisd my $recips = $msginfo->recips; my $myhelo = c('localhost_name'); # my EHLO/HELO/LHLO name, UTF-8 octets $myhelo = 'localhost' if $myhelo eq ''; $myhelo = $msginfo->smtputf8 ? idn_to_utf8($myhelo) : idn_to_ascii($myhelo); unshift(@$trace_ref, { ip => $conn->client_ip, port => $conn->client_port, from => $conn->smtp_helo, by => $myhelo, with => $conn->appl_proto, # id => $msginfo->mail_id, # not yet known $recips && @$recips==1 ? (for => qquote_rfc2821_local(@$recips)) : (), # ";" => rfc2822_timestamp($msginfo->rx_time), # not needed }); } my(@ip_trace_public); for my $hop (@$trace_ref) { next if !$hop; my $ip = $hop->{ip}; if ($ip) { my($public,$key,$err) = lookup_ip_acl($ip, @public_networks_maps); if ($public && !$err) { $hop->{public} = 1; push(@ip_trace_public,$ip) } } my $with = $hop->{with}; $hop->{with} = $with if defined $with && $with =~ tr/A-Za-z0-9.+-/_/c; } $msginfo->trace($trace_ref); $msginfo->ip_addr_trace_public(\@ip_trace_public); # ll(5) && do_log(5, "trace: %s", Amavis::JSON::encode($trace_ref)); ll(3) && do_log(3, "trace: %s", join(' < ', map( (!$_->{with} ? '' : $_->{with}.'://') . (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip} : '['.$_->{ip}.']:'.$_->{port}), @$trace_ref ) )); # check for mailing lists, bulk mail and auto-responses my $is_mlist; # mail from a mailing list my $is_auto; # bounce, auto-response, challenge-response, ... my $is_bulk; # bulk mail or $is_mlist or $is_auto if (defined $msginfo->get_header_field2('list-id')) { # RFC 2919 $is_mlist = $msginfo->get_header_field_body('list-id'); } elsif (defined $msginfo->get_header_field2('list-post')) { $is_mlist = $msginfo->get_header_field_body('list-post'); } elsif (defined $msginfo->get_header_field2('list-unsubscribe')) { $is_mlist = $msginfo->get_header_field_body('list-unsubscribe'); } elsif (defined $msginfo->get_header_field2('mailing-list')) { $is_mlist = $msginfo->get_header_field_body('mailing-list'); # non-std. } elsif ($sender =~ /^ (?: [^\@]+ -(?:request|bounces|owner|admin) | owner- [^\@]+ ) (?: \@ | \z )/xsi) { $is_mlist = 'sender=' . $sender; } elsif ($rfc2822_from[0] =~ /^ (?: [^\@]+ -(?:request|bounces|owner) | owner- [^\@]+ ) (?: \@ | \z )/xsi) { $is_mlist = 'From:' . $rfc2822_from[0]; } if (defined $is_mlist) { # sanitize a bit local($1); $is_mlist = $1 if $is_mlist =~ / < (.*) > [^>]* \z/xs; $is_mlist =~ s/\s+/ /g; $is_mlist =~ s/^ //; $is_mlist =~ s/ \z//; $is_mlist =~ s/^mailto://i; $is_mlist = 'ml:' . $is_mlist; } if (defined $msginfo->get_header_field2('precedence')) { my $prec = $msginfo->get_header_field_body('precedence'); $prec =~ s/^[ \t]+//; local($1); $is_mlist = $1 if !defined($is_mlist) && $prec =~ /^(list)/si; $is_auto = $1 if $prec =~ /^(auto.?reply)\b/si; $is_bulk = $1 if $prec =~ /^(bulk|junk)\b/si; } if (defined $is_auto) { # already set } elsif (defined $msginfo->get_header_field2('auto-submitted')) { my $auto = $msginfo->get_header_field_body('auto-submitted'); $auto =~ s/ \( [^)]* \) //gx; $auto =~ s/^[ \t]+//; $auto =~ s/[ \t]+\z//; $is_auto = 'Auto-Submitted:' . $auto if lc($auto) ne 'no'; } elsif ($sender eq '') { $is_auto = 'sender=<>'; } elsif ($sender =~ /^ (?: mailer-daemon|double-bounce|mailer|autoreply ) (?: \@ | \z )/xsi) { # 'postmaster' is also common, but a bit risky $is_auto = 'sender=' . $sender; } elsif ($rfc2822_from[0] =~ # just checks the first author, good enough /^ (?: mailer-daemon|double-bounce|mailer|autoreply ) (?: \@ | \z )/xsi) { $is_auto = 'From:' . $rfc2822_from[0]; } if (defined $is_mlist) { $is_bulk = $is_mlist; } elsif (defined $is_auto) { $is_bulk = $is_auto; } elsif (defined $is_bulk) { # already set } elsif ($rfc2822_from[0] =~ # just checks the first author, good enough /^ (?: [^\@]+ -relay | postmaster | uucp ) (?: \@ | \z )/xsi) { $is_bulk = 'From:' . $rfc2822_from[0]; } $is_mlist = 1 if defined $is_mlist && !$is_mlist; # make sure it is true $is_auto = 1 if defined $is_auto && !$is_auto; # make sure it is true $is_bulk = 1 if defined $is_bulk && !$is_bulk; # make sure it is true $msginfo->is_mlist($is_mlist) if $is_mlist; $msginfo->is_auto($is_auto) if $is_auto; $msginfo->is_bulk($is_bulk) if $is_bulk; # now that we have a parsed From, check if we have a valid # author domain signature and do other DKIM pre-processing if (c('enable_dkim_verification')) { Amavis::DKIM::collect_some_dkim_info($msginfo); } if ($sender ne '') { # provide some initial default for sender_credible my(@cred) = ( $msginfo->originating ? 'orig' : (), $msginfo->dkim_envsender_sig ? 'dkim' : () ); $msginfo->sender_credible(join(',',@cred)) if @cred; } } # Checks the message stored on a file. File must already # be open on file handle $msginfo->mail_text; it need not be positioned # properly, check_mail must not close the file handle. # Alternatively, the $msginfo->mail_text can be a ref to a string # containing an entire message - suitable for short messages. # sub check_mail($$) { my($msginfo, $dsn_per_recip_capable) = @_; my $which_section = 'check_init'; my $t0_sect; my $elapsed = {}; $msginfo->time_elapsed($elapsed); $elapsed->{'TimeElapsedReceiving'} = Time::HiRes::time - $msginfo->rx_time; my $point_of_no_return = 0; # past the point where mail or DSN was sent my $mail_id = $msginfo->mail_id; # typically undef at this stage my $am_id = $msginfo->log_id; my $conn = $msginfo->conn_obj; if (!defined($am_id)) { $am_id = am_id(); $msginfo->log_id($am_id) } $zmq_obj->register_proc(1,0,'=',$am_id) if $zmq_obj; # check begins $snmp_db->register_proc(1,0,'=',$am_id) if $snmp_db; my($smtp_resp, $exit_code, $preserve_evidence); my $custom_object; my $hold; # set to some string causes the message to be placed on hold # (frozen) by MTA (if configured to understand the inserted # header field). This can be used in cases when we stumble # across some permanent problem making us unable to decide # if the message is to be really delivered. # is any mail component password protected or otherwise non-decodable? my $any_undecipherable = 0; my $mime_err; # undef, or MIME parsing error string as given by MIME::Parser if (defined $last_task_completed_at) { my $dt = $msginfo->rx_time - $last_task_completed_at; do_log(3,"smtp connection cache, dt: %.1f, state: %d", $dt, $smtp_connection_cache_enable); if (!$smtp_connection_cache_on_demand) {} elsif (!$smtp_connection_cache_enable && $dt < 5) { do_log(3,"smtp connection cache, dt: %.1f -> enabling", $dt); $smtp_connection_cache_enable = 1; } elsif ($smtp_connection_cache_enable && $dt >= 15) { do_log(3,"smtp connection cache, dt: %.1f -> disabling", $dt); $smtp_connection_cache_enable = 0; } } # ugly - save in a global to make it accessible to %builtins $MSGINFO = $msginfo; eval { $msginfo->checks_performed({}) if !$msginfo->checks_performed; $msginfo->add_contents_category(CC_CLEAN,0); # CC_CLEAN is always present $_->add_contents_category(CC_CLEAN,0) for @{$msginfo->per_recip_data}; $msginfo->header_edits(Amavis::Out::EditHeader->new); add_entropy(Time::HiRes::gettimeofday, $child_task_count, $am_id, $msginfo->queue_id, $msginfo->mail_text_fn, $msginfo->sender); section_time($which_section); $which_section = 'check_init2'; { my $cwd = $msginfo->mail_tempdir; if (!defined $cwd || $cwd eq '') { $cwd = $TEMPBASE } chdir($cwd) or die "Can't chdir to $cwd: $!"; } # compute body digest, measure mail size, check for 8-bit data, get entropy get_body_digest($msginfo, c('mail_digest_algorithm')); $which_section = 'collect_info'; collect_some_info($msginfo); if (!defined($msginfo->client_addr)) { # fetch missing IP addr from header my $trace_ref = $msginfo->trace; # 'Received' trace info, top-down for my $hop ($trace_ref ? @$trace_ref : ()) { my $ip = $hop && $hop->{ip}; if (defined $ip && $ip ne '') { do_log(3,"client IP address unknown, fetched from Received: %s",$ip); $msginfo->client_addr($ip); last; } } } section_time($which_section); $which_section = 'check_init4'; my $mail_size = $msginfo->msg_size; # use corrected ESMTP size my $file_generator_object = # maxfiles 0 disables the $MAXFILES limit Amavis::Unpackers::NewFilename->new($MAXFILES?$MAXFILES:undef,$mail_size); Amavis::Unpackers::Part::init($file_generator_object); # fudge: keep in var my $parts_root = Amavis::Unpackers::Part->new; $msginfo->parts_root($parts_root); # section_time($which_section); if (!defined $mail_id && ($sql_store_info_for_all_msgs || !$sql_storage)) { $which_section = 'reg_proc'; $zmq_obj->register_proc(2,0,'G',$am_id) if $zmq_obj; $snmp_db->register_proc(2,0,'G',$am_id) if $snmp_db; # section_time($which_section); $which_section = 'gen_mail_id'; # create a mail_id unique to a database and save preliminary info to SQL generate_unique_mail_id($msginfo); $mail_id = $msginfo->mail_id; section_time($which_section) if $sql_storage; # || $redis_storage } $which_section = "custom-new"; eval { my $old_orig = c('originating'); # may load policy banks $custom_object = Amavis::Custom->new($conn,$msginfo); my $new_orig = c('originating'); # may have changed by a pol. bank load $msginfo->originating($new_orig) if ($old_orig?1:0) != ($new_orig?1:0); update_current_log_level(); 1; } or do { undef $custom_object; my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"custom new err: %s", $eval_stat); }; if (ref $custom_object) { do_log(5,"Custom hooks enabled"); section_time($which_section); } if ($redis_storage && c('enable_ip_repu')) { $which_section = 'redis_ip_repu'; my($score, $worst_ip) = $redis_storage->query_and_update_ip_reputation($msginfo); if ($score && $score >= 0.5) { my $score_limit = c('ip_repu_score_limit'); if ($score_limit && $score_limit > 0.5 && $score > $score_limit) { do_log(3,sprintf('AM.IP_BAD_%s capped from %.1f to %.1f', $worst_ip, $score, $score_limit)); $score = $score_limit; } $msginfo->ip_repu_score($score); my $spam_test = sprintf('AM.IP_BAD_%s=%.1f', $worst_ip, $score); for my $r (@{$msginfo->per_recip_data}) { $r->spam_level( ($r->spam_level || 0) + $score); $r->spam_tests([]) if !$r->spam_tests; unshift(@{$r->spam_tests}, \$spam_test); } } section_time($which_section); } my $cl_ip = $msginfo->client_addr; my($os_fingerprint_obj,$os_fingerprint); my $os_fingerprint_method = c('os_fingerprint_method'); if (!defined($os_fingerprint_method) || $os_fingerprint_method eq '') { # no fingerprinting service configured } elsif ($cl_ip eq '' || $cl_ip eq '0.0.0.0' || $cl_ip eq '::') { # original client IP address not available, can't query p0f } else { # launch a query $which_section = "os_fingerprint"; my $dst = c('os_fingerprint_dst_ip_and_port'); my($dst_ip,$dst_port); local($1,$2,$3); ($dst_ip,$dst_port) = ($1.$2, $3) if defined($dst) && $dst =~ m{^(?: \[ ([^\]]*) \] | ([^:]*) ) : ([^:]*) }six; $os_fingerprint_obj = Amavis::OS_Fingerprint->new( untaint(dynamic_destination($os_fingerprint_method,$conn)), 0.050, $cl_ip, $msginfo->client_port, $dst_ip, $dst_port, defined $mail_id ? $mail_id : sprintf("%08x",rand(0x7fffffff)) ); } my $sender = $msginfo->sender; my(@recips) = map($_->recip_addr, @{$msginfo->per_recip_data}); my $rfc2822_sender = $msginfo->rfc2822_sender; my $fm = $msginfo->rfc2822_from; my(@rfc2822_from) = !defined($fm) ? () : ref $fm ? @$fm : $fm; $mail_size = $msginfo->msg_size; # refresh after custom hook, just in case add_entropy("$cl_ip $mail_size $sender", \@recips); if (ll(1)) { my $pbn = c('policy_bank_path'); ll(1) && do_log(1,"Checking: %s %s%s%s -> %s", $mail_id||'', $pbn eq '' ? '' : "$pbn ", $cl_ip eq '' ? '' : "[$cl_ip] ", qquote_rfc2821_local($sender), join(',', qquote_rfc2821_local(@recips)) ); } if (ll(3)) { my $envsender = qquote_rfc2821_local($sender); my $hdrsender = qquote_rfc2821_local($rfc2822_sender), my $hdrfrom = qquote_rfc2821_local(@rfc2822_from); do_log(3,"2822.From: %s%s%s", @rfc2822_from==1 ? $hdrfrom : sprintf("%d:[%s]", scalar @rfc2822_from, $hdrfrom), !defined($rfc2822_sender) ? '' : ", 2822.Sender: $hdrsender", defined $rfc2822_sender && $envsender eq $hdrsender ? '' : $envsender eq $hdrfrom ? '' : ", 2821.Mail_From: $envsender"); } my $cnt_local = 0; my $cnt_remote = 0; for my $r (@{$msginfo->per_recip_data}) { my $recip = $r->recip_addr; my $is_local = lookup2(0,$recip, ca('local_domains_maps')); $is_local ? $cnt_local++ : $cnt_remote++; $r->recip_is_local($is_local ? 1 : 0); # canonical boolean, untainted if (!defined($r->bypass_virus_checks)) { my $bypassed_v = lookup2(0,$recip, ca('bypass_virus_checks_maps')); $r->bypass_virus_checks($bypassed_v); } if (!defined($r->bypass_banned_checks)) { my $bypassed_b = lookup2(0,$recip, ca('bypass_banned_checks_maps')); $r->bypass_banned_checks($bypassed_b); } if (!defined($r->bypass_spam_checks)) { my $bypassed_s = lookup2(0,$recip, ca('bypass_spam_checks_maps')); $r->bypass_spam_checks($bypassed_s); } if (defined $user_id_sql) { my($user_id_ref,$mk_ref) = # list of all id's that match lookup2(1, $recip, [$user_id_sql], Label=>"users.id"); $r->user_id($user_id_ref) if ref $user_id_ref; # listref or undef } if (defined $user_policy_id_sql) { my $user_policy_id = lookup2(0, $recip, [$user_policy_id_sql], Label=>"users.policy_id"); $r->user_policy_id($user_policy_id); # just the first match } } # update message count and message size snmp counters # orig local # 0 0 InMsgsOpenRelay # 0 1 InMsgsInbound # 0 x (non-originating: inbound or open relay) # 1 0 InMsgsOutbound # 1 1 InMsgsInternal # 1 x InMsgsOriginating (outbound or internal) # x 0 (departing: outbound or open relay) # x 1 (local: inbound or internal) # x x InMsgs snmp_count('InMsgs'); snmp_count('InMsgsBounceNullRPath') if $sender eq ''; snmp_count( ['InMsgsRecips', $cnt_local+$cnt_remote]); # recipients count snmp_count( ['InMsgsSize', $mail_size, 'C64'] ); if ($msginfo->originating) { snmp_count('InMsgsOriginating'); snmp_count( ['InMsgsRecipsOriginating', $cnt_local+$cnt_remote]); snmp_count( ['InMsgsSizeOriginating', $mail_size, 'C64'] ); } if ($cnt_local > 0) { my $d = $msginfo->originating ? 'Internal' : 'Inbound'; snmp_count('InMsgs'.$d); snmp_count( ['InMsgsRecips'.$d, $cnt_local]); snmp_count( ['InMsgsRecipsLocal', $cnt_local]); snmp_count( ['InMsgsSize'.$d, $mail_size, 'C64'] ); } if ($cnt_remote > 0) { my $d = $msginfo->originating ? 'Outbound' : 'OpenRelay'; snmp_count('InMsgs'.$d); snmp_count( ['InMsgsRecips'.$d, $cnt_remote]); snmp_count( ['InMsgsSize'.$d, $mail_size, 'C64'] ); if (!$msginfo->originating) { do_log(1,'Open relay? Nonlocal recips but not originating: %s', join(', ', map($_->recip_addr, grep(!$_->recip_is_local, @{$msginfo->per_recip_data})))); } } # mkdir can be a costly operation (must be atomic, flushes buffers). # If we can re-use directory 'parts' from the previous invocation it saves # us precious time. Together with matching rmdir this can amount to 10-15 % # of total elapsed time on some traditional file systems (no spam checking) $which_section = "creating_partsdir"; { my $tempdir = $msginfo->mail_tempdir; my $errn = lstat("$tempdir/parts") ? 0 : 0+$!; if ($errn == ENOENT) { # needs to be created mkdir("$tempdir/parts", 0750) or die "Can't create directory $tempdir/parts: $!"; section_time('mkdir parts'); } elsif ($errn != 0) { die "$tempdir/parts is not accessible: $!" } elsif (!-d _) { die "$tempdir/parts is not a directory" } else {} # fine, directory already exists and is accessible } # FIRST: what kind of e-mail did we get? call content scanners my($virus_presence_checked,$spam_presence_checked); my $virus_dejavu = 0; my($will_do_virus_scanning, $all_bypass_virus_checks); if ($extra_code_antivirus) { $all_bypass_virus_checks = !grep(!$_->bypass_virus_checks, @{$msginfo->per_recip_data}); $will_do_virus_scanning = !$virus_presence_checked && !$all_bypass_virus_checks; } my $will_do_banned_checking = # banned name checking will be needed? @{ca('banned_filename_maps')} || cr('banned_namepath_re'); my($bounce_header_fields_ref,$bounce_msgid,$bounce_type); if (c('bypass_decode_parts')) { do_log(5, 'decoding bypassed'); } elsif (!$will_do_virus_scanning && !$will_do_banned_checking && c('bounce_killer_score') <= 0) { do_log(5, 'decoding not needed'); } else { # decoding parts can take a lot of time $which_section = "mime_decode-1"; $zmq_obj->register_proc(2,0,'D',$am_id) if $zmq_obj; # decoding $snmp_db->register_proc(2,0,'D',$am_id) if $snmp_db; $t0_sect = Time::HiRes::time; $mime_err = ensure_mime_entity($msginfo) if !defined($msginfo->mime_entity); prolong_timer($which_section); if (c('bounce_killer_score') > 0) { $which_section = "dsn_parse"; # analyze a bounce after MIME decoding but before further archive # decoding (which often replaces original MIME parts by decoded files) eval { # just in case ($bounce_header_fields_ref,$bounce_type) = inspect_a_bounce_message($msginfo); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1, "inspect_a_bounce_message failed: %s", $eval_stat); }; if ($bounce_header_fields_ref && exists $bounce_header_fields_ref->{'message-id'}) { $bounce_msgid = $bounce_header_fields_ref->{'message-id'}; if (defined $bounce_msgid && $bounce_msgid ne '') { my $refs = $msginfo->references; if (!$refs) { $refs = []; $msginfo->references($refs) } push(@$refs, $bounce_msgid); } } prolong_timer($which_section); } $which_section = "parts_decode_ext"; snmp_count('OpsDec'); my($any_encrypted,$over_levels,$ambiguous); ($hold, $any_undecipherable, $any_encrypted, $over_levels, $ambiguous) = Amavis::Unpackers::decompose_mail($msginfo->mail_tempdir, $file_generator_object); $any_undecipherable ||= ($any_encrypted || $over_levels || $ambiguous); if ($any_undecipherable) { $msginfo->add_contents_category(CC_UNCHECKED,0); $msginfo->add_contents_category(CC_UNCHECKED,1) if $any_encrypted; $msginfo->add_contents_category(CC_UNCHECKED,2) if $over_levels; $msginfo->add_contents_category(CC_UNCHECKED,3) if $ambiguous; for my $r (@{$msginfo->per_recip_data}) { $r->add_contents_category(CC_UNCHECKED,3) if $ambiguous; next if $r->bypass_virus_checks; $r->add_contents_category(CC_UNCHECKED,0); $r->add_contents_category(CC_UNCHECKED,1) if $any_encrypted; $r->add_contents_category(CC_UNCHECKED,2) if $over_levels; } } $elapsed->{'TimeElapsedDecoding'} = Time::HiRes::time - $t0_sect; } my $bphcm = ca('bypass_header_checks_maps'); if (grep(!lookup2(0,$_->recip_addr,$bphcm), @{$msginfo->per_recip_data})) { $which_section = "check_header"; my $allowed_tests = cr('allowed_header_tests'); my($badh_ref,$minor_badh_cc); if ($allowed_tests && %$allowed_tests) { # any test enabled? ($badh_ref,$minor_badh_cc) = check_header_validity($msginfo); $msginfo->checks_performed->{H} = 1; if (@$badh_ref) { push(@bad_headers, @$badh_ref); $msginfo->add_contents_category(CC_BADH,$minor_badh_cc); } } my $allowed_mime_test = $allowed_tests && $allowed_tests->{'mime'}; # check for bad headers and for bad MIME subheaders / bad MIME structure if ($allowed_mime_test && defined $mime_err && $mime_err ne '') { push(@bad_headers, "MIME error: ".$mime_err); $msginfo->add_contents_category(CC_BADH,1); } for my $r (@{$msginfo->per_recip_data}) { my $bypassed = lookup2(0,$r->recip_addr,$bphcm); if (!$bypassed && @$badh_ref) { $r->add_contents_category(CC_BADH,$minor_badh_cc); } if (!$bypassed && $allowed_mime_test && defined $mime_err && $mime_err ne '') { $r->add_contents_category(CC_BADH,1); # CC_BADH min: 1=broken mime } } section_time($which_section); } if ($will_do_banned_checking) { # check for banned file contents $which_section = "check-banned"; check_for_banned_names($msginfo); # saves results in $msginfo $msginfo->checks_performed->{B} = 1; $banned_filename_any = 0; $banned_filename_all = 1; for my $r (@{$msginfo->per_recip_data}) { next if $r->bypass_banned_checks; my $a = $r->banned_parts; if (!defined $a || !@$a) { $banned_filename_all = 0; } else { my $rhs = $r->banning_rule_rhs; if (defined $rhs) { for my $j (0..$#{$a}) { $r->dsn_suppress_reason(sprintf("BANNED:%s suggested by rule", $rhs->[$j])) if $rhs->[$j] =~ /^DISCARD/; } } $banned_filename_any = 1; $r->add_contents_category(CC_BANNED,0); } } $msginfo->add_contents_category(CC_BANNED,0) if $banned_filename_any; ll(4) && do_log(4,"banned check: any=%d, all=%s (%d)", $banned_filename_any, $banned_filename_all?'Y':'N', scalar(@{$msginfo->per_recip_data})); } my $virus_checking_failed = 0; if (!$extra_code_antivirus) { do_log(5, "no anti-virus code loaded, skipping virus_scan"); } elsif ($all_bypass_virus_checks) { do_log(5, "bypassing of virus checks requested"); } elsif (defined $hold && $hold ne '') { # protect virus scanner from bombs do_log(0, "NOTICE: Virus scanning skipped: %s", $hold); $will_do_virus_scanning = 0; } else { if (!$will_do_virus_scanning) { do_log(-1, "NOTICE: will_do_virus_scanning is false???") } $mime_err = ensure_mime_entity($msginfo) if !defined($msginfo->mime_entity) && !c('bypass_decode_parts'); # special case to make available a complete mail file for inspection if ((defined $mime_err && $mime_err ne '') || !defined($msginfo->mime_entity) || lookup2(0, 'MAIL', \@keep_decoded_original_maps) || $any_undecipherable && lookup2(0,'MAIL-UNDECIPHERABLE', \@keep_decoded_original_maps)) { if (!defined($msginfo->mail_text_fn)) { do_log(5,"can't present full original message to scanners, no file"); } else { # keep the email.txt by making a hard link to it in ./parts/ $which_section = "linking-to-MAIL"; my $tempdir = $msginfo->mail_tempdir; my $newpart_obj = Amavis::Unpackers::Part->new("$tempdir/parts", $parts_root, 1); my $newpart = $newpart_obj->full_name; ll(3) && do_log(3,'presenting full original message to scanners '. 'as %s%s%s%s', $newpart, !$any_undecipherable ? '' : ", $any_undecipherable undecipherable", defined $msginfo->mime_entity ? '' : ', MIME not decoded', !defined $mime_err || $mime_err eq '' ? '' : ", MIME error: $mime_err"); link($msginfo->mail_text_fn, $newpart) or die sprintf("Can't create hard link %s to %s: %s", $newpart, $msginfo->mail_text_fn, $!); $newpart_obj->type_short('MAIL'); # case sensitive if ($msginfo->smtputf8 && $msginfo->header_8bit) { # RFC 6532 section 3.7 $newpart_obj->type_declared('message/global'); $newpart_obj->name_declared('message.u8msg'); } else { $newpart_obj->type_declared('message/rfc822'); $newpart_obj->name_declared('message.msg'); } } } $which_section = "virus_scan"; $zmq_obj->register_proc(2,0,'V',$am_id) if $zmq_obj; # virus scan $snmp_db->register_proc(2,0,'V',$am_id) if $snmp_db; my $av_ret; $t0_sect = Time::HiRes::time; $virus_checking_failed = 1; eval { my($vn, $ds, $avsr); ($av_ret, $av_output, $vn, $ds, $avsr) = Amavis::AV::virus_scan($msginfo, $child_task_count==1); @virusname = @$vn; @detecting_scanners = @$ds; # copy @av_scanners_results = @$avsr; if (defined $av_ret) { $virus_presence_checked = 1; $virus_checking_failed = 0; $msginfo->checks_performed->{V} = 1; } 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-2, "AV: %s", $eval_stat); $virus_checking_failed = $eval_stat; $virus_checking_failed = 1 if !$virus_checking_failed; }; $elapsed->{'TimeElapsedVirusCheck'} = Time::HiRes::time - $t0_sect; snmp_count('OpsVirusCheck'); if ($virus_presence_checked && @virusname && $snmp_db) { $which_section = "read_snmp_variables"; # true if none found with a counter value of zero or undef $virus_dejavu = 1 if !grep(!defined($_) || $_ == 0, @{$snmp_db->read_snmp_variables( map("virus.byname.$_", @virusname))}); section_time($which_section); } } if ($virus_checking_failed) { $msginfo->add_contents_category(CC_UNCHECKED,0); for my $r (@{$msginfo->per_recip_data}) { $r->add_contents_category(CC_UNCHECKED,0) if !$r->bypass_virus_checks; } if (c('virus_scanners_failure_is_fatal')) { $hold = 'AV: ' . $virus_checking_failed; die "$hold\n"; # TEMPFAIL } } $which_section = "post_virus_scan"; if (@virusname) { my $virus_suppress_reason; my($ccat_maj,$ccat_min) = (CC_VIRUS,0); my $vtfsm = ca('viruses_that_fake_sender_maps'); if (@$vtfsm) { for my $vn (@virusname) { my($result,$matchingkey) = lookup2(0,$vn,$vtfsm); if ($result) { # is a virus known to fake a sender address do_log(3,"Virus %s matches %s, sender addr ignored", $vn,$matchingkey); # try to get some info on sender source from his IP address my $first_rcvd_from_ip = oldest_public_ip_addr_from_received($msginfo); if (defined $first_rcvd_from_ip && $first_rcvd_from_ip ne '') { $msginfo->sender_source(sprintf('?@[%s]', $first_rcvd_from_ip)); } else { $msginfo->sender_source(undef); } $virus_suppress_reason = 'INFECTED'; # $ccat_min = 1; last; } } } $msginfo->add_contents_category($ccat_maj,$ccat_min); for my $r (@{$msginfo->per_recip_data}) { $r->add_contents_category( $ccat_maj,$ccat_min) if !$r->bypass_virus_checks; if (defined $virus_suppress_reason) { $r->dsn_suppress_reason($virus_suppress_reason . (!defined $_ ? '' : ", $_")) for $r->dsn_suppress_reason; } } $msginfo->virusnames([@virusname]); # save a copy of virus names my $vntpbm = ca('virus_name_to_policy_bank_maps'); if (@$vntpbm) { my(@bank_names); for my $vn (@virusname) { my($result,$matchingkey) = lookup2(0,$vn,$vntpbm); next if !$result; if ($result eq '1') { # a handy usability trick to supply a hardwired policy bank # name when acl-style lookup table is used, which can only # return a boolean (undef, 0, or 1) $result = 'VIRUS'; } # $result is a list of policy bank names as a comma-separated string local $1; my(@pbn) = map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $result)); if (@pbn) { push(@bank_names, @pbn); ll(2) && do_log(2, "virus %s loads policy bank(s) %s, match: %s", $vn, join(',',@pbn), $matchingkey); } } load_policy_bank($_) for @bank_names; } } if (defined($os_fingerprint_obj)) { $which_section = "fingerprint_collect"; $os_fingerprint = $os_fingerprint_obj->collect_response; if (defined $os_fingerprint && $os_fingerprint ne '') { $msginfo->checks_performed->{F} = 1; if ($msginfo->originating) { $os_fingerprint = 'MYNETWORKS' } # blank-out our smtp clients info $msginfo->client_os_fingerprint($os_fingerprint); # store info } } my($bypass_spam_checks_by_bounce_killer); if (!$bounce_header_fields_ref) { # not a bounce } elsif ($msginfo->originating) { # will be rescued from bounce killing by the originating flag } elsif (defined($bounce_msgid) && $bounce_msgid =~ /(\@[^\@>() \t][^\@>]*?)[ \t]*>?\z/ && lookup2(0,$1, ca('local_domains_maps'))) { # will be rescued from bounce killing by a local domain # in referenced Message-ID } elsif (!defined($sql_storage) || !$sql_store_info_for_all_msgs || c('penpals_bonus_score') <= 0 || c('penpals_halflife') <= 0) { # will be rescued from bounce killing by pen pals disabled } elsif (c('bounce_killer_score') > 20) { # is a bounce and is eligible to bounce killing, no need for spam scan $bypass_spam_checks_by_bounce_killer = 1; } # consider doing spam scanning if (!$extra_code_antispam) { do_log(5, "no anti-spam code loaded, skipping spam_scan"); } elsif ($bypass_spam_checks_by_bounce_killer) { do_log(5, "bypassing of spam checks by a bounce killer"); } elsif (!grep(!$_->bypass_spam_checks, @{$msginfo->per_recip_data})) { do_log(5, "bypassing of spam checks requested for all recips"); } else { # preliminary test - would a message be allowed to pass for any recipient # based on evidence collected so far (virus, banned) my $any_pass = 0; my $prelim_blocking_ccat; for my $r (@{$msginfo->per_recip_data}) { my $final_destiny = D_PASS; my $recip = $r->recip_addr; my(@fd_tuples) = $r->setting_by_main_contents_category_all( cr('final_destiny_maps_by_ccat'), cr('lovers_maps_by_ccat')); for my $tuple (@fd_tuples) { my($cc, $fd_map_ref, $lovers_map_ref) = @$tuple; my $fd = !ref $fd_map_ref ? $fd_map_ref # compatibility : lookup2(0, $recip, $fd_map_ref, Label => 'Destiny1'); if (!defined $fd || $fd == D_PASS) { $fd = D_PASS; # keep D_PASS } elsif (defined($lovers_map_ref) && lookup2(0, $recip, $lovers_map_ref, Label => 'Lovers1')) { $fd = D_PASS; # D_PASS for content lovers } elsif ($fd == D_BOUNCE && ($sender eq '' || $msginfo->is_bulk) && ccat_maj($cc) == CC_BADH) { # have mercy on bad header section from mailing lists and in DSN $fd = D_PASS; # change D_BOUNCE to D_PASS for CC_BADH } else { # $fd != D_PASS, blocked $prelim_blocking_ccat = $cc; $final_destiny = $fd; last; } } $any_pass = 1 if $final_destiny == D_PASS; } if (!$any_pass) { do_log(5, "bypassing of spam checks, message will be blocked anyway ". "due to %s", $prelim_blocking_ccat); } else { $which_section = "spam-wb-list"; my($any_wbl, $all_wbl) = Amavis::SpamControl::white_black_list( $msginfo, $sql_wblist, $user_id_sql, $ldap_lookups); section_time($which_section); if ($all_wbl) { do_log(5, "sender white/blacklisted, skipping spam_scan"); } elsif (!$spamcontrol_obj) { do_log(5, "spam scanning disabled, no spamcontrol_obj"); } else { $which_section = "spam_scan"; $zmq_obj->register_proc(2,0,'S',$am_id) if $zmq_obj; $snmp_db->register_proc(2,0,'S',$am_id) if $snmp_db; $t0_sect = Time::HiRes::time; # sets $msginfo->spam_level, spam_status, # spam_report, spam_summary, supplementary_info $spamcontrol_obj->spam_scan($msginfo); eval { # treat any failures there as non-fatal, just in case $spamcontrol_obj->auto_learn($msginfo); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1, "Auto-learn failed: %s", $eval_stat); }; $msginfo->checks_performed->{S} = 1; prolong_timer($which_section); $elapsed->{'TimeElapsedSpamCheck'} = Time::HiRes::time - $t0_sect; snmp_count('OpsSpamCheck'); $spam_presence_checked = 1; } } } if (ref $custom_object) { $which_section = "custom-checks"; eval { $custom_object->checks($conn,$msginfo); update_current_log_level(); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"custom checks error: %s", $eval_stat); }; section_time($which_section); } snmp_count("virus.byname.$_") for @virusname; my(@sa_tests,%sa_tests); { my $tests = $msginfo->supplementary_info('TESTS'); if (defined($tests) && $tests ne 'none') { @sa_tests = $tests =~ /([^=,;]+)(?==)/g; %sa_tests = map(($_,1), @sa_tests); } } # SECOND: now that we know what we got, decide what to do with it $which_section = 'after_scanning'; Amavis::DKIM::adjust_score_by_signer_reputation($msginfo) if $msginfo->dkim_signatures_valid; my($min_spam_level, $max_spam_level) = minmax(map($_->spam_level, @{$msginfo->per_recip_data})); $min_spam_level = 0 if !defined $min_spam_level; $max_spam_level = 0 if !defined $max_spam_level; $which_section = "penpals_check"; my $pp_age; if (!$redis_storage && !(defined $sql_storage && $sql_store_info_for_all_msgs)) { # pen pals disabled - data on past mail transactions unavailable } elsif ($msginfo->is_in_contents_category(CC_VIRUS)) { # pen pals disabled, not needed for infected messages } else { my $pp_bonus = c('penpals_bonus_score'); # score points my $pp_halflife = c('penpals_halflife'); # seconds if ($pp_bonus <= 0 || $pp_halflife <= 0) { # penpals disabled } elsif (defined($penpals_threshold_low) && !defined($bounce_msgid) && $max_spam_level < $penpals_threshold_low) { # low score for all recipients, no need for aid do_log(5,"penpals: low score, no need for penpals aid"); } elsif (defined($penpals_threshold_high) && !defined($bounce_msgid) && $min_spam_level - $pp_bonus > $penpals_threshold_high) { # spam, can't get below threshold_high even under best circumstances do_log(5,"penpals: high score, penpals won't help"); } elsif ($sender ne '' && !$msginfo->originating && lookup2(0, $sender, ca('local_domains_maps'))) { # no bonus to unauthent. senders from outside claiming a local domain do_log(5,"penpals: local sender from outside, ignored: %s", $sender); } else { $t0_sect = Time::HiRes::time; $zmq_obj->register_proc(2,0,'P',$am_id) if $zmq_obj; # penpals $snmp_db->register_proc(2,0,'P',$am_id) if $snmp_db; my $refs = $msginfo->references; my $sid = $msginfo->sender_maddr_id; section_time("pre-penpals"); if ($redis_storage) { # does all recipient queries in one go my $ok = eval { $redis_storage->penpals_find($msginfo, $refs) }; section_time("penpals-redis") if $ok; } for my $r (@{$msginfo->per_recip_data}) { next if $r->recip_done; # already dealt with my $recip = $r->recip_addr; if ($r->recip_is_local && lc($sender) ne lc($recip)) { # inbound or internal_to_internal, except self_to_self my $pp_mail_id = $r->recip_penpals_related; my $pp_age = $r->recip_penpals_age; my $pp_subj; my $rid = $r->recip_maddr_id; if ($sql_storage && defined $sid && defined $rid) { # NOTE: swap $rid and $sid as args in a query here, as we are # now checking for a potential reply mail - whether the current # recipient has recently sent any mail to the sender of the # current mail: my($pp_age_sql, $pp_mail_id_sql, $pp_subj_sql) = $sql_storage->penpals_find($rid, $sid, $refs, $msginfo); if (defined $pp_age_sql) { if (!defined $pp_age || $pp_age_sql < $pp_age) { $pp_age = $pp_age_sql; $pp_mail_id = $pp_mail_id_sql; $r->recip_penpals_age($pp_age); $r->recip_penpals_related($pp_mail_id); } $pp_subj = $pp_subj_sql; } section_time("penpals-sql"); } $msginfo->checks_performed->{P} = 1; if (defined $pp_age) { # found info about previous correspondence my $weight = exp(-($pp_age/$pp_halflife) * log(2)); # weight is a factor between 1 and 0, representing # exponential decay: weight(t) = 1 / 2^(t/halflife) # i.e. factors 1, 1/2, 1/4, 1/8... at age 0, hl, 2*hl, 3*hl... my $adj = - $weight * $pp_bonus; $r->recip_penpals_score($adj); $r->spam_level( ($r->spam_level || 0) + $adj); { my $spam_tests = 'AM.PENPAL=' . (0+sprintf("%.3f",$adj)); if (!$r->spam_tests) { $r->spam_tests([ \$spam_tests ]); } else { unshift(@{$r->spam_tests}, \$spam_tests); } } if (ll(2)) { do_log(2,"penpals: adj.bonus %.3f, age %s (%d), ". "SA score %.3f, <%s> replying to <%s>, ref mail_id: %s", -$adj, format_time_interval($pp_age), $pp_age, $r->spam_level, $sender, $recip, $pp_mail_id); if (defined $pp_subj) { my $this_subj = $msginfo->get_header_field_body('subject'); $this_subj = $1 if $this_subj =~ /^\s*(.*?)\s*$/; do_log(2,"penpals: prev Subject: %s", $pp_subj); do_log(2,"penpals: this Subject: %s", $this_subj); } } } } } # section_time($which_section); $elapsed->{'TimeElapsedPenPals'} = Time::HiRes::time - $t0_sect; } } $which_section = "bounce_killer"; if ($bounce_header_fields_ref) { # message looks like a DSN (= bounce) snmp_count('InMsgsBounce'); my $bounce_rescued; if (defined $pp_age && $pp_age < 8*24*3600) { # less than 8 days ago # found by pen pals by a Message-ID in attachment and recip. address; # is a bounce, refers to our previous outgoing message, treat it kindly snmp_count('InMsgsBounceRescuedByPenPals'); $bounce_rescued = 'by penpals'; } elsif ($msginfo->originating) { snmp_count('InMsgsBounceRescuedByOriginating'); $bounce_rescued = 'by originating'; } elsif (defined($bounce_msgid) && $bounce_msgid =~ /(\@[^\@>() \t][^\@>]*?)[ \t]*>?\z/ && lookup2(0,$1, ca('local_domains_maps'))) { # not in pen pals, but domain in Message-ID is a local domain; # it is only useful until spammers figure out the trick, # then it should be disabled snmp_count('InMsgsBounceRescuedByDomain'); $bounce_rescued = 'by domain'; } elsif (!defined($sql_storage) || c('penpals_bonus_score') <= 0 || c('penpals_halflife') <= 0) { $bounce_rescued = 'by: pen pals disabled'; } ll(2) && do_log(2, "bounce %s (%s), %s -> %s, %s", defined $bounce_rescued ?'rescued '.$bounce_rescued :'killed', $bounce_type, qquote_rfc2821_local($sender), join(',', qquote_rfc2821_local(@recips)), join(', ', map { $_ . ': ' . $bounce_header_fields_ref->{$_} } sort( grep(/^(?:From|Return-Path|Message-ID|Date)\z/i, keys %$bounce_header_fields_ref) )) ); if (!$bounce_rescued) { snmp_count('InMsgsBounceKilled'); my $bounce_killer_score = c('bounce_killer_score'); for my $r (@{$msginfo->per_recip_data}) { $r->spam_level( ($r->spam_level || 0) + $bounce_killer_score); my $spam_tests = 'AM.BOUNCE=' . $bounce_killer_score; if (!$r->spam_tests) { $r->spam_tests([ \$spam_tests ]); } else { unshift(@{$r->spam_tests}, \$spam_tests); } } } # else: not a recognizable bounce } elsif ($msginfo->is_auto || $sender =~ /^postmaster(?:\@|\z)/si || $rfc2822_from[0] =~ /^postmaster(?:\@|\z)/si || $sa_tests{'ANY_BOUNCE_MESSAGE'} ) { # message could be some kind of a non-standard bounce or autoresponse, # but lacks recognizable structure and a header section from orig. mail ll(2) && do_log(2, "bounce unverifiable%s, %s -> %s", !$msginfo->originating ? '' : ', originating', qquote_rfc2821_local($sender), join(',', qquote_rfc2821_local(@recips))); snmp_count('InMsgsBounce'); snmp_count('InMsgsBounceUnverifiable'); } $which_section = "decide_mail_destiny"; $zmq_obj->register_proc(2,0,'r',$am_id) if $zmq_obj; # results... $snmp_db->register_proc(2,0,'r',$am_id) if $snmp_db; my $considered_oversize_by_some_recips; my $mslm = ca('message_size_limit_maps'); for my $r (@{$msginfo->per_recip_data}) { next if $r->recip_done; # already dealt with my $recip = $r->recip_addr; my $spam_level = $r->spam_level; # consider adding CC_SPAM or CC_SPAMMY to the contents_category list; # spaminess is an individual matter, we must compare spam level # with each recipient setting, there is no single global criterion my($tag_level,$tag2_level,$tag3_level,$kill_level); my $bypassed = $r->bypass_spam_checks; if (!$bypassed) { $tag_level = lookup2(0,$recip, ca('spam_tag_level_maps')); $tag2_level = lookup2(0,$recip, ca('spam_tag2_level_maps')); $tag3_level = lookup2(0,$recip, ca('spam_tag3_level_maps')); $kill_level = lookup2(0,$recip, ca('spam_kill_level_maps')); } my $blacklisted = $r->recip_blacklisted_sender; my $whitelisted = $r->recip_whitelisted_sender; my $do_tag = !$bypassed && ( $blacklisted || !defined $tag_level || $tag_level eq '' || ($spam_level + ($whitelisted?-10:0) >= $tag_level)); my($do_tag2,$do_tag3,$do_kill) = map { !$bypassed && !$whitelisted && ($blacklisted || (defined($_) && $spam_level >= $_) ) } ($tag2_level,$tag3_level,$kill_level); $do_tag2 = $do_tag2 || $do_tag3; # tag3 implies tag2, just in case if ($do_tag) { # spaminess is at or above tag level $msginfo->add_contents_category(CC_CLEAN,1); $r->add_contents_category(CC_CLEAN,1) if !$bypassed; } if ($do_tag2) { # spaminess is at or above tag2 level $msginfo->add_contents_category(CC_SPAMMY); $r->add_contents_category(CC_SPAMMY) if !$bypassed; } if ($do_tag3) { # spaminess is at or above tag3 level $msginfo->add_contents_category(CC_SPAMMY,1); $r->add_contents_category(CC_SPAMMY,1) if !$bypassed; } if ($do_kill) { # spaminess is at or above kill level $msginfo->add_contents_category(CC_SPAM,0); $r->add_contents_category(CC_SPAM,0) if !$bypassed; } # consider adding CC_OVERSIZED to the contents_category list; if (@$mslm) { # checking of mail size is needed? my $size_limit = lookup2(0,$r->recip_addr,$mslm); if ($enforce_smtpd_message_size_limit_64kb_min && $size_limit && $size_limit < 65536) { $size_limit = 65536 } # RFC 5321 requires at least 64k if ($size_limit && $mail_size > $size_limit) { do_log(1,"OVERSIZED from %s to %s: size %s B, limit %s B", $msginfo->sender_smtp, $r->recip_addr_smtp, $mail_size, $size_limit) if !$considered_oversize_by_some_recips; $considered_oversize_by_some_recips = 1; $r->add_contents_category(CC_OVERSIZED,0); $msginfo->add_contents_category(CC_OVERSIZED,0); } } # determine true reason for blocking,considering lovers and final_destiny my $blocking_ccat; my $final_destiny = D_PASS; my $to_be_mangled; my(@fd_tuples) = $r->setting_by_main_contents_category_all( cr('final_destiny_maps_by_ccat'), cr('lovers_maps_by_ccat'), cr('defang_maps_by_ccat') ); for my $tuple (@fd_tuples) { my($cc, $fd_map_ref, $lovers_map_ref, $mangle_map_ref) = @$tuple; my $fd = !ref $fd_map_ref ? $fd_map_ref # compatibility : lookup2(0, $recip, $fd_map_ref, Label => 'Destiny2'); if (!defined $fd || $fd == D_PASS) { ll(5) && do_log(5, 'final_destiny (ccat=%s) is PASS, recip %s', $cc, $recip); $fd = D_PASS; # keep D_PASS } elsif (defined($lovers_map_ref) && lookup2(0, $recip, $lovers_map_ref, Label => 'Lovers2')) { ll(5) && do_log(5, 'contents lover (ccat=%s), '. 'changing final_destiny %d to PASS, recip %s', $cc, $fd, $recip); $fd = D_PASS; # change to D_PASS for content lovers } elsif ($fd == D_BOUNCE && ($sender eq '' || $msginfo->is_bulk) && ccat_maj($cc) == CC_BADH) { # have mercy on bad header section in mail from mailing lists and # in DSN: since a bounce for such mail will be suppressed, it is # probably better to just let a mail with a bad header section pass, # it is rather innocent my $is_bulk = $msginfo->is_bulk; do_log(1, 'allow bad header section from %s<%s> -> <%s>: %s, '. 'changing final_destiny %d to PASS', !$is_bulk ? '' : "($is_bulk) ", $sender, $recip, $bad_headers[0], $fd); $fd = D_PASS; # change D_BOUNCE to D_PASS for CC_BADH } else { # $fd != D_PASS, blocked $blocking_ccat = $cc; $final_destiny = $fd; my $cc_main = $r->contents_category; $cc_main = $cc_main->[0] if $cc_main; if ($blocking_ccat eq $cc_main) { do_log(3, 'blocking contents category is (%s) for %s, '. 'final_destiny %d', $blocking_ccat, $recip, $fd); } else { do_log(3, 'blocking ccat (%s) differs from ccat_maj=%s, %s, '. 'final_destiny %d', $blocking_ccat, $cc_main, $recip, $fd); } last; # first blocking wins, also skips turning on mangling } # topmost mangling reason wins if (!defined($to_be_mangled) && defined($mangle_map_ref)) { my $mangle_type = !ref($mangle_map_ref) ? $mangle_map_ref # compatibility : lookup2(0,$recip,$mangle_map_ref, Label=>'Mangling1'); $to_be_mangled = $mangle_type if $mangle_type ne ''; } } $r->recip_destiny($final_destiny); if (defined $blocking_ccat) { # save a blocking contents category $r->blocking_ccat($blocking_ccat); # summarize per-recipient blocking_ccat to a message level my $msg_bl_ccat = $msginfo->blocking_ccat; if (!defined($msg_bl_ccat) || cmp_ccat($blocking_ccat,$msg_bl_ccat)>0) { $msginfo->blocking_ccat($blocking_ccat) } } else { # defanging/mangling only has effect on passed mail # defang_all serves mostly for testing purposes and compatibility $to_be_mangled = 1 if !$to_be_mangled && c('defang_all'); if ($to_be_mangled) { my $orig_to_be_mangled = $to_be_mangled; if ($to_be_mangled =~ /^(?:disclaimer|nulldisclaimer)\z/i) { # disclaimers can only go to mail originating from internal # networks - the 'allow_disclaimers' should (only) be enabled # by an appropriate policy bank, e.g. MYNETS and/or ORIGINATING if (!c('allow_disclaimers')) { $to_be_mangled = 0; # not for remote or unauthorized clients do_log(5,"will not add disclaimer, allow_disclaimers is false"); } else { my $rf = $msginfo->rfc2822_resent_from; my $rs = $msginfo->rfc2822_resent_sender; # disclaimers should only go to mail with 2822.From or # 2822.Sender or 2822.Resent-From or 2822.Resent-Sender # or 2821.mail_from address matching local domains if (!grep(defined($_) && $_ ne '' && lookup2(0,$_, ca('local_domains_maps')), unique_list( (!$rf ? () : @$rf), (!$rs ? () : @$rs), @rfc2822_from, $rfc2822_sender, $sender))) { $to_be_mangled = 0; # not for foreign 'Sender:' or 'From:' do_log(5,"will not add disclaimer, sender not local"); } elsif (c('outbound_disclaimers_only') && $r->recip_is_local) { $to_be_mangled = 0; do_log(5, "will not add disclaimer, recipient is local"); } } } else { # defanging (not disclaiming) # defanging and other mail mangling/munging only applies to # incoming mail, i.e. for recipients matching local_domains_maps $to_be_mangled = 0 if !$r->recip_is_local; } # store a boolean or a mangling name (defang, disclaimer, ...) $r->mail_body_mangle($to_be_mangled) if $to_be_mangled; ll(2) && do_log(2, "mangling %s: %s (was: %s), ". "discl_allowed=%d, <%s> -> <%s>", $to_be_mangled ? 'YES' : 'NO', $to_be_mangled, $orig_to_be_mangled, c('allow_disclaimers'), $sender, $recip); } } # penpals_score is already accounted for in spam_level my $penpals_score = $r->recip_penpals_score; # is zero or negative! if ($penpals_score && $penpals_score < 0) { # only for logging and statistics purposes my($do_tag2_nopp, $do_tag3_nopp, $do_kill_nopp) = map { !$whitelisted && ($blacklisted || (defined($_) && $spam_level-$penpals_score >= $_) ) } ($tag2_level, $tag3_level, $kill_level); $do_tag2_nopp ||= $do_tag3_nopp; my $which = $do_kill_nopp && !$do_kill ? 'kill' : $do_tag3_nopp && !$do_tag3 ? 'tag3' : $do_tag2_nopp && !$do_tag2 ? 'tag2' : undef; if (defined $which) { snmp_count("PenPalsSavedFrom\u$which") if $final_destiny==D_PASS; do_log(2, "penpals: PenPalsSavedFrom%s %.3f%.3f%s, <%s> -> <%s>", "\u$which", $spam_level-$penpals_score, $penpals_score, ($final_destiny==D_PASS ? '' : ', but mail still blocked'), $sender, $recip); } } if ($final_destiny == D_PASS) { # recipient wants this message, malicious or not do_log(5, "final_destiny PASS, recip %s", $recip); } else { # recipient does not want this content do_log(5, "final_destiny %s, recip %s", $final_destiny, $recip); # supply RFC 3463 enhanced status codes, see also RFC 5248 my $status = setting_by_given_contents_category( $blocking_ccat, { CC_VIRUS, "554 5.7.0", CC_BANNED, "554 5.7.0", CC_UNCHECKED, "554 5.7.0", CC_SPAM, "554 5.7.0", CC_SPAMMY, "554 5.7.0", CC_BADH.",2", "554 5.6.3", # nonencoded 8-bit character CC_BADH, "554 5.6.0", CC_OVERSIZED, "552 5.3.4", CC_MTA, "550 5.3.5", CC_CATCHALL, "554 5.7.0", }); my($statoverride,$softfailed); $softfailed = ''; if ($status =~ /^[24]/) { # just in case # keep unchanged } elsif ($final_destiny == D_TEMPFAIL) { $statoverride = '450'; # 5xx -> 450 } elsif (c('soft_bounce')) { $statoverride = '450'; # 5xx -> 450 $softfailed = ' (soft_bounce)'; ll(5) && do_log(5, "soft_bounce: %s %s -> %s", $final_destiny == D_DISCARD ? 'discard' : 'bounce', $status, $statoverride); } elsif ($final_destiny == D_DISCARD) { $statoverride = '250'; # 5xx -> 250 } if (defined $statoverride) { my $code = substr($statoverride,0,1); local($1,$2); $status =~ s{^\d(\d\d) \d(\.\d\.\d)}{$statoverride $code$2}; } # get the custom smtp response reason text my $smtp_reason = setting_by_given_contents_category( $blocking_ccat, cr('smtp_reason_by_ccat')); $smtp_reason = '' if !defined $smtp_reason; if ($smtp_reason ne '') { my(%mybuiltins) = %builtins; # make a local copy $smtp_reason = expand(\$smtp_reason, \%mybuiltins); $smtp_reason = !ref($smtp_reason) ? '' : $$smtp_reason; chomp($smtp_reason); $smtp_reason = sanitize_str($smtp_reason,1); # coarsely chop to a sane size, wrap_smtp_resp() will finely adjust substr($smtp_reason,450) = '...' if length($smtp_reason) > 450+3; } my $response = sprintf("%s %s%s%s", $status, ($final_destiny == D_PASS ? "Ok" : $final_destiny == D_DISCARD ? "Ok, discarded" : $final_destiny == D_REJECT ? "Reject" : $final_destiny == D_BOUNCE ? "Bounce" : $final_destiny == D_TEMPFAIL ? "Temporary failure" : "Not ok ($final_destiny)" ), $softfailed, $smtp_reason eq '' ? '' : ', '.$smtp_reason); # the wrap_smtp_resp() will enforce the requirement in # RFC 5321 section 4.5.3.1.5 on a length of a reply line ll(4) && do_log(4, "blocking ccat=%s, SMTP response: %s", $blocking_ccat,$response); $r->recip_smtp_response($response); $r->recip_done(1); # fake a delivery (confirm delivery to a bit bucket) # note that 5xx status rejects may later be converted to bounces } } section_time($which_section); $which_section = "quar+notif"; $t0_sect = Time::HiRes::time; $zmq_obj->register_proc(2,0,'Q',$am_id) if $zmq_obj; # notify, quar $snmp_db->register_proc(2,0,'Q',$am_id) if $snmp_db; do_notify_and_quarantine($msginfo, $virus_dejavu); # $which_section = "aux_quarantine"; # do_quarantine($msginfo, undef, ['archive-files'], 'local:archive/%m'); # do_quarantine($msginfo, undef, ['archive@localhost'], 'local:all-%m'); # do_quarantine($msginfo, undef, ['sender-quarantine'], 'local:user-%m' # ) if lookup(0,$sender, ['user1@domain','user2@domain']); # section_time($which_section); $elapsed->{'TimeElapsedQuarantineAndNotify'} = Time::HiRes::time - $t0_sect; if (defined $hold && $hold ne '') { do_log(-1, "NOTICE: HOLD reason: %s", $hold) } # THIRD: now that we know what to do with it, do it! (deliver or bounce) { # update Content*Msgs* counters my $ccat_name = $msginfo->setting_by_contents_category(\%ccat_display_names_major); my $counter_name = 'Content'.$ccat_name.'Msgs'; snmp_count($counter_name); if ($msginfo->originating) { snmp_count($counter_name.'Originating'); } if ($cnt_local > 0) { my $d = $msginfo->originating ? 'Internal' : 'Inbound'; snmp_count($counter_name.$d); } if ($cnt_remote > 0) { my $d = $msginfo->originating ? 'Outbound' : 'OpenRelay'; snmp_count($counter_name.$d); } } # set $r->delivery_method according to forward_method_maps_by_ccat lookup # or defaults for my $r (@{$msginfo->per_recip_data}) { next if defined($r->delivery_method); my $fwd_map = $r->setting_by_contents_category( cr('forward_method_maps_by_ccat')); my $fwd_m; $fwd_m = lookup2(0, $r->recip_addr, $fwd_map, Label=>"forward_method") if ref $fwd_map; $fwd_m = '' if !defined $fwd_m; $r->delivery_method($fwd_m); } # a custom hook may change $r->delivery_method if (ref $custom_object) { $which_section = "custom-before_send"; eval { $custom_object->before_send($conn,$msginfo); update_current_log_level(); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"custom before_send error: %s", $eval_stat); }; section_time($which_section); } if (ll(3)) { # log delivery method by recipients my(%fwd_m_displ_log); for my $r (@{$msginfo->per_recip_data}) { my $fwd_m = $r->delivery_method; my $fwd_m_displ = !defined $fwd_m ? "undefined, mail will not be forwarded" : map(ref eq 'ARRAY' ? '('.join(', ',@$_).')' : $_, $fwd_m); if (!$fwd_m_displ_log{$fwd_m_displ}) { $fwd_m_displ_log{$fwd_m_displ} = [ $r ]; } else { push(@{$fwd_m_displ_log{$fwd_m_displ}}, $r); } } for my $log_msg (sort keys %fwd_m_displ_log) { do_log(3, "delivery method is %s, recips: %s", $log_msg, join(', ', map($_->recip_addr, @{$fwd_m_displ_log{$log_msg}}))); } } my $bcc = $msginfo->setting_by_contents_category(cr('always_bcc_by_ccat')); if (defined $bcc && $bcc ne '') { my $recip_obj = Amavis::In::Message::PerRecip->new; $recip_obj->recip_addr_modified($bcc); # leave recip_addr and recip_addr_smtp undefined to hide it from the log? $recip_obj->recip_addr($bcc); $recip_obj->recip_addr_smtp(qquote_rfc2821_local($bcc)); #**** $recip_obj->recip_is_local( lookup2(0, $bcc, ca('local_domains_maps')) ? 1 : 0); $recip_obj->recip_destiny(D_PASS); $recip_obj->dsn_notify(['NEVER']); $recip_obj->delivery_method(c('notify_method')); $recip_obj->contents_category($msginfo->contents_category); # $recip_obj->add_contents_category(CC_CLEAN,0); $msginfo->per_recip_data([@{$msginfo->per_recip_data}, $recip_obj]); do_log(2,"adding recipient - always_bcc: %s, delivery method %s", $bcc, $recip_obj->delivery_method); } my $hdr_edits = $msginfo->header_edits; # to be delivered explicitly (not by an AM.PDP client) if (grep(!$_->recip_done && $_->delivery_method ne '', @{$msginfo->per_recip_data})) { # forwarding is needed $which_section = "forwarding"; $t0_sect = Time::HiRes::time; $zmq_obj->register_proc(2,0,'F',$am_id) if $zmq_obj; # forwarding $snmp_db->register_proc(2,0,'F',$am_id) if $snmp_db; $hdr_edits = add_forwarding_header_edits_common( $msginfo, $hdr_edits, $hold, $any_undecipherable, $virus_presence_checked, $spam_presence_checked); for (;;) { # do the delivery, in batches if necessary my $r_hdr_edits = Amavis::Out::EditHeader->new; # per-recip edits set $r_hdr_edits->inherit_header_edits($hdr_edits); my $done_all; my $recip_cl; # ref to a list of recip objects needing same mail edits # prepare header section edits, clusterize ($r_hdr_edits, $recip_cl, $done_all) = add_forwarding_header_edits_per_recip( $msginfo, $r_hdr_edits, $hold, $any_undecipherable, $virus_presence_checked, $spam_presence_checked, undef); last if !@$recip_cl; $msginfo->header_edits($r_hdr_edits); # store edits for this batch # preserve information that may be changed by prepare_modified_mail() my($m_t,$m_tfn,$m_ofs) = ($msginfo->mail_text, $msginfo->mail_text_fn, $msginfo->skip_bytes); my(@m_dm) = map($_->delivery_method, @{$msginfo->per_recip_data}); # mail body mangling/defanging/sanitizing my $body_modified = prepare_modified_mail($msginfo,$hold,$any_undecipherable,$recip_cl); # defanged_mime_entity have modified header edits, refetch just in case $r_hdr_edits = $msginfo->header_edits; if ($body_modified) { my $resend_m = c('resend_method'); if (defined $resend_m && $resend_m ne '') { $_->delivery_method($resend_m) for @{$msginfo->per_recip_data}; do_log(3,"mail body mangling in effect, resend_m: %s", $resend_m); } else { do_log(3,"mail body mangling in effect"); } } if (mail_dispatch($msginfo, 0, $dsn_per_recip_capable, sub { my $r = $_[0]; grep($_ eq $r, @$recip_cl) })) { $point_of_no_return = 1; # now past the point where mail was sent } # close and delete replacement file, if any my $tmp_fh = $msginfo->mail_text; # replacement file, to be removed if ($tmp_fh && !$tmp_fh->isa('MIME::Entity') && $tmp_fh ne $m_t) { $tmp_fh->close or do_log(-1,"Can't close replacement: %s", $!); if (debug_oneshot()) { do_log(5, "defanging+debug, preserving %s",$msginfo->mail_text_fn); } else { unlink($msginfo->mail_text_fn) or do_log(-1,"Can't remove %s: %s", $msginfo->mail_text_fn, $!); } } # restore temporarily modified settings $msginfo->mail_text($m_t); $msginfo->mail_text_fn($m_tfn); $msginfo->skip_bytes($m_ofs); $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef); $_->delivery_method(shift @m_dm) for @{$msginfo->per_recip_data}; last if $done_all; } # turn on CC_MTA in case of MTA trouble (e.g, rejected by MTA on fwding) for my $r (@{$msginfo->per_recip_data}) { my $smtp_resp = $r->recip_smtp_response; # skip successful deliveries and non- MTA-generated status codes next if $smtp_resp =~ /^2/ || $r->recip_done != 2; my $min_ccat = $smtp_resp =~ /^5/ ? 2 : $smtp_resp =~ /^4/ ? 1 : 0; $r->add_contents_category(CC_MTA,$min_ccat); $msginfo->add_contents_category(CC_MTA,$min_ccat); my $blocking_ccat = sprintf("%d,%d", CC_MTA,$min_ccat); $r->blocking_ccat($blocking_ccat); $msginfo->blocking_ccat($blocking_ccat) if !defined($msginfo->blocking_ccat); my $fd_map_ref = $r->setting_by_contents_category(cr('final_destiny_maps_by_ccat')); my $final_destiny = !ref $fd_map_ref ? $fd_map_ref # compatibility : lookup2(0, $r->recip_addr, $fd_map_ref, Label => 'Destiny3'); $final_destiny = D_PASS if !defined $final_destiny; if ($final_destiny == D_PASS) { # impossible to pass, change to tempfail or reject $final_destiny = $smtp_resp =~ /^5/ ? D_REJECT : D_TEMPFAIL; } $r->recip_destiny($final_destiny); local($1,$2); if ($smtp_resp !~ /^5/) { # keep unchanged } elsif ($final_destiny == D_DISCARD) { $smtp_resp =~ s{^\d(\d\d) \d(\.\d\.\d)}{250 2$2}; # 5xx -> 250 } elsif (c('soft_bounce')) { do_log(5, "soft_bounce: (mta) %s -> 450", $smtp_resp); $smtp_resp =~ s{^\d(\d\d) \d(\.\d\.\d)}{450 4$2}; # 5xx -> 450 } my $smtp_reason = # get the custom smtp response reason text $r->setting_by_contents_category(cr('smtp_reason_by_ccat')); $smtp_reason = '' if !defined $smtp_reason; if ($smtp_reason ne '') { my(%mybuiltins) = %builtins; # make a local copy $smtp_reason = expand(\$smtp_reason, \%mybuiltins); $smtp_reason = !ref($smtp_reason) ? '' : $$smtp_reason; chomp($smtp_reason); $smtp_reason = sanitize_str($smtp_reason,1); # coarsely chop to a sane size, wrap_smtp_resp() will finely adjust substr($smtp_reason,450) = '...' if length($smtp_reason) > 450+3; } $smtp_resp =~ /^(\d\d\d(?: \d\.\d\.\d)?)\s*(.*)\z/s; my $dis = $final_destiny == D_DISCARD ? ' Discarded' : ''; # the wrap_smtp_resp() will enforce the requirement in # RFC 5321 section 4.5.3.1.5 on a length of a reply line $r->recip_smtp_response("$1$dis $smtp_reason, $2"); $r->recip_done(1); # fake a delivery (confirm delivery to a bit bucket) # note that 5xx status rejects may later be converted to bounces } $msginfo->header_edits($hdr_edits); # restore original edits just in case $elapsed->{'TimeElapsedForwarding'} = Time::HiRes::time - $t0_sect; } # AM.PDP or AM.CL (milter) if (grep(!$_->recip_done && $_->delivery_method eq '', @{$msginfo->per_recip_data})) { $which_section = "AM.PDP headers"; $hdr_edits = add_forwarding_header_edits_common( $msginfo, $hdr_edits, $hold, $any_undecipherable, $virus_presence_checked, $spam_presence_checked); my $done_all; my $recip_cl; # ref to a list of similar recip objects ($hdr_edits, $recip_cl, $done_all) = add_forwarding_header_edits_per_recip( $msginfo, $hdr_edits, $hold, $any_undecipherable, $virus_presence_checked, $spam_presence_checked, undef); if (c('enable_dkim_signing')) { # add DKIM signatures my(@signatures) = Amavis::DKIM::dkim_make_signatures($msginfo,0); $msginfo->dkim_signatures_new(\@signatures) if @signatures; for my $signature (@signatures) { my $s = $signature->as_string; local($1); $s =~ s{\015\012}{\n}gs; $s =~ s{\n+\z}{}gs; $s =~ s/^((?:DKIM|DomainKey)-Signature):[ \t]*//si; $hdr_edits->prepend_header($1, $s, 2); } } $msginfo->header_edits($hdr_edits); # store edits (redundant) if (@$recip_cl && !$done_all) { do_log(-1, "AM.PDP: RECIPIENTS REQUIRE DIFFERENT HEADERS"); }; } prolong_timer($which_section); if (ref $custom_object) { $which_section = "custom-after_send"; eval { $custom_object->after_send($conn,$msginfo); update_current_log_level(); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"custom after_send error: %s", $eval_stat); }; section_time($which_section); } $which_section = "delivery-notification"; $t0_sect = Time::HiRes::time; # generate a delivery status notification according to RFC 6522 & RFC 3464 my($notification,$suppressed) = delivery_status_notification( $msginfo, $dsn_per_recip_capable, \%builtins, [$sender], 'dsn', undef, undef); my $ndn_needed; ($smtp_resp, $exit_code, $ndn_needed) = one_response_for_all($msginfo, $dsn_per_recip_capable, $suppressed && !defined($notification) ); do_log(4, "notif=%s, suppressed=%d, ndn_needed=%s, exit=%s, %s", defined $notification ? 'Y' : 'N', $suppressed, $ndn_needed, $exit_code, $smtp_resp); section_time('prepare-dsn'); if ($suppressed && !defined($notification)) { $msginfo->dsn_sent(2); # would-be-bounced, but bounce was suppressed } elsif (defined $notification) { # dsn needed, send delivery notification mail_dispatch($notification, 'Dsn', 0); my($n_smtp_resp, $n_exit_code, $n_dsn_needed) = one_response_for_all($notification, 0); # check status if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) { # dsn successful? $msginfo->dsn_sent(1); # mark the message as bounced $point_of_no_return = 2; # now past the point where DSN was sent build_and_save_structured_report($notification,'DSN'); } elsif ($n_smtp_resp =~ /^4/) { die sprintf("temporarily unable to send DSN to <%s>: %s", $msginfo->sender, $n_smtp_resp); } else { do_log(-1,"NOTICE: UNABLE TO SEND DSN to <%s>: %s", $sender, $n_smtp_resp); # # if dsn cannot be sent, try to send it to postmaster # $notification->recips(['postmaster']); # # attempt double bounce # mail_dispatch($notification, 'Notif', 0); } # $notification->purge; } prolong_timer($which_section); $elapsed->{'TimeElapsedDSN'} = Time::HiRes::time - $t0_sect; $which_section = "snmp-counters"; $t0_sect = Time::HiRes::time; { # increment appropriate InMsgsStatus* SNMP counters and do some sanity # checking along the way; also sets $msginfo->actions_performed # my($err, %which_counts); my $orig = $msginfo->originating; my $dsn_sent = $msginfo->dsn_sent; # 1=bounced, 2=suppressed for my $r (@{$msginfo->per_recip_data}) { my $which; my $done = $r->recip_done; # 2=relayed to MTA, 1=faked deliv/quarant my $dest = $r->recip_destiny; my $resp_code = $smtp_resp; # per-msg status (one_response_for_all) $resp_code = $r->recip_smtp_response if $dsn_per_recip_capable; my $resp_class = substr($resp_code||'0', 0, 1); if (!$done) { $which = 'Accepted'; my $fwd_m = $r->delivery_method; # double-checking our sanity if (defined $fwd_m && $fwd_m ne '') { $err = "Recip not done, nonempty delivery method: $fwd_m"; } } elsif ($resp_class !~ /^[245]\z/) { $err = "Bad response code: $resp_code"; } elsif ($resp_class eq '4') { $which = 'TempFailed'; } elsif ($resp_class eq '5' && $dest == D_REJECT) { $which = 'Rejected'; } else { # $resp_class eq '2' || $resp_class eq '5' && $dest!=D_REJECT # a 2xx SMTP response code is set both by internal Discard and # by a genuine successful delivery. To distinguish between the two # we need to check $r->recip_destiny if ($done == 2) { # successful genuine forwarding $which = $r->recip_tagged ? 'RelayedTagged' : 'RelayedUntagged'; $err = "Forwarded, but destiny not D_PASS? ($dest)" if $dest != D_PASS; $err = "Forwarded, but status not 2xx? ($resp_code)" if $resp_class ne '2'; } elsif ($dest == D_DISCARD) { # forwarded to a bit bucket $which = 'Discarded'; } elsif ( $dest == D_BOUNCE || ($dest == D_REJECT && $resp_class eq '2') ) { if ($dsn_sent && $dsn_sent == 1) { $which = 'Bounced'; # genuine bounce (DSN) sent } elsif ($dsn_sent) { $which = 'NoBounce'; # bounce suppressed } else { # sanity check $err = "To be bounced, but DSN was neither sent nor suppressed?"; } } elsif ($dest == D_REJECT) { $which = 'Rejected'; $err = "Rejected, but status not 5xx? ($resp_code)" if $resp_class ne '5'; } else { # sanity check $err = "Recip forwarding suppressed but not DISCARD?"; } } $which = 'Unknown' if !defined $which; $which_counts{$which}++; # counts status without a direction $which_counts{'Relayed'}++ if $which eq 'RelayedTagged' || $which eq 'RelayedUntagged'; my $islocal = $r->recip_is_local; if ($orig) { if ($islocal) { $which_counts{$which.'Internal'}++ } else { $which_counts{$which.'Outbound'}++ } $which_counts{$which.'Originating'}++; } else { if ($islocal) { $which_counts{$which.'Inbound'}++ } else { $which_counts{$which.'OpenRelay'}++ } } do_log(0, "unexpected status/result, please verify: %s, %s", $err, $r->recip_addr_smtp) if defined $err; } my @which_list = sort keys %which_counts; # prefer this status in the list first, before a 'Quarantined' entry; # ignore a plain status name without mail direction to reduce clutter; # ignore Originating, as it is always paired with Internal or Outbound $msginfo->actions_performed([]) if !$msginfo->actions_performed; unshift(@{$msginfo->actions_performed}, map(/^RelayedUntagged(.*)/ ? "Relayed$1" : $_, # short log name grep(/(?:Inbound|Internal|Outbound|OpenRelay)\z/, @which_list))); snmp_count('InMsgsStatus'.$_) for @which_list; ll(3) && do_log(3, 'status counters: InMsgsStatus{%s}', join(',', @which_list)); } prolong_timer($which_section); # merge similar timing entries $elapsed->{'TimeElapsedSending'} = 0; $elapsed->{'TimeElapsedSending'} += delete $elapsed->{$_} for ('TimeElapsedQuarantineAndNotify', 'TimeElapsedForwarding', 'TimeElapsedDSN'); $which_section = 'report'; eval { # protect the new code just in case # structured_report returns a string as perl characters (not octets) $report_ref = structured_report($msginfo); 1; } or do { chomp $@; do_log(-1,"structured_report failed: %s", $@); }; section_time($which_section); # generate customized log report at log level 0 - this is usually the # only log entry interesting to administrators during normal operation $which_section = 'main_log_entry'; my(%mybuiltins) = %builtins; # make a local copy { # do a per-message log entry # macro %T has overloaded semantics, ugly $mybuiltins{'T'} = $mybuiltins{'TESTSSCORES'}; my($y,$n,$f) = delivery_short_report($msginfo); @mybuiltins{'D','O','N'} = ($y,$n,$f); if (ll(0)) { my $strr = expand(cr('log_templ'), \%mybuiltins); for my $logline (split(/[ \t]*\n/, $$strr)) { do_log(0, '%s', $logline) if $logline ne ''; } } } if (c('log_recip_templ') ne '') { # do per-recipient log entries # redefine some macros with a by-recipient semantics my $j = 0; for my $r (@{$msginfo->per_recip_data}) { # recipient counter in macro %. may indicate to the template # that a per-recipient expansion semantics is expected $j++; $mybuiltins{'.'} = sprintf("%d",$j); my $recip = $r->recip_addr; my $qrecip_addr = scalar(qquote_rfc2821_local($recip)); my $remote_mta = $r->recip_remote_mta; my $smtp_resp = $r->recip_smtp_response; $mybuiltins{'remote_mta'} = $remote_mta; $mybuiltins{'smtp_response'} = $smtp_resp; $mybuiltins{'remote_mta_smtp_response'} = $r->recip_remote_mta_smtp_response; $mybuiltins{'D'} = $mybuiltins{'O'} = $mybuiltins{'N'} = undef; if ($r->recip_destiny==D_PASS &&($smtp_resp=~/^2/ || !$r->recip_done)){ $mybuiltins{'D'} = $qrecip_addr; } else { $mybuiltins{'O'} = $qrecip_addr; $mybuiltins{'N'} = sprintf("%s:%s\n %s", $qrecip_addr, ($remote_mta eq '' ?'' :" [$remote_mta] said:"), $smtp_resp); } my(@b); @b = @{$r->banned_parts} if defined $r->banned_parts; my $b_chopped = @b > 2; @b = (@b[0,1],'...') if $b_chopped; s/[ \t]{6,}/ ... /g for @b; $mybuiltins{'banned_parts'} = \@b; # list of banned parts $mybuiltins{'F'} = $r->banning_reason_short; # just one name & comment $mybuiltins{'banning_rule_comment'} = !defined($r->banning_rule_comment) ? undef : unique_ref($r->banning_rule_comment); $mybuiltins{'banning_rule_rhs'} = !defined($r->banning_rule_rhs) ? undef : unique_ref($r->banning_rule_rhs); my $dn = $r->dsn_notify; $mybuiltins{'dsn_notify'} = uc(join(',', $sender eq '' ? 'NEVER' : !$dn ? 'FAILURE' : @$dn)); my($tag_level,$tag2_level,$kill_level); if (!$r->bypass_spam_checks) { $tag_level = lookup2(0,$recip, ca('spam_tag_level_maps')); $tag2_level = lookup2(0,$recip, ca('spam_tag2_level_maps')); $kill_level = lookup2(0,$recip, ca('spam_kill_level_maps')); } my $is_local = $r->recip_is_local; my $do_tag = $r->is_in_contents_category(CC_CLEAN,1); my $do_tag2 = $r->is_in_contents_category(CC_SPAMMY); my $do_kill = $r->is_in_contents_category(CC_SPAM); for ($do_tag,$do_tag2,$do_kill) { $_ = $_ ? 'Y' : '0' } # normalize for ($is_local) { $_ = $_ ? 'L' : '0' } # normalize for ($tag_level,$tag2_level,$kill_level) { $_ = 'x' if !defined($_) } $mybuiltins{'R'} = $recip; $mybuiltins{'c'} = $mybuiltins{'SCORE'} = $mybuiltins{'STARS'} = sub { macro_score($msginfo, $j-1, @_) }; # info on one recipient $mybuiltins{'T'} = $mybuiltins{'TESTSSCORES'} = $mybuiltins{'TESTS'} = sub { macro_tests($msginfo, $j-1, @_)}; # info on one recipient $mybuiltins{'tag_level'} = # replacement for deprecated %3 !defined($tag_level) ? '-' : 0+sprintf("%.3f",$tag_level); $mybuiltins{'tag2_level'} = $mybuiltins{'REQD'} = # replacement for %4 !defined($tag2_level) ? '-' : 0+sprintf("%.3f",$tag2_level); $mybuiltins{'kill_level'} = # replacement for deprecated %5 !defined($kill_level) ? '-' : 0+sprintf("%.3f",$kill_level); @mybuiltins{('0','1','2','k')} = ($is_local,$do_tag,$do_tag2,$do_kill); # macros %3, %4, %5 are deprecated, replaced by tag/tag2/kill_level @mybuiltins{('3','4','5')} = ($tag_level,$tag2_level,$kill_level); $mybuiltins{'ccat'} = sub { my($name,$attr,$which) = @_; $attr = lc $attr; # name | major | minor | <empty> # | is_blocking | is_nonblocking # | is_blocked_by_nonmain $which = lc $which; # main | blocking | auto my $result = ''; my $blocking_ccat = $r->blocking_ccat; if ($attr eq 'is_blocking') { $result = defined($blocking_ccat) ? 1 : ''; } elsif ($attr eq 'is_nonblocking') { $result = !defined($blocking_ccat) ? 1 : ''; } elsif ($attr eq 'is_blocked_by_nonmain') { if (defined($blocking_ccat)) { my $aref = $r->contents_category; $result = 1 if ref($aref) && @$aref > 0 && $blocking_ccat ne $aref->[0]; } } elsif ($attr eq 'name') { $result = $which eq 'main' ? $r->setting_by_main_contents_category(\%ccat_display_names) : $which eq 'blocking' ? $r->setting_by_blocking_contents_category( \%ccat_display_names) : $r->setting_by_contents_category( \%ccat_display_names); } else { # attr = major, minor, or anything else returns a pair my($maj,$min) = ccat_split( ($which eq 'blocking' || $which ne 'main' && defined $blocking_ccat) ? $blocking_ccat : $r->contents_category); $result = $attr eq 'major' ? $maj : $attr eq 'minor' ? sprintf("%d",$min) : sprintf("(%d,%d)",$maj,$min); } $result; }; my $strr = expand(cr('log_recip_templ'), \%mybuiltins); for my $logline (split(/[ \t]*\n/, $$strr)) { do_log(0, "%s", $logline) if $logline ne ''; } } } section_time($which_section); prolong_timer($which_section); if (defined $os_fingerprint && $os_fingerprint ne '') { $which_section = 'log_p0f'; # log and collect statistics on contents type vs. OS my $spam_ham_thd = 2.0; # reasonable threshold guesstimate local($1); my $os_short; # extract operating system name when avail. $os_short = $1 if $os_fingerprint =~ /^([^,([]*)/; $os_short = $1 if $os_short =~ /^[ \t,-]*(.*?)[ \t,-]*\z/; my $snmp_counter_name; if ($os_short ne '') { $os_short = $1 if $os_short =~ /^(Windows [^ ]+|[^ ]+)/; # drop vers. $os_short =~ s{[^0-9A-Za-z:./_+-]}{-}g; $os_short =~ s{\.}{,}g; $snmp_counter_name = $msginfo->setting_by_contents_category( { CC_VIRUS,'virus', CC_BANNED,'banned', CC_SPAM,'spam', CC_SPAMMY,'spammy', CC_CATCHALL,'clean' }); if ($snmp_counter_name eq 'clean') { $snmp_counter_name = $max_spam_level <= $spam_ham_thd ?'ham' : undef; } if (defined $snmp_counter_name) { snmp_count("$snmp_counter_name.byOS.$os_short"); if ($snmp_counter_name eq 'ham' && $os_fingerprint =~ /^Windows XP(?![^(]*\b2000 SP)/) { do_log(3, 'Ham from Windows XP? Most weird! %s [%s] score=%.3f', $mail_id||'', $cl_ip, $max_spam_level); } } } do_log(2, "OS_fingerprint: %s %s %s.%s - %s", $msginfo->client_addr, $max_spam_level, defined $snmp_counter_name ? $snmp_counter_name : 'x', $os_short, $os_fingerprint); } if ($redis_storage && defined $msginfo->mail_id) { $which_section = 'redis-update'; # save final information to Redis eval { $redis_storage->save_info_final($msginfo,$report_ref); 1; } or do { chomp $@; do_log(-1, 'save_info_final failed, Redis error: %s', $@); }; section_time($which_section); } if ($sql_storage && defined $msginfo->mail_id) { # save final information to SQL (if enabled) $which_section = 'sql-update'; for (my $attempt=5; $attempt>0; ) { # sanity limit on retries if ($sql_storage->save_info_final($msginfo,$report_ref)) { last; } elsif (--$attempt <= 0) { do_log(-2,"ERROR sql_storage: too many retries ". "on storing final, info not saved"); } else { do_log(2,"sql_storage: retrying on final, %d attempts remain", $attempt); sleep(int(1+rand(3))); # can't mix Time::HiRes::sleep with alarm } }; section_time($which_section); } if (ll(2)) { # log SpamAssassin timing report if available my $sa_tim = $msginfo->supplementary_info('TIMING'); if (defined $sa_tim && $sa_tim ne '') { my $sa_rusage = $msginfo->supplementary_info('RUSAGE-SA'); if ($sa_rusage && @$sa_rusage) { local $1; my $sa_cpu_sum = 0; $sa_cpu_sum += $_ for @$sa_rusage; $sa_tim =~ s{^(total [0-9.]+ ms)} {sprintf("[%s, cpu %.0f ms]", $1, $sa_cpu_sum*1000)}se; } do_log(2, "TIMING-SA %s", $sa_tim); } } if ($snmp_db || $zmq_obj) { $which_section = 'update_snmp'; my($log_lines, $log_entries_by_level_ref, $log_retries, $log_status_counts_ref) = collect_log_stats(); snmp_count( ['LogLines', $log_lines, 'C64'] ); my $log_entries_all_cnt = 0; for my $level_str (keys %$log_entries_by_level_ref) { my $level = 0+$level_str; my $cnt = $log_entries_by_level_ref->{$level_str}; $log_entries_all_cnt += $cnt; # snmp_count( ['LogEntriesEmerg', $cnt, 'C64'] ); # not in use # snmp_count( ['LogEntriesAlert', $cnt, 'C64'] ); # not in use snmp_count( ['LogEntriesCrit', $cnt, 'C64'] ) if $level <= -3; snmp_count( ['LogEntriesErr', $cnt, 'C64'] ) if $level <= -2; snmp_count( ['LogEntriesWarning', $cnt, 'C64'] ) if $level <= -1; snmp_count( ['LogEntriesNotice', $cnt, 'C64'] ) if $level <= 0; snmp_count( ['LogEntriesInfo', $cnt, 'C64'] ) if $level <= 1; snmp_count( ['LogEntriesDebug', $cnt, 'C64'] ); if ($level < 0) { $level_str = "0" } elsif ($level > 5) { $level_str = "5" } snmp_count( ['LogEntriesLevel'.$level_str, $cnt, 'C64'] ); } snmp_count( ['LogEntries', $log_entries_all_cnt, 'C64'] ); if ($log_retries > 0) { snmp_count( ['LogRetries', $log_retries, 'C64'] ); do_log(3,"Syslog retries: %d x %s", $log_status_counts_ref->{$_}, $_) for (keys %$log_status_counts_ref); } snmp_count( ['entropy',0,'STR'] ); $elapsed->{'TimeElapsedTotal'} = Time::HiRes::time - $msginfo->rx_time; # Will end up as SNMPv2-TC TimeInterval (INTEGER), units of 0.01 seconds, # but we keep it in milliseconds in the bdb database! # Note also the use of C32 instead of INT, we want cumulative time. snmp_count([$_, int(1000*$elapsed->{$_}+0.5), 'C32']) for keys %$elapsed; $snmp_db->update_snmp_variables if $snmp_db; $zmq_obj->update_snmp_variables if $zmq_obj; section_time($which_section); } if (ref $custom_object) { $which_section = "custom-mail_done"; eval { $custom_object->mail_done($conn,$msginfo); update_current_log_level(); 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"custom mail_done error: %s", $eval_stat); }; section_time($which_section); } $which_section = 'finishing'; 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; $preserve_evidence = 1 if $allow_preserving_evidence; my $msg = "$which_section FAILED: $eval_stat"; if ($point_of_no_return) { do_log(-2, "TROUBLE in check_mail, but must continue (%s): %s", $point_of_no_return, $msg); } else { do_log(-2, "TROUBLE in check_mail: %s", $msg); undef $smtp_resp; # to be provided below } if (!defined($smtp_resp)) { $smtp_resp = "451 4.5.0 Error in processing, id=$am_id, $msg"; $exit_code = EX_TEMPFAIL; for my $r (@{$msginfo->per_recip_data}) { next if $r->recip_done; $r->recip_smtp_response($smtp_resp); $r->recip_done(1); } } }; # if (defined $hold && $hold ne '') { # do_log(-1, "NOTICE: Evidence is to be preserved: %s", $hold); # $preserve_evidence = 1 if $allow_preserving_evidence; # } if (!$preserve_evidence && debug_oneshot()) { do_log(0, "DEBUG_ONESHOT CAUSES EVIDENCE TO BE PRESERVED"); $preserve_evidence = 1; # regardless of $allow_preserving_evidence } if ($redis_storage && $redis_logging_queue_size_limit && c('redis_logging_key') ) { if ($report_ref) { # already have it # last-minute update of the "elapsed" field structured_report_update_time($report_ref); } else { # prepare the log report eval { # protect the new code just in case # structured_report returns a string as perl characters (not octets) $report_ref = structured_report($msginfo); 1; } or do { chomp $@; do_log(-1, 'structured_report failed: %s', $@); }; } eval { $redis_storage->save_structured_report($report_ref, c('redis_logging_key'), $redis_logging_queue_size_limit); 1; } or do { chomp $@; do_log(-1, 'save_structured_report failed: %s', $@); }; } $zmq_obj->register_proc(1,0,'.') if $zmq_obj; # content checking done $snmp_db->register_proc(1,0,'.') if $snmp_db; do_log(-1, "signal: %s", join(', ',keys %got_signals)) if %got_signals; undef $MSGINFO; # release global reference ($smtp_resp, $exit_code, $preserve_evidence); } # end check_mail # ROT13 obfuscation (Caesar cipher) # (possibly useful as a weak privacy measure when analyzing logs) # sub rot13 { my $str = $_[0]; $str =~ tr/a-zA-Z/n-za-mN-ZA-M/; $str; } # Assemble a structured report, suitable for JSON serialization, useful # in save_info_final(). Resulting string is in Perl logical characters # (not necessarily with UTF8 flag set if all-ASCII). # sub structured_report($;$) { my($msginfo, $notification_type) = @_; my(@recipients); # per-recipient records my(@queued_as_list); # list of unique MTA queue IDs of forwarded mail my(@smtp_status_code_list); # list of unique SMTP responses my(@destiny_list); # list of destiny names my(@mail_id_related); # list of related mail_id's according to penpals my(%spam_test_names); my $true = Amavis::JSON::boolean(1); local($1,$2); my $sender_smtp = $msginfo->sender_smtp; $sender_smtp =~ s/^<(.*)>\z/$1/s; my(@rcpt_smtp) = map($_->recip_addr_smtp, @{$msginfo->per_recip_data}); s/^<(.*)>\z/$1/s for @rcpt_smtp; my $h_sender = $msginfo->rfc2822_sender; # undef or scalar my $h_from = $msginfo->rfc2822_from; # undef, scalar or listref my $h_to = $msginfo->rfc2822_to; # undef, scalar or listref my $h_cc = $msginfo->rfc2822_cc; # undef, scalar or listref my(@arr_h_from, @arr_h_to, @arr_h_cc); @arr_h_from = ref $h_from ? @$h_from : $h_from if defined $h_from; @arr_h_to = ref $h_to ? @$h_to : $h_to if defined $h_to; @arr_h_cc = ref $h_cc ? @$h_cc : $h_cc if defined $h_cc; # Message-ID can contain an international domain name with A-labels my(@arr_m_id, @arr_refs); my $m_id = $msginfo->get_header_field_body('message-id'); @arr_m_id = parse_message_id($m_id) if defined $m_id && $m_id ne ''; my $h_refs = $msginfo->references; @arr_refs = @$h_refs if $h_refs; $_ = mail_addr_decode($_) for (@arr_m_id, @arr_refs, $sender_smtp, @rcpt_smtp, $h_sender, @arr_h_from, @arr_h_to, @arr_h_cc); my $j = 0; for my $r (@{$msginfo->per_recip_data}) { my $recip_smtp = $rcpt_smtp[$j++]; # already processed for UTF-8 my $orig_rcpt = $r->dsn_orcpt; # RCPT command ORCPT option, RFC 3461 if (defined $orig_rcpt) { my($addr_type, $addr) = orcpt_encode($orig_rcpt,1); # to octets # is orcpt redundant? $orig_rcpt = defined $recip_smtp && $addr eq $recip_smtp ? undef : safe_decode_utf8($addr); # to characters } my $dest = $r->recip_destiny; my $resp = $r->recip_smtp_response; my $rem_smtp_resp = $r->recip_remote_mta_smtp_response; my($queued_as, $resp_code, $resp_code_enh); $queued_as = $1 if defined $rem_smtp_resp && $rem_smtp_resp =~ /\bqueued as ([0-9A-Za-z]+)$/; ($resp_code, $resp_code_enh) = ($1,$2) if $resp =~ /^(\d{3}) (?: [ \t]+ ([245] \. \d{1,3} \. \d{1,3}) \b)? /xs; my $d = $resp=~/^4/ ? 'TEMPFAIL' : ($dest==D_BOUNCE && $resp=~/^5/) ? 'BOUNCE' : ($dest!=D_BOUNCE && $resp=~/^5/) ? 'REJECT' : ($dest==D_DISCARD) ? 'DISCARD' : ($dest==D_PASS && ($resp=~/^2/ || !$r->recip_done)) ? ($notification_type ? $notification_type : 'PASS') : '?'; push(@destiny_list, $d); push(@smtp_status_code_list, $resp_code); push(@queued_as_list, $queued_as) if defined $queued_as; my $rid = $r->recip_maddr_id; # may be undefined my $o_rid = $r->recip_maddr_id_orig; # may be undefined my $banning_reason_short = $r->banning_reason_short; my $spam_level = $r->spam_level; my $user_policy_id = $r->user_policy_id; my $ccat_blk_name = $r->setting_by_blocking_contents_category(\%ccat_display_names); my $ccat_main_name = $r->setting_by_main_contents_category(\%ccat_display_names); if (!defined $ccat_main_name || # ($ccat_main_name =~ /^(?:Clean|CatchAll)\z/s) || (defined $ccat_blk_name && $ccat_main_name eq $ccat_blk_name)) { # not worth reporting main ccat if the same as blocking ccat (or clean?) undef $ccat_main_name; } my $spam_tests = $r->spam_tests; # arrayref of scalar refs if ($spam_tests) { for my $test_name_val (split(/,/,join(',',map($$_,@$spam_tests)))) { my($tname, $tscore) = split(/=/, $test_name_val, 2); $spam_test_names{$tname} = max($tscore, $spam_test_names{$tname}); } } my $penpals_age = $r->recip_penpals_age; # penpals age in seconds, or undef my $penpals_related = $r->recip_penpals_related; push(@mail_id_related, $penpals_related) if defined $penpals_related; my(%recip) = ( rcpt_to => $recip_smtp, defined $orig_rcpt ? (rcpt_to_orig => $orig_rcpt) : (), defined $rid ? (rid => $rid) : (), defined $o_rid ? (rid_orig => Amavis::JSON::numeric($o_rid)) : (), rcpt_is_local => Amavis::JSON::boolean($r->recip_is_local), defined $user_policy_id ? (sql_user_policy_id => $user_policy_id) : (), action => $d, # i.e. destiny defined $resp ? (smtp_response => $resp) : (), defined $resp_code ? (smtp_code => $resp_code) : (), # defined $resp_code_enh ? (smtp_code_enh => $resp_code_enh) : (), defined $queued_as ? (queued_as => $queued_as) : (), !defined $spam_level ? () : (spam_score => Amavis::JSON::numeric(sprintf("%.3f",$spam_level))), $r->recip_blacklisted_sender ? (blacklisted => $true) : (), $r->recip_whitelisted_sender ? (whitelisted => $true) : (), $r->bypass_virus_checks ? (bypass_virus_checks => $true) : (), $r->bypass_banned_checks ? (bypass_banned_checks => $true) : (), $r->bypass_spam_checks ? (bypass_spam_checks => $true) : (), defined $ccat_blk_name ? (ccat_blocking => $ccat_blk_name) : (), defined $ccat_main_name ? (ccat_main => $ccat_main_name) : (), $banning_reason_short ? (banning_reason => $banning_reason_short) : (), defined $penpals_related ? (mail_id_related => $penpals_related) : (), !defined $penpals_age ? () : (penpals_age => Amavis::JSON::numeric(int($penpals_age))), # recip_tagged # was tagged by address extension or Subject or X-Spam ); push(@recipients, \%recip); } my $q_type = $msginfo->quar_type; # only keep the first quarantine type used (e.g. ignore archival quar.) $q_type = $q_type->[0] if ref $q_type; my $q_to = $msginfo->quarantined_to; # ref to a list of quar. locations if (!$q_to || !@$q_to) { undef $q_to } else { $q_to = $q_to->[0]; # keep only the first quarantine location $q_to =~ s{^\Q$QUARANTINEDIR\E/}{}; # strip directory name } my($min_spam_level, $max_spam_level) = minmax(map($_->spam_level, @{$msginfo->per_recip_data})); my(@test_names_spam_topdown) = sort { $spam_test_names{$b} <=> $spam_test_names{$a} } grep($spam_test_names{$_} > 0, keys %spam_test_names); my(@test_names_ham_bottomup) = sort { $spam_test_names{$a} <=> $spam_test_names{$b} } grep($spam_test_names{$_} < 0, keys %spam_test_names); my $useragent = $msginfo->get_header_field_body('user-agent'); $useragent = $msginfo->get_header_field_body('x-mailer') if !$useragent; $useragent =~ s/^\s*(.*?)\s*\z/$1/s if $useragent; my $subj = $msginfo->get_header_field_body('subject'); my $from = $msginfo->get_header_field_body('from'); # raw full field for ($subj,$from) { # character set decoding, unfolding chomp; s/\n(?=[ \t])//gs; s/^[ \t]+//s; s/[ \t]+\z//s; # unfold, trim $_ = safe_decode_mime($_); # to logical characters } my($conn, $src_ip, $dst_ip, $dst_port, $appl_proto); $conn = $msginfo->conn_obj; if ($conn) { # MTA -> amavisd $src_ip = $conn->client_ip; # immediate client IP addr, i.e. our MTA $dst_ip = $conn->socket_ip; # IP address of our receiving socket $dst_port = $conn->socket_port; # port number of our receiving socket $appl_proto = $conn->appl_proto; # protocol - the 'WITH' field } my $client_addr = $msginfo->client_addr; # SMTP client -> MTA my $client_port = $msginfo->client_port; # SMTP client -> MTA my $trace_ref = $msginfo->trace; # "Received" trace entries (hashrefs) my $ip_trace_public = $msginfo->ip_addr_trace_public; # "Received" IP trace my $checks_performed = $msginfo->checks_performed; $checks_performed = join(' ', grep($checks_performed->{$_}, qw(V S H B F P D))) if $checks_performed; my $actions_performed = $msginfo->actions_performed; $actions_performed = join(' ', @$actions_performed) if $actions_performed; @destiny_list = unique_list(\@destiny_list); my $partition_tag = $msginfo->partition_tag; my $sid = $msginfo->sender_maddr_id; my $policy_bank_path = c('policy_bank_path'); my $is_mlist = $msginfo->is_mlist; $is_mlist =~ s/^ml:(?=.)//s if $is_mlist; # strip ml: prefix my $os_fp = $msginfo->client_os_fingerprint; my $dsn_sent = $msginfo->dsn_sent; my $queue_id = $msginfo->queue_id; @queued_as_list = unique_list(\@queued_as_list); @smtp_status_code_list = unique_list(\@smtp_status_code_list); my $dkim_author_sig = $msginfo->dkim_author_sig; my $dkim_sigs_new_ref = $msginfo->dkim_signatures_new; my $dkim_sigs_ref = $msginfo->dkim_signatures_valid; my(@dkim_sigs_valid, @dkim_sigs_new); # domain names, IDN-decoded @dkim_sigs_valid = unique_list(map(idn_to_utf8($_->domain), @$dkim_sigs_ref)) if $dkim_sigs_ref; @dkim_sigs_new = unique_list(map(idn_to_utf8($_->domain), @$dkim_sigs_new_ref)) if $dkim_sigs_new_ref; my $vn = $msginfo->virusnames; undef $vn if $vn && !@$vn; my(%scanners_report); # per-scanner report of virus names found if ($vn) { for (@av_scanners_results) { my($av, $status, @virus_names) = @$_; my $scanner = $av && $av->[0]; if ($status && defined $scanner) { $scanner =~ tr/"/'/; # sanitize scanner name for json $scanner =~ tr/\x00-\x1F\x7F\x80-\x9F\\/ /; $scanners_report{$scanner} = \@virus_names; } } } my $rx_time = $msginfo->rx_time; my $mjd = $rx_time/86400 + 40587; # Modified Julian Day, float my($iso8601_year, $iso8601_wn) = iso8601_year_and_week($rx_time); my(%elapsed); if (!$notification_type) { my $elapsed_ref = $msginfo->time_elapsed; if ($elapsed_ref) { while (my($k,$v) = each(%$elapsed_ref)) { next if $k eq 'TimeElapsedPenPals'; # quick, don't bother $k =~ s/^TimeElapsed//; $elapsed{$k} = $v; # cast to numeric later down } } } my $attached_file_names; { my @unvisited = $msginfo->parts_root; while (@unvisited) { my $part = shift @unvisited; next unless $part; if ($part->name_declared) { push @$attached_file_names, $part->name_declared } else { push @unvisited, @{$part->children} } } } my(%result) = ( type => 'amavis', host => safe_decode_utf8(idn_to_utf8(c('myhostname'))), log_id => $msginfo->log_id, # secret_id => $msginfo->secret_id, mail_id => $msginfo->mail_id, !defined $msginfo->parent_mail_id ? () : (mail_id_parent => $msginfo->parent_mail_id), @mail_id_related ? (mail_id_related => \@mail_id_related) : (), defined $src_ip ? (src_ip => $src_ip) : (), defined $dst_ip ? (dst_ip => $dst_ip) : (), $dst_port ? (dst_port => Amavis::JSON::numeric($dst_port)) : (), defined $client_addr ? (client_ip => $client_addr) : (), $client_port ? (client_port => Amavis::JSON::numeric($client_port)) : (), defined $partition_tag ? (partition => $partition_tag) : (), defined $queue_id && $queue_id ne '' ? (queue_id => $queue_id) : (), defined $sid ? (sid => $sid) : (), defined $appl_proto ? (protocol => $appl_proto) : (), $attached_file_names ? (attached_file_names => $attached_file_names) : (), # addresses from SMTP envelope: mail_from => $sender_smtp, rcpt_to => \@rcpt_smtp, # list of recipient addresses rcpt_num => Amavis::JSON::numeric(scalar @rcpt_smtp), # num. of recips recipients => \@recipients, # list of hashes # addresses from mail header: !defined $h_sender ? () : (sender => $h_sender), $h_from ? (author => \@arr_h_from) : (), $h_to ? (to_addr => \@arr_h_to) : (), $h_cc ? (cc_addr => \@arr_h_cc) : (), # defined $from ? (from_raw => $from) : (), defined $subj ? (subject => $subj) : (), defined $subj ? (subject_rot13 => rot13($subj)) : (), defined $m_id ? (message_id => join(' ',@arr_m_id)) : (), @arr_refs ? (references => \@arr_refs) : (), defined $useragent ? (user_agent => $useragent) : (), !defined $policy_bank_path ? () : (policy_banks => [ split(m{/}, $policy_bank_path) ]), $ip_trace_public ? (ip_trace => [ @$ip_trace_public ]) : (), !$trace_ref || !@$trace_ref ? () : (ip_proto_trace => [ map( (!$_->{with} ? '' : $_->{with}.'://') . (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip} : '['.$_->{ip}.']:'.$_->{port}), @$trace_ref) ]), !$msginfo->msg_size ? () : (size => Amavis::JSON::numeric(0+$msginfo->msg_size)), !$msginfo->body_digest ? () : (digest_body => $msginfo->body_digest), content_type => # blocking ccat if blocked, main ccat otherwise $msginfo->setting_by_contents_category(\%ccat_display_names), defined $q_to ? (quarantine => $q_to) : (), defined $q_type ? (quar_type => $q_type) : (), !defined $max_spam_level ? () : (spam_score => Amavis::JSON::numeric(sprintf("%.3f",$max_spam_level))), $notification_type ? () : (dsn_sent => Amavis::JSON::boolean($dsn_sent==1)), originating => Amavis::JSON::boolean($msginfo->originating), defined $os_fp && $os_fp ne '' ? (os_fp => $os_fp) : (), defined $actions_performed ? (actions_performed => $actions_performed): (), defined $checks_performed ? (checks_performed => $checks_performed) : (), $vn ? (virusnames => unique_ref($vn)) : (), $vn ? (av_scan => \%scanners_report) : (), # %spam_test_names ? (tests => { %spam_test_names }) : (), !%spam_test_names ? () : ( tests => [ sort keys %spam_test_names ], # alphabetically tests_spam => \@test_names_spam_topdown, # > 0, largest first tests_ham => \@test_names_ham_bottomup, # < 0, smallest first ), $msginfo->is_auto ? (is_auto_resp => $true) : (), # is an auto-response $msginfo->is_mlist? (is_mlist => $true) : (), # is a mailing list $msginfo->is_bulk ? (is_bulk => $true) : (), # bulk or m.list or auto-resp @dkim_sigs_valid ? (dkim_valid_sig => \@dkim_sigs_valid) : (), @dkim_sigs_new ? (dkim_new_sig => \@dkim_sigs_new) : (), defined $dkim_author_sig ? (dkim_author_sig => $dkim_author_sig) : (), !@smtp_status_code_list ? () : (smtp_code => \@smtp_status_code_list), !@queued_as_list ? () : (queued_as => \@queued_as_list), action => \@destiny_list, message => # a brief report sprintf("%s %s %s %s -> %s", $msginfo->log_id, join(',', @destiny_list), $msginfo->setting_by_contents_category(\%ccat_display_names), $sender_smtp, join(',', @rcpt_smtp)), time_unix => # UNIX time to millisecond precision Amavis::JSON::numeric(sprintf("%.3f", $rx_time)), # time_mjd => # Modified Julian Day to millisecond precision # Amavis::JSON::numeric(sprintf("%14.8f", $mjd)), '@timestamp' => iso8601_utc_timestamp($rx_time,undef,undef,1,1), time_iso_week_date => sprintf("%04d-W%02d-%d", $iso8601_year, # ISO week-numbering year $iso8601_wn, # ISO week number 1..53 iso8601_weekday($rx_time)), # 1..7, Mo=1, localtime !%elapsed ? () : (elapsed => \%elapsed), ); if (%elapsed) { # last-minute update of total elapsed time, cast to numeric my $el = $result{elapsed}; $el->{Total} = get_time_so_far(); $el->{Amavis} = $el->{Total}-($el->{SpamCheck}||0)-($el->{VirusCheck}||0); $el->{$_} = Amavis::JSON::numeric(sprintf("%.3f",$el->{$_})) for keys %$el; } \%result; } # Last-minute update of total elapsed time # sub structured_report_update_time($) { my $report_ref = $_[0]; if ($report_ref->{elapsed}) { # just Total, does not adjust $report_ref->{elapsed}{Amavis} $report_ref->{elapsed}{Total} = Amavis::JSON::numeric(sprintf("%.3f", get_time_so_far())); } $report_ref; } sub build_and_save_structured_report($$) { my($msginfo, $notification_type) = @_; if ($redis_storage && $redis_logging_queue_size_limit && c('redis_logging_key') ) { do_log(5,'build_and_save_structured_report on %s', $notification_type); eval { # protect the new code just in case $redis_storage->save_structured_report( structured_report($msginfo, $notification_type), c('redis_logging_key'), $redis_logging_queue_size_limit); 1; } or do { chomp $@; do_log(-1, 'save_structured_report failed: %s', $@); }; } } # Ensure we have $msginfo->$entity defined when we expect we'll need it, # sub ensure_mime_entity($) { my $msginfo = $_[0]; my($ent,$mime_err); if (!defined($msginfo->mime_entity)) { my $msg = $msginfo->mail_text; if (IO::File->VERSION >= 1.10) { # see mime_decode() for explanation my $msg_str_ref = $msginfo->mail_text_str; # have an in-memory copy? $msg = $msg_str_ref if ref $msg_str_ref; } ($ent,$mime_err) = mime_decode($msg, $msginfo->mail_tempdir, $msginfo->parts_root); $msginfo->mime_entity($ent); prolong_timer('mime_decode'); } $mime_err; } # Check if a message is a bounce, and if it is, try to obtain essential # information from a header section of an attached original message, # primarily the Message-ID. # sub inspect_a_bounce_message($) { my $msginfo = $_[0]; my(%header_field,$bounce_type); my $is_true_bounce = 0; my $parts_root = $msginfo->parts_root; if (!defined($parts_root)) { do_log(5, 'inspect_dsn: no parts root'); } else { my $sender = $msginfo->sender; my $structure_type = '?'; my $top_main; my $top = $parts_root->children; for my $e (!$top ? () : @$top) { # take a main message component, ignoring preamble/epilogue MIME parts # and pseudo components such as a fabricated 'MAIL' (i.e. a copy of # entire message for the benefit of some virus scanners) my($name, $type) = ($e->name_declared, $e->type_declared); next if !defined $type && defined $name && ($name eq 'preamble' || $name eq 'epilogue'); next if $e->type_short eq 'MAIL' && defined $type && $type =~ m{^message/(?:rfc822|global)\z}si; $top_main = $e; last; } my(@parts); my $fname_ind; my $plaintext = 0; if (defined $top_main) { # one level only my $ch = $top_main->children; @parts = ($top_main, !$ch ? () : @$ch); } my(@t) = map { my $t = $_->type_declared; lc(ref $t ? $t->[0] : $t) } @parts; ll(5) && do_log(5, "inspect_dsn: parts: %s", join(", ",@t)); my $fm = $msginfo->rfc2822_from; my(@rfc2822_from) = !defined $fm ? () : ref $fm ? @$fm : $fm; my $p0_report_type; $p0_report_type = $parts[0]->report_type if @parts; $p0_report_type = lc $p0_report_type if defined $p0_report_type; if ( @parts >= 2 && @parts <= 4 && $t[0] eq 'multipart/report' && # RFC 6522 ( $t[2] eq 'message/delivery-status' || # RFC 3464 $t[2] eq 'message/global-delivery-status' || # RFC 6533 $t[2] eq 'message/disposition-notification' || # RFC 3798 $t[2] eq 'message/global-disposition-notification' || # RFC 6533 $t[2] eq 'message/feedback-report' # RFC 5965 ) && defined $p0_report_type && $t[2] eq 'message/'.$p0_report_type && $t[3] =~ m{^ (?: text/rfc822-headers | # RFC 6522 message/(?: rfc822-headers | global-headers | rfc822 | global | partial )) \z}xs # message/rfc822-headers and message/partial are nonstandard ) { # standard DSN or MDN or feedback-report $bounce_type = $t[2] eq 'message/disposition-notification' ? 'MDN' : $t[2] eq 'message/global-disposition-notification' ? 'MDN' : $t[2] eq 'message/feedback-report' ? 'ARF' : 'DSN'; $structure_type = 'standard ' . $bounce_type; $fname_ind = $#parts; $is_true_bounce = 1; } elsif ( @parts == 5 && $t[0] eq 'multipart/report' && $t[-2] eq 'message/delivery-status' && defined $p0_report_type && $t[-2] eq 'message/'.$p0_report_type && $t[-1] =~ m{^ (?: text/rfc822-headers | message/(?: global-headers|rfc822|global )) \z}xs ) { # almost standard DSN, has two leading plain text parts $bounce_type = 'DSN'; # BorderWare Security Platform $structure_type = 'standard ' . $bounce_type; $fname_ind = $#parts; $is_true_bounce = 1; } elsif ( @parts >= 2 && @parts <= 4 && $t[0] eq 'multipart/report' && $t[2] eq 'message/delivery-status' && defined $p0_report_type && $t[2] eq 'message/'.$p0_report_type && $t[3] eq 'text/plain' ) { # nonstandard DSN, missing header, unless it is stashed in text/plain $fname_ind = 3; $structure_type = 'nostandard DSN-plain'; $plaintext = 1; $bounce_type = 'DSN'; } elsif (@parts >= 3 && @parts <= 4 && # a root with 2 or 3 leaves $t[0] eq 'multipart/report' && defined $p0_report_type && $p0_report_type eq 'delivery-status' && $t[-1] =~ m{^ (?: text/rfc822-headers | message/(?: global-headers|rfc822|global )) \z}xs) { # not quite std. DSN (missing message/delivery-status), but recognizable $fname_ind = -1; $is_true_bounce = 1; $bounce_type = 'DSN'; $structure_type = 'DSN, missing delivery-status part'; } elsif (@parts >= 3 && @parts <= 5 && $t[0] eq 'multipart/mixed' && $t[-1] =~ m{^ (?: text/rfc822-headers | message/(?: global-headers|rfc822|global| rfc822-headers )) \z}xs && ( $rfc2822_from[0] =~ /^MAILER-DAEMON(?:\@|\z)/si || $msginfo->get_header_field_body('subject') =~ /\b(?:Delivery Failure Notification|failure notice)\b/ ) ) { # qmail, msn?, mailman, C/R $fname_ind = -1; $structure_type = 'multipart/mixed(' . $msginfo->is_bulk . ')'; } elsif ( $msginfo->is_auto && $sender eq '' && # notify@yahoogroups.com notify@yahoogroupes.fr $rfc2822_from[0] =~ /^notify\@yahoo/si && @parts >= 3 && @parts <= 5 && $t[0] eq 'multipart/mixed' && $t[-1] =~ m{^ (?: text/rfc822-headers | message/(?: global-headers|rfc822|global )) \z}xs ) { $fname_ind = -1; $structure_type = 'multipart/mixed(yahoogroups)'; } elsif ( $msginfo->is_auto && $sender eq '' && @parts == 1 && $t[0] ne 'multipart/report' && $rfc2822_from[0] =~ /^(?:MAILER-DAEMON|postmaster)(?:\@|\z)/si ) { # nonstructured, possibly a non-standard bounce (qmail, gmail.com, ...) $fname_ind = 0; $plaintext = 1; $structure_type = 'nonstructured(' . $msginfo->is_auto . ')'; # } elsif ( $msginfo->is_auto && $sender eq '' && # ( grep($_->recip_addr eq 'xxx@example.com', # victim # @{$msginfo->per_recip_data}) ) ) { # # nonstructured, possibly a non-standard bounce # $fname_ind = 0; $plaintext = 1; $is_true_bounce = 1; # $structure_type = 'nonstructured, unknown'; # $bounce_type = 'INFO'; # } elsif (@parts == 3 && # $t[0] eq 'multipart/mixed' && # $t[-1] eq 'application/octet-stream' && # $parts[-1]->name_declared =~ /\.eml\z/) { # # MDaemon; too permissive! test for postmaster or mailer-daemon ? # $fname_ind = -1; # $structure_type = 'multipart/mixed with binary .eml'; # } elsif ( $msginfo->is_auto && @parts == 2 && # $t[0] eq 'multipart/mixed' && $t[1] eq 'text/plain' ) { # # nonstructured, possibly a broken bounce # $fname_ind = 1; $plaintext = 1; # $structure_type = $t[0] .' with '. $t[1] .'(' . $msginfo->is_auto .')'; # } elsif ( $msginfo->is_auto && @parts == 3 && # $t[0] eq 'multipart/alternative' && # $t[1] eq 'text/plain' && $t[2] eq 'text/html' ) { # # text/plain+text/html, possibly a challenge CR message # $fname_ind = 1; $plaintext = 1; # $structure_type = $t[0] .' with '. $t[1] .'(' . $msginfo->is_auto .')'; } if (defined $fname_ind && defined $parts[$fname_ind]) { # we probably have a header section from original mail, scan it $fname_ind = $#parts if $fname_ind == -1; my $fname = $parts[$fname_ind]->full_name; ll(5) && do_log(5,'inspect_dsn: struct: "%s", basenm(%s): %s, fname: %s', $structure_type, $fname_ind, $parts[$fname_ind]->base_name, $fname); if (defined $fname) { my(%collectable_header_fields); $collectable_header_fields{lc($_)} = 1 for qw(From To Return-Path Message-ID Date Received Subject MIME-Version Content-Type); my $fh = IO::File->new; $fh->open($fname,'<') or die "Can't open file $fname: $!"; binmode($fh,':bytes') or die "Can't cancel :utf8 mode: $!"; my $have_header_fields_cnt = 0; my $nonheader_cnt = 0; my($curr_head,$ln); my $nr = 0; my $eof = 0; local($1,$2); my $line_limit = $plaintext ? 200 : 1000; for (;;) { if ($eof) { $ln = "\n"; # fake a missing header/body separator line } else { $! = 0; $ln = $fh->getline; if (!defined($ln)) { $eof = 1; $ln = "\n"; $! == 0 or # returning EBADF at EOF is a perl bug $! == EBADF ? do_log(1,"Error reading mail header section: $!") : die "Error reading mail header section: $!"; } } last if ++$nr > $line_limit; # safety measure if ($ln =~ /^[ \t]/) { # folded $curr_head .= $ln if length($curr_head) < 2000; # safety measure } else { # a new header field, process previous if any if (defined $curr_head) { $curr_head =~ s/^[> ]+// if $plaintext; # be more conservative on accepted h.f.name than RFC 5322 allows # the '_' and '.' are quite rare, digits even rarer; # the longest non-X h.f.name is content-transfer-encoding (25) # the longest h.f.names in the wild are 59 chars, largest ever 77 if ($curr_head !~ /^([a-zA-Z0-9._-]{1,60})[ \t]*:(.*)\z/s) { $nonheader_cnt++; } else { my $hfname = lc($1); if ($collectable_header_fields{$hfname}) { $have_header_fields_cnt++ if !exists $header_field{$hfname}; $header_field{$hfname} = $2; } } } $curr_head = $ln; if (!$plaintext) { last if $ln eq "\n" || substr($ln,0,2) eq '--'; } elsif ($ln =~ /^\s*$/ || substr($ln,0,2) eq '--') { if (exists $header_field{'from'} && $have_header_fields_cnt >= 4 && $nonheader_cnt <= 1) { last; } else { # reset, hope for the next paragraph to be a header $have_header_fields_cnt = 0; $nonheader_cnt = 0; %header_field = (); $curr_head = undef; } } } } defined $ln || $! == 0 or # returning EBADF at EOF is a perl bug $! == EBADF ? do_log(1,"Error reading from %s: %s", $fname,$!) : die "Error reading from $fname: $!"; $fh->close or die "Error closing $fname: $!"; my $thd = exists $header_field{'message-id'} ? 3 : 5; $is_true_bounce = 1 if exists $header_field{'from'} && $have_header_fields_cnt >= $thd; if ($is_true_bounce) { ll(5) && do_log(5, "inspect_dsn: plain=%s, got %d: %s", $plaintext?"Y":"N", scalar(keys %header_field), join(", ", sort keys %header_field)); for (@header_field{keys %header_field}) { s/\n(?=[ \t])//gs; s/^[ \t]+//; s/[ \t\n]+\z// } if (!defined($header_field{'message-id'}) && $have_header_fields_cnt >= 5 && $nonheader_cnt <= 1) { $header_field{'message-id'} = ''; # fake: defined but empty do_log(5, "inspect_dsn: a header section with no Message-ID"); } elsif (defined($header_field{'message-id'})) { $header_field{'message-id'} = (parse_message_id($header_field{'message-id'}))[0] if defined $header_field{'message-id'}; } } section_time("inspect_dsn"); } } $bounce_type = 'bounce' if !defined $bounce_type; if ($is_true_bounce) { do_log(3, 'inspect_dsn: is a %s, struct: "%s", part(%s/%d), <%s>', $bounce_type, $structure_type, !defined($fname_ind) ? '-' : $fname_ind, scalar(@parts), $sender) if ll(3); } elsif ($msginfo->is_auto) { # bounce likely, but contents unrecognizable do_log(3, 'inspect_dsn: possibly a %s, unrecognizable, '. 'struct: "%s", parts(%s/%d): %s', $bounce_type, $structure_type, !defined($fname_ind) ? '-' : $fname_ind, scalar(@parts), join(", ",@t)) if ll(3); } else { # not a bounce do_log(3, 'inspect_dsn: not a bounce'); } } $bounce_type = undef if !$is_true_bounce; !$is_true_bounce ? () : (\%header_field,$bounce_type); } # obtain authserv-id from an Authentication-Results header field # or X-Amavis-Category field sub parse_authservid($) { local($_) = $_[0]; tr/\n//d; local($1); my $comm_lvl = 0; my $authservid; while (!/\G \z/gcsx) { if ( /\G \( /gcsx) { $comm_lvl++ } elsif ($comm_lvl > 0 && /\G \) /gcsx) { $comm_lvl-- } elsif ($comm_lvl > 0 && /\G(?: \\ . | [^()\\]+ )/gcsx) {} elsif (!$comm_lvl && /\G [ \t]+ /gcsx) {} elsif (!$comm_lvl && m{\G ( [^\x00-\x20\x7F()<>,;:"/?=\[\]\@\\]+ ) }gcsx) { $authservid = $1; last } # token elsif (!$comm_lvl && m{\G " ( (?: \\ [\t\x20-\x7E] | [\t\x20\x21\x23-\x5B\x5D-\x7E] | [\xC0-\xF4][\x80-\xBF]{1,3} )* ) " }gcsx) # qcontent (relaxed for UTF-8) { $authservid = $1; $authservid =~ s{\\(.)}{$1}gsx; last } else { last }; # syntax error } $authservid; } sub add_forwarding_header_edits_common($$$$$$) { my($msginfo, $hdr_edits, $hold, $any_undecipherable, $virus_presence_checked, $spam_presence_checked) = @_; my $use_our_hdrs = cr('prefer_our_added_header_fields'); my $allowed_hdrs = cr('allowed_added_header_fields'); if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Hold')}) { # discard existing X-Amavis-Hold header field, only allow our own $hdr_edits->delete_header('X-Amavis-Hold'); if (defined $hold && $hold ne '') { $hdr_edits->add_header('X-Amavis-Hold', $hold); do_log(0, "Inserting header field: X-Amavis-Hold: %s", $hold); } } if (c('enable_dkim_verification') && $allowed_hdrs && $allowed_hdrs->{lc('Authentication-Results')}) { # RFC 7601: For security reasons, any MTA conforming to this specification # MUST delete any discovered instance of this header field that claims, # by virtue of its authentication service identifier, to have been added # within its trust boundary but that did not come directly from another # trusted MTA. [...] For simplicity and maximum security, a border MTA # could remove all instances of this header field on mail crossing into # its trust boundary. [...] (Hmmm...!?) However, an MTA MUST remove such # a header field if the [SMTP] connection relaying the message is not from # a trusted internal MTA. my $authservid = c('myauthservid'); $authservid = c('myhostname') if !defined $authservid || $authservid eq ''; $authservid = idn_to_ascii($authservid); # delete header field if its authserv-id matches ours or is unparseable $hdr_edits->edit_header('Authentication-Results', sub { my($h,$b) = @_; my $aid = parse_authservid($b); if (defined $aid) { $aid =~ s{/.*}{}s; $authservid =~ s{/.*}{}s }; !defined $aid || lc($aid) eq lc($authservid) ? (undef,0) : ($b,1); } ); # [...] For simplicity and maximum security, a border MTA could remove all # instances of this header field on mail crossing into its trust boundary. # $hdr_edits->delete_header('Authentication-Results'); } # example on how to remove subject tag inserted by some other MTA: # $hdr_edits->edit_header('Subject', # sub { my($h,$s)=@_; $s=~s/^\s*\*\*\* Spam \*\*\*(.*)/$1/si; $s }); if ($extra_code_antivirus) { # $hdr_edits->delete_header('X-Amavis-Alert'); # it does not hurt to keep it my $am_hdr_fld_head = c('X_HEADER_TAG'); my $am_hdr_fld_body = c('X_HEADER_LINE'); $hdr_edits->delete_header($am_hdr_fld_head) if c('remove_existing_x_scanned_headers') && defined $am_hdr_fld_body && $am_hdr_fld_body ne '' && defined $am_hdr_fld_head && $am_hdr_fld_head =~ /^[!-9;-\176]+\z/; } my $myhost = c('myhostname'); $myhost = $msginfo->smtputf8 ? idn_to_utf8($myhost) : idn_to_ascii($myhost); for ('X-Spam-Checker-Version') { if ($extra_code_antispam_sa && $allowed_hdrs && $allowed_hdrs->{lc $_} && $use_our_hdrs && $use_our_hdrs->{lc $_}) { no warnings 'once'; $hdr_edits->add_header($_, sprintf("SpamAssassin %s (%s) on %s", Mail::SpamAssassin::Version(), $Mail::SpamAssassin::SUB_VERSION, $myhost)); } } $hdr_edits; } # Prepare header edits for the first not-yet-done recipient. # Inspect remaining recipients, returning the list of recipient objects # that are receiving the same set of header edits (so the message may be # delivered to them in one SMTP transaction). # sub add_forwarding_header_edits_per_recip($$$$$$$) { my($msginfo, $hdr_edits, $hold, $any_undecipherable, $virus_presence_checked, $spam_presence_checked, $filter) = @_; my(@recip_cluster); my(@per_recip_data) = grep(!$_->recip_done && (!$filter || &$filter($_)), @{$msginfo->per_recip_data}); my $per_recip_data_len = scalar(@per_recip_data); my $first = 1; my $cluster_key; my $cluster_full_spam_status; my $use_our_hdrs = cr('prefer_our_added_header_fields'); my $allowed_hdrs = cr('allowed_added_header_fields'); my $x_header_tag = c('X_HEADER_TAG'); my $adding_x_header_tag = $x_header_tag =~ /^[!-9;-\176]+\z/ && c('X_HEADER_LINE') ne '' && $allowed_hdrs && $allowed_hdrs->{lc($x_header_tag)}; my $mail_id = $msginfo->mail_id; my $os_fp = $msginfo->client_os_fingerprint; if (defined($os_fp) && $os_fp ne '' && $msginfo->client_addr ne '') { $os_fp .= ', ['. $msginfo->client_addr . ']:' . $msginfo->client_port } my(@headers_to_be_removed); # header fields that may need to be removed if ($extra_code_antispam) { @headers_to_be_removed = qw( X-Spam-Status X-Spam-Level X-Spam-Flag X-Spam-Score X-Spam-Report X-Spam-Checker-Version X-Spam-Tests); @headers_to_be_removed = grep(defined $msginfo->get_header_field2($_), @headers_to_be_removed); } my $header_tagged = 0; for my $r (@per_recip_data) { my $spam_level = $r->spam_level; my $recip = $r->recip_addr; my $is_local = $r->recip_is_local; my $blacklisted = $r->recip_blacklisted_sender; my $whitelisted = $r->recip_whitelisted_sender; my $bypassed = $r->bypass_spam_checks; my $do_tag = $r->is_in_contents_category(CC_CLEAN,1); my $do_tag2 = $r->is_in_contents_category(CC_SPAMMY); my $do_kill = $r->is_in_contents_category(CC_SPAM); my $do_tag_badh = $r->is_in_contents_category(CC_BADH); my $do_tag_banned = $r->is_in_contents_category(CC_BANNED); my $do_tag_virus = $r->is_in_contents_category(CC_VIRUS); my $mail_mangle = $r->mail_body_mangle; my $do_tag_virus_checked = $adding_x_header_tag && !$r->bypass_virus_checks; my $do_rem_hdr = @headers_to_be_removed && lookup2(0,$recip,ca('remove_existing_spam_headers_maps')); my $do_p0f = $is_local && defined($os_fp) && $os_fp ne '' && $allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-OS-Fingerprint')}; my $pp_age; if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-PenPals')}) { $pp_age = $r->recip_penpals_age; $pp_age = format_time_interval($pp_age) if defined $pp_age; } my($tag_level,$tag2_level,$subject_tag); if ($extra_code_antispam && !$bypassed) { $tag_level = lookup2(0,$recip, ca('spam_tag_level_maps')); $tag2_level = lookup2(0,$recip, ca('spam_tag2_level_maps')); } if ($is_local) { # || c('warn_offsite') my(@subj_maps_pairs) = $r->setting_by_main_contents_category_all( cr('subject_tag_maps_by_ccat')); for my $pair (@subj_maps_pairs) { my($cc,$map_ref) = @$pair; next if !ref($map_ref); $subject_tag = lookup2(0,$recip,$map_ref); # take the first nonempty string last if defined $subject_tag && $subject_tag ne ''; } } my $myhost = c('myhostname'); $myhost = $msginfo->smtputf8 ? idn_to_utf8($myhost) :idn_to_ascii($myhost); $subject_tag = '' if !defined $subject_tag; if ($subject_tag ne '') { # expand subject template # just implement a small subset of macro-lookalikes, not true macro calls # btw, the '0+' is there to trim trailing zeroes $subject_tag =~ s{_(SCORE|REQD|YESNO|YESNOCAPS|HOSTNAME|DATE|U|LOGID|MAILID|SUBJPREFIX)_} { $1 eq 'SCORE' ? (0+sprintf("%.3f",$spam_level)) : $1 eq 'REQD' ? (!defined($tag2_level) ? '-' : 0+sprintf("%.3f",$tag2_level)) : $1 eq 'YESNO' ? ($do_tag2 ? 'Yes' : 'No') : $1 eq 'YESNOCAPS' ? ($do_tag2 ? 'YES' : 'NO') : $1 eq 'HOSTNAME' ? $myhost #** characters or octets? : $1 eq 'DATE' ? rfc2822_timestamp($msginfo->rx_time) : $1 eq 'U' ? iso8601_utc_timestamp($msginfo->rx_time) : $1 eq 'LOGID' ? $msginfo->log_id : $1 eq 'MAILID' ? $mail_id||'' : $1 eq 'SUBJPREFIX'? $msginfo->supplementary_info('SUBJPREFIX')||'' : '_'.$1.'_' }xgse; } # normalize $_ = $_?1:0 for ($do_tag_virus_checked, $do_tag_virus, $do_tag_banned, $do_tag_badh, $do_tag, $do_tag2, $do_p0f, $do_rem_hdr, $is_local); my($spam_level_bar, $full_spam_status); if ($is_local && ($do_tag || $do_tag2)) { # prepare status and level bar # spam-related header fields should _not_ be inserted for: # - nonlocal recipients (outgoing mail), as a matter of courtesy # to our users; # - recipients matching bypass_spam_checks: even though spam checking # may have been done for other reasons, these recipients do not expect # such header fields, so let's pretend the check has not been done # and not insert spam-related header fields for them; # - everyone when the spam level is below the tag level # or the sender was whitelisted and tag level is below -10 # (undefined tag level is treated as lower than any spam score). my $autolearn_status = $msginfo->supplementary_info('AUTOLEARN'); my $slc = c('sa_spam_level_char'); if (defined $slc && $slc ne '') { my $bar_len = $whitelisted || $bypassed ? 0 : $blacklisted ? 64 : !defined $spam_level ? 0 : $spam_level > 64 ? 64 : $spam_level; $spam_level_bar = $bar_len < 1 ? '' : $slc x int $bar_len; } my $spam_tests = $r->spam_tests; $spam_tests = !$spam_tests ? '' : join(',',map($$_,@$spam_tests)); # allow header field wrapping at any comma my $s = $spam_tests; $s =~ s/,/,\n /g; $full_spam_status = sprintf( "%s,\n score=%s\n %s%s%stests=[%s]\n autolearn=%s", $do_tag2 ? 'Yes' : 'No', !defined $spam_level ? 'x' : 0+sprintf("%.3f",$spam_level), !defined $tag_level || $tag_level eq '' ? '' : sprintf("tagged_above=%s\n ",$tag_level), !defined $tag2_level ? '' : sprintf("required=%s\n ", $tag2_level), join('', $blacklisted ? "BLACKLISTED\n " : (), $whitelisted ? "WHITELISTED\n " : ()), $s, $autolearn_status||'unavailable'); } my $ccat_display_name = ''; if ( $allowed_hdrs && $allowed_hdrs->{ lc('X-Amavis-Category') } ) { $ccat_display_name = $r->setting_by_contents_category(\%ccat_display_names) } my $key = join("\000", map {defined $_ ? $_ : ''} ( $do_tag_virus_checked, $do_tag_virus, $do_tag_banned, $do_tag_badh, $do_tag && $is_local, $do_tag2 && $is_local, $subject_tag, $do_rem_hdr, $spam_level_bar, $full_spam_status, $mail_mangle, $do_p0f, $pp_age, $ccat_display_name) ); if ($first) { if (ll(4)) { my $sl = !defined($spam_level) ? 'x' : 0+sprintf("%.3f",$spam_level); # trim fraction do_log(4, "headers CLUSTERING: NEW CLUSTER <%s>: score=%s, ". "tag=%s, tag2=%s, local=%s, bl=%s, s=%s, mangle=%s, ccat_hdr=%s", $recip, $sl, $do_tag, $do_tag2, $is_local, $blacklisted, $subject_tag, $mail_mangle, $ccat_display_name); } $cluster_key = $key; $cluster_full_spam_status = $full_spam_status; } elsif ($key eq $cluster_key) { do_log(5,"headers CLUSTERING: <%s> joining cluster", $recip); } else { do_log(5,"headers CLUSTERING: skipping <%s> (t=%s, t2=%s, r=%s, l=%s, ccat_hdr=%s)", $recip,$do_tag,$do_tag2,$do_rem_hdr,$is_local,$ccat_display_name); next; # this recipient will be handled in some later pass } if ($first) { # insert header fields required for the new cluster my(%header_field_provided); # mainly applies to spam header fields if ($do_rem_hdr) { $hdr_edits->delete_header($_) for @headers_to_be_removed; } if ($is_local && defined $msginfo->quarantined_to && defined $mail_id) { $hdr_edits->add_header('X-Quarantine-ID', '<'.$mail_id.'>') if $allowed_hdrs && $allowed_hdrs->{lc('X-Quarantine-ID')}; } if ($mail_mangle) { # mail body modified, invalidates DKIM signatures if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Modified')}) { $hdr_edits->add_header('X-Amavis-Modified', sprintf("Mail body modified (%s) - %s", length($mail_mangle) > 1 ? "using $mail_mangle" : "defanged", $myhost )); } } if ($do_tag_virus_checked) { $hdr_edits->add_header(c('X_HEADER_TAG'), c('X_HEADER_LINE')); } if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Alert')}) { if ($do_tag_virus) { my $virusname_list = $msginfo->virusnames; $hdr_edits->add_header('X-Amavis-Alert', "INFECTED, message contains virus: " . (!$virusname_list ? '' : join(", ",@$virusname_list)) ); $header_tagged = 1; } if ($do_tag_banned) { $hdr_edits->add_header('X-Amavis-Alert', 'BANNED, message contains ' . $r->banning_reason_short); $header_tagged = 1; } if ($do_tag_badh) { $hdr_edits->add_header('X-Amavis-Alert', 'BAD HEADER SECTION, ' . $bad_headers[0]); # $header_tagged = 1; # not this one, it is mostly harmless } } if ($is_local && $allowed_hdrs && $use_our_hdrs) { for ('X-Spam-Checker-Version') { if ($extra_code_antispam_sa && $allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { # a hack instead of making %header_field_provided global: # just mark it as already provided, this header field was # already inserted by add_forwarding_header_edits_common() $header_field_provided{lc $_} = 1; } } for ('X-Spam-Flag') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { $hdr_edits->add_header($_, $do_tag2 ? 'YES' : 'NO') if $do_tag; $header_field_provided{lc $_} = 1; $header_tagged = 1 if $do_tag2; # SPAMMY } } for ('X-Spam-Score') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { if ($do_tag) { my $score = 0+$spam_level; $score = max(64,$score) if $blacklisted; # not below 64 if bl $score = min( 0,$score) if $whitelisted; # not above 0 if wl $hdr_edits->add_header($_, 0+sprintf("%.3f",$score)); } $header_field_provided{lc $_} = 1; } } for ('X-Spam-Level') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { if ($do_tag && defined $spam_level_bar) { $hdr_edits->add_header($_, $spam_level_bar); } $header_field_provided{lc $_} = 1; } } for ('X-Spam-Status') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { $hdr_edits->add_header($_, $full_spam_status, 1) if $do_tag; $header_field_provided{lc $_} = 1; } } for ('X-Spam-Report') { # SA reports may contain any octet, i.e. 8-bit data from a mail # that is reported by a matching rule; no charset is associated, so # it doesn't make sense to RFC 2047 -encode it, so just sanitize it if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { if ($do_tag2) { my $report = $r->spam_report; $report = $msginfo->spam_report if !defined $report; if (defined $report && $report ne '') { $hdr_edits->add_header($_, "\n".sanitize_str($report,1), 2); } } $header_field_provided{lc $_} = 1; } } } if ($is_local && $allowed_hdrs) { # add remaining header fields as provided by spam scanners my $sa_header = $msginfo->supplementary_info( $do_tag2 ? 'ADDEDHEADERSPAM' : 'ADDEDHEADERHAM'); if (defined $sa_header && $sa_header ne '') { for my $hf (split(/^(?![ \t])/m, $sa_header, -1)) { local($1,$2); if ($hf =~ /^([!-9;-\176]+)[ \t]*:(.*)\z/s) { my($hf_name,$hf_body) = ($1,$2); my $hf_name_lc = lc $hf_name; chomp($hf_body); if ($header_field_provided{$hf_name_lc}) { do_log(5,'fwd: scanner provided a header field %s, but we '. 'preferred our own', $hf_name); } elsif (!$allowed_hdrs->{$hf_name_lc}) { do_log(5,'fwd: scanner provided a header field %s, inhibited '. 'by %%allowed_added_header_fields', $hf_name); } else { do_log(5,'fwd: scanner provided a header field %s, inserting', $hf_name); $hdr_edits->add_header($hf_name, $hf_body, 2); } } } } for my $pair ( ['DSPAMRESULT', 'X-DSPAM-Result'], ['DSPAMSIGNATURE', 'X-DSPAM-Signature'], ['CRM114STATUS', 'X-CRM114-Status'], ['CRM114CACHEID', 'X-CRM114-CacheID'] ) { my($suppl_attr_name, $hf_name) = @$pair; my $suppl_attr_val = $msginfo->supplementary_info($suppl_attr_name); if (defined $suppl_attr_val && $suppl_attr_val ne '') { if (!$allowed_hdrs->{lc $hf_name}) { do_log(5,'fwd: scanner provided a tag/field %s, '. 'inhibited by %%allowed_added_header_fields', $hf_name); } else { do_log(5,'fwd: scanner provided a tag/field %s, '. 'inserting', $hf_name); $hdr_edits->add_header($hf_name, sanitize_str($suppl_attr_val), 2); } } } } $hdr_edits->add_header('X-Amavis-OS-Fingerprint', sanitize_str($os_fp)) if $do_p0f; $hdr_edits->add_header('X-Amavis-PenPals', 'age '.$pp_age) if defined $pp_age; if ($is_local && c('enable_dkim_verification') && $allowed_hdrs && $allowed_hdrs->{lc('Authentication-Results')}) { for my $h (Amavis::DKIM::generate_authentication_results($msginfo,0)) { $hdr_edits->add_header('Authentication-Results', $h, 1); } } if ($subject_tag ne '') { if (defined $msginfo->get_header_field2('subject')) { $hdr_edits->edit_header('Subject', sub { local($1,$2); $_[1] =~ /^([ \t]?)(.*)\z/s; my $subj = $2; $subj = safe_decode_mime($subj); # to characters $subj =~ s/\Q$subject_tag\E//sg if length($subject_tag) >= 3; # precaution safe_decode_utf8( ' ' . safe_encode_utf8($subject_tag) . safe_encode_utf8($subj)); } ); } else { # no Subject header field present, insert one $subject_tag =~ s/[ \t]+\z//; # trim $hdr_edits->add_header('Subject', $subject_tag); do_log(0,"INFO: no existing header field 'Subject', inserting it"); } $header_tagged = 1; } if ($allowed_hdrs && $allowed_hdrs->{lc('Received')} && grep($_->delivery_method ne '', @{$msginfo->per_recip_data})) { $hdr_edits->add_header('Received', make_received_header_field($msginfo,1), 1); } # X-Amavis-Category header field if ($ccat_display_name ne '') { my $authservid = c('myauthservid'); $authservid = c('myhostname') if !defined $authservid || $authservid eq ''; $authservid = idn_to_ascii($authservid); # replace own X-Amavis-Category header field ## Delete all headers is drastic ## $hdr_edits->delete_header('X-Amavis-Category'); # Delete header if it matches our authservid $hdr_edits->edit_header( 'X-Amavis-Category', sub { my ( $h, $b ) = @_; my $aid = parse_authservid($b); if ( defined $aid ) { $aid =~ s{/.*}{}s; $authservid =~ s{/.*}{}s } !defined $aid || lc($aid) eq lc($authservid) ? ( undef, 0 ) : ( $b, 1 ); } ); $hdr_edits->add_header('X-Amavis-Category', sprintf('%s; category=%s', $authservid, $ccat_display_name) ); } # if X-Amavis-Category } # if $first push(@recip_cluster,$r); $first = 0; $r->recip_tagged(1) if $header_tagged; my $delim = c('recipient_delimiter'); if ($is_local) { # rewrite/replace recipient addresses, possibly with multiple recipients my $rewrite_map = $r->setting_by_contents_category( cr('addr_rewrite_maps_by_ccat')); my $rewrite = !ref $rewrite_map ? undef : lookup2(0,$recip,$rewrite_map); if ($rewrite ne '') { my(@replacements) = map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $rewrite)); if (@replacements) { my $repl_addr = shift @replacements; my $modif_addr = replace_addr_fields($recip,$repl_addr,$delim); ll(5) && do_log(5,"addr_rewrite_maps: replacing <%s> by <%s>", $recip,$modif_addr); $r->recip_addr_modified($modif_addr); for my $bcc (@replacements) { # remaining addresses are extra Bcc my $new_addr = replace_addr_fields($recip,$bcc,$delim); ll(5) && do_log(5,"addr_rewrite_maps: recip <%s>, adding <%s>", $recip,$new_addr); # my $clone = $r->clone; # $clone->recip_addr_modified($new_addr); } } $r->dsn_orcpt(join(';', orcpt_decode(';'.$r->recip_addr_smtp))) if !defined $r->dsn_orcpt; } } if ($is_local && defined $delim && $delim ne '') { # append address extensions to mailbox names if desired my $ext_map = $r->setting_by_contents_category( cr('addr_extension_maps_by_ccat')); my $ext = !ref($ext_map) ? undef : lookup2(0,$recip,$ext_map); if ($ext ne '') { $ext = substr($delim,0,1) . $ext; my $orig_extension; my($localpart,$domain) = split_address($recip); ($localpart,$orig_extension) = split_localpart($localpart,$delim) if c('replace_existing_extension'); # strip existing extension my $new_addr = $localpart.$ext.$domain; if (ll(5)) { if (!defined($orig_extension)) { do_log(5, "appending addr ext '%s', giving '%s'", $ext,$new_addr); } else { do_log(5, "replacing addr ext '%s' by '%s', giving '%s'", $orig_extension,$ext,$new_addr); } } # RFC 3461: If no ORCPT parameter was present in the RCPT command when # the message was received, an ORCPT parameter MAY be added to the # RCPT command when the message is relayed. If an ORCPT parameter is # added by the relaying MTA, it MUST contain the recipient address # from the RCPT command used when the message was received by that MTA. $r->dsn_orcpt(join(';', orcpt_decode(';'.$r->recip_addr_smtp))) if !defined $r->dsn_orcpt; $r->recip_addr_modified($new_addr); $r->recip_tagged(1); } } } my $done_all; if (@recip_cluster == $per_recip_data_len) { do_log(5,"headers CLUSTERING: done all %d recips in one go", $per_recip_data_len); $done_all = 1; } else { ll(4) && do_log(4, "headers CLUSTERING: got %d recips out of %d: %s", scalar(@recip_cluster), $per_recip_data_len, join(', ', map($_->recip_addr_smtp, @recip_cluster))); } if (ll(2) && defined($cluster_full_spam_status) && @recip_cluster) { my $s = $cluster_full_spam_status; $s =~ s/\n[ \t]/ /g; do_log(2, "spam-tag, %s -> %s, %s", $msginfo->sender_smtp, join(',', map($_->recip_addr_smtp, @recip_cluster)), $s); } ($hdr_edits, \@recip_cluster, $done_all); } # Mail body mangling (defanging, sanitizing or adding disclaimers); # Prepare mail body replacement for the first recipient # in the @$per_recip_data list (which contains a subset of recipients # with the same mail edits, to be dispatched next as one message) # sub prepare_modified_mail($$$$) { my($msginfo, $hold, $any_undecipherable, $per_recip_data) = @_; my $body_modified = 0; for my $r (@$per_recip_data) { # a subset of recipients! my $recip = $r->recip_addr; my $mail_mangle = $r->mail_body_mangle; my $actual_mail_mangle; if (!$mail_mangle) { # skip } elsif ($mail_mangle =~ /^(?:null|nulldisclaimer)\z/i) { # for testing $body_modified = 1; # pretend mail was modified while actually it was not $msginfo->mail_text_str(undef); section_time('mangle-'.$mail_mangle); } elsif (( lc $mail_mangle ne 'attach' && ($enable_anomy_sanitizer || $altermime ne '') ) || $mail_mangle =~ /^(?:anomy|altermime|disclaimer)\z/i) { do_log(2,"mangling by: %s, <%s>", $mail_mangle,$recip); my $orig_fn = $msginfo->mail_text_fn; my $repl_fn = $msginfo->mail_tempdir . '/email-repl.txt'; my $file_position = $msginfo->skip_bytes; my $out_fh; my $repl_size; my $eval_stat; eval { $out_fh = IO::File->new; $out_fh->open($repl_fn, O_CREAT|O_EXCL|O_WRONLY, 0640) or die "Can't create file $repl_fn: $!"; binmode($out_fh,':bytes') or die "Can't cancel :utf8 mode: $!"; if (lc $mail_mangle eq 'anomy' && !$enable_anomy_sanitizer) { die 'Anomy requested, but $enable_anomy_sanitizer is false'; } elsif ($enable_anomy_sanitizer && $mail_mangle !~ /^(?:altermime|disclaimer)\z/i) { $actual_mail_mangle = 'anomy'; my $inp_fh = $msginfo->mail_text; $inp_fh->seek($file_position, 0) or die "Can't rewind mail file: $!"; $enable_anomy_sanitizer or die "Anomy disabled: $mail_mangle"; my(@scanner_conf); my $e; my $engine = Anomy::Sanitizer->new; if ($e = $engine->error) { die $e } $engine->configure(@scanner_conf, @{ca('anomy_sanitizer_args')}); if ($e = $engine->error) { die $e } my $ret = $engine->sanitize($inp_fh, $out_fh); if ($e = $engine->error) { die $e } # close flushes buffers, makes it possible to check file size below $out_fh->close or die "Can't close file $repl_fn: $!"; # re-open as read-only $out_fh = IO::File->new; $out_fh->open($repl_fn,'<') or die "Can't open file $repl_fn: $!"; binmode($out_fh,':bytes') or die "Can't cancel :utf8 mode: $!"; } else { # use altermime for adding disclaimers or defanging $actual_mail_mangle = 'altermime'; $altermime ne '' or die "altermime not available: $mail_mangle"; # prepare arguments to altermime my(@altermime_args); my $disclaimer_options; if (lc($mail_mangle) ne 'disclaimer') { # defang: no by-sender opts. @altermime_args = @{ca('altermime_args_defang')}; } else { # disclaimer @altermime_args = @{ca('altermime_args_disclaimer')}; my $opt_maps = ca('disclaimer_options_bysender_maps'); if ($opt_maps && @$opt_maps && # by sender options? grep(/_OPTION_/,@altermime_args)) { # determine whose by-sender options to use my $fm = $msginfo->rfc2822_from; my $rf = $msginfo->rfc2822_resent_from; my $rs = $msginfo->rfc2822_resent_sender; my(@rfc2822_from) = !defined($fm) ? () : ref $fm ? @$fm : $fm; my(@rfc2822_resent_from, @rfc2822_resent_sender); @rfc2822_resent_from = @$rf if defined $rf; @rfc2822_resent_sender = @$rs if defined $rs; # see comments in dkim_make_signatures my(@search_list); # collects candidate originator addresses # author addresses go first push(@search_list, map([$_,'2822.From'], @rfc2822_from)); # merge Resent-From and Resent-Sender addresses by resent blocks while (@rfc2822_resent_from || @rfc2822_resent_sender) { while (@rfc2822_resent_from) { my $addr = shift(@rfc2822_resent_from); last if !defined $addr; # undef delimits resent blocks push(@search_list, [$addr, '2822.Resent-From']); } while (@rfc2822_resent_sender) { my $addr = shift(@rfc2822_resent_sender); last if !defined $addr; # undef delimits resent blocks push(@search_list, [$addr, '2822.Resent-Sender']); } } push(@search_list, [$msginfo->rfc2822_sender, '2822.Sender']); push(@search_list, [$msginfo->sender, '2821.mail_from']); # # find disclaimer options pertaining to the # most appropriate originator address my(%addr_seen); for my $pair (@search_list) { my($addr,$addr_src) = @$pair; next if !defined($addr) || $addr eq ''; next if $addr_seen{$addr}++; do_log(5,"disclaimer options lookup (%s) %s", $addr_src,$addr); next if !lookup2(0,$addr, ca('local_domains_maps')); my($opt,$matchingkey) = lookup2(0,$addr,$opt_maps); if (defined $opt) { $disclaimer_options = $opt; do_log(3,"disclaimer options pertaining to (%s) %s: %s", $addr_src, $addr, $disclaimer_options); last; } } $disclaimer_options = '' if !defined $disclaimer_options; s/_OPTION_/$disclaimer_options/gs for @altermime_args; } } my $msg = $msginfo->mail_text; my $msg_str_ref = $msginfo->mail_text_str; # have an in-memory copy? $msg = $msg_str_ref if ref $msg_str_ref; # copy original mail to $repl_fn, altermime can't handle stdin well if (!defined $msg) { # empty mail } elsif (ref $msg eq 'SCALAR') { # do it in chunks, saves memory, cache friendly while ($file_position < length($$msg)) { $out_fh->print(substr($$msg,$file_position,16384)) or die "Error writing to $repl_fn: $!"; $file_position += 16384; # may overshoot, no problem } } elsif ($msg->isa('MIME::Entity')) { die "sanitizing a MIME::Entity object is not implemented"; } else { $msg->seek($file_position,0) or die "Can't rewind mail file: $!"; my($nbytes,$buff); while (($nbytes = $msg->read($buff,16384)) > 0) { $out_fh->print($buff) or die "Error writing to $repl_fn: $!"; } defined $nbytes or die "Error reading mail file: $!"; undef $buff; # release storage } $out_fh->close or die "Can't close file $repl_fn: $!"; undef $out_fh; my($proc_fh,$pid) = run_command(undef, '&1', $altermime, "--input=$repl_fn", @altermime_args); my($r,$status) = collect_results($proc_fh,$pid,$altermime,16384,[0]); undef $proc_fh; undef $pid; do_log(2,"program %s said: %s", $altermime, $$r) if ref $r && $$r ne ''; $status == 0 or die "Program $altermime failed: $status, $$r"; $out_fh = IO::File->new; $out_fh->open($repl_fn,'<') or die "Can't open file $repl_fn: $!"; binmode($out_fh,':bytes') or die "Can't cancel :utf8 mode: $!"; } my $errn = lstat($repl_fn) ? 0 : 0+$!; if ($errn) { die "Replacement $repl_fn inaccessible: $!" } else { $repl_size = 0 + (-s _) } 1; } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat }; if (defined $eval_stat || !defined $repl_size || $repl_size <= 0) { # handle failure my $msg = defined $eval_stat ? $eval_stat : sprintf("replacement size %d", $repl_size); do_log(-1,"mangling by %s failed: %s, mail will pass unmodified", $actual_mail_mangle, $msg); if (defined $out_fh) { $out_fh->close or do_log(-1,"Can't close %s: %s", $repl_fn,$!); undef $out_fh; } unlink($repl_fn) or do_log(-1,"Can't remove %s: %s", $repl_fn,$!); if ($actual_mail_mangle eq 'altermime') { # check for leftover files my $repl_tmp_fn = $repl_fn . '.tmp'; # altermime's temporary file my $errn = lstat($repl_tmp_fn) ? 0 : 0+$!; if ($errn == ENOENT) {} # fine, does not exist elsif ($errn) { do_log(-1,"Temporary file %s is inaccessible: %s",$repl_tmp_fn,$!); } else { # cleanup after failing altermime unlink($repl_tmp_fn) or do_log(-1,"Can't remove %s: %s",$repl_tmp_fn,$!); } } } else { do_log(1,"mangling by %s (%s) done, new size: %d, orig %d bytes", $actual_mail_mangle, $mail_mangle, $repl_size, $msginfo->msg_size); # don't close or delete the original file, we'll still need it $msginfo->mail_text($out_fh); $msginfo->mail_text_fn($repl_fn); $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef); $msginfo->skip_bytes(0); $body_modified = 1; } section_time('mangle-'.$actual_mail_mangle); } else { # 'attach' (default) - poor-man's defanging of dangerous contents do_log(2,"mangling by built-in defanger: %s, <%s>", $mail_mangle,$recip); $actual_mail_mangle = 'attach'; my(@explanation); my $spam_summary_inserted = 0; my(@df_pairs) = $r->setting_by_main_contents_category_all(cr('defang_maps_by_ccat')); for my $pair (@df_pairs) { # collect all defanging reasons that apply my($cc,$mangle_map_ref) = @$pair; my $df = !defined($mangle_map_ref) ? undef : !ref($mangle_map_ref) ? $mangle_map_ref # compatibility : lookup2(0,$recip,$mangle_map_ref, Label=>'Mangling2'); # the $r->mail_body_mangle happens to be the first noteworthy $df do_log(4,'defang? ccat "%s": %s', $cc,$df); next if !$df; my $ccm = ccat_maj($cc); if ($ccm==CC_VIRUS) { my $virusname_list = $msginfo->virusnames; push(@explanation, 'WARNING: contains virus ' . (!$virusname_list ? '' : join(", ",@$virusname_list))); } if ($ccm==CC_BANNED) { push(@explanation, "WARNING: banning rules detected suspect part(s),\n". "do not open unless you know what you are doing"); } if ($ccm==CC_UNCHECKED) { if (defined $hold && $hold ne '') { push(@explanation, "WARNING: NOT CHECKED FOR VIRUSES (mail bomb?):\n $hold"); } elsif ($any_undecipherable) { push(@explanation, "WARNING: contains undecipherable part"); } } if ($ccm==CC_BADH) { my $bad = join(' ',@bad_headers); substr($bad,1000) = '...' if length($bad) > 1000; push(@explanation, split(/\n/, wrap_string('WARNING: bad headers - '.$bad, 78,'',' ') )); } push(@explanation, 'WARNING: oversized') if $ccm==CC_OVERSIZED; if (!$spam_summary_inserted && # can be both CC_SPAMMY and CC_SPAM ($ccm==CC_SPAM || $ccm==CC_SPAMMY)) { push(@explanation, split(/\n/, $msginfo->spam_summary)); $spam_summary_inserted = 1; } } my $s = join(' ',@explanation); do_log(1, "DEFANGING MAIL: %s", length($s) <= 150 ? $s : substr($s,0,150-3).'[...]'); for (@explanation) { substr($_,100-3) = '...' if length($_) > 100 } $_ .= "\n" for (@explanation); # append newlines my $d = defanged_mime_entity($msginfo,\@explanation); $msginfo->mail_text($d); # substitute mail with a rewritten version $msginfo->mail_text_fn(undef); # remove filename information $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef); $msginfo->skip_bytes(0); $body_modified = 1; section_time('defang'); } # actually the 'for' loop is bogus and runs only once, all recipients # listed in the argument are known to be using the same setting for # $r->mail_body_mangle, ensured by add_forwarding_header_edits_per_recip; # just exit the loop last; } $body_modified; } sub do_quarantine($$$$;@) { shift(@_) if $_[0]->isa('Amavis::In::Connection'); # for compatibility my($msginfo, $hdr_edits_inherited, $recips_ref, $quarantine_method, @snmp_id) = @_; if ($quarantine_method eq '') { do_log(5, 'quarantine disabled'); } else { local($1); my $quar_m_protocol = !ref $quarantine_method ? $quarantine_method : $quarantine_method->[0]; $quar_m_protocol = lc $1 if $quar_m_protocol =~ /^([a-z][a-z0-9.+-]*):/si; my $quar_msg = Amavis::In::Message->new; $quar_msg->rx_time($msginfo->rx_time); # copy the reception time $quar_msg->log_id($msginfo->log_id); # use the same log_id $quar_msg->partition_tag($msginfo->partition_tag); # same partition_tag $quar_msg->parent_mail_id($msginfo->mail_id); $quar_msg->mail_id(scalar generate_mail_id()); $quar_msg->conn_obj($msginfo->conn_obj); $quar_msg->mail_id($msginfo->mail_id); # use the same mail_id $quar_msg->body_type($msginfo->body_type); # use the same BODY= type $quar_msg->header_8bit($msginfo->header_8bit); $quar_msg->body_8bit($msginfo->body_8bit); $quar_msg->msg_size($msginfo->msg_size); $quar_msg->body_digest($msginfo->body_digest); # copy original digest $quar_msg->dsn_ret($msginfo->dsn_ret); $quar_msg->dsn_envid($msginfo->dsn_envid); $quar_msg->smtputf8($msginfo->smtputf8); $quar_msg->auth_submitter($msginfo->sender_smtp); $quar_msg->auth_user(c('amavis_auth_user')); $quar_msg->auth_pass(c('amavis_auth_pass')); $quar_msg->originating(0); # disables DKIM signing my($orig_env_sender_retained, $orig_env_recips_retained); my $mftq = c('mailfrom_to_quarantine'); if (!defined $mftq || $quar_m_protocol =~ /^(?:bsmtp|sql)\z/) { # we keep the original envelope sender address if replacement sender # is not provided, or with quarantine methods which store to fixed # locations which do not depend on envelope $quar_msg->sender($msginfo->sender); # original sender $quar_msg->sender_smtp($msginfo->sender_smtp); $orig_env_sender_retained = 1; } elsif (defined $mftq) { # have a replacement, and protocol is smtp, lmtp, pipe, local $quar_msg->sender($mftq); $mftq = qquote_rfc2821_local($mftq); $quar_msg->sender_smtp($mftq); $quar_msg->auth_submitter($mftq); } my(@recips); if (!$recips_ref || $quar_m_protocol =~ /^(?:bsmtp|sql)\z/) { # we keep the original envelope recipients if replacement recipients # are not provided, or with quarantine methods which store to fixed # locations which do not depend on envelope information for my $r (@{$msginfo->per_recip_data}) { my $recip_obj = Amavis::In::Message::PerRecip->new; # copy original recipient addresses and DSN info $recip_obj->recip_addr($r->recip_addr); $recip_obj->recip_addr_smtp($r->recip_addr_smtp); $recip_obj->dsn_orcpt($r->dsn_orcpt); $recip_obj->recip_destiny(D_PASS); $recip_obj->dsn_notify(['NEVER']) if $orig_env_sender_retained; $recip_obj->delivery_method($quarantine_method); push(@recips,$recip_obj); } $orig_env_recips_retained = 1; } else { # have a replacement, and protocol is smtp, lmtp, pipe, local # with these quarantine methods the envelope information is used to # determine where and how to store a quarantined message, and may not # reflect original envelope sender and recipients addresses for my $rec (@$recips_ref) { # use recipients provided by a caller my $recip_obj = Amavis::In::Message::PerRecip->new; $recip_obj->recip_addr($rec); $recip_obj->recip_addr_smtp(qquote_rfc2821_local($rec)); $recip_obj->recip_destiny(D_PASS); $recip_obj->dsn_notify(['NEVER']) if $orig_env_sender_retained; $recip_obj->delivery_method($quarantine_method); push(@recips,$recip_obj); } } $quar_msg->per_recip_data(\@recips); my $hdr_edits = Amavis::Out::EditHeader->new; $hdr_edits->inherit_header_edits($hdr_edits_inherited); if (defined $msginfo->mail_id) { $hdr_edits->prepend_header('X-Quarantine-ID', '<'.$msginfo->mail_id.'>'); } if ($quar_m_protocol ne 'bsmtp') { # NOTE: RFC 2821 mentions possible header flds X-SMTP-MAIL & X-SMTP-RCPT # Exim uses: Envelope-To, Sendmail uses X-Envelope-To; # No need with bsmtp, which preserves the envelope. my(@blocked_recips) = map($_->recip_addr_smtp, grep($_->recip_done, @{$msginfo->per_recip_data})); $hdr_edits->prepend_header('X-Envelope-To-Blocked', join(",\n ", @blocked_recips), 1); $hdr_edits->prepend_header('X-Envelope-To', join(",\n ", map($_->recip_addr_smtp, @{$msginfo->per_recip_data})),1); } # X-Envelope-* could be redundant with $orig_env_sender_retained, but # let's provide this information unconditionally (for the benefit of SQL) $hdr_edits->prepend_header('X-Envelope-From', $msginfo->sender_smtp); $hdr_edits->add_header('Received', make_received_header_field($msginfo,1), 1); $quar_msg->header_edits($hdr_edits); $quar_msg->mail_text($msginfo->mail_text); # use the same mail contents $quar_msg->mail_text_str($msginfo->mail_text_str); $quar_msg->body_start_pos($msginfo->body_start_pos); $quar_msg->skip_bytes($msginfo->skip_bytes); if (ll(5)) { my $quar_m_displ = !ref $quarantine_method ? $quarantine_method : '(' . join(', ',@$quarantine_method) . ')'; do_log(5,"DO_QUARANTINE, %s, %s -> %s", $quar_m_displ, $quar_msg->sender_smtp, join(', ', map($_->recip_addr_smtp, @{$quar_msg->per_recip_data})) ); } snmp_count('QuarMsgs'); snmp_count( ['QuarMsgsSize', $quar_msg->msg_size, 'C64'] ); mail_dispatch($quar_msg, 'Quar', 0); my($n_smtp_resp, $n_exit_code, $n_dsn_needed) = one_response_for_all($quar_msg, 0); # check status if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) { # ok @snmp_id = ('Other') if !@snmp_id; for (unique_list(\@snmp_id)) { snmp_count('QuarMsgs'.$_); snmp_count( ['QuarMsgsSize'.$_, $quar_msg->msg_size, 'C64'] ); } my $any_arch = grep($_ eq 'Arch', @snmp_id); my $any_nonarch = grep($_ ne 'Arch', @snmp_id); my $act_perf = $msginfo->actions_performed; $msginfo->actions_performed($act_perf=[]) if !$act_perf; if ($any_nonarch && !grep($_ eq 'Quarantined', @$act_perf)) { push(@$act_perf, 'Quarantined'); } if ($any_arch && !grep($_ eq 'Archived', @$act_perf)) { push(@$act_perf, 'Archived'); } } elsif ($n_smtp_resp =~ /^4/) { snmp_count('QuarAttemptTempFails'); die "temporarily unable to quarantine: $n_smtp_resp"; } else { # abort if quarantining not successful snmp_count('QuarAttemptFails'); die "Can't quarantine: $n_smtp_resp"; } my($q_ty, $q_to, @quar_type, @quar_to); $q_ty = $msginfo->quar_type; $q_to = $msginfo->quarantined_to; @quar_type = ref $q_ty ? @$q_ty : ( $q_ty ) if defined $q_ty; @quar_to = ref $q_to ? @$q_to : ( $q_to ) if defined $q_to; my(%seen_q_ty); $seen_q_ty{$_}=1 for @quar_type; my(%seen_q_to); $seen_q_to{$_}=1 for @quar_to; for my $r (@{$quar_msg->per_recip_data}) { my $mbxname = $r->recip_mbxname; next if !defined $mbxname || $mbxname eq ''; my $p = $quar_m_protocol; $p = $p eq 'smtp' ? 'M' : $p eq 'lmtp' ? 'L' : $p eq 'bsmtp' ? 'B' : $p eq 'sql' ? 'Q' : $p eq 'local' ? ($mbxname =~ /\@/ ? 'M' : $mbxname =~ /\.gz\z/ ? 'Z' : 'F') : '?'; push(@quar_type,$p) if !$seen_q_ty{$p}++; push(@quar_to,$mbxname) if !$seen_q_to{$mbxname}++; } # remember quarantine methods/protocols and locations (quarantined_to) $msginfo->quar_type(\@quar_type) if @quar_type; $msginfo->quarantined_to(\@quar_to) if @quar_to; ll(5) && do_log(5, 'quar_types: %s, quar_to: %s', join(',', @quar_type), join(', ', @quar_to)); do_log(4, 'DO_QUARANTINE done'); } } # prepare header edits for the quarantined message # sub prepare_header_edits_for_quarantine($) { my $msginfo = $_[0]; my($blacklisted_any,$whitelisted_any) = (0,0); my($do_tag_any,$do_tag2_any,$do_kill_any) = (0,0,0); my($tag_level_min,$tag2_level_min,$kill_level_min); my(%all_spam_tests); my($min_spam_level, $max_spam_level) = minmax(map($_->spam_level, @{$msginfo->per_recip_data})); for my $r (@{$msginfo->per_recip_data}) { my $rec = $r->recip_addr; my $spam_level = $r->spam_level; if (ll(2)) { my $blocking_ccat = $r->blocking_ccat; my($rec_ccat_maj,$rec_ccat_min) = ccat_split( defined $blocking_ccat ? $blocking_ccat : $r->contents_category); my($ccat,$ccat_min) = ccat_split($msginfo->contents_category); do_log(2,"header_edits_for_quar: rec_bl_ccat=(%d,%d), ccat=(%d,%d) %s", $rec_ccat_maj, $rec_ccat_min, $ccat, $ccat_min, $rec) if $rec_ccat_maj != $ccat || $rec_ccat_min != $ccat_min; } my($tag_level,$tag2_level,$kill_level,$do_tag,$do_tag2,$do_kill); $do_tag = $r->is_in_contents_category(CC_CLEAN,1); $do_tag2 = $r->is_in_contents_category(CC_SPAMMY); $do_kill = $r->is_in_contents_category(CC_SPAM); if (!$r->bypass_spam_checks && ($do_tag || $do_tag2 || $do_kill)) { # do the more expensive lookups only when needed $tag_level = lookup2(0,$rec, ca('spam_tag_level_maps')); $tag2_level = lookup2(0,$rec, ca('spam_tag2_level_maps')); $kill_level = lookup2(0,$rec, ca('spam_kill_level_maps')); } # summarize $blacklisted_any = 1 if $r->recip_blacklisted_sender; $whitelisted_any = 1 if $r->recip_whitelisted_sender; $tag_level_min = $tag_level if defined($tag_level) && $tag_level ne '' && (!defined($tag_level_min) || $tag_level < $tag_level_min); $tag2_level_min = $tag2_level if defined($tag2_level) && (!defined($tag2_level_min) || $tag2_level < $tag2_level_min); $kill_level_min = $kill_level if defined($kill_level) && (!defined($kill_level_min) || $kill_level < $kill_level_min); $do_tag_any = 1 if $do_tag; $do_tag2_any = 1 if $do_tag2; $do_kill_any = 1 if $do_kill; my $spam_tests = $r->spam_tests; if ($spam_tests) { $all_spam_tests{$_} = 1 for split(/,/, join(',',map($$_,@$spam_tests))); } } my(%header_field_provided); # mainly applies to spam header fields my $use_our_hdrs = cr('prefer_our_added_header_fields'); my $allowed_hdrs = cr('allowed_added_header_fields'); my $hdr_edits = Amavis::Out::EditHeader->new; if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Alert')}) { if ($msginfo->is_in_contents_category(CC_VIRUS)) { my $virusname_list = $msginfo->virusnames; $hdr_edits->add_header('X-Amavis-Alert', "INFECTED, message contains virus: " . (!$virusname_list ? '' : join(", ",@$virusname_list)) ); } if ($msginfo->is_in_contents_category(CC_BANNED)) { for my $r (@{$msginfo->per_recip_data}) { if (defined($r->banning_reason_short)) { $hdr_edits->add_header('X-Amavis-Alert', 'BANNED, message contains ' . $r->banning_reason_short); last; # fudge: only the first recipient's banned hit will be shown } } } if ($msginfo->is_in_contents_category(CC_BADH)) { $hdr_edits->add_header('X-Amavis-Alert', 'BAD HEADER SECTION, '.$bad_headers[0]); } } if ($allowed_hdrs) { for ('X-Amavis-OS-Fingerprint') { my $p0f = $msginfo->client_os_fingerprint; if (defined($p0f) && $p0f ne '' && $allowed_hdrs->{lc $_}) { $hdr_edits->add_header($_, sanitize_str($p0f)); } } } if ($allowed_hdrs && $use_our_hdrs) { my $spam_level_bar; my $slc = c('sa_spam_level_char'); if (defined $slc && $slc ne '') { my $bar_len = $whitelisted_any ? 0 : $blacklisted_any ? 64 : !defined $max_spam_level ? 0 : $max_spam_level > 64 ? 64 : $max_spam_level; $spam_level_bar = $bar_len < 1 ? '' : $slc x int $bar_len; } # allow header field wrapping at any comma my $s = join(",\n ", sort keys %all_spam_tests); my $sl = 'x'; if (defined $min_spam_level) { my $minsl = 0+sprintf("%.3f",$min_spam_level); my $maxsl = 0+sprintf("%.3f",$max_spam_level); $sl = $minsl eq $maxsl ? $minsl : "$minsl..$maxsl"; } my $autolearn_status = $msginfo->supplementary_info('AUTOLEARN'); my $full_spam_status = sprintf( "%s,\n score=%s\n tag=%s\n tag2=%s\n kill=%s\n ". "%stests=[%s]\n autolearn=%s", $do_tag2_any||$do_kill_any ? 'Yes' : 'No', $sl, (map { !defined $_ ? 'x' : 0+sprintf("%.3f",$_) } ($tag_level_min, $tag2_level_min, $kill_level_min)), join('', $blacklisted_any ? "BLACKLISTED\n " : (), $whitelisted_any ? "WHITELISTED\n " : ()), $s, $autolearn_status||'unavailable'); if (ll(2)) { # log entry semi-compatible with older log parsers my $s = $full_spam_status; $s =~ s/\n[ \t]/ /g; do_log(2,"header_edits_for_quar: %s -> %s, %s", $msginfo->sender_smtp, join(',', qquote_rfc2821_local(@{$msginfo->recips})), $s); } for ('X-Spam-Flag') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { $hdr_edits->add_header($_, $do_tag2_any ? 'YES' : 'NO'); $header_field_provided{lc $_} = 1; } } for ('X-Spam-Score') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { my $score = 0+$max_spam_level; $score = max(64,$score) if $blacklisted_any; # not below 64 if bl $score = min( 0,$score) if $whitelisted_any; # not above 0 if wl $hdr_edits->add_header($_, 0+sprintf("%.3f",$score)); $header_field_provided{lc $_} = 1; } } for ('X-Spam-Level') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { $hdr_edits->add_header($_, $spam_level_bar) if defined $spam_level_bar; $header_field_provided{lc $_} = 1; } } for ('X-Spam-Status') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { $hdr_edits->add_header($_, $full_spam_status, 1); $header_field_provided{lc $_} = 1; } } for ('X-Spam-Report') { if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) { my $report = $msginfo->spam_report; if (defined $report && $report ne '') { $hdr_edits->add_header($_, "\n".sanitize_str($report,1), 2); } $header_field_provided{lc $_} = 1; } } } if ($allowed_hdrs) { # add remaining header fields as provided by spam scanners my $sa_header = $msginfo->supplementary_info( $do_tag2_any ? 'ADDEDHEADERSPAM' : 'ADDEDHEADERHAM'); if (defined $sa_header && $sa_header ne '') { for my $hf (split(/^(?![ \t])/m, $sa_header, -1)) { local($1,$2); if ($hf =~ /^([!-9;-\176]+)[ \t]*:(.*)\z/s) { my($hf_name,$hf_body) = ($1,$2); my $hf_name_lc = lc $hf_name; chomp($hf_body); if ($header_field_provided{$hf_name_lc}) { do_log(5,'quar: scanner provided a header field %s, but we '. 'preferred our own', $hf_name); } elsif (!$allowed_hdrs->{$hf_name_lc}) { do_log(5,'quar: scanner provided a header field %s, '. 'inhibited by %%allowed_added_header_fields', $hf_name); } else { do_log(5,'quar: scanner provided a header field %s, inserting', $hf_name); $hdr_edits->add_header($hf_name, $hf_body, 2); } } } } for my $pair ( ['DSPAMRESULT', 'X-DSPAM-Result'], ['DSPAMSIGNATURE', 'X-DSPAM-Signature'], ['CRM114STATUS', 'X-CRM114-Status'], ['CRM114CACHEID', 'X-CRM114-CacheID'] ) { my($suppl_attr_name, $hf_name) = @$pair; my $suppl_attr_val = $msginfo->supplementary_info($suppl_attr_name); if (defined $suppl_attr_val && $suppl_attr_val ne '') { if (!$allowed_hdrs->{lc $hf_name}) { do_log(5,'quar: scanner provided a tag/field %s, '. 'inhibited by %%allowed_added_header_fields', $hf_name); } else { do_log(5,'quar: scanner provided a tag/field %s, inserting', $hf_name); $hdr_edits->add_header($hf_name, sanitize_str($suppl_attr_val), 2); } } } } if (c('enable_dkim_verification') && $allowed_hdrs && $allowed_hdrs->{lc('Authentication-Results')}) { for my $h (Amavis::DKIM::generate_authentication_results($msginfo,0)) { $hdr_edits->add_header('Authentication-Results', $h, 1); } } section_time('quar-hdrs'); $hdr_edits; } # Quarantine according to contents and send admin & recip notif. as needed # (this subroutine replaces the former subroutines do_virus and do_spam) # sub do_notify_and_quarantine($$) { my($msginfo, $virus_dejavu) = @_; my($mailfrom_admin, $hdrfrom_admin, $notify_admin_templ_ref) = map(scalar $msginfo->setting_by_contents_category(cr($_)), qw(mailfrom_notify_admin_by_ccat hdrfrom_notify_admin_by_ccat notify_admin_templ_by_ccat)); safe_encode_utf8_inplace($mailfrom_admin); # to octets (if not already) safe_encode_utf8_inplace($hdrfrom_admin); # to octets (if not already) my $qar_method = c('archive_quarantine_method'); my(@ccat_names_pairs) = $msginfo->setting_by_main_contents_category_all(\%ccat_display_names); my($ccat,$ccat_min) = ccat_split($msginfo->contents_category); if (ll(3)) { my $ccat_name = ref $ccat_names_pairs[0] ? $ccat_names_pairs[0][1] :undef; do_log(3,"do_notify_and_quar: ccat=%s (%d,%d) (%s) ccat_block=(%s)". ", qar_mth=%s", $ccat_name, $ccat, $ccat_min, join(', ', map(sprintf('"%s":%s', $_->[0], $_->[1]), @ccat_names_pairs)), $msginfo->blocking_ccat, $qar_method); } my $virusname_list = $msginfo->virusnames; my $newvirus_admin_maps_ref = $virusname_list && @$virusname_list && !$virus_dejavu ? ca('newvirus_admin_maps') : undef; my $archive_any = 0; my $archive_transparent = 1; if (defined $qar_method && $qar_method ne '') { # archiving quarantine # test if @archive_quarantine_to_maps for all recipients yields # a magic placeholder '%a', indicating we want transparent archiving # which retains unmodified envelope recipient addresses my $aqtm = ca('archive_quarantine_to_maps'); for my $r (@{$msginfo->per_recip_data}) { my $q = lookup2(0, $r->recip_addr, $aqtm); $archive_any = 1 if defined $q && $q ne ''; $archive_transparent = 0 if !defined $q || $q ne '%a'; last if $archive_any && !$archive_transparent; } } my(@q_tuples, @a_addr); # per-recip quarantine address(es) and admins for my $r (@{$msginfo->per_recip_data}) { my $rec = $r->recip_addr; my $blacklisted = $r->recip_blacklisted_sender; my $whitelisted = $r->recip_whitelisted_sender; my $spam_level = $r->spam_level; # an alternative approach to determining which quarantine and notif. to take # my(@qmqta_tuples) = $r->setting_by_main_contents_category_all( # cr('quarantine_method_by_ccat'), cr('quarantine_to_maps_by_ccat'), # cr('admin_maps_by_ccat') ); # my $qq; # quarantine (pseudo) address associated with the recipient # my $quarantining_reason_ccat; # for my $tuple (@qmqta_tuples) { # my($cc, $q_method, $quarantine_to_maps_ref, $admin_maps_ref) = @$tuple; # if (defined($q_method) && $q_method ne '' && $quarantine_to_maps_ref) { # my $q = lookup2(0,$rec,$quarantine_to_maps_ref); # if (defined $q && $q ne '') # { $qq = $q; $quarantining_reason_ccat = $cc; last } # } # } # my $aa; # administrator's e-mail address # my $admin_notif_reason_ccat; # for my $tuple (@qmqta_tuples) { # my($cc, $q_method, $quarantine_to_maps_ref, $admin_maps_ref) = @$tuple; # if ($admin_maps_ref) { # my $a = lookup2(0,$rec,$admin_maps_ref); # if (defined $a && $a ne '') # { $aa = $a; $admin_notif_reason_ccat = $cc; last } # } # } # ($rec_ccat_maj,$rec_ccat_min) = ccat_split($quarantining_reason_ccat); my $blocking_ccat = $r->blocking_ccat; my($rec_ccat_maj,$rec_ccat_min) = ccat_split( defined $blocking_ccat ? $blocking_ccat : $r->contents_category); my $q_method = $r->setting_by_contents_category(cr('quarantine_method_by_ccat')); my $quarantine_to_maps_ref = $r->setting_by_contents_category(cr('quarantine_to_maps_by_ccat')); # get per-recipient quarantine address(es) and admins if (!defined($q_method) || $q_method eq '') { do_log(5,"do_notify_and_quarantine: not quarantining, q_method off"); } elsif (!$quarantine_to_maps_ref) { do_log(5,"do_notify_and_quarantine: not quarantining, null q_to maps"); } else { my $q; # quarantine (pseudo) address associated with the recipient $q = lookup2(0,$rec,$quarantine_to_maps_ref); if (defined $q && $q ne '' && ($rec_ccat_maj==CC_SPAM || $rec_ccat_maj==CC_SPAMMY)) { # consider suppressing spam quarantine my $cutoff = lookup2(0,$rec, ca('spam_quarantine_cutoff_level_maps')); if (!defined $cutoff || $cutoff eq '') { # no cutoff, quarantining all } elsif ($blacklisted && !$whitelisted) { do_log(2,"do_notify_and_quarantine: cutoff, blacklisted"); $q = ''; # disable quarantine on behalf of this recipient } elsif (($spam_level||0) >= $cutoff) { do_log(2,"do_notify_and_quarantine: spam level exceeds ". "quarantine cutoff level %s", $cutoff); $q = ''; # disable quarantine on behalf of this recipient } } # keep original recipient when q_to is '%a' or with BSMTP; some day # we may end up doing %k, %a, %l, %u, %e, %d placeholder replacements $q = $rec if defined $q && $q ne '' && ($q eq '%a' || $q_method =~ /^bsmtp:/i); if (!defined($q) || $q eq '') { do_log(5,"do_notify_and_quarantine: not quarantining, q_to off"); } else { my $ccat_name_major = $r->setting_by_contents_category(\%ccat_display_names_major); push(@q_tuples, [$q_method, $q, $ccat_name_major]); } } my $admin_maps_ref = $r->setting_by_contents_category(cr('admin_maps_by_ccat')); my $a; # administrator's e-mail address $a = lookup2(0,$rec,$admin_maps_ref) if $admin_maps_ref; if (defined $a && $a ne '' && ($rec_ccat_maj==CC_SPAM || $rec_ccat_maj==CC_SPAMMY)) { # consider suppressing spam admin notifications my $cutoff = lookup2(0,$rec, ca('spam_notifyadmin_cutoff_level_maps')); if (!defined $cutoff || $cutoff eq '') { # no cutoff, sending administrator notifications } elsif ($blacklisted && !$whitelisted) { do_log(2,"do_notify_and_quarantine: spam admin cutoff, blacklisted"); $a = ''; # disable admin notification on behalf of this recipient } elsif (($spam_level||0) >= $cutoff) { do_log(2,"do_notify_and_quarantine: spam level exceeds ". "spam admin cutoff level %s", $cutoff); $a = ''; # disable admin notification on behalf of this recipient } } push(@a_addr, $a) if defined $a && $a ne '' && !grep($_ eq $a, @a_addr); if (ccat_maj($r->contents_category)==CC_VIRUS && $newvirus_admin_maps_ref){ $a = lookup2(0,$rec,$newvirus_admin_maps_ref); push(@a_addr, $a) if defined $a && $a ne '' && !grep($_ eq $a, @a_addr); } if ($archive_any && !$archive_transparent) { # archiving quarantine my $q = lookup2(0,$rec, ca('archive_quarantine_to_maps')); # keep original recipient when q_to is '%a' or with BSMTP $q = $rec if defined $q && $q ne '' && ($q eq '%a' || $qar_method =~ /^bsmtp:/i); push(@q_tuples, [$qar_method, $q, 'Arch']) if defined $q && $q ne ''; } } # endfor per_recip_data if ($ccat == CC_SPAM) { my $sqbsm = ca('spam_quarantine_bysender_to_maps'); if (@$sqbsm) { # by-sender spam quarantine (hardly useful, rarely used) my $q = lookup2(0,$msginfo->sender, $sqbsm); if (defined $q && $q ne '') { my $msg_q_method = $msginfo->setting_by_contents_category( cr('quarantine_method_by_ccat')); push(@q_tuples, [$msg_q_method, $q, 'Spam']) if defined $msg_q_method && $msg_q_method ne ''; } } } section_time('notif-quar'); if (@q_tuples || $archive_any) { if (!defined($msginfo->mail_id) && grep($_->[2] ne 'Arch', @q_tuples)) { # delayed mail_id generation - now we really need it $zmq_obj->register_proc(2,0,'G',$msginfo->log_id) if $zmq_obj; # generate $snmp_db->register_proc(2,0,'G',$msginfo->log_id) if $snmp_db; # create a mail_id unique to a database and save preliminary info to SQL generate_unique_mail_id($msginfo); section_time('gen_mail_id') if $sql_storage; } # compatibility: replace quarantine method 'local:xxx' # with $notify_method when quarantine_to looks like an e-mail address my $notif_m = c('notify_method'); for my $tuple (@q_tuples) { my($q_method,$q_to,$ccat_name) = @$tuple; $tuple->[0] = $notif_m if $q_method =~ /^local:/i && $q_to =~ /\@/; } my $hdr_edits = prepare_header_edits_for_quarantine($msginfo); if (@q_tuples) { do_log(4,"do_notify_and_quarantine: quarantine %s", join(',', map($_->[1], @q_tuples))); my(@q_tuples_tmp) = @q_tuples; while (@q_tuples_tmp) { my($q_method,$q_to,$ccat_name) = @{$q_tuples_tmp[0]}; my(@same_method_tuples) = grep($_->[0] eq $q_method, @q_tuples_tmp); @q_tuples_tmp = grep($_->[0] ne $q_method, @q_tuples_tmp); my(@q_to) = unique_list(map($_->[1], @same_method_tuples)); # per-recipient blocking ccat names select snmp counter names my(@snmp_id) = unique_list(map($_->[2], @same_method_tuples)); do_quarantine($msginfo, $hdr_edits, \@q_to, $q_method, @snmp_id); } } if ($archive_any && $archive_transparent) { # transparent archiving retains envelope recipient addresses do_log(4,"do_notify_and_quarantine: transparent archiving"); do_quarantine($msginfo, $hdr_edits, undef, $qar_method, 'Arch'); } } if (!@a_addr) { do_log(4,"skip admin notification, no administrators"); } elsif (!ref($notify_admin_templ_ref) || (ref($notify_admin_templ_ref) eq 'ARRAY' ? !@$notify_admin_templ_ref : $$notify_admin_templ_ref eq '')) { do_log(5,"skip admin notifications - empty template"); } else { # notify per-recipient administrators ll(5) && do_log(5, "Admin notifications to %s; sender: %s", join(',',qquote_rfc2821_local(@a_addr)), $msginfo->sender_smtp); $hdrfrom_admin = expand_variables($hdrfrom_admin); if (!defined $mailfrom_admin) { # defaults to email address in hdrfrom_notify_admin $mailfrom_admin = unquote_rfc2821_local( (parse_address_list($hdrfrom_admin))[0] ); } my $notification = Amavis::In::Message->new; $notification->rx_time($msginfo->rx_time); # copy the reception time $notification->log_id($msginfo->log_id); # copy log id $notification->partition_tag($msginfo->partition_tag); # same partition_tag $notification->parent_mail_id($msginfo->mail_id); $notification->mail_id(scalar generate_mail_id()); $notification->conn_obj($msginfo->conn_obj); $notification->originating(1); $notification->add_contents_category(CC_CLEAN,0); safe_encode_utf8_inplace($_) for @a_addr; # make sure addrs are in octets if (grep( / [^\x00-\x7F] .*? \@ [^@]* \z/sx && is_valid_utf_8($_), ($mailfrom_admin, @a_addr) )) { # localpart is non-ASCII UTF-8, we must use SMTPUTF8 $notification->smtputf8(1); do_log(2, 'admin notification requires SMTPUTF8'); } else { $_ = mail_addr_idn_to_ascii($_) for ($mailfrom_admin, @a_addr); } $notification->sender($mailfrom_admin); $notification->sender_smtp(qquote_rfc2821_local($mailfrom_admin)); $notification->auth_submitter($notification->sender_smtp); $notification->auth_user(c('amavis_auth_user')); $notification->auth_pass(c('amavis_auth_pass')); $notification->recips([@a_addr]); my $notif_m = c('notify_method'); $_->delivery_method($notif_m) for @{$notification->per_recip_data}; my(@rfc2822_from_admin) = map(unquote_rfc2821_local($_), parse_address_list($hdrfrom_admin)); $notification->rfc2822_from($rfc2822_from_admin[0]); # if ($mailfrom_admin ne '') # { $_->dsn_notify(['NEVER']) for @{$notification->per_recip_data} } my(%mybuiltins) = %builtins; # make a local copy $mybuiltins{'f'} = safe_decode_utf8($hdrfrom_admin); # From: $mybuiltins{'T'} = # To: [ map(mail_addr_idn_to_ascii(qquote_rfc2821_local($_)), @a_addr) ]; $notification->mail_text( build_mime_entity(expand($notify_admin_templ_ref,\%mybuiltins), $msginfo, undef,undef,0, 1,0) ); # $notification->body_type('7BIT'); # '8BITMIME' my $hdr_edits = Amavis::Out::EditHeader->new; $notification->header_edits($hdr_edits); mail_dispatch($notification, 'Notif', 0); my($n_smtp_resp, $n_exit_code, $n_dsn_needed) = one_response_for_all($notification, 0); # check status if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) { # ok build_and_save_structured_report($notification,'NOTIF'); } elsif ($n_smtp_resp =~ /^4/) { die "temporarily unable to notify admin: $n_smtp_resp"; } else { do_log(-1, "FAILED to notify admin: %s", $n_smtp_resp); } # $notification->purge; } # recipient notifications my $wrmbc = cr('warnrecip_maps_by_ccat'); for my $r (@{$msginfo->per_recip_data}) { my $rec = $r->recip_addr; # if ($r->is_in_contents_category(CC_SPAM)) { # if ($wrmbc->{&CC_VIRUS}) { # $wrmbc = { %$wrmbc }; # copy # delete $wrmbc->{&CC_VIRUS}; # do_log(5,"disabling virus recipient notifications for infected spam"); # } # } my $warnrecip_maps_ref = $r->setting_by_contents_category($wrmbc); my $wr; my $notify_recips_templ_ref; $wr = lookup2(0,$rec,$warnrecip_maps_ref) if $warnrecip_maps_ref; if ($wr) { $notify_recips_templ_ref = $r->setting_by_contents_category(cr('notify_recips_templ_by_ccat')); if (!ref($notify_recips_templ_ref) || (ref($notify_recips_templ_ref) eq 'ARRAY' ? !@$notify_recips_templ_ref : $$notify_recips_templ_ref eq '')){ do_log(5,"skip recipient notifications - empty template"); $wr = 0; # do not send empty notifications } elsif (!c('warn_offsite') && !$r->recip_is_local) { do_log(5,"skip recipient notifications - nonlocal recipient"); $wr = 0; # do not notify foreign recipients # } elsif ($r->recip_destiny == D_PASS) { # do_log(5,"skip recipient notifications - mail will be delivered"); # $wr = 0; # do not notify recips which will be getting a message anyway # } elsif ($msginfo->sender eq '') { # (not general enough) # do_log(5,"skip recipient notifications for null sender"); # $wr = 0; } } if ($wr) { # warn recipient my $mailfrom_recip = $r->setting_by_contents_category(cr('mailfrom_notify_recip_by_ccat')); my $hdrfrom_recip = $r->setting_by_contents_category(cr('hdrfrom_notify_recip_by_ccat')); # make sure it's in octets safe_encode_utf8_inplace($mailfrom_recip); # to octets (if not already) safe_encode_utf8_inplace($hdrfrom_recip); # to octets (if not already) $hdrfrom_recip = expand_variables($hdrfrom_recip); if (!defined $mailfrom_recip) { # defaults to email address in hdrfrom_notify_recip $mailfrom_recip = unquote_rfc2821_local( (parse_address_list($hdrfrom_recip))[0] ); } my $notification = Amavis::In::Message->new; $notification->rx_time($msginfo->rx_time); # copy the reception time $notification->log_id($msginfo->log_id); # copy log id $notification->partition_tag($msginfo->partition_tag); # same partition $notification->parent_mail_id($msginfo->mail_id); $notification->mail_id(scalar generate_mail_id()); $notification->conn_obj($msginfo->conn_obj); $notification->originating(1); $notification->add_contents_category(CC_CLEAN,0); if (grep( / [^\x00-\x7F] .*? \@ [^@]* \z/sx && is_valid_utf_8($_), ($mailfrom_recip, $rec) )) { # localpart is non-ASCII UTF-8, we must use SMTPUTF8 do_log(2, 'recipient notification requires SMTPUTF8'); $notification->smtputf8(1); } else { $_ = mail_addr_idn_to_ascii($_) for ($mailfrom_recip, $rec); } $notification->sender($mailfrom_recip); $notification->sender_smtp(qquote_rfc2821_local($mailfrom_recip)); $notification->auth_submitter($notification->sender_smtp); $notification->auth_user(c('amavis_auth_user')); $notification->auth_pass(c('amavis_auth_pass')); $notification->recips([$rec]); my $notif_m = c('notify_method'); $_->delivery_method($notif_m) for @{$notification->per_recip_data}; my(@rfc2822_from_recip) = map(unquote_rfc2821_local($_), parse_address_list($hdrfrom_recip)); $notification->rfc2822_from($rfc2822_from_recip[0]); # if ($mailfrom_recip ne '') # { $_->dsn_notify(['NEVER']) for @{$notification->per_recip_data} } my(@b); @b = @{$r->banned_parts} if defined $r->banned_parts; my $b_chopped = @b > 2; @b = (@b[0,1],'...') if $b_chopped; s/[ \t]{6,}/ ... /g for @b; my(%mybuiltins) = %builtins; # make a local copy $mybuiltins{'banned_parts'} = \@b; # list of banned parts $mybuiltins{'F'} = $r->banning_reason_short; # just one name & comment $mybuiltins{'banning_rule_comment'} = !defined($r->banning_rule_comment) ? undef : unique_ref($r->banning_rule_comment); $mybuiltins{'banning_rule_rhs'} = !defined($r->banning_rule_rhs) ? undef : unique_ref($r->banning_rule_rhs); $mybuiltins{'f'} = safe_decode_utf8($hdrfrom_recip); # From: $mybuiltins{'T'} = mail_addr_idn_to_ascii(qquote_rfc2821_local($rec)); $notification->mail_text( build_mime_entity(expand($notify_recips_templ_ref,\%mybuiltins), $msginfo, undef,undef,0, 0,0) ); # $notification->body_type('7BIT'); # '8BITMIME' my $hdr_edits = Amavis::Out::EditHeader->new; $notification->header_edits($hdr_edits); mail_dispatch($notification, 'Notif', 0); my($n_smtp_resp, $n_exit_code, $n_dsn_needed) = one_response_for_all($notification, 0); # check status if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) { # ok build_and_save_structured_report($notification,'NOTIF'); } elsif ($n_smtp_resp =~ /^4/) { die "temporarily unable to notify recipient rec: $n_smtp_resp"; } else { do_log(-1, "FAILED to notify recipient %s: %s", $rec,$n_smtp_resp); } # $notification->purge; } } do_log(5, "do_notify_and_quarantine - done"); } # Calculate a message body digest; # While at it, also get message size, verify DKIM signatures, check for 8-bit # data, collect entropy, and store original header section since we need it # for the %H macro, and MIME::Tools may modify its copy. # sub get_body_digest($$) { my($msginfo, $alg) = @_; my($remaining_time, $dkim_deadline) = # sanity limit for DKIM verification get_deadline('get_body_digest', 0.5, 8, 30); prolong_timer('digest_pre'); # restart the timer my($hctx,$bctx); # choose a message digest: MD5: 128 bits (32 hex), SHA family: 160..512 bits if (uc $alg eq 'MD5') { $hctx = Digest::MD5->new; $bctx = Digest::MD5->new } else { $hctx = Digest::SHA->new($alg); $bctx = Digest::SHA->new($alg) } my $dkim_verifier; if (c('enable_dkim_verification')) { if (!defined $dns_resolver && Mail::DKIM::Verifier->VERSION >= 0.40) { # Create a persistent DNS resolver object for the benefit # of Mail::DKIM::Verifier; this avoids repeating initializations # with each request, and allows us to turn on EDNS. # The controversial need for 'config_file' option was debated in # [rt.cpan.org #96608] https://rt.cpan.org/Ticket/Display.html?id=96608 # With Net::DNS 1.03 the semantics of a "retry" option has changed: # [rt.cpan.org #109183] https://rt.cpan.org/Ticket/Display.html?id=109183 $dns_resolver = Net::DNS::Resolver->new( config_file => '/etc/resolv.conf', defnames => 0, force_v4 => !$have_inet6, retry => 2, # number of times to try the query (not REtries) persistent_udp => 1, tcp_timeout => 3, udp_timeout => 3, retrans => 2, # seconds ); if (!$dns_resolver) { do_log(-1, "Failed to create a Net::DNS::Resolver object"); $dns_resolver = 0; # defined but false } else { # RFC 2460 (for IPv6) requires that a minimal MTU is 1280 bytes, # taking away 40 bytes for a basic IP header gives 1240; # RFC 3226: minimum of 1220 for RFC 2535 compliant servers # RFC 6891: choosing between 1280 and 1410 bytes for IP (v4 or v6) # over Ethernet would be reasonable. my $payload_size = 1220; # a conservative default # RFC 6891 (ex RFC 2671) - EDNS0, set requestor's UDP payload size $dns_resolver->udppacketsize($payload_size) if $payload_size > 512; ll(5) && do_log(5, "DNS resolver created, UDP payload size %s, NS: %s", $dns_resolver->udppacketsize, join(', ',$dns_resolver->nameservers) ); Mail::DKIM::DNS::resolver($dns_resolver); } } $dkim_verifier = Mail::DKIM::Verifier->new; } # section_time('digest_init'); my($header_size, $body_size, $h_8bit, $b_8bit) = (0) x 4; my $orig_header = []; # array of header fields, with folding and trailing NL my $orig_header_fields = {}; my $sanity_limit = 4*1024*1024; # 4 MiB header size sanity limit my $dkim_sanity_limit = 256*1024; # 256 KiB header size sanity limit my $msg = $msginfo->mail_text; my $msg_str_ref = $msginfo->mail_text_str; # have an in-memory copy? $msg = $msg_str_ref if ref $msg_str_ref; my $pos = 0; if (!defined $msg) { # empty mail $msginfo->body_start_pos(0); } elsif (ref $msg eq 'SCALAR') { do_log(5, "get_body_digest: reading header section from memory"); my $header; $pos = min($msginfo->skip_bytes, length($$msg)); if ($pos >= length($$msg)) { # empty message $header = ''; $pos = length($$msg); } elsif (substr($$msg,$pos,1) eq "\n") { # empty header section $header = ''; $pos++; } else { my $ind = index($$msg, "\n\n", $pos); # find header/body separator $header = $ind < 0 ? substr($$msg, $pos) : substr($$msg, $pos, $ind+1-$pos); $h_8bit = 1 if $header =~ tr/\x00-\x7F//c; $hctx->add($header); $pos = $ind < 0 ? length($$msg) : $ind+2; } # $pos now points to the first byte of a body $msginfo->body_start_pos($pos); local($1); my($j,$k,$ln); for ($j = 0; $j < length($header); $j = $k+1) { $k = index($header, "\n", $j); $ln = $k < 0 ? substr($header, $j) : substr($header, $j, $k-$j+1); if ($ln =~ /^[ \t]/) { # header field continuation $$orig_header[-1] .= $ln; # includes NL } else { # starts a new header field push(@$orig_header, $ln); # includes NL if ($ln =~ /^([^: \t]+)[ \t]*:/) { # remember array index of each occurrence of a header field, top down my $curr_entry = $orig_header_fields->{lc($1)}; if (!defined $curr_entry) { # optimized: if there is only one element, it is stored as itself $orig_header_fields->{lc($1)} = $#$orig_header; } elsif (ref $curr_entry) { # already an arrayref, append push(@{$orig_header_fields->{lc($1)}}, $#$orig_header); } else { # was a single element as a scalar, now there are two $orig_header_fields->{lc($1)} = [ $curr_entry, $#$orig_header ]; } } } last if $k < 0; } $header =~ s{\n}{\015\012}gs; # needed for DKIM and for size $header_size = length($header); # size includes CRLF (RFC 1870) if (defined $dkim_verifier) { do_log(5, "get_body_digest: feeding header section to DKIM verifier"); eval { $dkim_verifier->PRINT($header) or die "Error writing mail header to DKIM: $!"; 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"Error feeding header to DKIM verifier: %s",$eval_stat); undef $dkim_verifier; }; } } elsif ($msg->isa('MIME::Entity')) { die "get_body_digest: reading from a MIME::Entity object not implemented"; } else { # a file handle assumed do_log(5, "get_body_digest: reading header section from a file"); $pos = $msginfo->skip_bytes; # should be 0, but anyway... $msg->seek($pos,0) or die "Can't rewind mail file: $!"; # read mail header section local($1); my $ln; for ($! = 0; defined($ln=$msg->getline); $! = 0) { $pos += length($ln); last if $ln eq "\n"; $hctx->add($ln); $h_8bit = 1 if !$h_8bit && ($ln =~ tr/\x00-\x7F//c); if ($ln =~ /^[ \t]/) { # header field continuation $$orig_header[-1] .= $ln; # including NL } else { # starts a new header field push(@$orig_header,$ln); # including NL if ($ln =~ /^([^: \t]+)[ \t]*:/) { # remember array index of each occurrence of a header field, top down my $curr_entry = $orig_header_fields->{lc($1)}; if (!defined $curr_entry) { # optimized: if there is only one element, it is stored as itself $orig_header_fields->{lc($1)} = $#$orig_header; } elsif (ref $curr_entry) { # already an arrayref, append push(@{$orig_header_fields->{lc($1)}}, $#$orig_header); } else { # was a single element as a scalar, now there are two $orig_header_fields->{lc($1)} = [ $curr_entry, $#$orig_header ]; } } } chomp($ln); if (!defined $dkim_verifier) { # don't bother } elsif ($header_size > $dkim_sanity_limit) { do_log(-1,"Stopped feeding header to DKIM verifier: ". "%.0f KiB sanity limit exceeded", $dkim_sanity_limit/1024); undef $dkim_verifier; } elsif (Time::HiRes::time > $dkim_deadline) { do_log(-1,"Stopped feeding header to DKIM verifier: deadline exceeded"); undef $dkim_verifier; } else { eval { $dkim_verifier->PRINT($ln."\015\012") or die "Error writing mail header to DKIM: $!"; 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"Error feeding header line to DKIM verifier: %s", $eval_stat); undef $dkim_verifier; }; } $header_size += length($ln)+2; # size includes CRLF (RFC 1870) # exceeded $sanity_limit will break DKIM signatures, too bad... last if $header_size > $sanity_limit; } defined $ln || $! == 0 or # returning EBADF at EOF is a perl bug $! == EBADF ? do_log(0,"Error reading mail header section: $!") : die "Error reading mail header section: $!"; $msginfo->body_start_pos($pos); } add_entropy($hctx->digest); if (defined $dkim_verifier) { do_log(5, "get_body_digest: sending h/b separator to DKIM"); eval { # h/b separator will trigger signature pre-processing in DKIM module $dkim_verifier->PRINT("\015\012") or die "Error writing h/b separator to DKIM: $!"; 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"Error feeding h/b separ to DKIM verifier: %s", $eval_stat); undef $dkim_verifier; }; } $header_size += 2; # include a separator CRLF line in a header section size untaint_inplace($header_size); # length(tainted) stays tainted too section_time('digest_hdr'); # a DNS lookup in Mail::DKIM older than 0.30 stops the timer! # The lookup is performed at a header/body separator line or at CLOSE, at # which point signatures become available through the $dkim_verifier object. prolong_timer('digest_hdr'); # restart timer if stopped my(@dkim_signatures); @dkim_signatures = $dkim_verifier->signatures if defined $dkim_verifier; # don't bother feeding body to DKIM if there are no signature header fields my $feed_dkim = @dkim_signatures > 0; if ($feed_dkim) { $msginfo->checks_performed({}) if !$msginfo->checks_performed; $msginfo->checks_performed->{D} = 1; } if (!defined $msg) { # empty mail } elsif (ref $msg eq 'SCALAR') { ll(5) && do_log(5, "get_body_digest: reading mail body from memory, ". "%d DKIM signatures", scalar @dkim_signatures); my($buff, $buff_l); while ($pos < length($$msg)) { # do it in chunks to avoid unnecessarily large memory use # for temporary variables $buff = substr($$msg,$pos,32768); $buff_l = length($buff); $pos += $buff_l; $bctx->add($buff); $b_8bit = 1 if !$b_8bit && ($buff =~ tr/\x00-\x7F//c); if (!$feed_dkim) { # count \n, compensating for CRLF (RFC 1870) $body_size += $buff_l + ($buff =~ tr/\n//); } else { $buff =~ s{\n}{\015\012}gs; $body_size += length($buff); eval { $dkim_verifier->PRINT($buff) or die "Error writing mail body to DKIM: $!"; 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"Error feeding body to DKIM verifier: %s",$eval_stat); undef $dkim_verifier; }; } } } elsif ($msg->isa('MIME::Entity')) { die "get_body_digest: reading from MIME::Entity is not implemented"; } else { #*** # only read further if not already at end-of-file ll(5) && do_log(5, "get_body_digest: reading mail body from a file, ". "%d DKIM signatures", scalar @dkim_signatures); my($buff, $buff_l); while (($buff_l = $msg->read($buff,65536)) > 0) { $bctx->add($buff); $b_8bit = 1 if !$b_8bit && ($buff =~ tr/\x00-\x7F//c); if (!$feed_dkim) { # count \n, compensating for CRLF (RFC 1870) $body_size += $buff_l + ($buff =~ tr/\n//); } else { $buff =~ s{\n}{\015\012}gs; $body_size += length($buff); eval { $dkim_verifier->PRINT($buff) or die "Error writing mail body to DKIM: $!"; 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"Error feeding body to DKIM verifier: %s",$eval_stat); undef $dkim_verifier; }; } } defined $buff_l or die "Error reading mail body: $!"; } if (defined $dkim_verifier) { eval { # this will trigger signature verification in the DKIM module $dkim_verifier->CLOSE or die "Can't close dkim_verifier: $!"; 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(-1,"Error closing DKIM verifier: %s",$eval_stat); undef $dkim_verifier; }; @dkim_signatures = $dkim_verifier->signatures if defined $dkim_verifier; } prolong_timer('digest_body'); # restart timer if stopped my $body_digest = untaint($bctx->digest); add_entropy($body_digest); # store information obtained if (@dkim_signatures) { if (@dkim_signatures > 50) { # sanity do_log(-1, "Too many DKIM or DK signatures (%d), truncating to 50", scalar(@dkim_signatures)); $#dkim_signatures = 49; } $msginfo->dkim_signatures_all(\@dkim_signatures); } if (ll(5)) { my $mail_size_old = $msginfo->msg_size; my $mail_size_new = $header_size + $body_size; if (defined($mail_size_old) && $mail_size_new != $mail_size_old) { # copy_smtp_data() provides a message size which is not adjusted for # dot-destuffing - for speed. We finely adjust the message size here, # now that we have the necessary information available. do_log(5, "get_body_digest: message size adjusted %d -> %d, ". "header+sep %d, body %d", $mail_size_old, $mail_size_new, $header_size, $body_size); } else { do_log(5, "get_body_digest: message size %d, header+sep %d, body %d", $mail_size_new, $header_size, $body_size); } } $msginfo->msg_size($header_size + $body_size); $msginfo->orig_header_fields($orig_header_fields); # stores just indices $msginfo->orig_header($orig_header); # header section, without separator line $msginfo->orig_header_size($header_size); # size includes a separator line! $msginfo->orig_body_size($body_size); my $body_digest_hex = unpack('H*', $body_digest); # high nybble first # store hex-encoded to retain backward compatibility with pre-2.8.0 $msginfo->body_digest($body_digest_hex); $msginfo->header_8bit($h_8bit ? 1 : 0); $msginfo->body_8bit($b_8bit ? 1 : 0); # check for 8-bit characters and adjust body type if necessary (RFC 6152) my $bt_orig = $msginfo->body_type; $bt_orig = defined $bt_orig ? uc $bt_orig : ''; if ($h_8bit || $b_8bit) { # just keep original label whatever it is (garbage-in - garbage-out); # keeping 8-bit mail unlabeled might avoid breaking DKIM in transport # (labeling as 8-bit may invoke 8>7 downgrades in MTA, breaking signatures) } elsif ($bt_orig eq '') { # unlabeled on reception $msginfo->body_type('7BIT'); # safe to label as all-ASCII } elsif ($bt_orig eq '8BITMIME') { # redundant (quite common) $msginfo->body_type('7BIT'); # turn a redundant 8BITMIME into 7BIT } if (ll(4)) { my $remark = ($bt_orig eq '' && $b_8bit) ? ", but 8-bit body" : ($bt_orig eq '' && $h_8bit) ? ", but 8-bit header" : ($bt_orig eq '7BIT' && ($h_8bit || $b_8bit)) ? " inappropriately" : ($bt_orig eq '8BITMIME' && !($h_8bit || $b_8bit)) ? " unnecessarily" : ", good"; do_log(4, "body type (8bit-MIMEtransport): %s%s (h=%s, b=%s)", $bt_orig eq '' ? 'unlabeled' : "labeled $bt_orig", $remark, $h_8bit, $b_8bit); } do_log(3, "body hash: %s", $body_digest_hex); section_time(defined $dkim_verifier ? 'digest_body_dkim' : 'digest_body'); $body_digest_hex; } sub find_program_path($$) { my($fv_list, $path_list_ref) = @_; $fv_list = [$fv_list] if !ref $fv_list; my $found; for my $fv (@$fv_list) { # search through alternatives my(@fv_cmd) = split(' ',$fv); my $cmd = $fv_cmd[0]; if (!@fv_cmd) { # empty, not available } elsif ($cmd =~ m{^/}s) { # absolute path my $errn = stat($cmd) ? 0 : 0+$!; if ($errn == ENOENT) { # file does not exist } elsif ($errn) { do_log(-1, "find_program_path: %s inaccessible: %s", $cmd,$!); } elsif (-d _) { do_log(0, "find_program_path: %s is a directory", $cmd); } elsif (!-x _) { do_log(0, "find_program_path: %s is not executable", $cmd); } else { $found = join(' ', @fv_cmd); } } elsif ($cmd =~ m{/}s) { # relative path die "find_program_path: relative paths not implemented: @fv_cmd\n"; } else { # walk through the specified PATH for my $p (@$path_list_ref) { my $errn = stat("$p/$cmd") ? 0 : 0+$!; if ($errn == ENOENT) { # file does not exist } elsif ($errn) { do_log(-1, "find_program_path: %s/%s inaccessible: %s", $p,$cmd,$!); } elsif (-d _) { do_log(0, "find_program_path: %s/%s is a directory", $p,$cmd); } elsif (!-x _) { do_log(0, "find_program_path: %s/%s is not executable", $p,$cmd); } else { $found = $p . '/' . join(' ', @fv_cmd); last; } } } last if defined $found; } $found; } sub find_external_programs($) { my $path_list_ref = $_[0]; for my $f (qw($file $altermime)) { my $g = $f; $g =~ s/\$/Amavis::Conf::/; my $fv_list = eval('$' . $g); my $found = find_program_path($fv_list, $path_list_ref); { no strict 'refs'; $$g = $found } # NOTE: a symbolic reference if (!defined $found) { do_log(0,"No %-19s not using it", "$f,") } else { do_log(1, "Found %-16s at %s%s", $f, $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '', $found); } } # map program name path hints to full paths for decoders my(%any_st); for my $f (@{ca('decoders')}) { next if !defined $f || !ref $f; # empty, skip my $short_types = $f->[0]; if (!defined $short_types || (ref $short_types && !@$short_types)) { undef $f; next; } my(@tried,@found); my $any = 0; for my $d (@$f[2..$#$f]) { # all but the first two elements are programs # find the program, allow one level of indirection my $dd = (ref $d eq 'SCALAR' || ref $d eq 'REF') ? $$d : $d; my $found = find_program_path($dd, $path_list_ref); if (defined $found) { $any = 1; $d = $dd = $found; push(@found,$dd); } else { push(@tried, !ref($dd) ? $dd : join(", ",@$dd)) if $dd ne ''; undef $d; } } my $any_in_use; for my $short_type (ref $short_types ? @$short_types : $short_types) { my $is_a_backup = $any_st{$short_type}; my($ll,$tier) = !$is_a_backup ? (1,'') : (2,' (backup, not used)'); if (@$f <= 2) { # no external programs specified if (!$is_a_backup) { $any_in_use = 1; $any_st{$short_type} = 1 } do_log($ll, "Internal decoder for .%-4s%s", $short_type,$tier); } elsif (!$any) { # external programs specified but none found do_log(0, "No ext program for .%s, tried: %s", $short_type, join('; ',@tried)) if @tried && !$is_a_backup; } else { if (!$is_a_backup) { $any_in_use = 1; $any_st{$short_type} = 1 } do_log($ll, "Found decoder for .%-4s at %s%s%s", $short_type, $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '', join('; ',@found), $tier); } # defined but false, collect a list of tried short types as hash keys $any_st{$short_type} = 0 if !defined $any_st{$short_type}; } if (!$any_in_use) { undef $f; # discard a backup entry } else { # turn array (in the first element) into a hash $f->[0] = { map(($_,1), @$short_types) } if ref $short_types; } } for my $short_type (sort grep(!$any_st{$_}, keys %any_st)) { do_log(0, "No decoder for .%-4s", $short_type); } # map program name hints to full paths - av scanners my $tier = 'primary'; # primary, secondary, ... av scanners for my $f (@{ca('av_scanners')}, "\000", @{ca('av_scanners_backup')}) { if ($f eq "\000") { # next tier $tier = 'secondary'; } elsif (!defined $f || !ref $f) { # empty, skip } elsif (ref($f->[1]) eq 'CODE') { do_log(0, "Using %s internal av scanner code for %s", $tier,$f->[0]); } else { my $found = $f->[1] = find_program_path($f->[1], $path_list_ref); if (!defined $found) { do_log(3, "No %s av scanner: %s", $tier, $f->[0]); undef $f; # release its storage } else { do_log(0, "Found %s av scanner %-11s at %s%s", $tier, $f->[0], $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '', $found); } } } for my $f (@{ca('spam_scanners')}) { if (!defined $f || !ref $f) { # empty, skip } elsif ($f->[1] ne 'Amavis::SpamControl::ExtProg') { do_log(5, "Using internal spam scanner code for %s", $f->[0]); } else { # using the Amavis::SpamControl::ExtProg interface module my $found = $f->[2] = find_program_path($f->[2], $path_list_ref); if (!defined $found) { do_log(3, "No spam scanner: %s", $f->[0]); undef $f; # release its storage } else { do_log(0, "Found spam scanner %-11s at %s%s", $f->[0], $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '', $found); } } } } # Fetch remaining modules, all must be loaded before chroot and fork occurs # sub fetch_modules_extra() { my(@modules,@optmodules); if ($extra_code_sql_base) { push(@modules, 'DBI'); push(@optmodules, 'DBI::Const::GetInfoType', 'DBI::Const::GetInfo::ANSI'); for (@lookup_sql_dsn, @storage_sql_dsn) { my(@dsn) = split(/:/, $_->[0], -1); push(@modules, 'DBD::'.$dsn[1]) if uc($dsn[0]) eq 'DBI'; } } push(@modules, qw(Net::LDAP Net::LDAP::Util Net::LDAP::Search Net::LDAP::Bind Net::LDAP::Extension)) if $extra_code_ldap; if (c('tls_security_level_in') || c('tls_security_level_out')) { push(@modules, qw(IO::Socket::SSL Crypt::OpenSSL::RSA Net::SSLeay auto::Net::SSLeay::ssl_write_all auto::Net::SSLeay::ssl_read_until auto::Net::SSLeay::dump_peer_certificate)); } push(@modules, 'Anomy::Sanitizer') if $enable_anomy_sanitizer; Amavis::Boot::fetch_modules('REQUIRED ADDITIONAL MODULES', 1, @modules); push(@optmodules, qw( bytes bytes_heavy.pl utf8 utf8_heavy.pl Encode Encode::Byte Encode::MIME::Header Encode::Unicode::UTF7 Encode::CN Encode::TW Encode::KR Encode::JP unicore::To::Lower.pl unicore::To::Upper.pl unicore::To::Fold.pl unicore::To::Title.pl unicore::To::Digit.pl unicore::lib::Perl::Alnum.pl unicore::lib::Perl::SpacePer.pl unicore::lib::Perl::Word.pl unicore::lib::Alpha::Y.pl unicore::lib::Nt::De.pl )); if (@Amavis::Conf::decoders && grep { exists $policy_bank{$_}{'bypass_decode_parts'} && !do { my $v = $policy_bank{$_}{'bypass_decode_parts'}; !ref $v ? $v : $$v } } keys %policy_bank) { # at least one bypass_decode_parts is explicitly false push(@modules, qw(Archive::Zip)); # push(@modules, qw(Convert::TNEF Convert::UUlib Archive::Tar)); } push(@optmodules, $] >= 5.012000 ? qw(unicore::Heavy.pl) : qw(unicore::Canonical.pl unicore::Exact.pl unicore::PVA.pl)); # unicore::lib::Perl::Word.pl unicore::lib::Perl::SpacePer.pl # unicore::lib::Perl::Alnum.pl unicore::lib::Alpha::Y.pl # unicore::lib::Nt::De.pl unicore::lib::Hex::Y.pl push(@optmodules, qw(Unix::Getrusage)); push(@optmodules, 'Authen::SASL') if $extra_code_ldap && !grep($_ eq 'Authen::SASL', @modules); push(@optmodules, defined($min_servers) ? 'Net::Server::PreFork' : 'Net::Server::PreForkSimple'); push(@optmodules, @additional_perl_modules); my $missing; $missing = Amavis::Boot::fetch_modules('PRE-COMPILE OPTIONAL MODULES', 0, @optmodules) if @optmodules; do_log(2, 'INFO: no optional modules: %s', join(' ',@$missing)) if ref $missing && @$missing; # require minimal version 0.32, Net::LDAP::Util::escape_filter_value() needed Net::LDAP->VERSION(0.32) if $extra_code_ldap; # needed a working last_insert_id in the past, no longer so but nevertheless: DBI->VERSION(1.43) if $extra_code_sql_base; MIME::Entity->VERSION != 5.419 or die "MIME::Entity 5.419 breaks quoted-printable encoding, ". "please upgrade to 5.420 or later (or use 5.418)"; # load optional modules SAVI and Mail::ClamAV if available and requested if ($extra_code_antivirus) { my $clamav_module_ok; for my $entry (@{ca('av_scanners')}, @{ca('av_scanners_backup')}) { if (ref($entry) ne 'ARRAY') { # none } elsif ($entry->[0] eq 'Sophos SAVI') { if (defined(eval { require SAVI }) && SAVI->VERSION(0.30) && Amavis::AV::sophos_savi_init(@$entry)) {} # ok, loaded else { undef $entry->[1] } # disable entry } elsif ($entry->[0] =~ /^Mail::ClamAV/) { if (!defined($clamav_module_ok)) { $clamav_module_ok = eval { require Mail::ClamAV }; $clamav_module_ok = 0 if !defined $clamav_module_ok; } undef $entry->[1] if !$clamav_module_ok; # disable entry } } } } sub usage() { my $myprogram_name = c('myprogram_name'); return <<"EOD"; Usage: $myprogram_name [-u user] [-g group] [-i instance_name] {-c config_file} [-d log_level,area,...] [-X magic1,magic2,...] [-m max_servers] {-p listen_port_or_socket} [-L lock_file] [-P pid_file] [-H home_dir] [-D db_home_dir | -D ''] [-Q quarantine_dir | -Q ''] [-R chroot_dir | -R ''] [-S helpers_home_dir] [-T tempbase_dir] ( [start] | stop | reload | restart | debug | debug-sa | foreground | showkeys {domains} | testkeys {domains} | genrsa file_name [nbits] convert_keysfile file_name | test-config ) where area is a SpamAssassin debug area, e.g. all,util,rules,plugin,dkim,dcc or: $myprogram_name (-h | -V) ... show help or version, then exit EOD } # drop privileges # sub drop_priv(@) { my($desired_user,@desired_groups) = @_; eval { set_gid(@desired_groups) if @desired_groups; set_uid($desired_user) if defined $desired_user; 1; } or die "drop_priv: $@"; $> != 0 or die "drop_priv: Still running as root, aborting\n"; $< != 0 or die "Effective UID changed, but Real UID is 0, aborting\n"; } sub read_configs_and_exit { # Don't try to drop_priv if we are unprivileged already if ($< == 0 || $> == 0) { my $user = $ENV{AMAVIS_TEST_CONFIG_USER}; my @groups = split "\n", $ENV{AMAVIS_TEST_CONFIG_GROUPS}; if ($user && $user ne '') { drop_priv($user, @groups); } } Amavis::Conf::include_config_files(@config_files); exit 0; } sub configs_readable($) { my $amavisd = shift; local $ENV{AMAVIS_TEST_CONFIG} = 1; local $ENV{AMAVIS_TEST_CONFIG_USER} = $daemon_user; local $ENV{AMAVIS_TEST_CONFIG_GROUPS} = join "\n", @daemon_groups; return 0 == system map untaint($_), $amavisd, @ARGV; } sub sig_hup { my $self = $_[0]; if (configs_readable($self->commandline->[0])) { $self->SUPER::sig_hup(@_); } else { do_log(-1, 'Rejecting reload, some config files unreadable or erroneous'); } } # # Main program starts here # stir_random(); add_entropy($], @INC, %ENV); delete @ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'}; STDERR->autoflush(1); STDERR->fcntl(F_SETFL, O_APPEND) or warn "Error setting O_APPEND on STDERR: $!"; umask(0027); # set our preferred umask POSIX::setlocale(LC_TIME,'C'); # English dates required in syslog and RFC 5322 # using Net::Server internal mechanism for a restart on HUP $warm_restart = defined $ENV{BOUND_SOCKETS} && $ENV{BOUND_SOCKETS} ne '' ?1:0; update_current_log_level(); # Read dynamic source code, and logging and notification message templates # from the end of this file (pseudo file handle DATA) # $Amavis::Conf::notify_spam_admin_templ = ''; # not used $Amavis::Conf::notify_spam_recips_templ = ''; # not used do { local($/) = "__DATA__\n"; # set line terminator to this string binmode(\*Amavis::DATA, ':encoding(UTF-8)') or die "Can't set \*DATA encoding to UTF-8: $!"; for ( $Amavis::Conf::log_short_templ, $Amavis::Conf::log_verbose_templ, $Amavis::Conf::log_recip_templ, $Amavis::Conf::notify_sender_templ, $Amavis::Conf::notify_virus_sender_templ, $Amavis::Conf::notify_virus_admin_templ, $Amavis::Conf::notify_virus_recips_templ, $Amavis::Conf::notify_spam_sender_templ, $Amavis::Conf::notify_spam_admin_templ, $Amavis::Conf::notify_release_templ, $Amavis::Conf::notify_report_templ, $Amavis::Conf::notify_autoresp_templ) { $_ = <Amavis::DATA>; defined($_) or die "Error reading templates from the source file: $!"; chomp($_); } }; # restore line terminator close(\*Amavis::DATA) or die "Error closing *Amavis::DATA: $!"; # close(STDIN) or die "Error closing STDIN: $!"; # note: don't close STDIN just yet to prevent some other file taking up fd 0 { local($1); s/^(.*?)[\r\n]+\z/$1/s # discard trailing NL for ($Amavis::Conf::log_short_templ, $Amavis::Conf::log_verbose_templ, $Amavis::Conf::log_recip_templ); }; $Amavis::Conf::log_templ = $Amavis::Conf::log_short_templ; # Consider dropping privileges early, before reading a config file. # This is only possible if running under chroot will not be needed. # my $desired_groups; # space separated my $desired_user; # username or UID if ($> != 0) { $desired_user = $> } # use effective UID if not root # Use a default, guaranteed safe path during startup, before loading # an user-supplied one from the config file $ENV{PATH} = "/bin:/usr/bin"; # collect and parse command line options my($log_level_override, $max_servers_override); my($myhome_override, $tempbase_override, $helpers_home_override); my($quarantinedir_override, $db_home_override, $daemon_chroot_dir_override); my($lock_file_override, $pid_file_override); my(@listen_sockets_override, $listen_sockets_overridden); my(@argv) = @ARGV; # preserve @ARGV, may modify @argv while (@argv >= 2 && $argv[0] =~ /^-[ugdimcpDHLPQRSTX]\z/ || @argv >= 1 && $argv[0] =~ /^-/) { my($opt,$val); $opt = shift @argv; $val = shift @argv if $opt !~ /^-[hV-]\z/; # these take no arguments if ($opt eq '--') { last; } elsif ($opt eq '-h') { # -h (help) die "$myversion\n\n" . usage(); } elsif ($opt eq '-V') { # -V (version) die "$myversion\n"; } elsif ($opt eq '-X') { # -X (magic options: debugging, testing, ...) $i_know_what_i_am_doing{$_} = 1 for split(/\s*,\s*/, $val); } elsif ($opt eq '-u') { # -u username if ($> == 0) { $desired_user = $val } else { print STDERR "Ignoring option -u when not running as root\n" } } elsif ($opt eq '-g') { # -g group print STDERR "NOTICE: Option -g may not achieve desired result when ". "running as non-root\n" if $> != 0; $desired_groups = $val; } elsif ($opt eq '-i') { # -i instance_name, may be of use to a .conf file $val =~ /^[a-z0-9._+-]*\z/i or die "Special chars in option -i $val\n"; $instance_name = untaint($val); # not used by amavisd directly } elsif ($opt eq '-d') { # -d log_level or -d SAdbg1,SAdbg2,..,SAdbg3 $log_level_override = untaint($val); } elsif ($opt eq '-m') { # -m max_servers $val =~ /^\+?\d+\z/ or die "Option -m requires a numeric argument\n"; $max_servers_override = untaint($val); } elsif ($opt eq '-c') { # -c config_file push(@config_files, untaint($val)) if $val ne ''; } elsif ($opt eq '-p') { # -p port_or_socket $listen_sockets_overridden = 1; # may disable all sockets by -p '' push(@listen_sockets_override, untaint($val)) if $val ne ''; } elsif ($opt eq '-D') { # -D db_home_dir, empty string turns off db use $db_home_override = untaint($val); } elsif ($opt eq '-H') { # -H home_dir $myhome_override = untaint($val) if $val ne ''; } elsif ($opt eq '-L') { # -L lock_file $lock_file_override = untaint($val) if $val ne ''; } elsif ($opt eq '-P') { # -P pid_file $pid_file_override = untaint($val); # empty disables pid_file } elsif ($opt eq '-Q') { # -Q quarantine_dir, empty string disables quarant. $quarantinedir_override = untaint($val); } elsif ($opt eq '-R') { # -R chroot_dir, empty string or '/' avoids chroot $daemon_chroot_dir_override = $val eq '/' ? '' : untaint($val); } elsif ($opt eq '-S') { # -S helpers_home_dir for SA $helpers_home_override = untaint($val) if $val ne ''; } elsif ($opt eq '-T') { # -T tempbase_dir $tempbase_override = untaint($val) if $val ne ''; } else { die "Error in parsing command line options: $opt\n\n" . usage(); } } my $cmd = lc(shift @argv); if ($cmd !~ /^(?:start|debug|debug-sa|foreground|reload|restart|stop| showkeys?|testkeys?|genrsa|convert_keysfile|test-config)?\z/xs) { die "$myversion:\n Unknown command line parameter: $cmd\n\n" . usage(); } elsif (@argv > 0 && $cmd !~ /^(:?showkeys?|testkeys?|genrsa|convert_keysfile)/xs) { die sprintf("%s:\n Only one command line parameter allowed: %s\n\n%s\n", $myversion, join(' ',@argv), usage()); } if (grep($_, values %i_know_what_i_am_doing)) { my(@known, @unknown); push(@{/^no_conf_file_writable_check\z/ ? \@known : \@unknown}, $_) for grep($i_know_what_i_am_doing{$_}, keys %i_know_what_i_am_doing); $unknown[0] = 'unknown: ' . $unknown[0] if @unknown; warn sprintf("I know what I'm doing: %s\n", join(', ',@known,@unknown)); } # deal with debugging early, based on a command line arg if ($cmd =~ /^(?:start|debug|debug-sa|foreground)?\z/) { $daemonize=0 if $cmd eq 'foreground'; $daemonize=0, $DEBUG=1 if $cmd eq 'debug'; $daemonize=0, $sa_debug='all' if $cmd eq 'debug-sa'; } if (!defined($desired_user)) { # early dropping of privileges not requested } elsif ($> != 0 && $< != 0) { # early dropping of privileges not needed } elsif (defined $daemon_chroot_dir_override && $daemon_chroot_dir_override ne '') { # early dropping of privs would prevent later chroot and is to be skipped } else { # drop privileges early if a uid was specified on a command line, option -u drop_priv($desired_user,$desired_groups // Amavis::Util::get_user_groups($desired_user)); } if ($cmd eq 'genrsa') { require Amavis::Tools; Amavis::Tools::generate_dkim_private_key(@argv); exit(0); } if ($cmd eq 'convert_keysfile') { require Amavis::Tools; Amavis::Tools::convert_dkim_keys_file(@argv); exit(0); } # these settings must be overridden before and after read_config # because some other settings in a config file may be derived from them $Amavis::Conf::MYHOME = $myhome_override if defined $myhome_override; $Amavis::Conf::TEMPBASE = $tempbase_override if defined $tempbase_override; $Amavis::Conf::QUARANTINEDIR = $quarantinedir_override if defined $quarantinedir_override; $Amavis::Conf::helpers_home = $helpers_home if defined $helpers_home; $Amavis::Conf::daemon_chroot_dir = $daemon_chroot_dir_override if defined $daemon_chroot_dir_override; # some remaining initialization, possibly after dropping privileges by -u, # but before reading configuration file init_local_delivery_aliases(); init_builtin_macros(); $instance_name = '' if !defined $instance_name; # convert arrayref to Amavis::Lookup::RE object, the Amavis::Lookup::RE module # was not yet available during BEGIN phase $Amavis::Conf::map_full_type_to_short_type_re = Amavis::Lookup::RE->new(@$Amavis::Conf::map_full_type_to_short_type_re); # default location of the config file if none specified if (!@config_files) { # Debian/Ubuntu specific: @config_files = Amavis::Util::find_config_files('/usr/share/amavis/conf.d', '/etc/amavis/conf.d'); } # Read and evaluate config files, which may override default settings read_configs_and_exit if $ENV{AMAVIS_TEST_CONFIG}; Amavis::Conf::include_config_files(@config_files); Amavis::Conf::supply_after_defaults(); exit 1 unless $warm_restart || $cmd eq 'stop' || configs_readable($0); exit 0 if $cmd eq 'test-config'; update_current_log_level(); add_entropy($Amavis::Conf::myhostname, $Amavis::Conf::myversion_date); # not needed any longer, reclaim storage undef $Amavis::Conf::log_short_templ; undef $Amavis::Conf::log_verbose_templ; if (defined $desired_user && defined $daemon_user && $daemon_user ne '') { local($1); # compare the config file settings to current UID my($username,$passwd,$uid,$gid) = $daemon_user=~/^(\d+)$/ ? (undef,undef,$1,undef) : getpwnam($daemon_user); ($desired_user eq $daemon_user || $desired_user eq $uid) or warn sprintf("WARN: running under user '%s' (UID=%s), ". "the config file specifies \$daemon_user='%s' (UID=%s)\n", $desired_user, $>, $daemon_user, defined $uid ? $uid : '?'); } if ($> != 0 && $< != 0) { # dropping of privs is not needed } elsif (defined $daemon_chroot_dir && $daemon_chroot_dir ne '') { # dropping of privs now would prevent later chroot and is to be skipped } elsif (defined $daemon_user && $daemon_user ne '') { # drop privileges, unless needed for chrooting drop_priv($daemon_user,@daemon_groups); } # override certain config file options by command line arguments $sa_debug='all' if $cmd eq 'debug-sa'; if (defined $log_level_override) { for my $item (split(/[ \t]*,[ \t]*/, $log_level_override, -1)) { if ($item =~ /^[+-]?\d+\z/) { $Amavis::Conf::log_level = $item } elsif ($item =~ /^[A-Za-z0-9_-]+\z/) { no warnings 'once'; push(@Amavis::SpamControl::SpamAssassin::sa_debug_fac,$item) } } update_current_log_level(); } $Amavis::Conf::MYHOME = $myhome_override if defined $myhome_override; $Amavis::Conf::TEMPBASE = $tempbase_override if defined $tempbase_override; $Amavis::Conf::QUARANTINEDIR = $quarantinedir_override if defined $quarantinedir_override; $Amavis::Conf::helpers_home = $helpers_home if defined $helpers_home; $Amavis::Conf::daemon_chroot_dir = $daemon_chroot_dir_override if defined $daemon_chroot_dir_override; if (defined $db_home_override) { if ($db_home_override =~ /^\s*\z/) { $enable_db = 0 } else { $Amavis::Conf::db_home = $db_home_override } } if (defined $max_servers_override && $max_servers_override ne '') { $Amavis::Conf::max_servers = $max_servers_override; } if ($cmd =~ /^(?:showkeys?|testkeys?)\z/) { # useful for preparing DNS zone files and testing public keys in DNS require Amavis::DKIM; require Amavis::Tools; Amavis::DKIM::dkim_key_postprocess(); Amavis::Tools::show_or_test_dkim_public_keys($cmd,\@argv); exit(0); } for ($unix_socketname, $inet_socket_port) { push(@listen_sockets, ref $_ ? @$_ : $_) if defined $_ && $_ ne ''; } @listen_sockets = @listen_sockets_override if $listen_sockets_overridden; for my $s (@listen_sockets) { # convert to a Net::Server::Proto syntax local($1); if ($s =~ m{^unix:(/\S+)\z}s) { $s = "$1|unix" } elsif ($s =~ m{^inet:(.*)\z}s) { $s = "$1/tcp" } elsif ($s =~ m{^inet6:(.*)\z}s) { $s = "$1/tcp" } elsif ($s =~ m{^/\S+}s) { $s = "$s|unix" } elsif ($s =~ m{^\d+\z}s) { $s = "$s/tcp" } # port number elsif ($s =~ m{^[^/|]+\z}s) { $s = "$s/tcp" } # almost anything goes elsif ($s =~ m{^.+\z}s) { $s = "$s" } # anything goes else { die "Socket specification syntax error: $s\n" } } @listen_sockets > 0 or die "No listen sockets or ports specified\n"; # %modules_basic = %INC; # helps to track missing modules in chroot # compile optional modules if needed # NOTE: when releasing memory occupied by the source code, keep in mind: # use undef(), see: http://www.perlmonks.org/?node_id=803515 if ($enable_zmq) { require Amavis::ZMQ; } if ($enable_db) { require Amavis::DB::SNMP; require Amavis::DB; } { my $any_dkim_verification = scalar(grep { my $v = $policy_bank{$_}{'enable_dkim_verification'}; !ref $v ? $v : $$v } keys %policy_bank); my $any_dkim_signing = scalar(grep { my $v = $policy_bank{$_}{'enable_dkim_signing'}; !ref $v ? $v : $$v } keys %policy_bank); if ($any_dkim_verification || $any_dkim_signing) { require Amavis::DKIM; } if ($any_dkim_signing) { Amavis::DKIM::dkim_key_postprocess(); } else { # release storage undef %dkim_signing_keys_by_domain; undef @dkim_signing_keys_list; undef @dkim_signing_keys_storage; } } { my(%needed_protocols_in); for my $bank_name (keys %policy_bank) { my $var = $policy_bank{$bank_name}{'protocol'}; $var = $$var if ref($var) eq 'SCALAR'; # allow one level of indirection $needed_protocols_in{$var} = 1 if defined $var; } # compatibility with older config files unaware of $protocol config variable # $needed_protocols_in{'AM.CL'} = 1 # AM.CL is no longer supported # if grep(m{\|unix\z}i, @listen_sockets) && # !grep($needed_protocols_in{$_}, qw(AM.PDP COURIER)); $needed_protocols_in{'SMTP'} = 1 if grep(m{/(?:tcp|ssleay|ssl)\z}i, @listen_sockets) && !grep($needed_protocols_in{$_}, qw(SMTP LMTP QMQPqq)); if ($needed_protocols_in{'AM.PDP'} || $needed_protocols_in{'AM.CL'}) { require Amavis::In::AMPDP; } if ($needed_protocols_in{'SMTP'} || $needed_protocols_in{'LMTP'}) { require Amavis::In::SMTP; } if ($needed_protocols_in{'COURIER'}) { die "In::Courier code not available" } if ($needed_protocols_in{'QMQPqq'}) { die "In::QMQPqq code not available" } } if (@lookup_sql_dsn) { $extra_code_sql_lookup = 1 } if (@storage_sql_dsn) { $extra_code_sql_log = 1 } if (@storage_redis_dsn) { require Amavis::Redis } # sql quarantine depends on sql log $extra_code_sql_quar = $extra_code_sql_log; { my(%needed_protocols_out); local($1); for my $bank_name (keys %policy_bank) { for my $method_name (qw( forward_method notify_method resend_method release_method requeue_method os_fingerprint_method virus_quarantine_method banned_files_quarantine_method unchecked_quarantine_method spam_quarantine_method bad_header_quarantine_method clean_quarantine_method archive_quarantine_method )) { local($1); my $var = $policy_bank{$bank_name}{$method_name}; $var = $$var if ref($var) eq 'SCALAR'; # allow one level of indirection $needed_protocols_out{uc($1)} = 1 if $var =~ /^([a-z][a-z0-9.+-]*):/si; } } if (!$needed_protocols_out{'SMTP'} && !$needed_protocols_out{'LMTP'}) { } else { require Amavis::Out::SMTP; } if (!$needed_protocols_out{'PIPE'}) { } else { require Amavis::Out::Pipe; } if (!$needed_protocols_out{'BSMTP'}) { } else { require Amavis::Out::BSMTP; } if (!$needed_protocols_out{'LOCAL'}) { } else { require Amavis::Out::Local; } if (!$needed_protocols_out{'SQL'}) { undef $extra_code_sql_quar } else { # deal with it in the next section } if ($needed_protocols_out{'P0F'}) { require Amavis::OS_Fingerprint; } } if (!defined($extra_code_sql_log) && !defined($extra_code_sql_quar) && !defined($extra_code_sql_lookup)) { } else { require Amavis::Out::SQL::Connection; $extra_code_sql_base = 1; } if (defined $extra_code_sql_log) { require Amavis::Out::SQL::Log; } if (defined $extra_code_sql_quar) { require Amavis::IO::SQL; require Amavis::Out::SQL::Quarantine; } if (defined $extra_code_sql_lookup) { require Amavis::Lookup::SQLfield; require Amavis::Lookup::SQL; } if (!grep { my $v = $policy_bank{$_}{'enable_ldap'}; !ref $v ? $v : $$v } keys %policy_bank) { } else { # at least one enable_ldap is true require Amavis::LDAP::Connection; require Amavis::Lookup::LDAPattr; require Amavis::Lookup::LDAP; $extra_code_ldap = 1; } my $bpvcm = ca('bypass_virus_checks_maps'); if (!@{ca('av_scanners')} && !@{ca('av_scanners_backup')}) { } elsif (@$bpvcm && !ref($bpvcm->[0]) && $bpvcm->[0]) { # do a simple-minded test to make it easy to turn off virus checks } else { require Amavis::AV; $extra_code_antivirus = 1; } if (!$extra_code_antivirus) { # release storage undef @Amavis::Conf::av_scanners; undef @Amavis::Conf::av_scanners_backup; } my(%spam_scanners_used); my $bpscm = ca('bypass_spam_checks_maps'); if (!@{ca('spam_scanners')}) { } elsif (@$bpscm && !ref($bpscm->[0]) && $bpscm->[0]) { # simple-minded } else { require Amavis::SpamControl; $extra_code_antispam = 1; for my $as (@{ca('spam_scanners')}) { next if !ref $as || !defined $as->[1]; my($scanner_name,$module) = @$as; $spam_scanners_used{$module} = 1; } } if (!$extra_code_antispam) { undef @Amavis::Conf::spam_scanners } # load required built-in spam scanning modules if ($spam_scanners_used{'Amavis::SpamControl::ExtProg'}) { require Amavis::SpamControl::ExtProg; } if ($spam_scanners_used{'Amavis::SpamControl::RspamdClient'}) { require Amavis::SpamControl::RspamdClient; } if ($spam_scanners_used{'Amavis::SpamControl::SpamdClient'}) { require Amavis::SpamControl::SpamdClient; } if ($spam_scanners_used{'Amavis::SpamControl::SpamAssassin'}) { require Amavis::SpamControl::SpamAssassin; $extra_code_antispam_sa = 1; } if (!grep { exists $policy_bank{$_}{'bypass_decode_parts'} && !do { my $v = $policy_bank{$_}{'bypass_decode_parts'}; !ref $v ? $v : $$v } } keys %policy_bank) { } else { # at least one bypass_decode_parts is explicitly false require Amavis::Unpackers; } if ($enable_zmq && @zmq_sockets) { # better to catch and report potential ZMQ problems early before forking $zmq_obj = Amavis::ZMQ->new(@zmq_sockets); if ($zmq_obj && !$warm_restart && $cmd !~ /^(?:reload|stop)\z/) { sleep 1; # a crude way to avoid a "slow joiner" syndrome #*** $zmq_obj->put_initial_snmp_data('FLUSH'); $zmq_obj->register_proc(1,1,'FLUSH'); } } Amavis::Log::init($do_syslog, $logfile); # initialize logging Amavis::Log::log_to_stderr($cmd eq 'debug' || $cmd eq 'debug-sa' ? 1 : 0); do_log(1, 'logging initialized, log level %s, %s%s', c('log_level'), $do_syslog ? sprintf("syslog: %s.%s",c('syslog_ident'),c('syslog_facility')): $logfile ne '' ? "logfile: $logfile" : "STDERR", !$enable_log_capture ? '' : ', log capture enabled'); do_log(2, 'ZMQ enabled: %s', Amavis::ZMQ::zmq_version()) if $zmq_obj; sd_notify(0, "STATUS=Config files have been read, modules loaded."); # insist on a FQDN in $myhostname my $myhn = idn_to_utf8(c('myhostname')); $myhn =~ /[^.]\.[^.]+\.?\z/s || lc($myhn) eq 'localhost' or die <<"EOD"; The value of variable \$myhostname is \"$myhn\", but should have been a fully qualified domain name; perhaps uname(3) did not provide such. You must explicitly assign a FQDN of this host to variable \$myhostname in /etc/amavis/conf.d/05-node_id, or fix what uname(3) provides as a host's network name! EOD $mail_id_size_bits > 0 && $mail_id_size_bits == int $mail_id_size_bits && $mail_id_size_bits % 24 == 0 or die "\$mail_id_size_bits ($mail_id_size_bits) must be a multiple of 24\n"; my $amavisd_pid; # PID of the currently running amavisd daemon (not our pid) my $amavisd_pid_by_mainpid; # is $amavisd_pid provided by $ENV{MAINPID} ? eval { # is amavisd daemon already running? if (defined $ENV{MAINPID}) { # provided by systemd.exec(5) ? local($1); if ($ENV{MAINPID} =~ /^\s* ( [0-9]{1,10} ) \s*\z/xs && $1 > 0) { $amavisd_pid = untaint($1); $amavisd_pid_by_mainpid = 1; } } my $pidf = defined $pid_file_override ? $pid_file_override : $pid_file; if (defined $amavisd_pid) { if (defined $pidf && $pidf ne '') { do_log(2, 'Master PID [%s] provided by the MAINPID env.var, '. 'not checking $pid_file', $amavisd_pid); } else { do_log(2, 'Master PID [%s] provided by the MAINPID env.var, '. 'no $pid_file', $amavisd_pid); } } elsif (!defined $pidf || $pidf eq '') { do_log(2, 'no $pid_file configured, not checking it'); } elsif ($warm_restart) { # skip pid file checking, let Net::Server handle it } else { my(@stat_list) = lstat($pidf); my $errn = @stat_list ? 0 : 0+$!; if ($errn == ENOENT) { die "The amavisd daemon is apparently not running, no PID file $pidf\n" if $cmd =~ /^(?:reload|restart|stop)\z/; } elsif ($errn != 0) { die "PID file $pidf is inaccessible: $!\n"; } elsif (!-f _) { die "PID file $pidf is not a regular file\n"; } else { # find and validate PID of the currently running amavisd daemon my $ln; my $lcnt = 0; my $pidf_h = IO::File->new; $pidf_h->open($pidf,'<') or die "Can't open PID file $pidf: $!"; for ($! = 0; defined($ln=$pidf_h->getline); $! = 0) { chomp($ln); $lcnt++; last if $lcnt > 100; $amavisd_pid = $ln if $lcnt == 1 && $ln =~ /^\d{1,10}\z/; } defined $ln || $! == 0 or die "Error reading from file $pidf: $!"; $pidf_h->close or die "Error closing file $pidf: $!"; if ($lcnt <= 1 && !defined $amavisd_pid) { # empty or junk one-line pid file treated the same as nonexisting file die "The amavisd daemon is apparently not running, ". "empty PID file $pidf\n" if $cmd =~ /^(?:reload|restart|stop)\z/; # prevent Net::Server from seeing this crippled file do_log(-1, "removing empty or crippled PID file %s", $pidf); unlink($pidf) or die "Can't remove PID file $pidf: $!"; undef $amavisd_pid; } else { $lcnt <= 1 or die "More than one line in file $pidf"; defined $amavisd_pid or die "Missing process ID in file $pidf"; $amavisd_pid >= 1 or die "Invalid PID in file $pidf: [$amavisd_pid]"; # note that amavisd under Docker may run as PID #1 } my $mtime = $stat_list[9]; if (defined $amavisd_pid && defined $mtime) { # got a PID from a file # Is pid file older than system uptime? If so, it should be disregarded, # it must not prevent starting up amavisd after unclean shutdown. my $now = int(time); my($uptime,$uptime_fmt); # sys uptime in seconds my(@prog_args); my(@progs) = ('/usr/bin/uptime','uptime'); if (lc($^O) eq 'freebsd') { @progs = ('/sbin/sysctl','sysctl'); @prog_args = 'kern.boottime' } my $prog = find_program_path(\@progs, [split(/:/,$path,-1)] ); if (!defined($prog)) { do_log(1,'No programs: %s',join(", ",@progs)); } else { # obtain system uptime my($proc_fh,$uppid); eval { ($proc_fh,$uppid) = run_command(undef,'/dev/null',$prog,@prog_args); for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) { local($1,$2,$3,$4); chomp($ln); if (defined $uptime) {} elsif ($ln =~ /{[^}]*\bsec\s*=\s*(\d+)[^}]*}/) { $uptime = $now - $1; # amazingly broken reports from uptime(1) soon after boot! } elsif ($ln =~ /\b up \s+ (?: (\d{1,4}) \s* days? )? [,\s]* (\d{1,2}) : (\d{1,2}) (?: : (\d{1,2}))? (?! \d ) /ix || $ln =~ /\b up (?: \s* \b (\d{1,4}) \s* days? )? (?: [,\s]* \b (\d{1,2}) \s* hrs? )? (?: [,\s]* \b (\d{1,2}) \s* mins? )? (?: [,\s]* \b (\d{1,2}) \s* secs? )? /ix ) { $uptime = (($1*24 + $2)*60 + $3)*60 + $4; } elsif ($ln =~ /\b (\d{1,2}) \s* secs?/ix) { $uptime = $1; # OpenBSD } $uptime_fmt = format_time_interval($uptime); do_log(5,"system uptime %s: %s", $uptime_fmt,$ln); } defined $ln || $! == 0 or die "Reading uptime: $!"; my $err=0; $proc_fh->close or $err = $!; my $child_stat = defined $uppid && waitpid($uppid,0)>0 ? $? : undef; undef $proc_fh; undef $uppid; proc_status_ok($child_stat,$err) or die "Error running $prog: " . exit_status_str($child_stat,$err) . "\n"; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(1,"uptime: %s", $eval_stat); }; if (defined $proc_fh) { $proc_fh->close } # ignoring status if (defined $uppid) { waitpid($uppid,0) } # ignoring status } if (!defined $uptime) { do_log(1,'Unable to determine system uptime, will trust PID file %s', $pidf); } elsif ($now-$mtime <= $uptime+70) { do_log(1,'Valid PID file %s (younger than sys uptime %s)', $pidf, $uptime_fmt); } else { # must not kill an unrelated process which happens to have the # same pid as amavisd had before a system shutdown or crash undef $amavisd_pid; do_log(1,'Ignoring stale PID file %s, older than system uptime %s', $pidf, $uptime_fmt); } } } } if (defined $amavisd_pid) { untaint_inplace($amavisd_pid); if (!kill(0,$amavisd_pid)) { # does a process exist? $! == ESRCH or die "Can't send SIG 0 to process [$amavisd_pid]: $!"; do_log(2, 'No such process [%s], supposedly the current amavisd '. 'master process', $amavisd_pid); undef $amavisd_pid; # process does not exist }; } if ($warm_restart) { # a semi-documented Net::Server mechanism for a restart on HUP; # assume we have just been reincarnated by exec as a result of a HUP, # so just ignore the command parameter and let Net::Server do the rest } elsif ($cmd =~ /^(?:start|debug|debug-sa|foreground)?\z/) { !defined($amavisd_pid) or die "The amavisd daemon is already running, PID: [$amavisd_pid]\n"; } elsif ($cmd eq 'reload') { # reload: send a HUP signal to a running daemon my $pidf = defined $pid_file_override ? $pid_file_override : $pid_file; if (!defined $amavisd_pid && (!defined $pidf || $pidf eq '')) { die "No PID file, cannot determine a process ID of a running daemon.\n" . "To reload an existing amavisd daemon send it a SIGHUP signal.\n"; } elsif (!defined $amavisd_pid) { die "The amavisd daemon is apparently not running, cannot reload it.\n"; } else { kill('HUP',$amavisd_pid) or $! == ESRCH or die "Can't SIGHUP amavisd[$amavisd_pid]: $!"; my $msg = "Signalling a SIGHUP to a running daemon [$amavisd_pid]"; do_log(2,"%s",$msg); # print STDOUT "$msg\n"; exit(0); } } elsif ($cmd =~ /^(?:restart|stop)\z/) { # stop or restart my $pidf = defined $pid_file_override ? $pid_file_override : $pid_file; if (!defined $amavisd_pid && (!defined $pidf || $pidf eq '')) { die "No PID file, cannot determine a process ID of a running daemon.\n" . "To stop an existing amavisd daemon send it a SIGTERM signal.\n"; } elsif (!defined $amavisd_pid) { die "The amavisd daemon is apparently not running, cannot stop it.\n"; } else { my($kill_sig_used, $killed_amavisd_pid); eval { # first stop a running daemon $kill_sig_used = 'TERM'; kill($kill_sig_used,$amavisd_pid) or $! == ESRCH or die "Can't SIG$kill_sig_used amavisd[$amavisd_pid]: $!"; my $waited = 0; my $sigkill_sent = 0; my $delay = 1; # seconds for (;;) { # wait for the old running daemon to go away sleep($delay); $waited += $delay; $delay = 5; if (!kill(0,$amavisd_pid)) { # is the old daemon still there? $! == ESRCH or die "Can't send SIG 0 to amavisd[$amavisd_pid]: $!"; $killed_amavisd_pid = $amavisd_pid; # old process is gone, done last; } if ($waited < 60 || $sigkill_sent) { do_log(2,"Waiting for the process [%s] to terminate",$amavisd_pid); print STDOUT "Waiting for the process [$amavisd_pid] to terminate\n"; } else { # use stronger hammer do_log(2,"Sending SIGKILL to amavisd[%s]",$amavisd_pid); print STDERR "Sending SIGKILL to amavisd[$amavisd_pid]\n"; $kill_sig_used = 'KILL'; kill($kill_sig_used,$amavisd_pid) or $! == ESRCH or warn "Can't SIGKILL amavisd[$amavisd_pid]: $!"; $sigkill_sent = 1; } } 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; die "$eval_stat, can't $cmd the process\n"; }; my $msg = !defined($killed_amavisd_pid) ? undef : "Daemon [$killed_amavisd_pid] terminated by SIG$kill_sig_used"; if ($cmd eq 'stop') { if (defined $msg) { do_log(2,"%s",$msg); print STDOUT "$msg\n" } exit(0); } if (defined $killed_amavisd_pid) { print STDOUT "$msg, waiting for dust to settle...\n"; sleep 5; # wait for TCP sockets to be released } print STDOUT "becoming a new daemon...\n"; } } else { die "$myversion: Unknown command line parameter: $cmd\n\n" . usage(); } 1; } or do { my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; do_log(2,"%s", $eval_stat); die "$eval_stat\n"; }; $daemonize = 0 if $DEBUG; # in case $DEBUG came from a config file # Set path, home and term explicitly. Don't trust environment $ENV{PATH} = $path if defined $path && $path ne ''; $ENV{HOME} = $helpers_home if defined $helpers_home && $helpers_home ne ''; $ENV{TERM} = 'dumb'; $ENV{COLUMNS} = '80'; $ENV{LINES} = '100'; { my $msg = ''; $msg .= ", instance=$instance_name" if $instance_name ne ''; $msg .= ", nl=".sprintf('\\x%02X',ord("\n")) if "\n" ne "\012"; $msg .= ", Unicode aware"; # ensured by 'require 5.008' for (qw(PERLIO LC_ALL LANG LC_CTYPE LC_TIME LC_MESSAGES)) { $msg .= sprintf(', %s="%s"', $_, $ENV{$_}) if defined $ENV{$_} && $ENV{$_} ne ''; } do_log(0,"starting.%s %s at %s %s%s", !$warm_restart?'':' (warm)', $0, idn_to_utf8(c('myhostname')), $myversion, $msg); } # report version of Perl and process UID/GID do_log(0, "perl=%s, user=%s, EUID: %s (%s); group=(%s), EGID: %s (%s)", $], $desired_user, $>, $<, $desired_groups, $), $(); if ($warm_restart) { # a semi-documented Net::Server mechanism to let a restarted process # re-acquire sockets from its predecessor on a HUP my $str = $ENV{BOUND_SOCKETS}; $str =~ s/\n/, /gs; do_log(1,"warm restart on HUP [%s]: '%s', sockets: %s", $$, join(' ',$0,@ARGV), $str); } # $SIG{USR2} = sub { # my $msg = Carp::longmess("SIG$_[0] received, backtrace:"); # print STDERR "\n",$msg,"\n"; do_log(-1,"%s",$msg); # }; fetch_modules_extra(); # bring additional modules into memory and compile them $spamcontrol_obj = Amavis::SpamControl->new if $extra_code_antispam; $spamcontrol_obj->init_pre_chroot if $spamcontrol_obj; # log warnings and uncaught errors $SIG{'__DIE__' } = sub { return if $^S || !defined $^S; my $m = $_[0]; chomp($m); do_log(-1,"_DIE: %s", $m); }; $SIG{'__WARN__'} = sub { my $m = $_[0]; chomp($m); do_log(2,"_WARN: %s", $m) }; # use Data::Dumper; # my $m2 = Carp::longmess(); do_log(2,"%s",Dumper($m2)); if (!defined $io_socket_module_name) { do_log(-1,"no INET or INET6 socket modules available"); } else { do_log(2,"socket module %s, protocol families available: %s", $io_socket_module_name, join(', ', !$have_inet4 ? () :'INET', !$have_inet6 ? () :'INET6')); } # matches global unicast addresses # (i.e. valid addresses except: local, private or multicast addresses) # RFC 6890 (ex RFC 5735/3330), RFC 3513 (IPv6), RFC 4193 (ULA), RFC 6598 (CGN) @public_networks_maps = ( Amavis::Lookup::Label->new('public_nets'), Amavis::Lookup::IP->new(qw( !127.0.0.0/8 !::1 !0.0.0.0/8 !:: !169.254.0.0/16 !fe80::/10 !10.0.0.0/8 !172.16.0.0/12 !192.168.0.0/16 !fc00::/7 !100.64.0.0/10 !240.0.0.0/4 !224.0.0.0/4 !ff00::/8 ::ffff:0:0/96 ::/0 )) ); # set up Net::Server configuration my(@bind_to); { # merge port numbers, unix sockets and default binding host address into # a unified list @listen_sockets, which will be passed on to Net::Server # local($1); @bind_to = ref $inet_socket_bind ? @$inet_socket_bind : $inet_socket_bind; $_ = !defined $_ || $_ eq '' ? '*' : /^\[(.*)\]\z/s ? $1 : $_ for @bind_to; @bind_to = ( '*' ) if !@bind_to; my(@merged_listen_sockets, @ignored); for (@listen_sockets) { # roughly mimic the Net::Server::Proto and Net::Server::Proto::TCP parsing if (m{^/} || m{[/|]unix\z}si) { push(@merged_listen_sockets, $_); # looks like a Unix socket } elsif (m{^ \[ [^\]]* \] : }xs || m{^ [^/|:]* : }xs) { push(@merged_listen_sockets, $_); # explicit host & port specified } else { # assume port (or service) specification only, supply bind addr for my $bind_addr (@bind_to) { # Cartesian product: bind_addr x port # need brackets around an IPv6 address (as per RFC 5952, RFC 3986) push(@merged_listen_sockets, $bind_addr =~ /:[0-9a-f]*:/i ? "[$bind_addr]:$_" : "$bind_addr:$_" ); } } } # filter listen sockets according to protocol families available @listen_sockets = (); for (@merged_listen_sockets) { if (m{^/} || m{[/|]unix\z}si) { push(@listen_sockets, $_); # looks like a Unix socket } elsif (m{^ \[ ( [^\]]* ) \] : }xs || m{^ ([^/|:]*) : }xs) { my $addr = $1; if ($addr =~ /:[0-9a-f]*:/i) { # looks like an IPv6 address push(@{$have_inet6 ? \@listen_sockets : \@ignored}, $_); } elsif ($addr =~ /^\d+\.\d+\.\d+\.\d+\z/s) { # an IPv4 address push(@{$have_inet4 ? \@listen_sockets : \@ignored}, $_); } else { # can't tell without resolving, take it without checking push(@listen_sockets, $_); } } } do_log(2,"ignored due to unsupported protocol family: %s", join(', ',@ignored)) if @ignored; @listen_sockets or die "No listen sockets specified, aborting\n"; do_log(2,"will bind to %s", join(', ',@listen_sockets)); } # better catch and report potential Redis problems early before forking if (@storage_redis_dsn) { eval { my $redis_storage_tmp = Amavis::Redis->new(@storage_redis_dsn); $redis_storage_tmp->connect; undef $redis_storage_tmp; 1; } or do { warn "Redis error, starting anyway: $@"; }; } # DESTROY a ZMQ context (if any) of the main process, # it would not survive across daemonization / forking, # each child process needs to make its own context and sockets undef $zmq_obj; my $server = Amavis->new({ # command args to be used after HUP must be untainted, deflt: [$0,@ARGV] # commandline => ['/usr/local/sbin/amavisd','-c',$config_file[0] ], # commandline => [], # disable commandline => [ map(untaint($_), ($0,@ARGV)) ], port => \@listen_sockets, # listen on these sockets (Unix, inet, inet6) host => $bind_to[0], # default bind, redundant, merged to @listen_sockets listen => $listen_queue_size, # undef for a default max_servers => $max_servers, # number of pre-forked children !defined($min_servers) ? () : ( min_servers => $min_servers, min_spare_servers => $min_spare_servers, max_spare_servers => $max_spare_servers), max_requests => defined $max_requests && $max_requests > 0 ? $max_requests : 2E9, # avoid default of 1000 user => ($> == 0 || $< == 0) ? $daemon_user : undef, group => (($> == 0 || $< == 0) && @daemon_groups) ? "@daemon_groups" : undef, pid_file => $amavisd_pid_by_mainpid ? undef : defined $pid_file_override ? $pid_file_override : $pid_file, # socket serialization lockfile lock_file => defined $lock_file_override? $lock_file_override: $lock_file, # serialize => 'flock', # flock, semaphore, pipe background => $daemonize ? 1 : undef, setsid => $daemonize ? 1 : undef, chroot => $daemon_chroot_dir ne '' ? $daemon_chroot_dir : undef, no_close_by_child => 1, leave_children_open_on_hup => 1, # no_client_stdout introduced with Net::Server 0.92, but is broken in 0.92 no_client_stdout => (Net::Server->VERSION >= 0.93 ? 1 : 0), # controls log level for Net::Server internal log messages: # 0=err, 1=warning, 2=notice, 3=info, 4=debug log_level => ($DEBUG || c('log_level') >= 5) ? 4 : 2, log_file => undef, # method will be overridden by a call to do_log() # SSL_cert_file => "$MYHOME/cert/mail-cert.pem", # SSL_key_file => "$MYHOME/cert/mail-key.pem", }); $0 = c('myprogram_name') . ' (master)'; sd_notify(0, "STATUS=Transferring control to Net::Server."); $server->run; # transferring control to Net::Server # shouldn't get here exit 1; 1; # make perlcritic happy # we read text (such as notification templates) from DATA sections # to avoid any interpretations of special characters (e.g. \ or ') by Perl # __DATA__ # # ============================================================================= # This text section governs how a main per-message amavis log entry (at # log level 0) is formed (config variable $log_short_templ). Empty disables it. [?%#D|#|Passed # [? [:ccat|major] |# OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\ UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]# {[:actions_performed]}# ,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,]# [? %q ||, quarantine: %q]# [? %Q ||, Queue-ID: %Q]# [? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]# [? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]# [? %i ||, mail_id: %i]# , Hits: [:SCORE]# , size: %z# [? [:partition_tag] ||, pt: [:partition_tag]]# [~[:remote_mta_smtp_response]|["^$"]||[", queued_as: "]]\ [remote_mta_smtp_response|[~%x|["queued as ([0-9A-Za-z]+)$"]|["%1"]|["%0"]]|/]# #, Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]# #, From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]# #[? %#T ||, Tests: \[[%T|,]\]]# [? [:dkim|sig_sd] ||, dkim_sd=[:dkim|sig_sd]]# [? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]# , %y ms# ] [?%#O|#|Blocked # [? [:ccat|major|blocking] |# OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\ UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]# {[:actions_performed]}# ,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:mail_addr_decode_octets|%s] -> [%O|[:mail_addr_decode_octets|%O]|,]# [? %q ||, quarantine: %q]# [? %Q ||, Queue-ID: %Q]# [? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]# [? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]# [? %i ||, mail_id: %i]# , Hits: [:SCORE]# , size: %z# [? [:partition_tag] ||, pt: [:partition_tag]]# #, Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]# #, From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]# #[? %#T ||, Tests: \[[%T|,]\]]# [? [:dkim|sig_sd] ||, dkim_sd=[:dkim|sig_sd]]# [? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]# , %y ms# ] __DATA__ # # ============================================================================= # This text section governs how a verbose per-message amavis log entry # is formed (config variable $log_verbose_templ). An empty text will prevent # a verbose log entry, multiline text will produce multiple log entries, one # for each nonempty line. Syntax is explained in the README.customize file. [?%#D|#|Passed # [? [:ccat|major] |# OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\ UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]# {[:actions_performed]}# ,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:client_protocol]/[:protocol] [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,]# #, ([ip_trace_public|%x| < ])# , ([ip_proto_trace_public|%x| < ])# [? [:tls_in] ||, tls: [:tls_in]]# [? %q ||, quarantine: %q]# [? %Q ||, Queue-ID: %Q]# [? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]# [? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]# , mail_id: %i# #, secret_id: [:secret_id]# , b: [:substr|[:b64urlenc|[:body_digest]]|0|9]# , Hits: [:SCORE]# , size: %z# [? [:partition_tag] ||, pt: [:partition_tag]]# [~[:remote_mta_smtp_response]|["^$"]||[", queued_as: "]]\ [remote_mta_smtp_response|[~%x|["queued as ([0-9A-Za-z]+)$"]|["%1"]|["%0"]]|/]# , Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]# , From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]# [? [:dkim|author] || (dkim:AUTHOR)]# [? [:useragent|name] ||, [:useragent|name]: [:uquote|[:useragent|body]]]# , helo=[:client_helo]# [? %#T ||, Tests: \[[%T|,]\]]# #[:supplementary_info|VERSION|, SA: %%s]# #[:supplementary_info|RULESVERSION|, rules: %%s]# [? [:banning_rule_key] ||, b.key=[:banning_rule_key]]# [? [:banning_rule_comment] ||, b.com=[:banning_rule_comment]]# [? [:banning_rule_rhs] ||, b.rhs=[:banning_rule_rhs]]# [? [:banned_parts_as_attr] ||, b.parts=[:banned_parts_as_attr]]# [:supplementary_info|SCTYPE|, shortcircuit=%%s]# [:supplementary_info|AUTOLEARN|, autolearn=%%s]# [:supplementary_info|AUTOLEARNSCORE|, autolearnscore=%%s]# [? [:supplementary_info|LANGUAGES] ||, languages=[:uquote|[:supplementary_info|LANGUAGES]]]# [? [:supplementary_info|RELAYCOUNTRY] ||, relaycountry=[:uquote|[:supplementary_info|RELAYCOUNTRY]]]# [? [:supplementary_info|ASN] ||, asn=[:uquote|[:supplementary_info|ASN] [:supplementary_info|ASNCIDR]]]# #[? [:supplementary_info|DCCB] ||, dcc=[:supplementary_info|DCCB]:[:uquote|[:supplementary_info|DCCR]]]# #[? [:supplementary_info|DCCREP] ||, dcc_rep=[:supplementary_info|DCCREP]]# #[:supplementary_info|AWLSIGNERMEAN|, signer_avg=%%s]# #[? [:dkim|domain] ||, dkim_d=[:dkim|domain]]# [? [:dkim|identity] ||, dkim_i=[:dkim|identity]]# [? [:dkim|sig_sd] ||, dkim_sd=[:dkim|sig_sd]]# [? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]# [? [:rusage|ru_maxrss] ||, rss=[:rusage|ru_maxrss]]# , %y ms# ] [?%#O|#|Blocked # [? [:ccat|major|blocking] |# OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\ UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]# {[:actions_performed]}# ,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:client_protocol]/[:protocol] [:mail_addr_decode_octets|%s] -> [%O|[:mail_addr_decode_octets|%O]|,]# #, ([ip_trace_public|%x| < ])# , ([ip_proto_trace_public|%x| < ])# [? [:tls_in] ||, tls: [:tls_in]]# [? %q ||, quarantine: %q]# [? %Q ||, Queue-ID: %Q]# [? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]# [? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]# , mail_id: %i# #, secret_id: [:secret_id]# , b: [:substr|[:b64urlenc|[:body_digest]]|0|9]# , Hits: [:SCORE]# , size: %z# [? [:partition_tag] ||, pt: [:partition_tag]]# , Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]# , From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]# [? [:dkim|author] || (dkim:AUTHOR)]# [? [:useragent|name] ||, [:useragent|name]: [:uquote|[:useragent|body]]]# , helo=[:client_helo]# [? %#T ||, Tests: \[[%T|,]\]]# #[:supplementary_info|VERSION|, SA: %%s]# #[:supplementary_info|RULESVERSION|, rules: %%s]# [? [:banning_rule_key] ||, b.key=[:banning_rule_key]]# [? [:banning_rule_comment] ||, b.com=[:banning_rule_comment]]# [? [:banning_rule_rhs] ||, b.rhs=[:banning_rule_rhs]]# [? [:banned_parts_as_attr] ||, b.parts=[:banned_parts_as_attr]]# [:supplementary_info|SCTYPE|, shortcircuit=%%s]# [:supplementary_info|AUTOLEARN|, autolearn=%%s]# [:supplementary_info|AUTOLEARNSCORE|, autolearnscore=%%s]# [? [:supplementary_info|LANGUAGES] ||, languages=[:uquote|[:supplementary_info|LANGUAGES]]]# [? [:supplementary_info|RELAYCOUNTRY] ||, relaycountry=[:uquote|[:supplementary_info|RELAYCOUNTRY]]]# [? [:supplementary_info|ASN] ||, asn=[:uquote|[:supplementary_info|ASN] [:supplementary_info|ASNCIDR]]]# #[? [:supplementary_info|DCCB] ||, dcc=[:supplementary_info|DCCB]:[:uquote|[:supplementary_info|DCCR]]]# #[? [:supplementary_info|DCCREP] ||, dcc_rep=[:supplementary_info|DCCREP]]# #[:supplementary_info|AWLSIGNERMEAN|, signer_avg=%%s]# #[? [:dkim|domain] ||, dkim_d=[:dkim|domain]]# [? [:dkim|identity] ||, dkim_i=[:dkim|identity]]# [? [:dkim|sig_sd] ||, dkim_sd=[:dkim|sig_sd]]# [? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]# [? [:rusage|ru_maxrss] ||, rss=[:rusage|ru_maxrss]]# , %y ms# ] __DATA__ # # ============================================================================= # This text section governs how a main per-recipient amavis log entry # is formed (config variable $log_recip_templ). An empty text will prevent a # log entry, multi-line text will produce multiple log entries, one for each # nonempty line. Macro %. might be useful, it counts recipients starting # from 1. Syntax is explained in the README.customize file. # Long header fields will be automatically wrapped by the program. # [?%#D|#|Passed # #([:ccat|name|main]) # [? [:ccat|major] |OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER|SPAMMY|SPAM|\ UNCHECKED|BANNED (%F)|INFECTED (%V)]# , [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,], Hits: %c# , tag=[:tag_level], tag2=[:tag2_level], kill=[:kill_level]# [~[:remote_mta_smtp_response]|["^$"]||\ ["queued as ([0-9A-Za-z]+)"]|[", queued_as: %1"]|[", fwd: %0"]]# , %0/%1/%2/%k# ] [?%#O|#|Blocked # #([:ccat|name|blocking]) # [? [:ccat|major|blocking] |# OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER|SPAMMY|SPAM|\ UNCHECKED|BANNED (%F)|INFECTED (%V)]# , [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,], Hits: %c# , tag=[:tag_level], tag2=[:tag2_level], kill=[:kill_level]# , %0/%1/%2/%k# ] __DATA__ # # ============================================================================= # This is a template for (neutral: non-virus, non-spam, non-banned) # DELIVERY STATUS NOTIFICATIONS to sender. # For syntax and customization instructions see README.customize. # The From, To and Date header fields will be provided automatically. # Long header fields will be automatically wrapped by the program. # Subject: [?%#D|Undeliverable mail|Delivery status notification]\ [? [:ccat|major] |||, MTA-BLOCKED\ |, OVERSIZED message\ |, invalid header section[=explain_badh|1]\ [?[:ccat|minor]||: bad MIME|: unencoded 8-bit character\ |: improper use of control char|: all-whitespace header line\ |: header line longer than 998 characters|: header field syntax error\ |: missing required header field|: duplicate header field|]\ |, UNSOLICITED BULK EMAIL apparently from you\ |, UNSOLICITED BULK EMAIL apparently from you\ |, contents UNCHECKED\ |, BANNED contents type (%F)\ |, VIRUS in message apparently from you (%V)\ ] Message-ID: <DSN%i@%h> [? %#D |#|Your message WAS SUCCESSFULLY RELAYED to:\ [%D|\n [:mail_addr_decode|%D]|] [~[:dsn_notify]|["\\bSUCCESS\\b"]|\ and you explicitly requested a delivery status notification on success.\n]\ ] [? %#N |#|The message WAS NOT relayed to:\ [%N|\n [:mail_addr_decode|%N]|] ] [:wrap|78|||This [?%#D|nondelivery|delivery] report was \ generated by the program amavis at host %h. \ Our internal reference code for your message is %n/%i] # ccat_min 0: other, 1: bad MIME, 2: 8-bit char, 3: NUL/CR, # 4: empty, 5: long, 6: syntax, 7: missing, 8: multiple [? [:explain_badh] ||[? [:ccat|minor] |INVALID HEADER |INVALID HEADER: BAD MIME HEADER SECTION OR BAD MIME STRUCTURE |INVALID HEADER: INVALID NON-ASCII CHARACTERS IN HEADER SECTION |INVALID HEADER: INVALID CONTROL CHARACTERS IN HEADER SECTION |INVALID HEADER: FOLDED HEADER FIELD LINE MADE UP ENTIRELY OF WHITESPACE |INVALID HEADER: HEADER LINE LONGER THAN RFC 5322 LIMIT OF 998 CHARACTERS |INVALID HEADER: HEADER FIELD SYNTAX ERROR |INVALID HEADER: MISSING REQUIRED HEADER FIELD |INVALID HEADER: DUPLICATE HEADER FIELD |INVALID HEADER ] [[:wrap|78| | |%X]\n] ]\ # [:wrap|78|| |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] [? %#X|#|[? [:useragent] |#|[:wrap|78|| |[:useragent]]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] # ccat_min 0: other, 1: bad MIME, 2: 8-bit char, 3: NUL/CR, # 4: empty, 5: long, 6: syntax, 7: missing, 8: multiple [? [:explain_badh] ||[? [:ccat|minor] |# 0: other |# 1: bad MIME |# 2: 8-bit char WHAT IS AN INVALID CHARACTER IN A MAIL HEADER SECTION? The RFC 5322 document specifies rules for forming internet messages. It does not allow the use of characters with codes above 127 to be used directly (non-encoded) in a mail header section. If such characters (e.g. with diacritics, or non-Latin) from UTF-8 or other character set need to be included in a message header section, such message needs to be submitted to an SMTPUTF8-capable mailer (RFC 6532), or these characters need to be properly encoded according to RFC 2047. Necessary encoding is normally done transparently by a mail reader or other mail generating software. If automatic encoding is not available (e.g. by some old MUA) it is a user's responsibility to avoid using such characters in a header section, or to encode them manually. Typically offending header fields in this category are 'Subject', 'Organization', and comment fields or display names in e-mail addresses of 'From', 'To', or 'Cc'. Sometimes such invalid header fields are inserted automatically by some MUA, MTA, content filter, or other mail handling service. If this is the case, such service needs to be fixed or properly configured. Typically the offending header fields in this category are 'Date', 'Received', 'X-Mailer', 'X-Priority', 'X-Scanned', etc. If you don't know how to fix or avoid the problem, please report it to _your_ postmaster or system manager. # [~[:useragent]|^X-Mailer:\\s*Microsoft Outlook Express 6\\.00|[" If using Microsoft Outlook Express as your MUA, make sure its settings under: Tools -> Options -> Send -> Mail Sending Format -> Plain & HTML are: "MIME format" MUST BE selected, and "Allow 8-bit characters in headers" MUST NOT be enabled! "]]# |# 3: NUL/CR IMPROPER USE OF CONTROL CHARACTER IN A MESSAGE HEADER SECTION The RFC 5322 document specifies rules for forming internet messages. It does not allow the use of control characters NUL and bare CR to be used directly in a mail header section. |# 4: empty IMPROPERLY FOLDED HEADER FIELD LINE MADE UP ENTIRELY OF WHITESPACE The RFC 5322 document specifies rules for forming internet messages. In section '3.2.2. Folding white space and comments' it explicitly prohibits folding of header fields in such a way that any line of a folded header field is made up entirely of white-space characters (control characters SP and HTAB) and nothing else. |# 5: long HEADER LINE LONGER THAN RFC 5322 LIMIT OF 998 CHARACTERS The RFC 5322 document specifies rules for forming internet messages. Section '2.1.1. Line Length Limits' prohibits each line of a header section to be more than 998 characters in length (excluding the CRLF). |# 6: syntax |# 7: missing MISSING REQUIRED HEADER FIELD The RFC 5322 document specifies rules for forming internet messages. Section '3.6. Field Definitions' specifies that certain header fields are required (origination date field and the "From:" originator field). |# 8: multiple DUPLICATE HEADER FIELD The RFC 5322 document specifies rules for forming internet messages. Section '3.6. Field Definitions' specifies that certain header fields must not occur more than once in a message header section. |# other ]]# __DATA__ # # ============================================================================= # This is a template for VIRUS/BANNED SENDER NOTIFICATIONS. # For syntax and customization instructions see README.customize. # The From, To and Date header fields will be provided automatically. # Long header fields will be automatically wrapped by the program. # Subject: [? [:ccat|major] |Clean message from you\ |Clean message from you\ |Clean message from you (MTA blocked)\ |OVERSIZED message from you\ |BAD-HEADER in message from you\ |Spam claiming to be from you\ |Spam claiming to be from you\ |A message with UNCHECKED contents from you\ |BANNED contents from you (%F)\ |VIRUS in message apparently from you (%V)\ ] [? %m |#|In-Reply-To: [:mail_addr_decode|%m]] Message-ID: <VS%i@%h> [? [:ccat|major] |Clean|Clean|MTA-BLOCKED|OVERSIZED|INVALID HEADER|\ Spammy|Spam|UNCHECKED contents|BANNED CONTENTS ALERT|VIRUS ALERT] Our content checker found [? %#V |#|[:wrap|78| | |[? %#V |viruses|virus|viruses]: %V]] [? %#F |#|[:wrap|78| | |banned [? %#F |names|name|names]: %F]] [? %#X |#|[[:wrap|78| | |%X]\n]] in email presumably from you [:mail_addr_decode|%s] to the following [? %#R |recipients|recipient|recipients]:\ [%R|\n-> [:mail_addr_decode|%R]|] Our internal reference code for your message is %n/%i [? %a |#|[:wrap|78|| |First upstream SMTP client IP address: [:client_addr_port] %g]] [:wrap|78|| |Received trace: [ip_proto_trace_all|%x| < ]] [:wrap|78|| |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] [? %#D |Delivery of the email was stopped! ]# [? %#V ||Please check your system for viruses, or ask your system administrator to do so. ]# [? %#V |[? %#F ||# The message [?%#D|has been blocked|triggered this warning] because it contains a component (as a MIME part or nested within) with declared name or MIME type or contents type violating our access policy. To transfer contents that may be considered risky or unwanted by site policies, or simply too large for mailing, please consider publishing your content on the web, and only sending a URL of the document to the recipient. Depending on the recipient and sender site policies, with a little effort it might still be possible to send any contents (including viruses) using one of the following methods: - encrypted using pgp, gpg or other encryption methods; - wrapped in a password-protected or scrambled container or archive (e.g.: zip -e, arj -g, arc g, rar -p, or other methods) Note that if the contents is not intended to be secret, the encryption key or password may be included in the same message for recipient's convenience. We are sorry for inconvenience if the contents was not malicious. The purpose of these restrictions is to avoid the most common propagation methods used by viruses and other malware. These often exploit automatic mechanisms and security holes in more popular mail readers. By requiring an explicit and decisive action from a recipient to decode mail, a danger of automatic malware propagation is largely reduced. # # Details of our mail restrictions policy are available at ... ]]# __DATA__ # # ============================================================================= # This is a template for non-spam (e.g. VIRUS,...) ADMINISTRATOR NOTIFICATIONS. # For syntax and customization instructions see README.customize. # Long header fields will be automatically wrapped by the program. # From: %f Date: %d Subject: [? [:ccat|major] |Clean mail|Clean mail|MTA-blocked mail|\ OVERSIZED mail|INVALID HEADER in mail|Spammy|Spam|UNCHECKED contents in mail|\ BANNED contents (%F) in mail|VIRUS (%V) in mail]\ FROM [?%l||LOCAL ][?%a||[:client_addr_port] ][:mail_addr_decode|%s] To: [? %#T |undisclosed-recipients:;|[%T|, ]] [? %#C |#|Cc: [%C|, ]] Message-ID: <VA%i@%h> [? %#V |No viruses were found. |A virus was found: %V |Two viruses were found:\n %V |%#V viruses were found:\n %V ] [? %#F |#|[:wrap|78|| |Banned [?%#F|names|name|names]: %F]] [? %#X |#|Bad header:[\n[:wrap|78| | |%X]]] [? %#W |#\ |Scanner detecting a virus: %W |Scanners detecting a virus: %W ] Content type: [:ccat|name|main]# [? [:ccat|is_blocked_by_nonmain] ||, blocked for [:ccat|name]] Internal reference code for the message is %n/%i [? %a |#|[:wrap|78|| |First upstream SMTP client IP address: [:client_addr_port] %g]] [:wrap|78|| |Received trace: [ip_proto_trace_all|%x| < ]] [:wrap|78|| |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] [? %q |Not quarantined.|The message has been quarantined as: %q] [? %#S |Notification to sender will not be mailed. ]# [? %#D |#|The message WILL BE relayed to:[%D|\n[:mail_addr_decode|%D]|] ] [? %#N |#|The message WAS NOT relayed to:[%N|\n[:mail_addr_decode|%N]|] ] [? %#V |#|[? %#v |#|Virus scanner output:[\n %v] ]] __DATA__ # # ============================================================================= # This is a template for VIRUS/BANNED/BAD-HEADER RECIPIENTS NOTIFICATIONS. # For syntax and customization instructions see README.customize. # Long header fields will be automatically wrapped by the program. # From: %f Date: %d Subject: [? [:ccat|major] |Clean mail|Clean mail|MTA-blocked mail|\ OVERSIZED mail|INVALID HEADER in mail|Spammy|Spam|UNCHECKED contents in mail|\ BANNED contents (%F) in mail|VIRUS (%V) in mail] TO YOU from [:mail_addr_decode|%s] [? [:header_field|To] |To: undisclosed-recipients:;|To: [:header_field|To]] [? [:header_field|Cc] |#|Cc: [:header_field|Cc]] Message-ID: <VR%i@%h> [? %#V |[? %#F ||BANNED CONTENTS ALERT]|VIRUS ALERT] Our content checker found [? %#V |#|[:wrap|78| | |[?%#V|viruses|virus|viruses]: %V]] [? %#F |#|[:wrap|78| | |banned [?%#F|names|name|names]: %F]] [? %#X |#|[[:wrap|78| | |%X]\n]] in an email to you [? %#V |from:|from probably faked sender:] [:mail_addr_decode|%o] [? %#V |#|claiming to be: [:mail_addr_decode|%s]] Content type: [:ccat|name|main]# [? [:ccat|is_blocked_by_nonmain] ||, blocked for [:ccat|name]] Our internal reference code for your message is %n/%i [? %a |#|[:wrap|78|| |First upstream SMTP client IP address: [:client_addr_port] %g]] [:wrap|78|| |Received trace: [ip_proto_trace_all|%x| < ]] [:wrap|78|| |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] [? [:useragent] |#|[:wrap|78|| |[:useragent]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] [? %q |Not quarantined.|The message has been quarantined as: %q] Please contact your system administrator for details. __DATA__ # # ============================================================================= # This is a template for spam SENDER NOTIFICATIONS. # For syntax and customization instructions see README.customize. # The From, To and Date header fields will be provided automatically. # Long header fields will be automatically wrapped by the program. # Subject: Considered UNSOLICITED BULK EMAIL, apparently from you [? %m |#|In-Reply-To: [:mail_addr_decode|%m]] Message-ID: <SS%i@%h> A message from [:mail_addr_decode|%s]\ [%R|\nto: [:mail_addr_decode|%R]|] was considered unsolicited bulk e-mail (UBE). Our internal reference code for your message is %n/%i The message carried your return address, so it was either a genuine mail from you, or a sender address was faked and your e-mail address abused by third party, in which case we apologize for undesired notification. We do try to minimize backscatter for more prominent cases of UBE and for infected mail, but for less obvious cases some balance between losing genuine mail and sending undesired backscatter is sought, and there can be some collateral damage on either side. [? %a |#|[:wrap|78|| |First upstream SMTP client IP address: [:client_addr_port] %g]] [:wrap|78|| |Received trace: [ip_proto_trace_all|%x| < ]] [:wrap|78|| |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] # [? [:useragent] |#|[:wrap|78|| |[:useragent]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] [? %#X |#|\n[[:wrap|78|| |%X]\n]] [? %#D |Delivery of the email was stopped! ]# # # Spam scanner report: # [%A # ]\ __DATA__ # # ============================================================================= # This is a template for spam ADMINISTRATOR NOTIFICATIONS. # For syntax and customization instructions see README.customize. # Long header fields will be automatically wrapped by the program. # From: %f Date: %d Subject: Spam FROM [?%l||LOCAL ][?%a||[:client_addr_port] ][:mail_addr_decode|%s] To: [? %#T |undisclosed-recipients:;|[%T|, ]] [? %#C |#|Cc: [%C|, ]] Message-ID: <SA%i@%h> Content type: [:ccat|name|main]# [? [:ccat|is_blocked_by_nonmain] ||, blocked for [:ccat|name]] Internal reference code for the message is %n/%i [? %a |#|[:wrap|78|| |First upstream SMTP client IP address: [:client_addr_port] %g]] [:wrap|78|| |Received trace: [ip_proto_trace_all|%x| < ]] [:wrap|78|| |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] [? [:useragent] |#|[:wrap|78|| |[:useragent]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] [? %q |Not quarantined.|The message has been quarantined as: %q] [? %#D |#|The message WILL BE relayed to:[%D|\n[:mail_addr_decode|%D]|] ] [? %#N |#|The message WAS NOT relayed to:[%N|\n[:mail_addr_decode|%N]|] ] Spam scanner report: [%A ]\ __DATA__ # # ============================================================================= # This is a template for the plain text part of a RELEASE FROM A QUARANTINE, # applicable if a chosen release format is 'attach' (not 'resend'). # From: %f Date: %d Subject: \[released message\] %j To: [? %#T |undisclosed-recipients:;|[%T|, ]] [? %#C |#|Cc: [%C|, ]] Message-ID: <QRA%i@%h> Please find attached a message which was held in a quarantine, and has now been released. [:wrap|78|| |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] # [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] # [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] # [? [:useragent] |#|[:wrap|78|| |[:useragent]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] Our internal reference code for the message is %n/%i # [~[:report_format]|["^attach$"]|["[? [:attachment_password] |#| Contents of the attached mail message may pose a threat to your computer or could be a social engineering deception, so it should be handled cautiously. To prevent undesired automatic opening, the attached original mail message has been wrapped in a password-protected ZIP archive. Here is the password that allows opening of the attached archive: [:attachment_password] Note that the attachment is not strongly encrypted and the password is not a strong secret (being displayed in this non-encrypted text), so this attachment is not suitable for guarding a secret contents. The sole purpose of this password protection it to prevent undesired accidental or automatic opening of a message, either by some filtering software, a virus scanner, or by a mail reader. ]"]|]# __DATA__ # # ============================================================================= # This is a template for the plain text part of a problem/feedback report, # with either the original message included in-line, or attached, # or the message is structured as a FEEDBACK REPORT NOTIFICATIONS format. # See RFC 5965 - "An Extensible Format for Email Feedback Reports". # From: %f Date: %d Subject: Fw: %j To: [? %#T |undisclosed-recipients:;|[%T|, ]] [? %#C |#|Cc: [%C|, ]] Message-ID: <ARF%i@%h> #Auto-Submitted: auto-generated This is an e-mail [:feedback_type] report for a message \ [? %a |\nreceived on %d,|received from\nIP address [:client_addr_port] on %d,] [:wrap|78|| |Return-Path: [:mail_addr_decode|%s]] [:wrap|78|| |From: [:mime_decode|[:header_field_octets|From]|100]\ [?[:dkim|author]|| (dkim:AUTHOR)]] [? [:header_field|Sender]|#|\ [:wrap|78|| |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\ [?[:dkim|sender]|| (dkim:SENDER)]]] [? %m |#|[:wrap|78|| |Message-ID: [:mail_addr_decode|%m]]] [? %r |#|[:wrap|78|| |Resent-Message-ID: [:mail_addr_decode|%r]]] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] [?[:dkim|author]|#| A first-party DKIM or DomainKeys signature is valid, d=[:dkim|author].] Reporting-MTA: %h Our internal reference code for the message is %n/%i [~[:report_format]|["^(arf|attach|dsn)$"]|["\ A complete original message is attached. [~[:report_format]|["^arf$"]|\ For more information on the ARF format please see RFC 5965. ]"]|["\ A complete original message in its pristine form follows: "]]# __DATA__ # # ============================================================================= # This is a template for the plain text part of an auto response (e.g. # vacation, out-of-office), see RFC 3834. # From: %f Date: %d To: [? %#T |undisclosed-recipients:;|[%T|, ]] [? %#C |#|Cc: [%C|, ]] Reply-To: postmaster@%h Message-ID: <ARE%i@%h> Auto-Submitted: auto-replied [:wrap|76||\t|Subject: Auto: autoresponse to: [:mail_addr_decode|%s]] [? %m |#|In-Reply-To: [:mail_addr_decode|%m]] Precedence: junk This is an auto-response to a message \ [? %a |\nreceived on %d,|received from\nIP address [:client_addr_port] on %d,] envelope sender: [:mail_addr_decode|%s] (author) From: [:rfc2822_from] [? %j |#|[:wrap|78|| |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]] [?[:dkim|author]|#| A first-party DKIM or DomainKeys signature is valid, d=[:dkim|author].]