# SPDX-License-Identifier: GPL-2.0-or-later

package Amavis::In::AMPDP;
use strict;
use re 'taint';
use warnings;
use warnings FATAL => qw(utf8 void);
no warnings 'uninitialized';
# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';

BEGIN {
  require Exporter;
  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
  $VERSION = '2.412';
  @ISA = qw(Exporter);
}
use subs @EXPORT;

use Errno qw(ENOENT EACCES);
use IO::File ();
use Time::HiRes ();
use Digest::MD5;
use MIME::Base64;

use Amavis::Conf qw(:platform :confvars c cr ca);
use Amavis::In::Connection;
use Amavis::In::Message;
use Amavis::IO::Zlib;
use Amavis::Lookup qw(lookup lookup2);
use Amavis::Lookup::IP qw(lookup_ip_acl normalize_ip_addr);
use Amavis::Notify qw(msg_from_quarantine);
use Amavis::Out qw(mail_dispatch);
use Amavis::Out::EditHeader qw(hdr);
use Amavis::rfc2821_2822_Tools;
use Amavis::TempDir;
use Amavis::Timing qw(section_time);
use Amavis::Util qw(ll do_log debug_oneshot dump_captured_log
                    untaint snmp_counters_init read_file
                    snmp_count proto_encode proto_decode
                    switch_to_my_time switch_to_client_time
                    am_id new_am_id add_entropy rmdir_recursively
                    generate_mail_id);

sub new($) { my $class = $_[0];  bless {}, $class }

# used with sendmail milter and traditional (non-SMTP) MTA interface,
# but also to request a message release from a quarantine
#
sub process_policy_request($$$$) {
  my($self, $sock, $conn, $check_mail, $old_amcl) = @_;
  # $sock:       connected socket from Net::Server
  # $conn:       information about client connection
  # $check_mail: subroutine ref to be called with file handle

  my(%attr);
  $0 = sprintf("%s (ch%d-P-idle)",
               c('myprogram_name'), $Amavis::child_invocation_count);
  ll(5) && do_log(5, "process_policy_request: %s, %s, fileno=%s",
                     $old_amcl, c('myprogram_name'), fileno($sock));
  if ($old_amcl) {
    # Accept a single request from traditional amavis helper program.
    # Receive TEMPDIR/SENDER/RCPTS/LDA/LDAARGS from client
    # Simple protocol: \2 means LDA follows; \3 means EOT (end of transmission)
    die "process_policy_request: old AM.CL protocol is no longer supported\n";

  } else {  # new amavis helper protocol AM.PDP or a Postfix policy server
    # for Postfix policy server see Postfix docs SMTPD_POLICY_README
    my(@response); local($1,$2,$3);
    local($/) = "\012";  # set line terminator to LF (Postfix idiosyncrasy)
    my $ln;  # can accept multiple tasks
    switch_to_client_time("start receiving AM.PDP data");
    $conn->appl_proto('AM.PDP');
    for ($! = 0; defined($ln=$sock->getline); $! = 0) {
      my $end_of_request = $ln =~ /^\015?\012\z/ ? 1 : 0;  # end of request?
      switch_to_my_time($end_of_request ? 'rx entire AM.PDP request'
                                        : 'rx AM.PDP line');
      $0 = sprintf("%s (ch%d-P)",
                   c('myprogram_name'), $Amavis::child_invocation_count);
      Amavis::Timing::init(); snmp_counters_init();
      # must not use \r and \n, not the same as \015 and \012 on some platforms
      if ($end_of_request) {  # end of request
        section_time('got data');
        my $msg_size;
        eval {
          my($msginfo,$bank_names_ref) = preprocess_policy_query(\%attr,$conn);
          $Amavis::MSGINFO = $msginfo;  # ugly
          my $req = lc($attr{'request'});
          @response = $req eq 'smtpd_access_policy'
                        ? postfix_policy($msginfo,\%attr)
                  : $req =~ /^(?:release|requeue|report)\z/
                        ? dispatch_from_quarantine($msginfo, $req,
                                 $req eq 'report' ? 'abuse' : 'miscategorized')
                  : check_ampdp_policy($msginfo,$check_mail,0,$bank_names_ref);
          $msg_size = $msginfo->msg_size;
          undef $Amavis::MSGINFO;  # release global reference
          1;
        } or do {
          my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
          do_log(-2, "policy_server FAILED: %s", $err);
          @response = (proto_encode('setreply','450','4.5.0',"Failure: $err"),
                       proto_encode('return_value','tempfail'),
                       proto_encode('exit_code',sprintf("%d",EX_TEMPFAIL)));
          die $err  if $err =~ /^timed out\b/;  # resignal timeout
        # last;
        };
        $sock->print( join('', map($_."\015\012", (@response,'')) ))
          or die "Can't write response to socket: $!, fileno=".fileno($sock);
        %attr = (); @response = ();
        if (ll(2)) {
          my $rusage_report = Amavis::Timing::rusage_report();
          do_log(2,"size: %d, %s", $msg_size, Amavis::Timing::report());
          do_log(2,"size: %d, RUSAGE %s", $msg_size, $rusage_report)
            if defined $rusage_report;
        }
      } elsif ($ln =~ /^ ([^=\000\012]*?) (=|:[ \t]*)
                         ([^\012]*?) \015?\012 \z/xsi) {
        my $attr_name = proto_decode($1);
        my $attr_val  = proto_decode($3);
        if (!exists $attr{$attr_name}) {
          $attr{$attr_name} = $attr_val;
        } else {
          $attr{$attr_name} = [ $attr{$attr_name} ]  if !ref $attr{$attr_name};
          push(@{$attr{$attr_name}}, $attr_val);
        }
        my $known_attr = scalar(grep($_ eq $attr_name, qw(
          request protocol_state version_client protocol_name helo_name
          client_name client_address client_port client_source sender recipient
          delivery_care_of queue_id partition_tag mail_id secret_id quar_type
          mail_file tempdir tempdir_removed_by policy_bank requested_by) ));
        do_log(!$known_attr?1:3,
               "policy protocol: %s=%s", $attr_name,$attr_val);
      } else {
        do_log(-1, "policy protocol: INVALID AM.PDP ATTRIBUTE LINE: %s", $ln);
      }
      $0 = sprintf("%s (ch%d-P-idle)",
                   c('myprogram_name'), $Amavis::child_invocation_count);
      switch_to_client_time("receiving AM.PDP data");
    }
    defined $ln || $! == 0  or die "Read from client socket FAILED: $!";
    switch_to_my_time('end of AM.PDP session');
  };
  $0 = sprintf("%s (ch%d-P)",
               c('myprogram_name'), $Amavis::child_invocation_count);
}

