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 (
          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 {
  if (shift->{reloading}) {
      # appropriate sd_notify in restart_close_hook
      return
  }
  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%s", $mail_id||'',
                 $pbn eq '' ? '' : "$pbn ",  $cl_ip eq '' ? '' : "[$cl_ip] ",
                 qquote_rfc2821_local($sender),
                 join(',', qquote_rfc2821_local(@recips)),
                 $msginfo->queue_id ? " queue_id=".$msginfo->queue_id : '');
    }
    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 '') {
        $hdr_edits->upsert_header('Subject',
                      sub { local($1,$2);
                            my $subj = $_[1] // '';
                            $subj =~ /^([ \t]?)(.*)\z/s; $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));
                          } );
        $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($) {
  if ($i_know_what_i_am_doing{no_conf_file_readable_check}) {
    return 1
  }
  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->{reloading} = 1;
      $self->SUPER::sig_hup(@_);
  } else {
      do_log(-1, 'Rejecting reload, some config files unreadable or erroneous');
  }
}

sub main {
  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

  # 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) {
    @config_files = ( '/etc/amavisd.conf' );
  # # 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 amavisd.conf, 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* day(?:s|\(s\))? )? [,\s]*
                             (\d{1,2}) : (\d{1,2}) (?: : (\d{1,2}))? (?! \d ) /ix
                    || $ln =~ /\b up (?:   \s*  \b (\d{1,4}) \s* day(?:s|\(s\))? )?
                                     (?: [,\s]* \b (\d{1,2}) \s* hr(?:s|\(s\))?  )?
                                     (?: [,\s]* \b (\d{1,2}) \s* min(?:s|\(s\))? )?
                                     (?: [,\s]* \b (\d{1,2}) \s* sec(?:s|\(s\))? )? /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].]
