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

package Amavis::SpamControl::SpamdClient;
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 Errno qw(ENOENT EACCES);

use Amavis::Conf qw(:platform :confvars :sa c cr ca);
use Amavis::rfc2821_2822_Tools qw(qquote_rfc2821_local);
use Amavis::Timing qw(section_time);
use Amavis::Util qw(ll do_log sanitize_str min max minmax get_deadline);

sub new {
  my($class, $scanner_name,$module,@args) = @_;
  my(%options) = @args;
  bless { scanner_name => $scanner_name, options => \%options }, $class;
}

# needs spamd running, could be started like this:
#   spamd -H /var/amavis/home -r /var/amavis/home/spamd.pid -s stderr \
#     -u vscan -g vscan -x -P --allow-tell --min-children=2 --max-children=2

sub check {
  my($self,$msginfo) = @_;
  my($which_section, $spam_level, $sa_tests, $size_limit, %attr);
  my $scanner_name = $self->{scanner_name};
  my $mbsl = $self->{options}->{'mail_body_size_limit'};
  $mbsl = c('sa_mail_body_size_limit')  if !defined $mbsl;
  if (defined $mbsl) {
    $size_limit = min(64*1024, $msginfo->orig_header_size) + 1 +
                  min($mbsl,   $msginfo->orig_body_size);
    # don't bother if slightly oversized, it's faster without size checks
    undef $size_limit  if $msginfo->msg_size < $size_limit + 5*1024;
  }
  my $hdr_edits = $msginfo->header_edits;
  # fake a local delivery agent by inserting Return-Path
  $which_section = 'prepare pseudo header section';
  my $hdr_prefix = '';
  $hdr_prefix .= sprintf("Return-Path: %s\n", $msginfo->sender_smtp);
  $hdr_prefix .= sprintf("X-Envelope-To: %s\n",
       join(",\n ",qquote_rfc2821_local(@{$msginfo->recips})));
  my $os_fp = $msginfo->client_os_fingerprint;
  $hdr_prefix .= sprintf("X-Amavis-OS-Fingerprint: %s\n",
       sanitize_str($os_fp))  if defined($os_fp) && $os_fp ne '';
  my(@av_tests);
  my $per_recip_data = $msginfo->per_recip_data;
  $per_recip_data = []  if !$per_recip_data;
  for my $r (@$per_recip_data) {
    my $spam_tests = $r->spam_tests;
    push(@av_tests, grep(/^AV\..+=/,
                 split(/,/, join(',',map($$_,@$spam_tests)))))  if $spam_tests;
  }
  $hdr_prefix .= sprintf("X-Amavis-AV-Status: %s\n",
                         sanitize_str(join(',',@av_tests)))  if @av_tests;
  $hdr_prefix .= sprintf("X-Amavis-PolicyBank: %s\n", c('policy_bank_path'));
  $hdr_prefix .= sprintf("X-Amavis-MessageSize: %d%s\n", $msginfo->msg_size,
                   !defined $size_limit ? '' : ", TRUNCATED to $size_limit");
  my($remaining_time, $deadline) = get_deadline('spamd check', 1, 5);
  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;
  eval {
    $which_section = 'spamd_connect';  do_log(3,"connecting to spamd");
    my $spamd_handle = Amavis::IO::RW->new(
      [ '127.0.0.1:783', '[::1]:783' ], Eol => "\015\012", Timeout => 10);
    defined $spamd_handle or die "Can't connect to spamd, $@ ($!)";
    $spamd_handle->timeout(max(3, $deadline - Time::HiRes::time));
    section_time($which_section);

    $which_section = 'spamd_tx';  do_log(4,"sending to spamd");
    $hdr_prefix =~ s{\n}{\015\012}gs;
    my $file_position = $msginfo->skip_bytes;
    my $msgsize = length($hdr_prefix);  # prepended lines...
    $msgsize += $msginfo->msg_size;     # size as defined by RFC 1870
    $msgsize -= $file_position;   # TODO: adjust for CRLF (alright for 0)
    ll(5) && do_log(5, "spamc: message size: %d + %d - %d = %s",
               length($hdr_prefix), $msginfo->msg_size, $file_position,
               defined $size_limit && $msgsize > $size_limit
                 ? "LIM:$size_limit" : $msgsize);
    if (defined $size_limit && $msgsize > $size_limit) {
      # consider $size_limit in the RFC 1870 sense for simplicity
      $msgsize = $size_limit;
    }
    $spamd_handle->print("SYMBOLS SPAMC/1.3\015\012");  # HEADERS
    $spamd_handle->print("Content-length: " . $msgsize . "\015\012");
    $spamd_handle->print("\015\012");
    $spamd_handle->print($hdr_prefix);
    my $bytes_written = length($hdr_prefix);
    if (!defined $msg) {
      # empty mail
    } elsif (ref $msg eq 'SCALAR') {
      # do it in chunks, saves memory, cache friendly
      my $done;
      while ($file_position < length($$msg)) {
        my $buff = substr($$msg,$file_position,16384);
        $file_position += length($buff);
        $buff =~ s{\n}{\015\012}gs;
        if (defined $size_limit &&
            $bytes_written + length($buff) >= $size_limit) {
          substr($buff, $size_limit - $bytes_written) = '';  # truncate
          # spamd reads line-by-line and hangs if not terminated by a NL
          substr($buff,-1,1) = "\012";
          do_log(5,"spamc: reached size limit %d bytes, ".
                   "%d = %d (sent) + %d (still to go)",
                   $size_limit, $bytes_written+length($buff),
                   $bytes_written, length($buff));
          $done = 1;
        }
        $spamd_handle->print($buff);
        $bytes_written += length($buff);
        last if $done;
      }
    } elsif ($msg->isa('MIME::Entity')) {  # TODO - content length won't match!
      do_log(3,"spamc: message is MIME::Entity, size won't match");
      $msg->print_body($spamd_handle);
    } else {
      $msg->seek($file_position,0) or die "Can't rewind mail file: $!";
      my($nbytes,$buff,$done);
      while ( $nbytes=$msg->sysread($buff,16384) ) {
        $file_position += $nbytes;
        $buff =~ s{\n}{\015\012}gs;
        if (defined $size_limit &&
            $bytes_written + length($buff) >= $size_limit) {
          substr($buff, $size_limit - $bytes_written) = '';  # truncate
          # spamd reads line-by-line and hangs if not terminated by a NL
          substr($buff,-1,1) = "\012";
          do_log(5,"spamc: reached size limit %d bytes, ".
                   "%d = %d (sent) + %d (still to go)",
                   $size_limit, $bytes_written+length($buff),
                   $bytes_written, length($buff));
          $done = 1;
        }
        $spamd_handle->print($buff);
        $bytes_written += length($buff);
        last if $done;
      }
      defined $nbytes or die "Error reading: $!";
    }
    $spamd_handle->flush;
    $hdr_prefix = undef;
    section_time($which_section);

    $which_section = 'spamd_rx';  do_log(4,"receiving from spamd");
    my($version, $resp_code, $resp_msg);
    local($1,$2,$3); my($ln,$error,$first); $first = 1;
    while (defined($ln = $spamd_handle->get_response_line)) {
      do_log(4,"from spamd - resp.hdr: %s", $ln);
      if ($ln eq "\015\012") {
        last;
      } elsif ($first) {
        $first = 0; $ln =~ s/\015\012\z//;
        ($version,$resp_code,$resp_msg) = split(/[ \t]+/,$ln,3);
      } elsif ($ln =~ /^([^:]*?)[ \t]*:[ \t]*(.*)\015\012\z/i) {
        $attr{lc($1)} = $2;
      } else { $error = $ln }
    }
    if ($first) { do_log(-1,"Empty spamd response") }
    elsif (defined $error) { do_log(-1,"Error in spamd resp: %s",$error) }
    elsif ($resp_code !~ /^\d+\z/ || $resp_code != 0) {
      do_log(-1,"Failure reported by spamd: %s %s %s",
                $version,$resp_code,$resp_msg);
    } else {
      my $reply_len = 0;
      while (defined($ln = $spamd_handle->get_response_line)) {
        do_log(5,"from spamd: %s", $ln);
        $reply_len += length($ln); $ln =~ s/\015\012\z//; $sa_tests = $ln;
      }
      do_log(-1,"Reply from spamd size mismatch: %d %s",
                $reply_len, $attr{'content-length'}
            )  if $reply_len != $attr{'content-length'};
    }
    $spamd_handle->close;  # terminate the session, ignoring status
    undef $spamd_handle;
    $spam_level = $2  if $attr{'spam'} =~ m{(\S+) ; (\S+) / (\S+)};
    1;
  } or do {
    my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
    do_log(-1,"%s client failed: %s", $scanner_name, $eval_stat);
  };
  section_time($which_section);
  my $score_factor = $self->{options}->{'score_factor'};
  if (defined $spam_level && defined $score_factor) {
    $spam_level *= $score_factor;
  }
  do_log(2,"%s spamd score=%s, tests=%s",
           $scanner_name, $spam_level, $sa_tests);
  $msginfo->supplementary_info('SCORE-'.$scanner_name, $spam_level);
  $msginfo->supplementary_info('VERDICT-'.$scanner_name,
                               $attr{'spam'} =~ /^True/  ? 'Spam'
                             : $attr{'spam'} =~ /^False/ ? 'Ham' : 'Unknown');
  for my $r (@$per_recip_data) {
    $r->spam_level( ($r->spam_level || 0) + $spam_level );
    if (!$r->spam_tests) {
      $r->spam_tests([ \$sa_tests ]);
    } else {
      push(@{$r->spam_tests}, \$sa_tests);
    }
  }
}

1;