# Based on given query attributes describing a message to be checked or
# released, return a new Amavis::In::Message object with filled-in information
#
sub preprocess_policy_query($$) {
  my($attr_ref,$conn) = @_;

  my $now = Time::HiRes::time;
  my $msginfo = Amavis::In::Message->new;
  $msginfo->rx_time($now);
  $msginfo->log_id(am_id());
  $msginfo->conn_obj($conn);
  $msginfo->originating(1);
  $msginfo->add_contents_category(CC_CLEAN,0);
  add_entropy(%$attr_ref);

  # amavisd -> amavis-helper protocol query consists of any number of
  # the following lines, the response is terminated by an empty line.
  # The 'request=AM.PDP' is a required first field, the order of
  # remaining fields is arbitrary, but multivalued attributes such as
  # 'recipient' must retain their relative order.
  # Required AM.PDP fields are: request, tempdir, sender, recipient(s)
  #   request=AM.PDP
  #   version_client=n             (currently ignored)
  #   tempdir=/var/amavis/amavis-milter-MWZmu9Di
  #   tempdir_removed_by=client    (tempdir_removed_by=server is a default)
  #   mail_file=/var/amavis/am.../email.txt (defaults to tempdir/email.txt)
  #   sender=<foo@example.com>
  #   recipient=<bar1@example.net>
  #   recipient=<bar2@example.net>
  #   recipient=<bar3@example.net>
  #   delivery_care_of=server      (client or server, client is a default)
  #   queue_id=qid
  #   protocol_name=ESMTP
  #   helo_name=host.example.com
  #   client_address=10.2.3.4
  #   client_port=45678
  #   client_name=host.example.net
  #   client_source=LOCAL/REMOTE/[UNAVAILABLE]
  #     (matches local_header_rewrite_clients, see Postfix XFORWARD_README)
  #   policy_bank=SMTP_AUTH,TLS,ORIGINATING,MYNETS,...
  # Required 'release' or 'requeue' or 'report' fields are: request, mail_id
  #   request=release  (or request=requeue, or request=report)
  #   mail_id=xxxxxxxxxxxx
  #   secret_id=xxxxxxxxxxxx              (authorizes a release/report)
  #   partition_tag=xx                    (required if mail_id is not unique)
  #   quar_type=x                         F/Z/B/Q/M  (defaults to Q or F)
  #                                       file/zipfile/bsmtp/sql/mailbox
  #   mail_file=...  (optional: overrides automatics; $QUARANTINEDIR prepended)
  #   requested_by=<releaser@example.com> (optional: lands in Resent-From:)
  #   sender=<foo@example.com>            (optional: replaces envelope sender)
  #   recipient=<bar1@example.net>        (optional: replaces envelope recips)
  #   recipient=<bar2@example.net>
  #   recipient=<bar3@example.net>
  my(@recips); my(@bank_names);
  exists $attr_ref->{'request'} or die "Missing 'request' field";
  my $ampdp = $attr_ref->{'request'} =~
                               /^(?:AM\.CL|AM\.PDP|release|requeue|report)\z/i;
  local $1;
  @bank_names =
    map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $attr_ref->{'policy_bank'}))
    if defined $attr_ref->{'policy_bank'};
  my $d_co  = $attr_ref->{'delivery_care_of'};
  my $td_rm = $attr_ref->{'tempdir_removed_by'};
  $msginfo->client_delete(defined($td_rm) && lc($td_rm) eq 'client' ? 1 : 0);
  $msginfo->queue_id($attr_ref->{'queue_id'})
    if exists $attr_ref->{'queue_id'};
  $msginfo->client_proto($attr_ref->{'protocol_name'})
    if exists $attr_ref->{'protocol_name'};
  if (exists $attr_ref->{'client_address'}) {
    $msginfo->client_addr(normalize_ip_addr($attr_ref->{'client_address'}));
  }
  $msginfo->client_port($attr_ref->{'client_port'})
    if exists $attr_ref->{'client_port'};
  $msginfo->client_name($attr_ref->{'client_name'})
    if exists $attr_ref->{'client_name'};
  $msginfo->client_source($attr_ref->{'client_source'})
    if exists $attr_ref->{'client_source'}
       &&  uc($attr_ref->{'client_source'}) ne '[UNAVAILABLE]';
  $msginfo->client_helo($attr_ref->{'helo_name'})
    if exists $attr_ref->{'helo_name'};
# $msginfo->body_type('8BITMIME');
  $msginfo->requested_by(unquote_rfc2821_local($attr_ref->{'requested_by'}))
    if exists $attr_ref->{'requested_by'};
  if (exists $attr_ref->{'sender'}) {
    my $sender = $attr_ref->{'sender'};
    $sender = '<'.$sender.'>'  if $sender !~ /^<.*>\z/;
    $msginfo->sender_smtp($sender);
    $sender = unquote_rfc2821_local($sender);
    $msginfo->sender($sender);
  }
  if (exists $attr_ref->{'recipient'}) {
    my $r = $attr_ref->{'recipient'}; @recips = ();
    for my $addr (!ref($r) ? $r : @$r) {
      my $addr_quo = $addr;
      my $addr_unq = unquote_rfc2821_local($addr);
      $addr_quo = '<'.$addr_quo.'>'  if $addr_quo !~ /^<.*>\z/;
      my $recip_obj = Amavis::In::Message::PerRecip->new;
      $recip_obj->recip_addr($addr_unq);
      $recip_obj->recip_addr_smtp($addr_quo);
      $recip_obj->dsn_orcpt($addr_quo);
      $recip_obj->recip_destiny(D_PASS);  # default is Pass
      $recip_obj->delivery_method('')  if !defined($d_co) ||
                                          lc($d_co) eq 'client';
      push(@recips,$recip_obj);
    }
    $msginfo->per_recip_data(\@recips);
  }
  if (!exists $attr_ref->{'tempdir'}) {
    my $tempdir = Amavis::TempDir->new;
    $tempdir->prepare_dir;
    $msginfo->mail_tempdir($tempdir->path);
    # Save the Amavis::TempDir object from destruction by keeping a ref to it
    # in $msginfo. When $msginfo is destroyed, the temporary directory will be
    # automatically destroyed too. This is specific to AM.PDP requests without
    # a working directory provided by a caller, and different from usual
    # SMTP sessions which keep a per-process permanent reference to an
    # Amavis::TempDir object, which makes keeping it in mail_tempdir_obj
    # not necessary.
    $msginfo->mail_tempdir_obj($tempdir);
  } else {
    local($1,$2); my $tempdir = $attr_ref->{tempdir};
    $tempdir =~ m{^ (?: \Q$TEMPBASE\E | \Q$MYHOME\E )
                    (?: / (?! \.\. (?:\z|/)) [A-Za-z0-9_.-]+ )*
                    / [A-Za-z0-9_.-]+ \z}xso
      or die "Suspicious temporary directory name '$tempdir'";
    $msginfo->mail_tempdir(untaint($tempdir));
  }
  my $quar_type;
  my $p_mail_id;
  if (!$ampdp) {
    # don't bother with filenames
  } elsif ($attr_ref->{'request'} =~ /^(?:release|requeue|report)\z/i) {
    exists $attr_ref->{'mail_id'} or die "Missing 'mail_id' field";
    $msginfo->partition_tag($attr_ref->{'partition_tag'});  # may be undef
    $p_mail_id = $attr_ref->{'mail_id'};
    # amavisd almost-base64: 62 +, 63 -  (in use up to 2.6.4, dropped in 2.7.0)
    # RFC 4648 base64:       62 +, 63 /  (not used here)
    # RFC 4648 base64url:    62 -, 63 _
    $p_mail_id =~ m{^ [A-Za-z0-9] [A-Za-z0-9_+-]* ={0,2} \z}xs
      or die "Invalid mail_id '$p_mail_id'";
    $p_mail_id = untaint($p_mail_id);
    $msginfo->parent_mail_id($p_mail_id);
    $msginfo->mail_id(scalar generate_mail_id());
    if (!exists($attr_ref->{'secret_id'}) || $attr_ref->{'secret_id'} eq '') {
      die "Secret_id is required, but missing"  if c('auth_required_release');
    } else {
      # version 2.7.0 and later uses RFC 4648 base64url and id=b64(md5(sec)),
      # versions before 2.7.0 used almost-base64 and id=b64(md5(b64(sec)))
      { # begin block, 'last' exits it
        my $secret_b64 = $attr_ref->{'secret_id'};
        $secret_b64 = ''  if !defined $secret_b64;
        if (index($secret_b64,'+') < 0) {  # new or undetermined format
          local($_) = $secret_b64;  tr{-_}{+/};  # revert base64url to base64
          my $secret_bin = decode_base64($_);
          my $id_new_b64 = Digest::MD5->new->add($secret_bin)->b64digest;
          substr($id_new_b64, 12) = '';
          $id_new_b64 =~ tr{+/}{-_};  # base64 -> RFC 4648 base64url
          last  if $id_new_b64 eq $p_mail_id;  # exit enclosing block
        }
        if (index($secret_b64,'_') < 0) {  # old or undetermined format
          my $id_old_b64 = Digest::MD5->new->add($secret_b64)->b64digest;
          substr($id_old_b64, 12) = '';
          $id_old_b64 =~ tr{/}{-};  # base64 -> almost-base64
          last  if $id_old_b64 eq $p_mail_id;  # exit enclosing block
        }
        die "Secret_id $secret_b64 does not match mail_id $p_mail_id";
      };  # end block, 'last' arrives here
    }
    $quar_type = $attr_ref->{'quar_type'};
    if (!defined($quar_type) || $quar_type eq '') {
      # choose some reasonable default (simpleminded)
      $quar_type = c('spam_quarantine_method') =~ /^sql:/i ? 'Q' : 'F';
    }
    my $fn = $p_mail_id;
    if ($quar_type eq 'F' || $quar_type eq 'Z') {
      $QUARANTINEDIR ne '' or die "Config variable \$QUARANTINEDIR is empty";
      if ($attr_ref->{'mail_file'} ne '') {
        $fn = $attr_ref->{'mail_file'};
        $fn =~ m{^[A-Za-z0-9][A-Za-z0-9/_.+-]*\z}s && $fn !~ m{\.\.(?:/|\z)}
          or die "Unsafe filename '$fn'";
        $fn = $QUARANTINEDIR.'/'.untaint($fn);
      } else {  # automatically guess a filename - simpleminded
        if ($quarantine_subdir_levels < 1) { $fn = "$QUARANTINEDIR/$fn" }
        else { my $subd = substr($fn,0,1);   $fn = "$QUARANTINEDIR/$subd/$fn" }
        $fn .= '.gz'  if $quar_type eq 'Z';
      }
    }
    $msginfo->mail_text_fn($fn);
  } elsif (!exists $attr_ref->{'mail_file'}) {
    $msginfo->mail_text_fn($msginfo->mail_tempdir . '/email.txt');
  } else {
    # SECURITY: just believe the supplied file name, blindly untainting it
    $msginfo->mail_text_fn(untaint($attr_ref->{'mail_file'}));
  }
  my $fname = $msginfo->mail_text_fn;
  if ($ampdp && defined($fname) && $fname ne '') {
    my $fh;
    my $releasing = $attr_ref->{'request'}=~ /^(?:release|requeue|report)\z/i;
    new_am_id('rel-'.$msginfo->mail_id)  if $releasing;
    if ($releasing && $quar_type eq 'Q') {  # releasing from SQL
      do_log(5, "preprocess_policy_query: opening in sql: %s", $p_mail_id);
      my $obj = $Amavis::sql_storage;
      $Amavis::extra_code_sql_quar && $obj
        or die "SQL quarantine code not enabled (3)";
      my $conn_h = $obj->{conn_h}; my $sql_cl_r = cr('sql_clause');
      my $sel_msg  = $sql_cl_r->{'sel_msg'};
      my $sel_quar = $sql_cl_r->{'sel_quar'};
      if (!defined($msginfo->partition_tag) &&
          defined($sel_msg) && $sel_msg ne '') {
        do_log(5, "preprocess_policy_query: missing partition_tag in request,".
                  " fetching msgs record for mail_id=%s", $p_mail_id);
        # find a corresponding partition_tag if missing from a release request
        $conn_h->begin_work_nontransaction;  #(re)connect if necessary
        $conn_h->execute($sel_msg, $p_mail_id);
        my $a_ref; my $cnt = 0; my $partition_tag;
        while ( defined($a_ref=$conn_h->fetchrow_arrayref($sel_msg)) ) {
          $cnt++;
          $partition_tag = $a_ref->[0]  if !defined $partition_tag;
          ll(5) && do_log(5, "release: got msgs record for mail_id=%s: %s",
                             $p_mail_id, join(', ',@$a_ref));
        }
        $conn_h->finish($sel_msg)  if defined $a_ref;  # only if not all read
        $cnt <= 1 or die "Multiple ($cnt) records with same mail_id exist, ".
                         "specify a partition_tag in the AM.PDP request";
        if ($cnt < 1) {
          do_log(0, "release: no records with msgs.mail_id=%s in a database, ".
                    "trying to read from a quar. anyway", $p_mail_id);
        }
        $msginfo->partition_tag($partition_tag);  # could still be undef/NULL !
      }
      ll(5) && do_log(5, "release: opening mail_id=%s, partition_tag=%s",
                         $p_mail_id, $msginfo->partition_tag);
      $conn_h->begin_work_nontransaction;  # (re)connect if not connected
      $fh = Amavis::IO::SQL->new;
      $fh->open($conn_h, $sel_quar, $p_mail_id,
                'r', untaint($msginfo->partition_tag))
        or die "Can't open sql obj for reading: $!";  1;
    } else {  # mail checking or releasing from a file
      do_log(5, "preprocess_policy_query: opening mail '%s'", $fname);
      # set new amavis message id
      new_am_id( ($fname =~ m{amavis-(milter-)?([^/ \t]+)}s ? $2 : undef),
                 $Amavis::child_invocation_count )  if !$releasing;
      # file created by amavis helper program or other client, just open it
      my(@stat_list) = lstat($fname); my $errn = @stat_list ? 0 : 0+$!;
      if ($errn == ENOENT) { die "File $fname does not exist" }
      elsif ($errn) { die "File $fname inaccessible: $!" }
      elsif (!-f _) { die "File $fname is not a plain file" }
      add_entropy(@stat_list);
      if ($fname =~ /\.gz\z/) {
        $fh = Amavis::IO::Zlib->new;
        $fh->open($fname,'rb') or die "Can't open gzipped file $fname: $!";
      } else {
      # $msginfo->msg_size(0 + (-s _));  # underestimates the RFC 1870 size
        $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 $file_size = $stat_list[7];
        if ($file_size < 100*1024) {  # 100 KiB 'small mail', read into memory
          do_log(5, 'preprocess_policy_query: reading from %s to memory, '.
                    'file size %d bytes', $fname, $file_size);
          my $str = ''; read_file($fh,\$str);
          $fh->seek(0,0) or die "Can't rewind file $fname: $!";
          $msginfo->mail_text_str(\$str);  # save mail as a string
        }
      }
    }
    $msginfo->mail_text($fh);  # save file handle to object
    $msginfo->log_id(am_id());
  }
  if ($ampdp && ll(3)) {
    do_log(3, "Request: %s %s %s: %s -> %s", $attr_ref->{'request'},
              $attr_ref->{'mail_id'}, $msginfo->mail_tempdir,
              $msginfo->sender_smtp,
              join(',', map($_->recip_addr_smtp, @recips)) );
  } else {
    do_log(3, "Request: %s(%s): %s %s %s: %s[%s] <%s> -> <%s>",
              @$attr_ref{qw(request protocol_state mail_id protocol_name
              queue_id client_name client_address sender recipient)});
  }
  ($msginfo, \@bank_names);
}

sub check_ampdp_policy($$$$) {
  my($msginfo,$check_mail,$old_amcl,$bank_names_ref) = @_;
  my($smtp_resp, $exit_code, $preserve_evidence);
  my(%baseline_policy_bank) = %current_policy_bank;
  # do some sanity checks before deciding to call check_mail()
  if (!ref($msginfo->per_recip_data) || !defined($msginfo->mail_text)) {
    $smtp_resp = '450 4.5.0 Incomplete request'; $exit_code = EX_TEMPFAIL;
  } else {
    # loading a policy bank can affect subsequent c(), cr() and ca() results,
    # so it is necessary to load each policy bank in the right order and soon
    # after information becomes available; general principle is that policy
    # banks are loaded in order in which information becomes available:
    # interface/socket, client IP, SMTP session info, sender, ...
    my $cl_ip  = $msginfo->client_addr;
    my $cl_src = $msginfo->client_source;
    my(@bank_names_cl);
    { my $cl_ip_tmp = $cl_ip;
      # treat unknown client IP addr as 0.0.0.0, from "This" Network, RFC 1700
      $cl_ip_tmp = '0.0.0.0'  if !defined($cl_ip) || $cl_ip eq '';
      my(@cp) = @{ca('client_ipaddr_policy')};
      do_log(-1,'@client_ipaddr_policy must contain pairs, '.
                'number of elements is not even')  if @cp % 2 != 0;
      my $labeler = Amavis::Lookup::Label->new('client_ipaddr_policy');
      while (@cp > 1) {
        my $lookup_table = shift(@cp);
        my $policy_names = shift(@cp);  # comma-separated string of names
        next if !defined $policy_names;
        if (lookup_ip_acl($cl_ip_tmp, $labeler, $lookup_table)) {
          local $1;
          push(@bank_names_cl,
               map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $policy_names)));
          last;  # should we stop here or not?
        }
      }
    }
    # load policy banks from the 'client_ipaddr_policy' lookup
    Amavis::load_policy_bank($_,$msginfo) for @bank_names_cl;
    # additional banks from the request
    Amavis::load_policy_bank(untaint($_),$msginfo) for @$bank_names_ref;
    $msginfo->originating(c('originating'));
    my $sender = $msginfo->sender;
    if (defined $policy_bank{'MYUSERS'} &&
        $sender ne '' && $msginfo->originating &&
        lookup2(0,$sender, ca('local_domains_maps'))) {
      Amavis::load_policy_bank('MYUSERS',$msginfo);
    }
    my $debrecipm = ca('debug_recipient_maps');
    if (lookup2(0, $sender, ca('debug_sender_maps')) ||
        @$debrecipm && grep(lookup2(0, $_->recip_addr, $debrecipm),
                                    @{$msginfo->per_recip_data})) {
      debug_oneshot(1);
    }
    # check_mail() expects open file on $fh, need not be rewound
    Amavis::check_mail_begin_task();
    ($smtp_resp, $exit_code, $preserve_evidence) = &$check_mail($msginfo,0);
    my $fh = $msginfo->mail_text;  my $tempdir = $msginfo->mail_tempdir;
    $fh->close or die "Error closing temp file: $!"   if $fh;
    undef $fh; $msginfo->mail_text(undef);
    $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef);
    my $errn = $tempdir eq '' ? ENOENT : (stat($tempdir) ? 0 : 0+$!);
    if ($tempdir eq '' || $errn == ENOENT) {
      # do nothing
    } elsif ($msginfo->client_delete) {
      do_log(4, "AM.PDP: deletion of %s is client's responsibility", $tempdir);
    } elsif ($preserve_evidence) {
      do_log(-1,'AM.PDP: tempdir is to be PRESERVED: %s', $tempdir);
    } else {
      my $fname = $msginfo->mail_text_fn;
      do_log(4, 'AM.PDP: tempdir and file being removed: %s, %s',
                $tempdir,$fname);
      unlink($fname) or die "Can't remove file $fname: $!"  if $fname ne '';
      # must step out of the directory which is about to be deleted,
      # otherwise rmdir can fail (e.g. on Solaris)
      chdir($TEMPBASE) or die "Can't chdir to $TEMPBASE: $!";
      rmdir_recursively($tempdir);
    }
  }
  # amavisd -> amavis-helper protocol response consists of any number of
  # the following lines, the response is terminated by an empty line:
  #   version_server=2
  #   log_id=xxx
  #   delrcpt=<recipient>
  #   addrcpt=<recipient>
  #   delheader=hdridx hdr_head
  #   chgheader=hdridx hdr_head hdr_body
  #   insheader=hdridx hdr_head hdr_body
  #   addheader=hdr_head hdr_body
  #   replacebody=new_body  (not implemented)
  #   quarantine=reason  (currently never used, supposed to call
  #                       smfi_quarantine, placing message on hold)
  #   return_value=continue|reject|discard|accept|tempfail
  #   setreply=rcode xcode message
  #   exit_code=n

  my(@response); my($rcpt_deletes,$rcpt_count)=(0,0);
  push(@response, proto_encode('version_server', '2'));
  push(@response, proto_encode('log_id', $msginfo->log_id));
  for my $r (@{$msginfo->per_recip_data}) {
    $rcpt_count++;
    $rcpt_deletes++  if $r->recip_done;
  }
  local($1,$2,$3);
  if ($smtp_resp=~/^([1-5]\d\d) ([245]\.\d{1,3}\.\d{1,3})(?: |\z)(.*)\z/s)
    { push(@response, proto_encode('setreply', $1,$2,$3)) }
  if (     $exit_code == EX_TEMPFAIL) {
    push(@response, proto_encode('return_value','tempfail'));
  } elsif ($exit_code == EX_NOUSER) {          # reject the whole message
    push(@response, proto_encode('return_value','reject'));
  } elsif ($exit_code == EX_UNAVAILABLE) {     # reject the whole message
    push(@response, proto_encode('return_value','reject'));
  } elsif ($exit_code == 99 || $rcpt_deletes >= $rcpt_count) {
    $exit_code = 99; # let MTA discard the message, it was already handled here
    push(@response, proto_encode('return_value','discard'));
  } elsif (grep($_->delivery_method ne '', @{$msginfo->per_recip_data})) {
    # explicit forwarding by us
    die "Not all recips done, but explicit forwarding";  # just in case
  } else {  # EX_OK
    for my $r (@{$msginfo->per_recip_data}) {  # modified recipient addresses?
      my $newaddr = $r->recip_final_addr;
      if ($r->recip_done) {           # delete
        push(@response, proto_encode('delrcpt', $r->recip_addr_smtp))
          if defined $r->recip_addr;  # if in the original list, not always_bcc
      } elsif ($newaddr ne $r->recip_addr) {   # modify, e.g. adding extension
        push(@response, proto_encode('delrcpt', $r->recip_addr_smtp))
          if defined $r->recip_addr;  # if in the original list, not always_bcc
        push(@response, proto_encode('addrcpt',
                                     qquote_rfc2821_local($newaddr)));
      }
    }
    my $hdr_edits = $msginfo->header_edits;
    if ($hdr_edits) {  # any added or modified header fields?
      local($1,$2); my($field_name,$edit,$field_body);
      while ( ($field_name,$edit) = each %{$hdr_edits->{edit}} ) {
        $field_body = $msginfo->get_header_field_body($field_name,0);  # first
        if (!defined($field_body)) {
          # such header field does not exist or is not available, do nothing
        } else {                 # edit the first occurrence
          chomp($field_body);
          my $orig_field_body = $field_body;
          for my $e (@$edit) {   # possibly multiple (iterative) edits
            if (!defined($e)) { $field_body = undef; last }  # delete existing
            my($new_fbody,$verbatim) = &$e($field_name,$field_body);
            if (!defined($new_fbody)) { $field_body = undef; last }  # delete
            my $curr_head = $verbatim ? ($field_name . ':' . $new_fbody)
                                      : hdr($field_name, $new_fbody, 0,
                                            $msginfo->smtputf8);
            chomp($curr_head); $curr_head .= "\n";
            $curr_head =~ /^([^:]*?)[ \t]*:(.*)\z/s;
            $field_body = $2; chomp($field_body);  # carry to next iteration
          }
          if (!defined($field_body)) {
            push(@response, proto_encode('delheader','1',$field_name));
          } elsif ($field_body ne $orig_field_body) {
            # sendmail inserts a space after a colon, remove ours
            $field_body =~ s/^[ \t]//;
            push(@response, proto_encode('chgheader','1',
                                         $field_name,$field_body));
          }
        }
      }
      my $hdridx = c('prepend_header_fields_hdridx');  # milter insertion index
      $hdridx = 0  if !defined($hdridx) || $hdridx < 0;
      $hdridx = sprintf("%d",$hdridx);  # convert to string
      # prepend header fields one at a time, topmost field last
      for my $hf (map(ref $hdr_edits->{$_} ? reverse @{$hdr_edits->{$_}} : (),
                      qw(addrcvd prepend)) ) {
        if ($hf =~ /^([^:]*?)[ \t]*:[ \t]*(.*?)$/s)
          { push(@response, proto_encode('insheader',$hdridx,$1,$2)) }
      }
      # append header fields
      for my $hf (map(ref $hdr_edits->{$_} ? @{$hdr_edits->{$_}} : (),
                      qw(append)) ) {
        if ($hf =~ /^([^:]*?)[ \t]*:[ \t]*(.*?)$/s)
          { push(@response, proto_encode('addheader',$1,$2)) }
      }
    }
    if ($old_amcl) {   # milter via old amavis helper program
      # warn if there is anything that should be done but MTA is not capable of
      # (or a helper program cannot pass the request)
      for (grep(/^(delrcpt|addrcpt)=/, @response))
        { do_log(-1, "WARN: MTA can't do: %s", $_) }
      if ($rcpt_deletes && $rcpt_count-$rcpt_deletes > 0) {
        do_log(-1, "WARN: ACCEPT THE WHOLE MESSAGE, ".
                   "MTA-in can't do selective recips deletion");
      }
    }
    push(@response, proto_encode('return_value','continue'));
  }
  push(@response, proto_encode('exit_code',sprintf("%d",$exit_code)));
  ll(3) && do_log(3, 'mail checking ended: %s', join("\n",@response));
  dump_captured_log(1, c('enable_log_capture_dump'));
  %current_policy_bank = %baseline_policy_bank;  # restore bank settings
  @response;
}

# just a proof-of-concept, experimental
#
sub postfix_policy($$) {
  my($msginfo,$attr_ref) = @_;
  my(@response);
  if ($attr_ref->{'request'} ne 'smtpd_access_policy') {
    die("unknown 'request' value: " . $attr_ref->{'request'});
  } else {
    @response = 'action=DUNNO';
  }
  @response;
}

sub dispatch_from_quarantine($$$) {
  my($msginfo,$request_type,$feedback_type) = @_;
  my $err;
  eval {
    # feed information to a msginfo object, possibly replacing it
    $msginfo = msg_from_quarantine($msginfo,$request_type,$feedback_type);
    mail_dispatch($msginfo,0,1);  # re-send the original mail or report
    1;
  } or do {
    $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
    do_log(0, "WARN: dispatch_from_quarantine failed: %s",$err);
    die $err  if $err =~ /^timed out\b/;  # resignal timeout
  };
  my(@response);
  my $per_recip_data = $msginfo->per_recip_data;
  if (!defined($per_recip_data) || !@$per_recip_data) {
    push(@response, proto_encode('setreply','250','2.5.0',
                                 "No recipients, nothing to do"));
  } else {
    Amavis::build_and_save_structured_report($msginfo,'SEND');
    for my $r (@$per_recip_data) {
      local($1,$2,$3); my($smtp_s,$smtp_es,$msg);
      my $resp = $r->recip_smtp_response;
      if ($err ne '')
        { ($smtp_s,$smtp_es,$msg) = ('450', '4.5.0', "ERROR: $err") }
      elsif ($resp =~ /^([1-5]\d\d) ([245]\.\d{1,3}\.\d{1,3})(?: |\z)(.*)\z/s)
        { ($smtp_s,$smtp_es,$msg) = ($1,$2,$3) }
      elsif ($resp =~ /^(([1-5])\d\d)(?: |\z)(.*)\z/s)
        { ($smtp_s,$smtp_es,$msg) = ($1, "$2.0.0" ,$3) }
      else
        { ($smtp_s,$smtp_es,$msg) = ('450', '4.5.0', "Unexpected: $resp") }
      push(@response, proto_encode('setreply',$smtp_s,$smtp_es,$msg));
    }
  }
  @response;
}

1;
