1#!/usr/local/bin/perl -T
2
3### profiling:
4### #!/usr/bin/perl -d:NYTProf
5###   NYTPROF=start=no:addpid=1:forkdepth=1 amavisd -m 5 foreground
6
7#------------------------------------------------------------------------------
8# This is amavisd-new.
9# It is an interface between a message transfer agent (MTA) and virus
10# scanners and/or spam scanners, functioning as a mail content filter.
11#
12# It is a performance-enhanced and feature-enriched version of amavisd
13# (which in turn is a daemonized version of AMaViS), initially based
14# on amavisd-snapshot-20020300).
15#
16# All work since amavisd-snapshot-20020300:
17#   Copyright (C) 2002-2018 Mark Martinec,
18#   All Rights Reserved.
19# with contributions from the amavis-user mailing list and individuals,
20# as acknowledged in the release notes.
21#
22#    This program is free software; you can redistribute it and/or modify
23#    it under the terms of the GNU General Public License as published by
24#    the Free Software Foundation; either version 2 of the License, or
25#    (at your option) any later version.
26#
27#    This program is distributed in the hope that it will be useful,
28#    but WITHOUT ANY WARRANTY; without even the implied warranty of
29#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
30#    GNU General Public License for details.
31#
32#    You should have received a copy of the GNU General Public License
33#    along with this program; if not, write to the Free Software
34#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
35
36# Author: Mark Martinec <Mark.Martinec@ijs.si>
37# Patches and problem reports are welcome.
38#
39# The latest version of this program is available at:
40#   http://www.ijs.si/software/amavisd/
41#------------------------------------------------------------------------------
42
43# Here is a boilerplate from the amavisd(-snapshot) version, which is
44# the version (from 2002-03) that served as a base code for the initial
45# version of amavisd-new. License terms were the same:
46#
47#   Author:  Chris Mason <cmason@unixzone.com>
48#   Current maintainer: Lars Hecking <lhecking@users.sourceforge.net>
49#   Based on work by:
50#         Mogens Kjaer, Carlsberg Laboratory, <mk@crc.dk>
51#         Juergen Quade, Softing GmbH, <quade@softing.com>
52#         Christian Bricart <shiva@aachalon.de>
53#         Rainer Link <link@foo.fh-furtwangen.de>
54#   This script is part of the AMaViS package.  For more information see:
55#     http://amavis.org/
56#   Copyright (C) 2000 - 2002 the people mentioned above
57#   This software is licensed under the GNU General Public License (GPL)
58#   See:  http://www.gnu.org/copyleft/gpl.html
59#------------------------------------------------------------------------------
60
61#------------------------------------------------------------------------------
62#Index of packages in this file
63#  Amavis::Boot
64#  Amavis::Conf
65#  Amavis::JSON
66#  Amavis::Log
67#  Amavis::DbgLog
68#  Amavis::Timing
69#  Amavis::Util
70#  Amavis::ProcControl
71#  Amavis::rfc2821_2822_Tools
72#  Amavis::Lookup::RE
73#  Amavis::Lookup::IP
74#  Amavis::Lookup::Opaque
75#  Amavis::Lookup::OpaqueRef
76#  Amavis::Lookup::Label
77#  Amavis::Lookup::SQLfield (just the new() method declared here)
78#  Amavis::Lookup::LDAPattr (just the new() method declared here)
79#  Amavis::Lookup
80#  Amavis::Expand
81#  Amavis::TempDir
82#  Amavis::IO::FileHandle
83#  Amavis::IO::Zlib
84#  Amavis::IO::RW
85#  Amavis::In::Connection
86#  Amavis::In::Message::PerRecip
87#  Amavis::In::Message
88#  Amavis::Out::EditHeader
89#  Amavis::Out
90#  Amavis::UnmangleSender
91#  Amavis::Unpackers::NewFilename
92#  Amavis::Unpackers::Part
93#  Amavis::Unpackers::OurFiler
94#  Amavis::Unpackers::Validity
95#  Amavis::Unpackers::MIME
96#  Amavis::Notify
97#  Amavis::Custom
98#  Amavis
99#optionally compiled-in packages: ---------------------------------------------
100#  Amavis::ZMQ
101#  Amavis::DB::SNMP
102#  Amavis::DB
103#  Amavis::Lookup::SQLfield (the rest)
104#  Amavis::Lookup::SQL
105#  Amavis::LDAP::Connection
106#  Amavis::Lookup::LDAP
107#  Amavis::Lookup::LDAPattr (the rest)
108#  Amavis::In::AMPDP
109#  Amavis::In::SMTP
110#( Amavis::In::Courier )
111#  Amavis::Out::SMTP::Protocol
112#  Amavis::Out::SMTP::Session
113#  Amavis::Out::SMTP
114#  Amavis::Out::Pipe
115#  Amavis::Out::BSMTP
116#  Amavis::Out::Local
117#  Amavis::OS_Fingerprint
118#  Amavis::Redis
119#  Amavis::Out::SQL::Connection
120#  Amavis::Out::SQL::Log
121#  Amavis::IO::SQL
122#  Amavis::Out::SQL::Quarantine
123#  Amavis::AV
124#  Amavis::SpamControl
125#  Amavis::SpamControl::ExtProg
126#  Amavis::SpamControl::RspamdClient
127#  Amavis::SpamControl::SpamdClient
128#  Mail::SpamAssassin::Logger::Amavislog
129#  Amavis::SpamControl::SpamAssassin
130#  Amavis::Unpackers
131#  Amavis::DKIM::CustomSigner
132#  Amavis::DKIM
133#  Amavis::Tools
134#------------------------------------------------------------------------------
135
136BEGIN { pop @INC if $INC[-1] eq '.' }  # CVE-2016-1238 (perl)
137use sigtrap qw(stack-trace BUS SEGV EMT FPE ILL SYS TRAP);  # ABRT
138
139use strict;
140use re 'taint';
141use warnings;
142use warnings FATAL => qw(utf8 void);
143no warnings 'uninitialized';
144# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
145
146#
147package Amavis::Boot;
148use strict;
149use re 'taint';
150use Errno qw(ENOENT EACCES);
151
152# replacement for a 'require' with a more informative error handling
153#sub my_require($) {
154# my $filename = $_[0];
155# my $result;
156# if (exists $INC{$filename} && !$INC{$filename}) {
157#   die "Compilation failed in require\n";
158# } elsif (exists $INC{$filename}) {
159#   $result = 1;  # already loaded
160# } else {
161#   my $found = 0;
162#   for my $prefix (@INC) {
163#     my $full_fname = "$prefix/$filename";
164#     my(@stat_list) = stat($full_fname);  # symlinks-friendly
165#     my $errn = @stat_list ? 0 : 0+$!;
166#     if ($errn != ENOENT) {
167#       $found = 1;
168#       $INC{$filename} = $full_fname;
169#       my $owner_uid = $stat_list[4];
170#       my $msg;
171#       if ($errn)         { $msg = "is inaccessible: $!" }
172#       elsif (-d _)       { $msg = "is a directory" }
173#       elsif (!-f _)      { $msg = "is not a regular file" }
174#       elsif ($> && -o _) { $msg = "should not be owned by EUID $>"  }
175#       elsif ($> && -w _) { $msg = "is writable by EUID $>, EGID $)" }
176#       elsif ($owner_uid) { $msg = "should be owned by root (uid 0) "}
177#       !defined($msg) or die "Requiring $full_fname, file $msg,\n";
178#       $! = 0;
179#       $result = do $full_fname;
180#       if (!defined($result) && $@ ne '') {
181#         undef $INC{$filename}; chomp($@);
182#         die "Error in file $full_fname: $@\n";
183#       } elsif (!defined($result) && $! != 0) {
184#         undef $INC{$filename};
185#         die "Error reading file $full_fname: $!\n";
186#       } elsif (!$result) {
187#         undef $INC{$filename};
188#         die "Module $full_fname did not return a true value\n";
189#       }
190#       last;
191#     }
192#   }
193#   die sprintf("my_require: Can't locate %s in \@INC (\@INC contains: %s)\n",
194#               $filename, join(' ',@INC))  if !$found;
195# }
196# $result;
197#}
198
199# Fetch all required modules (or nicely report missing ones), and compile them
200# once-and-for-all at the parent process, so that forked children can inherit
201# and share already compiled code in memory. Children will still need to 'use'
202# modules if they want to inherit from their name space.
203#
204sub fetch_modules($$@) {
205  my($reason, $required, @modules) = @_;
206  my(@missing);
207  for my $m (@modules) {
208    local $_ = $m;
209    $_ .= /^auto::/ ? '.al' : '.pm'  if !m{^/} && !m{\.(?:pm|pl|al|ix)\z};
210    s{::}{/}g;
211    eval {
212      require $_;
213    # my_require $_;  # more informative on err, but some problems reported
214    } or do {
215      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
216      push(@missing,$m);
217      $eval_stat =~ s/^/  /gms;  # indent
218      printf STDERR ("fetch_modules: error loading %s module %s:\n%s\n",
219                     $required ? 'required' : 'optional',  $_, $eval_stat)
220        if $eval_stat !~ /\bCan't locate \Q$_\E in \@INC\b/;
221    };
222  }
223  die "ERROR: MISSING $reason:\n" . join('', map("  $_\n", @missing))
224    if $required && @missing;
225  \@missing;
226}
227
228BEGIN {
229  if ($] <= 5.008) {  # deal with a glob() taint bug (perl 5.6.1, 5.8.0)
230    fetch_modules('REQUIRED BASIC MODULES', 1, qw(File::Glob));
231    File::Glob->import(':globally');  # use the same module as Perl 5.8 uses
232  }
233  fetch_modules('REQUIRED BASIC MODULES', 1, qw(
234    Exporter POSIX Fcntl Socket Errno Carp Time::HiRes
235    IO::Handle IO::File IO::Socket IO::Socket::UNIX
236    IO::Stringy Digest::MD5 Unix::Syslog File::Basename
237    Compress::Zlib MIME::Base64 MIME::QuotedPrint MIME::Words
238    MIME::Head MIME::Body MIME::Entity MIME::Parser MIME::Decoder
239    MIME::Decoder::Base64 MIME::Decoder::Binary MIME::Decoder::QuotedPrint
240    MIME::Decoder::NBit MIME::Decoder::UU MIME::Decoder::Gzip64
241    Net::LibIDN Net::Server Net::Server::PreFork
242  ));
243  # with earlier versions of Perl one may need to add additional modules
244  # to the list, such as: auto::POSIX::setgid auto::POSIX::setuid ...
245  fetch_modules('OPTIONAL BASIC MODULES', 0, qw(
246    PerlIO PerlIO::scalar Unix::Getrusage
247    Carp::Heavy auto::POSIX::setgid auto::POSIX::setuid
248    auto::POSIX::SigAction::new auto::POSIX::SigAction::safe
249    MIME::Decoder::BinHex
250  ));
251  1;
252}
253
2541;
255
256#
257package Amavis::Conf;
258use strict;
259use re 'taint';
260
261# constants;  intentionally leave value -1 unassigned for compatibility
262use constant D_TEMPFAIL => -4;
263use constant D_REJECT   => -3;
264use constant D_BOUNCE   => -2;
265use constant D_DISCARD  =>  0;
266use constant D_PASS     =>  1;
267
268# major contents_category constants, in increasing order of importance
269use constant CC_CATCHALL  => 0;
270use constant CC_CLEAN     => 1;  # tag_level = "CC_CLEAN,1"
271use constant CC_MTA       => 2;  # trouble passing mail back to MTA
272use constant CC_OVERSIZED => 3;
273use constant CC_BADH      => 4;
274use constant CC_SPAMMY    => 5;  # tag2_level  (and: tag3_level = CC_SPAMMY,1)
275use constant CC_SPAM      => 6;  # kill_level
276use constant CC_UNCHECKED => 7;
277use constant CC_BANNED    => 8;
278use constant CC_VIRUS     => 9;
279#
280#  in other words:              major_ccat minor_ccat %subject_tag_maps_by_ccat
281## if    score >= kill level  =>  CC_SPAM    0
282## elsif score >= tag3 level  =>  CC_SPAMMY  1        @spam_subject_tag3_maps
283## elsif score >= tag2 level  =>  CC_SPAMMY  0        @spam_subject_tag2_maps
284## elsif score >= tag  level  =>  CC_CLEAN   1        @spam_subject_tag_maps
285## else                       =>  CC_CLEAN   0
286
287BEGIN {
288  require Exporter;
289  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
290  $VERSION = '2.412';
291  @ISA = qw(Exporter);
292  %EXPORT_TAGS = (
293    'dynamic_confvars' =>  # per- policy bank settings
294    [qw(
295      $child_timeout $smtpd_timeout
296      $policy_bank_name $protocol $haproxy_target_enabled @inet_acl
297      $myhostname $myauthservid $snmp_contact $snmp_location
298      $myprogram_name $syslog_ident $syslog_facility
299      $log_level $log_templ $log_recip_templ $enable_log_capture_dump
300      $forward_method $notify_method $resend_method $report_format
301      $release_method $requeue_method $release_format
302      $attachment_password $attachment_email_name $attachment_outer_name
303      $mail_digest_algorithm $mail_part_digest_algorithm
304      $os_fingerprint_method $os_fingerprint_dst_ip_and_port
305      $originating @smtpd_discard_ehlo_keywords $soft_bounce
306      $propagate_dsn_if_possible $terminate_dsn_on_notify_success
307      $amavis_auth_user $amavis_auth_pass $auth_reauthenticate_forwarded
308      $auth_required_out $auth_required_inp $auth_required_release
309      @auth_mech_avail $tls_security_level_in $tls_security_level_out
310      $local_client_bind_address $smtpd_message_size_limit
311      $localhost_name $smtpd_greeting_banner $smtpd_quit_banner
312      $mailfrom_to_quarantine $warn_offsite $bypass_decode_parts @decoders
313      @av_scanners @av_scanners_backup @spam_scanners
314      $first_infected_stops_scan $virus_scanners_failure_is_fatal
315      $sa_spam_level_char $sa_mail_body_size_limit
316      $penpals_bonus_score $penpals_halflife $bounce_killer_score
317      $reputation_factor
318      $undecipherable_subject_tag $localpart_is_case_sensitive
319      $recipient_delimiter $replace_existing_extension
320      $hdr_encoding $bdy_encoding $hdr_encoding_qb
321      $allow_disclaimers $outbound_disclaimers_only
322      $prepend_header_fields_hdridx
323      $allow_fixing_improper_header
324      $allow_fixing_improper_header_folding $allow_fixing_long_header_lines
325      %allowed_added_header_fields %prefer_our_added_header_fields
326      %allowed_header_tests
327      $X_HEADER_TAG $X_HEADER_LINE
328      $remove_existing_x_scanned_headers $remove_existing_spam_headers
329      %sql_clause $partition_tag
330      %local_delivery_aliases $banned_namepath_re
331      $per_recip_whitelist_sender_lookup_tables
332      $per_recip_blacklist_sender_lookup_tables
333      @anomy_sanitizer_args @altermime_args_defang
334      @altermime_args_disclaimer @disclaimer_options_bysender_maps
335      %signed_header_fields @dkim_signature_options_bysender_maps
336      $enable_dkim_verification $enable_dkim_signing $dkim_signing_service
337      $dkim_minimum_key_bits $enable_ldap $enable_ip_repu $redis_logging_key
338
339      @local_domains_maps
340      @mynetworks_maps @client_ipaddr_policy @ip_repu_ignore_maps
341      @forward_method_maps @newvirus_admin_maps @banned_filename_maps
342      @spam_quarantine_bysender_to_maps
343      @spam_tag_level_maps @spam_tag2_level_maps @spam_tag3_level_maps
344      @spam_kill_level_maps
345      @spam_subject_tag_maps @spam_subject_tag2_maps @spam_subject_tag3_maps
346      @spam_dsn_cutoff_level_maps @spam_dsn_cutoff_level_bysender_maps
347      @spam_crediblefrom_dsn_cutoff_level_maps
348      @spam_crediblefrom_dsn_cutoff_level_bysender_maps
349      @spam_quarantine_cutoff_level_maps @spam_notifyadmin_cutoff_level_maps
350      @whitelist_sender_maps @blacklist_sender_maps @score_sender_maps
351      @author_to_policy_bank_maps @signer_reputation_maps
352      @message_size_limit_maps @debug_sender_maps @debug_recipient_maps
353      @bypass_virus_checks_maps @bypass_spam_checks_maps
354      @bypass_banned_checks_maps @bypass_header_checks_maps
355      @viruses_that_fake_sender_maps
356      @virus_name_to_spam_score_maps @virus_name_to_policy_bank_maps
357      @remove_existing_spam_headers_maps
358      @sa_userconf_maps @sa_username_maps
359
360      %final_destiny_maps_by_ccat %forward_method_maps_by_ccat
361      %lovers_maps_by_ccat %defang_maps_by_ccat %subject_tag_maps_by_ccat
362      %quarantine_method_by_ccat %quarantine_to_maps_by_ccat
363      %notify_admin_templ_by_ccat %notify_recips_templ_by_ccat
364      %notify_sender_templ_by_ccat %notify_autoresp_templ_by_ccat
365      %notify_release_templ_by_ccat %notify_report_templ_by_ccat
366      %warnsender_by_ccat
367      %hdrfrom_notify_admin_by_ccat %mailfrom_notify_admin_by_ccat
368      %hdrfrom_notify_recip_by_ccat %mailfrom_notify_recip_by_ccat
369      %hdrfrom_notify_sender_by_ccat
370      %hdrfrom_notify_release_by_ccat %hdrfrom_notify_report_by_ccat
371      %admin_maps_by_ccat %warnrecip_maps_by_ccat
372      %always_bcc_by_ccat %dsn_bcc_by_ccat
373      %addr_extension_maps_by_ccat %addr_rewrite_maps_by_ccat
374      %smtp_reason_by_ccat
375    )],
376    'confvars' =>  # global settings (not per-policy, not per-recipient)
377    [qw(
378      $myproduct_name $myversion_id $myversion_id_numeric $myversion_date
379      $myversion $instance_name @additional_perl_modules
380      $MYHOME $TEMPBASE $QUARANTINEDIR $quarantine_subdir_levels
381      $daemonize $courierfilter_shutdown $pid_file $lock_file $db_home
382      $enable_db $enable_zmq @zmq_sockets $mail_id_size_bits
383      $daemon_user $daemon_group $daemon_chroot_dir $path
384      $DEBUG %i_know_what_i_am_doing
385      $do_syslog $logfile $allow_preserving_evidence $enable_log_capture
386      $log_short_templ $log_verbose_templ $logline_maxlen
387      $nanny_details_level $max_servers $max_requests
388      $min_servers $min_spare_servers $max_spare_servers
389      %current_policy_bank %policy_bank %interface_policy
390      @listen_sockets $inet_socket_port $inet_socket_bind $listen_queue_size
391      $smtpd_recipient_limit $unix_socketname $unix_socket_mode
392      $smtp_connection_cache_on_demand $smtp_connection_cache_enable
393      %smtp_tls_client_options %smtpd_tls_server_options
394      $smtpd_tls_cert_file $smtpd_tls_key_file
395      $enforce_smtpd_message_size_limit_64kb_min
396      $MAXLEVELS $MAXFILES
397      $MIN_EXPANSION_QUOTA $MIN_EXPANSION_FACTOR
398      $MAX_EXPANSION_QUOTA $MAX_EXPANSION_FACTOR
399      $database_sessions_persistent $lookup_maps_imply_sql_and_ldap
400      @lookup_sql_dsn @storage_sql_dsn @storage_redis_dsn
401      $storage_redis_ttl $redis_logging_queue_size_limit
402      $sql_schema_version $timestamp_fmt_mysql
403      $sql_quarantine_chunksize_max $sql_allow_8bit_address
404      $sql_lookups_no_at_means_domain $ldap_lookups_no_at_means_domain
405      $sql_store_info_for_all_msgs $default_ldap
406      $trim_trailing_space_in_lookup_result_fields
407      @keep_decoded_original_maps @map_full_type_to_short_type_maps
408      %banned_rules $penpals_threshold_low $penpals_threshold_high
409      %dkim_signing_keys_by_domain
410      @dkim_signing_keys_list @dkim_signing_keys_storage
411      $file $altermime $enable_anomy_sanitizer
412    )],
413    'sa' =>  # global SpamAssassin settings
414    [qw(
415      $spamcontrol_obj $sa_num_instances
416      $helpers_home $sa_configpath $sa_siteconfigpath $sa_userprefs_file
417      $sa_local_tests_only $sa_timeout $sa_debug
418      $dspam $sa_spawned
419    )],
420    'platform' => [qw(
421      $profiling $can_truncate $my_pid
422      $AF_INET6 $have_inet4 $have_inet6 $io_socket_module_name
423      &D_TEMPFAIL &D_REJECT &D_BOUNCE &D_DISCARD &D_PASS
424      &CC_CATCHALL &CC_CLEAN &CC_MTA &CC_OVERSIZED &CC_BADH
425      &CC_SPAMMY &CC_SPAM &CC_UNCHECKED &CC_BANNED &CC_VIRUS
426      %ccat_display_names %ccat_display_names_major
427    )],
428    # other variables settable by user in amavisd.conf,
429    # but not directly accessible to the program
430    'hidden_confvars' => [qw(
431      $mydomain
432    )],
433    'legacy_dynamic_confvars' =>
434      # the rest of the program does not use these settings directly and they
435      # should not be visible in, or imported to other modules, but may be
436      # referenced indirectly through *_by_ccat variables for compatibility
437    [qw(
438      $final_virus_destiny $final_banned_destiny $final_unchecked_destiny
439      $final_spam_destiny $final_bad_header_destiny
440      @virus_lovers_maps @spam_lovers_maps @unchecked_lovers_maps
441      @banned_files_lovers_maps @bad_header_lovers_maps
442      $always_bcc $dsn_bcc
443      $mailfrom_notify_sender $mailfrom_notify_recip
444      $mailfrom_notify_admin  $mailfrom_notify_spamadmin
445      $hdrfrom_notify_sender  $hdrfrom_notify_recip
446      $hdrfrom_notify_admin   $hdrfrom_notify_spamadmin
447      $hdrfrom_notify_release $hdrfrom_notify_report
448      $notify_virus_admin_templ  $notify_spam_admin_templ
449      $notify_virus_recips_templ $notify_spam_recips_templ
450      $notify_virus_sender_templ $notify_spam_sender_templ
451      $notify_sender_templ $notify_release_templ
452      $notify_report_templ $notify_autoresp_templ
453      $warnbannedsender $warnbadhsender
454      $defang_virus $defang_banned $defang_spam
455      $defang_bad_header $defang_undecipherable $defang_all
456      $virus_quarantine_method $banned_files_quarantine_method
457      $unchecked_quarantine_method $spam_quarantine_method
458      $bad_header_quarantine_method $clean_quarantine_method
459      $archive_quarantine_method
460      @virus_quarantine_to_maps @banned_quarantine_to_maps
461      @unchecked_quarantine_to_maps @spam_quarantine_to_maps
462      @bad_header_quarantine_to_maps @clean_quarantine_to_maps
463      @archive_quarantine_to_maps
464      @virus_admin_maps @banned_admin_maps
465      @spam_admin_maps @bad_header_admin_maps @spam_modifies_subj_maps
466      @warnvirusrecip_maps @warnbannedrecip_maps @warnbadhrecip_maps
467      @addr_extension_virus_maps  @addr_extension_spam_maps
468      @addr_extension_banned_maps @addr_extension_bad_header_maps
469    )],
470    'legacy_confvars' =>
471      # legacy variables, predeclared for compatibility of amavisd.conf
472      # The rest of the program does not use them directly and they should
473      # not be visible in other modules, but may be referenced through
474      # @*_maps variables for backward compatibility
475    [qw(
476      %local_domains @local_domains_acl $local_domains_re
477      @mynetworks @ip_repu_ignore_networks
478      %bypass_virus_checks @bypass_virus_checks_acl $bypass_virus_checks_re
479      %bypass_spam_checks @bypass_spam_checks_acl $bypass_spam_checks_re
480      %bypass_banned_checks @bypass_banned_checks_acl $bypass_banned_checks_re
481      %bypass_header_checks @bypass_header_checks_acl $bypass_header_checks_re
482      %virus_lovers @virus_lovers_acl $virus_lovers_re
483      %spam_lovers @spam_lovers_acl $spam_lovers_re
484      %banned_files_lovers @banned_files_lovers_acl $banned_files_lovers_re
485      %bad_header_lovers @bad_header_lovers_acl $bad_header_lovers_re
486      %virus_admin %spam_admin
487      $newvirus_admin $virus_admin $banned_admin $bad_header_admin $spam_admin
488      $warnvirusrecip $warnbannedrecip $warnbadhrecip
489      $virus_quarantine_to $banned_quarantine_to $unchecked_quarantine_to
490      $spam_quarantine_to $spam_quarantine_bysender_to
491      $bad_header_quarantine_to $clean_quarantine_to $archive_quarantine_to
492      $keep_decoded_original_re $map_full_type_to_short_type_re
493      $banned_filename_re $viruses_that_fake_sender_re
494      $sa_tag_level_deflt $sa_tag2_level_deflt $sa_tag3_level_deflt
495      $sa_kill_level_deflt
496      $sa_quarantine_cutoff_level @spam_notifyadmin_cutoff_level_maps
497      $sa_dsn_cutoff_level $sa_crediblefrom_dsn_cutoff_level
498      $sa_spam_modifies_subj $sa_spam_subject_tag1 $sa_spam_subject_tag
499      %whitelist_sender @whitelist_sender_acl $whitelist_sender_re
500      %blacklist_sender @blacklist_sender_acl $blacklist_sender_re
501      $addr_extension_virus $addr_extension_spam
502      $addr_extension_banned $addr_extension_bad_header
503      $sql_select_policy $sql_select_white_black_list
504      $gets_addr_in_quoted_form @debug_sender_acl
505      $arc $bzip2 $lzop $lha $unarj $gzip $uncompress $unfreeze
506      $unrar $zoo $pax $cpio $ar $rpm2cpio $cabextract $ripole $tnef
507      $gunzip $bunzip2 $unlzop $unstuff
508      $SYSLOG_LEVEL $syslog_priority $append_header_fields_to_bottom
509      $insert_received_line $notify_xmailer_header $relayhost_is_client
510      $sa_spam_report_header $sa_auto_whitelist
511      $warnvirussender $warnspamsender
512      $enable_global_cache
513      $virus_check_negative_ttl $virus_check_positive_ttl
514      $spam_check_negative_ttl $spam_check_positive_ttl
515    )],
516  );
517  Exporter::export_tags qw(dynamic_confvars confvars sa platform
518                      hidden_confvars legacy_dynamic_confvars legacy_confvars);
519  1;
520} # BEGIN
521
522use POSIX ();
523use Carp ();
524use Errno qw(ENOENT EACCES EBADF);
525
526use vars @EXPORT;
527
528sub c($); sub cr($); sub ca($); sub dkim_key($$$;@);  # prototypes
529use subs qw(c cr ca dkim_key);  # access subroutines to config vars and keys
530BEGIN { push(@EXPORT,qw(c cr ca dkim_key)) }
531
532# access to dynamic config variables, returns a scalar config variable value;
533# one level of indirection is allowed
534#
535sub c($) {
536  my $var = $current_policy_bank{$_[0]};
537  if (!defined $var) {
538    my $name = $_[0];
539    if (!exists $current_policy_bank{$name}) {
540      Carp::croak(sprintf('No entry "%s" in policy bank "%s"',
541                          $name, $current_policy_bank{'policy_bank_name'}));
542    }
543  }
544  my $r = ref $var;
545  !$r ? $var : $r eq 'SCALAR' || $r eq 'REF' ? $$var : $var;
546}
547
548# return a ref to a config variable value, or undef if var is undefined
549#
550sub cr($) {
551  my $var = $current_policy_bank{$_[0]};
552  if (!defined $var) {
553    my $name = $_[0];
554    if (!exists $current_policy_bank{$name}) {
555      Carp::croak(sprintf('No entry "%s" in policy bank "%s"',
556                          $name, $current_policy_bank{'policy_bank_name'}));
557    }
558  }
559  ref $var ? $var : defined $var ? \$var : undef;
560}
561
562# return a ref to a config variable value (which is supposed to be an array),
563# converting undef to an empty array, and a scalar to a one-element array
564# if necessary
565#
566sub ca($) {
567  my $var = $current_policy_bank{$_[0]};
568  if (!defined $var) {
569    my $name = $_[0];
570    if (!exists $current_policy_bank{$name}) {
571      Carp::croak(sprintf('No entry "%s" in policy bank "%s"',
572                          $name, $current_policy_bank{'policy_bank_name'}));
573    }
574  }
575  ref $var ? $var : defined $var ? [$var] : [];
576}
577
578sub deprecate_var($$$) {
579  my($data_type, $var_name, $init_value) = @_;
580  my $code = <<'EOD';
581    tie(%n, '%p', %v)  or die 'Tieing a variable %n failed';
582    package %p;
583    use strict; use Carp ();
584    sub TIESCALAR { my($class,$val) = @_; bless \$val, $class }
585    sub FETCH { my $self = shift; $$self }
586    sub STORE { my($self,$newv) = @_; my $oldv = $$self;
587      if ((defined $oldv || defined $newv) && (%t)) {
588        Carp::carp('Variable %n was retired, changing its value has no effect.'
589                   . " See release notes.\n");
590      }
591      $$self = $newv;
592    }
593    1;
594EOD
595  if ($data_type eq 'bool') {
596    $code =~ s{%t}'($oldv ? 1 : 0) != ($newv ? 1 : 0)'g;
597  } elsif ($data_type eq 'num') {
598    $code =~ s{%t}'!defined $oldv || !defined $newv || $oldv != $newv'g;
599  } elsif ($data_type eq 'str') {
600    $code =~ s{%t}'!defined $oldv || !defined $newv || $oldv ne $newv'g;
601  } else {
602    die "Error deprecating a variable $var_name: bad type $data_type";
603  }
604  $code =~ s/%n/$var_name/g;
605  $code =~ s/%v/\$init_value/g;
606  my $barename = $var_name;
607  $barename =~ s/^[\$\@%&]//; $code =~ s/%p/Amavis::Deprecate::$barename/g;
608  eval $code
609    or do { chomp $@; die "Error deprecating a variable $var_name: $@" };
610}
611
612# Store a private DKIM signing key for a given domain and selector.
613# The argument $key can be a Mail::DKIM::PrivateKey object or a file
614# name containing a key in a PEM format (e.g. as generated by openssl).
615# For compatibility with dkim_milter the signing domain can include a '*'
616# as a wildcard - this is not recommended as this way amavisd could produce
617# signatures which have no corresponding public key published in DNS.
618# The proper way is to have one dkim_key entry for each published DNS RR.
619# Optional arguments can provide additional information about the resource
620# record (RR) of a public key, i.e. its options according to RFC 6376.
621# The subroutine is typically called from a configuration file, once for
622# each signing key available.
623#
624sub dkim_key($$$;@) {
625  my($domain,$selector,$key) = @_;  shift; shift; shift;
626  @_%2 == 0 or die "dkim_key: a list of key/value pairs expected as options\n";
627  my(%key_options) = @_;  # remaining args are options from a public key RR
628  defined $domain && $domain ne ''
629    or die "dkim_key: domain must not be empty: ($domain,$selector,$key)";
630  defined $selector && $selector ne ''
631    or die "dkim_key: selector must not be empty: ($domain,$selector,$key)";
632  my $key_storage_ind;
633  if (ref $key) {  # key already preprocessed and provided as an object
634    push(@dkim_signing_keys_storage, [$key]);
635    $key_storage_ind = $#dkim_signing_keys_storage;
636  } else {  # assume a name of a file containing a private key in PEM format
637    my $fname = $key;
638    my $pem_fh = IO::File->new;  # open a file with a private key
639    $pem_fh->open($fname,'<') or die "Can't open PEM file $fname: $!";
640    my(@stat_list) = stat($pem_fh);  # soft-link friendly
641    @stat_list or warn "Error accessing $fname: $!";
642    my($dev,$inode) = @stat_list;
643    if ($dev && $inode) {
644      for my $j (0..$#dkim_signing_keys_storage) {  # same file reused?
645        my($k,$dv,$in,$fn) = @{$dkim_signing_keys_storage[$j]};
646        if ($dv == $dev && $in == $inode) { $key_storage_ind = $j; last }
647      }
648    }
649    if (!defined($key_storage_ind)) {
650      # read file and store its contents as a new entry
651      $key = ''; Amavis::Util::read_file($pem_fh,\$key);
652      my $key_fit = $key;  # shrink allocated storage size to actual size
653      undef $key;  # release storage
654      push(@dkim_signing_keys_storage, [$key_fit, $dev, $inode, $fname]);
655      $key_storage_ind = $#dkim_signing_keys_storage;
656    }
657    $pem_fh->close or die "Error closing file $fname: $!";
658    $key_options{k} = 'rsa'  if defined $key_options{k};  # force RSA
659  }
660  # possibly the $domain is a regexp
661  $domain   = Amavis::Util::idn_to_ascii($domain)  if !ref $domain;
662  $selector = Amavis::Util::idn_to_ascii($selector);
663  $key_options{domain} = $domain; $key_options{selector} = $selector;
664  $key_options{key_storage_ind} = $key_storage_ind;
665  if (@dkim_signing_keys_list > 100) {
666    # sorry, skip the test to avoid slow O(n^2) searches
667  } else {
668    !grep($_->{domain} eq $domain && $_->{selector} eq $selector,
669          @dkim_signing_keys_list)
670     or die "dkim_key: selector $selector for domain $domain already in use\n";
671  }
672  $key_options{key_ind} = $#dkim_signing_keys_list + 1;
673  push(@dkim_signing_keys_list, \%key_options);  # using a list preserves order
674}
675
676# essential initializations, right at the program start time, may run as root!
677#
678use vars qw($read_config_files_depth @actual_config_files);
679BEGIN {  # init_primary: version, base policy bank
680  $myprogram_name = $0;  # typically 'amavisd'
681  local $1; $myprogram_name =~ s{([^/]*)\z}{$1}s;
682  $myproduct_name = 'amavisd-new';
683  $myversion_id = '2.12.0'; $myversion_date = '20190725';
684
685  $myversion = "$myproduct_name-$myversion_id ($myversion_date)";
686  $myversion_id_numeric =  # x.yyyzzz, allows numerical compare, like Perl $]
687    sprintf('%8.6f', $1 + ($2 + $3/1000)/1000)
688    if $myversion_id =~ /^(\d+)(?:\.(\d*)(?:\.(\d*))?)?(.*)$/s;
689  $sql_schema_version = $myversion_id_numeric;
690
691  $read_config_files_depth = 0;
692  # initialize policy bank hash to contain dynamic config settings
693  for my $tag (@EXPORT_TAGS{'dynamic_confvars', 'legacy_dynamic_confvars'}) {
694    for my $v (@$tag) {
695      local($1,$2);
696      if ($v !~ /^([%\$\@])(.*)\z/s) { die "Unsupported variable type: $v" }
697      else {
698        no strict 'refs'; my($type,$name) = ($1,$2);
699        $current_policy_bank{$name} = $type eq '$' ? \${"Amavis::Conf::$name"}
700                                    : $type eq '@' ? \@{"Amavis::Conf::$name"}
701                                    : $type eq '%' ? \%{"Amavis::Conf::$name"}
702                                    : undef;
703      }
704    }
705  }
706  $current_policy_bank{'policy_bank_name'} = '';  # builtin policy
707  $current_policy_bank{'policy_bank_path'} = '';
708  $policy_bank{''} = { %current_policy_bank };    # copy
709  1;
710} # end BEGIN - init_primary
711
712
713# boot-time initializations of simple global settings, may run as root!
714#
715BEGIN {
716  # serves only as a quick default for other configuration settings
717  $MYHOME = '/var/amavis';
718  $mydomain = '!change-mydomain-variable!.example.com';#intentionally bad deflt
719
720  # Create debugging output - true: log to stderr; false: log to syslog/file
721  $DEBUG = 0;
722
723  # Is Devel::NYTProf profiler loaded?
724  $profiling = 1  if DB->UNIVERSAL::can('enable_profile');
725
726  # In case of trouble, allow preserving temporary files for forensics
727  $allow_preserving_evidence = 1;
728
729  # Cause Net::Server parameters 'background' and 'setsid' to be set,
730  # resulting in the process to detach itself from the terminal
731  $daemonize = 1;
732
733  # Net::Server pre-forking settings - defaults, overruled by amavisd.conf
734  $max_servers  = 2;   # number of pre-forked children
735  $max_requests = 20;  # retire a child after that many accepts, 0=unlimited
736
737  # timeout for our processing:
738  $child_timeout = 8*60; # abort child if it does not complete a task in n sec
739
740  # timeout for waiting on client input:
741  $smtpd_timeout = 8*60; # disconnect session if client is idle for too long;
742  #  $smtpd_timeout should be higher than Postfix's max_idle (default 100s)
743
744  # Assume STDIN is a courierfilter pipe and shutdown when it becomes readable
745  $courierfilter_shutdown = 0;
746
747  # Can file be truncated?
748  # Set to 1 if 'truncate' works (it is XPG4-UNIX standard feature,
749  #                               not required by Posix).
750  # Things will go faster with SMTP-in, otherwise (e.g. with milter)
751  # it makes no difference as file truncation will not be used.
752  $can_truncate = 1;
753
754  # Customizable notification messages, logging
755
756  $syslog_ident = 'amavis';
757  $syslog_facility = 'mail';
758
759  $log_level = 0;
760
761  # should be less than (1023 - prefix), i.e. 980,
762  # to avoid syslog truncating lines; see sub write_log
763  $logline_maxlen = 980;
764
765  $nanny_details_level = 1;  # register_proc verbosity: 0, 1, 2
766
767# $inner_sock_specs in amavis-services should match one of the sockets
768# in the @zmq_sockets list
769# @zmq_sockets = ( "ipc://$MYHOME/amavisd-zmq.sock" );  # after-default
770
771# $enable_zmq = undef;  # load optional module Amavis::ZMQ
772#                       #   (interface to 0MQ or Crossroads I/O)
773# $enable_db = undef;   # load optional modules Amavis::DB & Amavis::DB::SNMP
774# $enable_dkim_signing = undef;
775# $enable_dkim_verification = undef;
776
777  $enable_ip_repu = 1;  # ignored when @storage_redis_dsn is empty
778
779  # a key (string) for a redis list serving as a queue of json events
780  # for logstash / elasticsearch use;  undef or empty or '0' disables
781  # logging of events to redis
782  $redis_logging_key = undef;  # e.g. "amavis-log";
783
784  # a limit on the length of a redis list - new log events will be dropped
785  # while the queue size limit is exceeded; undef or 0 disables logging;
786  # reasonable value: 100000, takes about 250 MB of memory in a redis server
787  # when noone is pulling events from the list
788  $redis_logging_queue_size_limit = undef;
789
790  $reputation_factor = 0.2;  # DKIM reputation: a value between 0 and 1,
791    # controlling the amount of 'bending' of a calculated spam score
792    # towards a fixed score assigned to a signing domain (its 'reputation')
793    # through @signer_reputation_maps;  the formula is:
794    #   adjusted_spam_score = f*reputation + (1-f)*spam_score
795    # which has the same semantics as auto_whitelist_factor in SpamAssassin AWL
796
797  # keep SQL, LDAP and Redis sessions open when idle
798  $database_sessions_persistent = 1;
799
800  $lookup_maps_imply_sql_and_ldap = 1;  # set to 0 to disable
801
802  # Algorithm name for generating a mail header digest and a mail body digest:
803  # either 'MD5' (will use Digest::MD5, fastest and smallest digest), or
804  # anything else accepted by Digest::SHA->new(), e.g. 'SHA-1' or 'SHA-256'.
805  # The generated digest may end up as part of a quarantine file name
806  # or via macro %b in log or notification templates.
807  #
808  $mail_digest_algorithm = 'MD5';  # or 'SHA-1' or 'SHA-256', ...
809
810  # Algorithm name for generating digests of decoded MIME parts of a message.
811  # The value is an algorithm name as accepted by Digest::SHA->new(),
812  # e.g. 'SHA-1' or 'SHA-256' or 'sha256', or a string 'MD5' which implies
813  # the MD5 algorithm as implemented by a module Digest::MD5.
814  # For compatibility with SpamAssassin the chosen algorithm should be SHA1,
815  # otherwise bayes tokens won't match those generated by sa-learn.
816  # Undefined value disables generating digests of MIME parts.
817  #
818  $mail_part_digest_algorithm = 'SHA1';
819
820  # Where to find SQL server(s) and database to support SQL lookups?
821  # A list of triples: (dsn,user,passw). Specify more than one
822  # for multiple (backup) SQL servers.
823  #
824  #@storage_sql_dsn =
825  #@lookup_sql_dsn =
826  #   ( ['DBI:mysql:mail:host1', 'some-username1', 'some-password1'],
827  #     ['DBI:mysql:mail:host2', 'some-username2', 'some-password2'] );
828
829  # Does a database mail address field with no '@' character represent a
830  # local username or a domain name?  By default it implies a username in
831  # SQL and LDAP lookups (but represents a domain in hash and acl lookups),
832  # so domain names in SQL and LDAP should be specified as '@domain'.
833  # Setting these to true will cause 'xxx' to be interpreted as a domain
834  # name, just like in hash or acl lookups.
835  #
836  $sql_lookups_no_at_means_domain  = 0;
837  $ldap_lookups_no_at_means_domain = 0;
838
839  # Maximum size (in bytes) for data written to a field 'quarantine.mail_text'
840  # when quarantining to SQL. Must not exceed size allowed for a data type
841  # on a given SQL server. It also determines a buffer size in amavisd.
842  # Too large a value may exceed process virtual memory limits or just waste
843  # memory, too small a value splits large mail into too many chunks, which
844  # may be less efficient to process.
845  #
846  $sql_quarantine_chunksize_max = 16384;
847  $sql_allow_8bit_address = 0;
848
849  # the length of mail_id in bits, must be an integral multiple of 24
850  # (i.e. divisible by 6 and 8);  the mail_id is represented externally
851  # as a base64url-encoded string of size $mail_id_size_bits / 6
852  #
853  $mail_id_size_bits = 72;  # 24, 48, 72, 96
854
855  # redis data (penpals) expiration - time-to-live in seconds of stored items
856  $storage_redis_ttl = 16*24*60*60;  # 16 days (only affects penpals data)
857
858  $sql_store_info_for_all_msgs = 1;
859  $penpals_bonus_score = undef;  # maximal (positive) score value by which spam
860       # score is lowered when sender is known to have previously received mail
861       # from our local user from this mail system. Zero or undef disables
862       # pen pals lookups in Redis or in SQL tables msgs and msgrcpt, and
863       # is a default.
864  $penpals_halflife = 7*24*60*60; # exponential decay time constant in seconds;
865       # pen pal bonus is halved for each halflife period since the last mail
866       # sent by a local user to a current message's sender
867  $penpals_threshold_low = 1.0;   # SA score below which pen pals lookups are
868       # not performed to save time; undef lets the threshold be ignored;
869  $penpals_threshold_high = undef;
870       # when (SA_score - $penpals_bonus_score > $penpals_threshold_high)
871       # pen pals lookup will not be performed to save time, as it could not
872       # influence blocking of spam even at maximal penpals bonus (age=0);
873       # usual choice for value would be a kill level or other reasonably high
874       # value; undef lets the threshold be ignored and is a default (useful
875       # for testing and statistics gathering);
876
877  $bounce_killer_score = 0;
878
879  #
880  # Receiving mail related
881
882  # $unix_socketname = '/var/amavis/amavisd.sock';  # e.g. milter or release
883  # $inet_socket_port = 10024;      # accept SMTP on this TCP port
884  # $inet_socket_port = [10024,10026,10027];  # ...possibly on more than one
885
886  $AF_INET6 = eval { require Socket;  Socket::AF_INET6()  } ||
887              eval { require Socket6; Socket6::AF_INET6() };
888
889  # prefer using module IO::Socket::IP if available,
890  # otherwise fall back to IO::Socket::INET6 or to IO::Socket::INET
891  #
892  if (eval { require IO::Socket::IP }) {
893    $io_socket_module_name = 'IO::Socket::IP';
894  } elsif (eval { require IO::Socket::INET6 }) {
895    $io_socket_module_name = 'IO::Socket::INET6';
896  } elsif (eval { require IO::Socket::INET }) {
897    $io_socket_module_name = 'IO::Socket::INET';
898  }
899
900  $have_inet4 =  # can we create a PF_INET socket?
901    defined $io_socket_module_name && eval {
902      my $sock =
903        $io_socket_module_name->new(LocalAddr => '0.0.0.0', Proto => 'tcp');
904      $sock->close or die "error closing socket: $!"  if $sock;
905      $sock ? 1 : undef;
906    };
907
908  $have_inet6 =  # can we create a PF_INET6 socket?
909    defined $io_socket_module_name &&
910    $io_socket_module_name ne 'IO::Socket::INET' &&
911    eval {
912      my $sock =
913        $io_socket_module_name->new(LocalAddr => '::', Proto => 'tcp');
914      $sock->close or die "error closing socket: $!"  if $sock;
915      $sock ? 1 : undef;
916    };
917
918# if (!$have_inet6 && $io_socket_module_name ne 'IO::Socket::INET') {
919#   # ok, let's stay on proven grounds, use the IO::Socket::INET anyway
920#   if (eval { require IO::Socket::INET }) {
921#     $io_socket_module_name = 'IO::Socket::INET';
922#   }
923# }
924
925  # bind socket to a loopback interface
926  if (Net::Server->VERSION < 2) {
927    $inet_socket_bind = '127.0.0.1';
928  } else {  # requires Net::Server 2 or a patched 0.99 with IPv6 support)
929    $inet_socket_bind = $have_inet4 && $have_inet6 ? ['127.0.0.1', '[::1]']
930                      : $have_inet6 ? '[::1]' : '127.0.0.1';
931  }
932  @inet_acl   = qw( 127.0.0.1 [::1] );  # allow SMTP access only from localhost
933  @mynetworks = qw( 127.0.0.0/8 [::1] 169.254.0.0/16 [fe80::]/10
934                    10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
935                    [fc00::]/7 );  # consider also RFC 6598: 100.64.0.0/10
936  $originating = 0;  # a boolean, initially reflects @mynetworks match,
937                     # but may be modified later through a policy bank
938
939  $forward_method = $have_inet6 && !$have_inet4 ? 'smtp:[::1]:10025'
940                                                : 'smtp:[127.0.0.1]:10025';
941  $notify_method = $forward_method;
942
943  $resend_method  = undef; # overrides $forward_method on defanging if nonempty
944  $release_method = undef; # overrides $notify_method on releasing
945                           #   from quarantine if nonempty
946  $requeue_method =        # requeuing release from a quarantine
947    $have_inet6 && !$have_inet4 ? 'smtp:[::1]:25' : 'smtp:[127.0.0.1]:25';
948
949  $release_format = 'resend';  # (dsn), (arf), attach,  plain,  resend
950  $report_format  = 'arf';     # (dsn),  arf,  attach,  plain,  resend
951
952  # when $release_format is 'attach', the following control the attachment:
953  $attachment_password = ''; # '': no pwd; undef: PIN; code ref; or static str
954  $attachment_email_name = 'msg-%m.eml';
955  $attachment_outer_name = 'msg-%m.zip';
956
957  $virus_quarantine_method              = 'local:virus-%m';
958  $banned_files_quarantine_method       = 'local:banned-%m';
959  $spam_quarantine_method               = 'local:spam-%m.gz';
960  $bad_header_quarantine_method         = 'local:badh-%m';
961  $unchecked_quarantine_method = undef; # 'local:unchecked-%m';
962  $clean_quarantine_method     = undef; # 'local:clean-%m';
963  $archive_quarantine_method   = undef; # 'local:archive-%m.gz';
964
965  $prepend_header_fields_hdridx      = 0;  # normally 0, use 1 for co-existence
966                                           # with signing DK and DKIM milters
967  $remove_existing_x_scanned_headers = 0;
968  $remove_existing_spam_headers      = 1;
969
970  # fix improper header fields in passed or released mail - this setting
971  # is a pre-condition for $allow_fixing_improper_header_folding and similar
972  # (future) fixups; (desirable, but may break DKIM validation of messages
973  # with illegal header section)
974  $allow_fixing_improper_header = 1;
975
976  # fix improper folded header fields made up entirely of whitespace, by
977  # removing all-whitespace lines ($allow_fixing_improper_header must be true)
978  $allow_fixing_improper_header_folding = 1;
979
980  # truncate header section lines longer than 998 characters as limited
981  # by the RFC 5322 ($allow_fixing_improper_header must be true)
982  $allow_fixing_long_header_lines = 1;
983
984  # encoding (charset in MIME terminology)
985  # to be used in RFC 2047-encoded ...
986  $hdr_encoding = 'UTF-8';       # ... header field bodies
987  $bdy_encoding = 'UTF-8';       # ... notification body text
988
989  # encoding (encoding in MIME terminology)
990  $hdr_encoding_qb = 'Q';        # quoted-printable (default)
991# $hdr_encoding_qb = 'B';        # base64
992
993  $smtpd_recipient_limit = 1100; # max recipients (RCPT TO) - sanity limit
994
995  # $myhostname is used by SMTP server module in the initial SMTP welcome line,
996  # in inserted Received: lines, Message-ID in notifications, log entries, ...
997  $myhostname = (POSIX::uname)[1];  # should be a FQDN !
998
999  $snmp_contact  = '';  # a value of sysContact OID
1000  $snmp_location = '';  # a value of sysLocation OID
1001
1002  $smtpd_greeting_banner = '${helo-name} ${protocol} ${product} service ready';
1003  $smtpd_quit_banner = '${helo-name} ${product} closing transmission channel';
1004  $enforce_smtpd_message_size_limit_64kb_min = 1;
1005
1006  # $localhost_name is the name of THIS host running amavisd
1007  # (often just 'localhost'). It is used in HELO SMTP command
1008  # when reinjecting mail back to MTA via SMTP for final delivery,
1009  # and in inserted Received header field
1010  $localhost_name = $myhostname;
1011
1012  $propagate_dsn_if_possible = 1;  # pass on DSN if MTA announces this
1013            # capability; useful to be turned off globally but enabled in
1014            # MYNETS policy bank to hide internal mail routing from outsiders
1015  $terminate_dsn_on_notify_success = 0;  # when true=>handle DSN NOTIFY=SUCCESS
1016            # locally, do not let NOTIFY=SUCCESS propagate to MTA (but allow
1017            # other DSN options like NOTIFY=NEVER/FAILURE/DELAY, ORCPT, RET,
1018            # and ENVID to propagate if possible)
1019
1020  #@auth_mech_avail = ('PLAIN','LOGIN');   # empty list disables incoming AUTH
1021  #$auth_required_inp = 1;  # incoming SMTP authentication required by amavisd?
1022  #$auth_required_out = 1;  # SMTP authentication required by MTA
1023  $auth_required_release = 1;  # secret_id is required for a quarantine release
1024
1025  $tls_security_level_in  = undef;  # undef, 'may', 'encrypt', ...
1026  $tls_security_level_out = undef;  # undef, 'may', 'encrypt', ...
1027
1028  # Server side certificate and key: $smtpd_tls_cert_file, $smtpd_tls_key_file.
1029  # These two settings are now deprecated, set fields 'SSL_key_file'
1030  # and 'SSL_cert_file' directly in %smtpd_tls_server_options instead.
1031  # For compatibility with 2.10 the values of $smtpd_tls_cert_file
1032  # and $smtpd_tls_key_file are fed into %smtpd_tls_server_options
1033  # if fields 'SSL_key_file' and 'SSL_cert_file' are not provided.
1034  #
1035  # $smtpd_tls_cert_file = undef;   # e.g. "$MYHOME/cert/amavisd-cert.pem"
1036  # $smtpd_tls_key_file  = undef;   # e.g. "$MYHOME/cert/amavisd-key.pem"
1037
1038  # The following options are passed to IO::Socket::SSL::start_SSL() when
1039  # setting up a server side of a TLS session (from MTA). The only options
1040  # passed implicitly are SSL_server, SSL_hostname, and SSL_error_trap.
1041  # See IO::Socket::SSL documentation.
1042  #
1043  %smtpd_tls_server_options = (
1044    SSL_verifycn_scheme => 'smtp',
1045    SSL_session_cache => 2,
1046#   SSL_key_file    => $smtpd_tls_key_file,
1047#   SSL_cert_file   => $smtpd_tls_cert_file,
1048#   SSL_dh_file     => ... ,
1049#   SSL_ca_file     => ... ,
1050#   SSL_version     => '!SSLv2,!SSLv3',
1051#   SSL_cipher_list => 'HIGH:!MD5:!DSS:!aNULL',
1052#   SSL_passwd_cb => sub { 'example' },
1053#   ...
1054  );
1055
1056  # The following options are passed to IO::Socket::SSL::start_SSL() when
1057  # setting up a client side of a TLS session back to MTA. The only options
1058  # passed implicitly are SSL_session_cache and SSL_error_trap.
1059  # See IO::Socket::SSL documentation.
1060  #
1061  %smtp_tls_client_options = (
1062    SSL_verifycn_scheme => 'smtp',
1063#   SSL_version     => '!SSLv2,!SSLv3',
1064#   SSL_cipher_list => 'HIGH:!MD5:!DSS:!aNULL',
1065#   SSL_client_ca_file => ... ,
1066  );
1067
1068  $dkim_minimum_key_bits = 1024;    # min acceptable DKIM key size (in bits)
1069                                    # for whitelisting
1070
1071  # SMTP AUTH username and password for notification submissions
1072  # (and reauthentication of forwarded mail if requested)
1073  #$amavis_auth_user = undef;  # perhaps: 'amavisd'
1074  #$amavis_auth_pass = undef;
1075  #$auth_reauthenticate_forwarded = undef;  # supply our own credentials also
1076                                            # for forwarded (passed) mail
1077  $smtp_connection_cache_on_demand = 1;
1078  $smtp_connection_cache_enable = 1;
1079
1080  # whom quarantined messages appear to be sent from (envelope sender)
1081  # $mailfrom_to_quarantine = undef; # orig. sender if undef, or set explicitly
1082
1083  # where to send quarantined malware - specify undef to disable, or an
1084  # e-mail address containing '@', or just a local part, which will be
1085  # mapped by %local_delivery_aliases into local mailbox name or directory.
1086  # The lookup key is a recipient address
1087  $virus_quarantine_to      = 'virus-quarantine';
1088  $banned_quarantine_to     = 'banned-quarantine';
1089  $unchecked_quarantine_to  = 'unchecked-quarantine';
1090  $spam_quarantine_to       = 'spam-quarantine';
1091  $bad_header_quarantine_to = 'bad-header-quarantine';
1092  $clean_quarantine_to      = 'clean-quarantine';
1093  $archive_quarantine_to    = 'archive-quarantine';
1094
1095  # similar to $spam_quarantine_to, but the lookup key is the sender address:
1096  $spam_quarantine_bysender_to = undef;  # dflt: no by-sender spam quarantine
1097
1098  # quarantine directory or mailbox file or empty
1099  #   (only used if $*_quarantine_to specifies direct local delivery)
1100  $QUARANTINEDIR = undef;  # no quarantine unless overridden by config
1101
1102  $undecipherable_subject_tag = '***UNCHECKED*** ';
1103
1104  # NOTE: all entries can accept mail_body_size_limit and score_factor options
1105  @spam_scanners = (
1106    ['SpamAssassin', 'Amavis::SpamControl::SpamAssassin' ],
1107  # ['SpamdClient',  'Amavis::SpamControl::SpamdClient',
1108  #   mail_body_size_limit => 65000, score_factor => 1.0,
1109  # ],
1110  # ['DSPAM', 'Amavis::SpamControl::ExtProg', $dspam,
1111  #   [ qw(--stdout --classify --deliver=innocent,spam
1112  #        --mode=toe --feature noise
1113  #        --user), $daemon_user ],
1114  #   mail_body_size_limit => 65000, score_factor => 1.0,
1115  # ],
1116  # ['CRM114', 'Amavis::SpamControl::ExtProg', 'crm',
1117  #   [ qw(-u /var/amavis/home/.crm114 mailreaver.crm
1118  #        --dontstore --report_only --stats_only
1119  #        --good_threshold=10 --spam_threshold=-10) ],
1120  #   mail_body_size_limit => 65000, score_factor => -0.20,
1121  #   lock_file => '/var/amavis/crm114.lock',
1122  #   lock_type => 'shared', learner_lock_type => 'exclusive',
1123  # ],
1124  # ['Bogofilter', 'Amavis::SpamControl::ExtProg', 'bogofilter',
1125  #   [ qw(-e -v)],  # -u
1126  #   mail_body_size_limit => 65000, score_factor => 1.0,
1127  # ],
1128  # ['Rspamd', 'Amavis::SpamControl::RspamdClient',
1129  #   score_factor => $sa_tag2_level_deflt / 15.0,
1130  #   mta_name => 'mail.example.com',
1131  # ],
1132  );
1133
1134  $sa_spawned = 0;  # true: run SA in a subprocess;  false: call SA directly
1135
1136  # string to prepend to Subject header field when message qualifies as spam
1137  # $sa_spam_subject_tag1 = undef;  # example: '***Possible Spam*** '
1138  # $sa_spam_subject_tag  = undef;  # example: '***Spam*** '
1139  $sa_spam_level_char = '*'; # character to be used in X-Spam-Level bar;
1140                             # empty or undef disables adding this header field
1141  $sa_num_instances = 1;  # number of SA instances,
1142                          # usually 1, memory-expensive, keep small
1143  $sa_local_tests_only = 0;
1144  $sa_debug = undef;
1145  $sa_timeout = 30;  # no longer used since 2.6.5
1146
1147  $file = 'file';  # path to the file(1) utility for classifying contents
1148  $altermime = 'altermime';  # path to the altermime utility (optional)
1149  @altermime_args_defang     = qw(--verbose --removeall);
1150  @altermime_args_disclaimer = qw(--disclaimer=/etc/altermime-disclaimer.txt);
1151  # @altermime_args_disclaimer =
1152  #  qw(--disclaimer=/etc/_OPTION_.txt --disclaimer-html=/etc/_OPTION_.html);
1153  # @disclaimer_options_bysender_maps = ( 'altermime-disclaimer' );
1154
1155  $MIN_EXPANSION_FACTOR =   5;  # times original mail size
1156  $MAX_EXPANSION_FACTOR = 500;  # times original mail size
1157# $MIN_EXPANSION_QUOTA  = ...   # bytes, undef=not enforced
1158# $MAX_EXPANSION_QUOTA  = ...   # bytes, undef=not enforced
1159
1160  # See amavisd.conf and README.lookups for details.
1161
1162  # What to do with the message (this is independent of quarantining):
1163  #   Reject:  tell MTA to generate a non-delivery notification,  MTA gets 5xx
1164  #   Bounce:  generate a non-delivery notification by ourselves, MTA gets 250
1165  #   Discard: drop the message and pretend it was delivered,     MTA gets 250
1166  #   Pass:    accept/forward a message,                          MTA gets 250
1167  #   TempFail: temporary failure, client should retry,           MTA gets 4xx
1168  #
1169  # COMPATIBILITY NOTE: the separation of *_destiny values into
1170  #   D_BOUNCE, D_REJECT, D_DISCARD and D_PASS made settings $warn*sender only
1171  #   still useful with D_PASS. The combination of D_DISCARD + $warn*sender=1
1172  #   is mapped into D_BOUNCE for compatibility.
1173
1174  # The following symbolic constants can be used in *destiny settings:
1175  #
1176  # D_PASS     mail will pass to recipients, regardless of contents;
1177  #
1178  # D_DISCARD  mail will not be delivered to its recipients, sender will NOT be
1179  #            notified. Effectively we lose mail (but it will be quarantined
1180  #            unless disabled).
1181  #
1182  # D_BOUNCE   mail will not be delivered to its recipients, a non-delivery
1183  #            notification (bounce) will be sent to the sender by amavisd-new
1184  #            (unless suppressed). Bounce (DSN) will not be sent if a virus
1185  #            name matches $viruses_that_fake_sender_maps, or to messages
1186  #            from mailing lists (Precedence: bulk|list|junk), or for spam
1187  #            exceeding spam_dsn_cutoff_level
1188  #
1189  # D_REJECT   mail will not be delivered to its recipients, amavisd will
1190  #            return a 5xx status response. Depending on an MTA/amavisd setup
1191  #            this will result either in a reject status passed back to a
1192  #            connecting SMTP client (in a pre-queue setup: proxy or milter),
1193  #            or an MTA will generate a bounce in a post-queue setup.
1194  #            If not all recipients agree on rejecting a message (like when
1195  #            different recipients have different thresholds on bad mail
1196  #            contents and LMTP is not used) amavisd sends a bounce by itself
1197  #            (same as D_BOUNCE).
1198  #
1199  # D_TEMPFAIL indicates a temporary failure, mail will not be delivered to
1200  #            its recipients, sender should retry the operation later.
1201  #
1202  # Notes:
1203  #   D_REJECT and D_BOUNCE are similar,the difference is in who is responsible
1204  #            for informing the sender about non-delivery, and how informative
1205  #            the notification can be (amavisd-new knows more than MTA);
1206  #   With D_REJECT, MTA may reject original SMTP, or send DSN (delivery status
1207  #            notification, colloquially called 'bounce') - depending on MTA
1208  #            and its interface to a content checker; best suited for sendmail
1209  #            milter or other pre-queue filtering setups
1210  #   With D_BOUNCE, amavisd-new (not MTA) sends DSN (can better explain the
1211  #            reason for mail non-delivery but unable to reject the original
1212  #            SMTP session, and is in position to suppress DSN if considered
1213  #            unsuitable). Best suited for Postfix and other dual-MTA setups.
1214  #            Exceeded spam cutoff limit or faked virus sender implicitly
1215  #            turns D_BOUNCE into a D_DISCARD;
1216
1217  # D_REJECT, D_BOUNCE, D_DISCARD, D_PASS, D_TEMPFAIL
1218  $final_virus_destiny      = D_DISCARD;
1219  $final_banned_destiny     = D_DISCARD;
1220  $final_unchecked_destiny  = D_PASS;
1221  $final_spam_destiny       = D_PASS;
1222  $final_bad_header_destiny = D_PASS;
1223
1224  # If decided to pass viruses (or spam) to certain recipients
1225  # by %final_destiny_maps_by_ccat yielding a D_PASS, or %lovers_maps_by_ccat
1226  # yielding a true, one may set the corresponding %addr_extension_maps_by_ccat
1227  # to some string, and the recipient address will have this string appended
1228  # as an address extension to a local-part (mailbox part) of the address.
1229  # This extension can be used by a final local delivery agent for example
1230  # to place such mail in different folder. Leaving this variable undefined
1231  # or an empty string prevents appending address extension. Recipients
1232  # which do not match @local_domains_maps are not affected (i.e. non-local
1233  # recipients (=outbound mail) do not get address extension appended).
1234  #
1235  # LDAs usually default to stripping away address extension if no special
1236  # handling for it is specified, so having this option enabled normally
1237  # does no harm, provided the $recipients_delimiter character matches
1238  # the setting at the final MTA's local delivery agent (LDA).
1239  #
1240  # $addr_extension_virus  = 'virus';  # for example
1241  # $addr_extension_spam   = 'spam';
1242  # $addr_extension_banned = 'banned';
1243  # $addr_extension_bad_header = 'badh';
1244
1245  # Delimiter between local part of the recipient address and address extension
1246  # (which can optionally be added, see variable %addr_extension_maps_by_ccat.
1247  # E.g. recipient address <user@domain.example> gets
1248  # changed to <user+virus@domain.example>.
1249  #
1250  # Delimiter should match an equivalent (final) MTA delimiter setting.
1251  # (e.g. for Postfix add 'recipient_delimiter = +' to main.cf).
1252  # Setting it to an empty string or to undef disables this feature
1253  # regardless of %addr_extension_maps_by_ccat setting.
1254
1255  # $recipient_delimiter = '+';
1256  $replace_existing_extension = 1;   # true: replace ext; false: append ext
1257
1258  # Affects matching of localpart of e-mail addresses (left of '@')
1259  # in lookups: true = case sensitive, false = case insensitive
1260  $localpart_is_case_sensitive = 0;
1261
1262  # Trim trailing whitespace from SQL fields, LDAP attribute values
1263  # and hash righthand-sides as read by read_hash(); disabled by default;
1264  # turn it on for compatibility with pre-2.4.0 versions.
1265  $trim_trailing_space_in_lookup_result_fields = 0;
1266
1267  # since 2.7.0: deprecated some old variables:
1268  #
1269  deprecate_var('bool', '$insert_received_line',  1);
1270  deprecate_var('bool', '$relayhost_is_client',   undef);
1271  deprecate_var('bool', '$warnvirussender',       undef);
1272  deprecate_var('bool', '$warnspamsender',        undef);
1273  deprecate_var('bool', '$sa_spam_report_header', undef);
1274  deprecate_var('bool', '$sa_spam_modifies_subj', 1);
1275  deprecate_var('bool', '$sa_auto_whitelist',     undef);
1276  deprecate_var('num',  '$sa_timeout',            30);
1277  deprecate_var('str',  '$syslog_priority',       'debug');
1278  deprecate_var('str',  '$SYSLOG_LEVEL',          'mail.debug');
1279  deprecate_var('str',  '$notify_xmailer_header', undef);
1280# deprecate_var('array','@spam_modifies_subj_maps');
1281  1;
1282} # end BEGIN - init_secondary
1283
1284
1285# init structured variables like %sql_clause, $map_full_type_to_short_type_re,
1286# %ccat_display_names, @decoders, build default maps;  may run as root!
1287#
1288BEGIN {
1289  $allowed_added_header_fields{lc($_)} = 1  for qw(
1290    Received DKIM-Signature Authentication-Results VBR-Info
1291    X-Quarantine-ID X-Amavis-Alert X-Amavis-Hold X-Amavis-Modified
1292    X-Amavis-PenPals X-Amavis-OS-Fingerprint X-Amavis-PolicyBank
1293    X-Spam-Status X-Spam-Level X-Spam-Flag X-Spam-Score
1294    X-Spam-Report X-Spam-Checker-Version X-Spam-Tests
1295    X-CRM114-Status X-CRM114-CacheID X-CRM114-Notice X-CRM114-Action
1296    X-DSPAM-Result X-DSPAM-Class X-DSPAM-Signature X-DSPAM-Processed
1297    X-DSPAM-Confidence X-DSPAM-Probability X-DSPAM-User X-DSPAM-Factors
1298    X-Bogosity
1299  );
1300  $allowed_added_header_fields{lc('X-Spam-Report')} = 0;
1301  $allowed_added_header_fields{lc('X-Spam-Checker-Version')} = 0;
1302  # $allowed_added_header_fields{lc(c(lc $X_HEADER_TAG))}=1; #later:read_config
1303
1304  # even though SpamAssassin does provide the following header fields, we
1305  # prefer to provide our own version (per-recipient scores, version hiding);
1306  # our own non-"X-Spam" header fields are always preferred and need not
1307  # be listed here
1308  $prefer_our_added_header_fields{lc($_)} = 1  for qw(
1309    X-Spam-Status X-Spam-Level X-Spam-Flag X-Spam-Score X-Spam-Report
1310    X-Spam-Checker-Version
1311    X-CRM114-Status X-CRM114-CacheID X-DSPAM-Result X-DSPAM-Signature
1312  );
1313
1314  # controls which header section tests are performed in check_header_validity,
1315  # keys correspond to minor contents categories for CC_BADH
1316  $allowed_header_tests{lc($_)} = 1  for qw(
1317              other mime syntax empty long control 8bit utf8 missing multiple);
1318  $allowed_header_tests{'utf8'} = 0;  # turn this test off by default
1319
1320  # RFC 6376 standard set of header fields to be signed:
1321  my(@sign_headers) = qw(From Sender Reply-To Subject Date Message-ID To Cc
1322    In-Reply-To References MIME-Version Content-Type Content-Transfer-Encoding
1323    Content-ID Content-Description Resent-Date Resent-From Resent-Sender
1324    Resent-To Resent-Cc Resent-Message-ID List-Id List-Post List-Owner
1325    List-Subscribe List-Unsubscribe List-Help List-Archive);
1326  # additional header fields considered appropriate, see also RFC 4021
1327  # and IANA registry "Permanent Message Header Field Names";
1328  # see RFC 3834 for Auto-Submitted; RFC 5518 for VBR-Info (Vouch By Reference)
1329  push(@sign_headers, qw(Received Precedence
1330    Original-Message-ID Message-Context PICS-Label Sensitivity Solicitation
1331    Content-Location Content-Features Content-Disposition Content-Language
1332    Content-Alternative Content-Base Content-MD5 Content-Duration Content-Class
1333    Accept-Language Auto-Submitted Archived-At VBR-Info));
1334  # note that we are signing Received despite the advise in RFC 6376;
1335  # some additional nonstandard header fields:
1336  push(@sign_headers, qw(Organization Organisation User-Agent X-Mailer));
1337  $signed_header_fields{lc($_)} = 1  for @sign_headers;
1338  # Excluded:
1339  #   DKIM-Signature DomainKey-Signature Authentication-Results
1340  #   Keywords Comments Errors-To X-Virus-Scanned X-Archived-At X-No-Archive
1341  # Some MTAs are dropping Disposition-Notification-To, exclude:
1342  #   Disposition-Notification-To Disposition-Notification-Options
1343  # Some mail scanners are dropping Return-Receipt-To, exclude it.
1344  # Signing a 'Sender' may not be a good idea because when such mail is sent
1345  # through a mailing list, this header field is usually replaced by a new one,
1346  # invalidating a signature. Long To and Cc address lists are often mangled,
1347  # especially when containing non-encoded display names.
1348  # Off: Sender - conflicts with mailing lists which must replace a Sender
1349  # Off: To, Cc, Resent-To, Resent-Cc - too often get garbled by mailers
1350  $signed_header_fields{lc($_)} = 0  for qw(Sender To Cc Resent-To Resent-Cc);
1351  #
1352  # a value greater than 1 causes signing of one additional null instance of
1353  # a header field, thus prohibiting prepending additional occurrences of such
1354  # header field without breaking a signature
1355  $signed_header_fields{lc($_)} = 2  for qw(From Date Subject Content-Type);
1356
1357  # provide names for content categories - to be used only for logging,
1358  # SNMP counter names, and display purposes
1359  %ccat_display_names = (
1360    CC_CATCHALL,   'CatchAll',   # last resort, should not normally appear
1361    CC_CLEAN,      'Clean',
1362    CC_CLEAN.',1', 'CleanTag',   # tag_level
1363    CC_MTA,        'MtaFailed',  # unable to forward (general)
1364    CC_MTA.',1',   'MtaTempFailed',  # MTA response was 4xx
1365    CC_MTA.',2',   'MtaRejected',    # MTA response was 5xx
1366    CC_OVERSIZED,  'Oversized',
1367    CC_BADH,       'BadHdr',
1368    CC_BADH.',1',  'BadHdrMime',
1369    CC_BADH.',2',  'BadHdr8bit',
1370    CC_BADH.',3',  'BadHdrChar',
1371    CC_BADH.',4',  'BadHdrSpace',
1372    CC_BADH.',5',  'BadHdrLong',
1373    CC_BADH.',6',  'BadHdrSyntax',
1374    CC_BADH.',7',  'BadHdrMissing',
1375    CC_BADH.',8',  'BadHdrDupl',
1376    CC_SPAMMY,     'Spammy',     # tag2_level
1377    CC_SPAMMY.',1','Spammy3',    # tag3_level
1378    CC_SPAM,       'Spam',       # kill_level
1379    CC_UNCHECKED,      'Unchecked',
1380    CC_UNCHECKED.',1', 'UncheckedEncrypted',
1381    CC_UNCHECKED.',2', 'UncheckedOverLimits',
1382    CC_BANNED,     'Banned',
1383    CC_VIRUS,      'Virus',
1384  );
1385
1386  # provide names for content categories - to be used only for logging,
1387  # SNMP counter names, and display purposes, similar to %ccat_display_names
1388  # but only major contents category names are listed
1389  %ccat_display_names_major = (
1390    CC_CATCHALL,   'CatchAll',   # last resort, should not normally appear
1391    CC_CLEAN,      'Clean',
1392    CC_MTA,        'MtaFailed',  # unable to forward
1393    CC_OVERSIZED,  'Oversized',
1394    CC_BADH,       'BadHdr',
1395    CC_SPAMMY,     'Spammy',     # tag2_level
1396    CC_SPAM,       'Spam',       # kill_level
1397    CC_UNCHECKED,  'Unchecked',
1398    CC_BANNED,     'Banned',
1399    CC_VIRUS,      'Virus',
1400  );
1401
1402  # $partition_tag is a user-specified SQL field value in tables maddr, msgs,
1403  # msgrcpt and quarantine, inserted into new records, but can be useful even
1404  # without SQL, accessible through a macro %P and in quarantine templates.
1405  # It is usually an integer, but depending on a schema may be of other data
1406  # type e.g. a string. May be used to speed up purging of old records by using
1407  # partitioned tables (MySQL 5.1+, PostgreSQL 8.1+). A possible usage can
1408  # be a week-of-a-year, or some other slowly changing value, allowing to
1409  # quickly drop old table partitions without wasting time on deleting
1410  # individual records. Mail addresses in table maddr are self-contained
1411  # within a partition tag, which means that the same mail address may
1412  # appear in more than one maddr partition (using different 'id's), and
1413  # that tables msgs and msgrcpt are guaranteed to reference a maddr.id
1414  # within their own partition tag. The $partition_tag may be a scalar
1415  # (an integer or a string), or a reference to a subroutine, which will be
1416  # called with an object of type Amavis::In::Message as argument, and its
1417  # result will be used as a partition tag value. Possible usage:
1418  #
1419  #  $partition_tag =
1420  #    sub { my($msginfo)=@_; iso8601_week($msginfo->rx_time) };
1421  #or:
1422  #  $partition_tag =
1423  #    sub { my($msginfo)=@_; iso8601_yearweek($msginfo->rx_time) };
1424  #
1425  #or based on a day of a week for short-term cycling (Mo=1, Tu=2,... Su=7):
1426  #  $partition_tag =
1427  #    sub { my($msginfo)=@_; iso8601_weekday($msginfo->rx_time) };
1428  #
1429  #  $spam_quarantine_method = 'local:W%P/spam/%m.gz';  # quar dir by week num
1430
1431  # The SQL select clause to fetch per-recipient policy settings.
1432  # The %k will be replaced by a comma-separated list of query addresses
1433  # for a recipient (e.g. a full address, domain only, catchall), %a will be
1434  # replaced by an exact recipient address (same as the first entry in %k,
1435  # suitable for pattern matching), %l by a full unmodified localpart, %u by
1436  # a lowercased username (a localpart without extension), %e by lowercased
1437  # addr extension (which includes a delimiter), and %d for lowercased domain.
1438  # Use ORDER if there is a chance that multiple records will match - the
1439  # first match wins (i.e. the first returned record). If field names are
1440  # not unique (e.g. 'id'), the later field overwrites the earlier in a hash
1441  # returned by lookup, which is why we use 'users.*, policy.*, users.id',
1442  # i.e. the id is repeated at the end.
1443  # This is a legacy variable for upwards compatibility, now only referenced
1444  # by the program through a %sql_clause entry 'sel_policy' - newer config
1445  # files may assign directly to $sql_clause{'sel_policy'} if preferred.
1446  #
1447  $sql_select_policy =
1448    'SELECT users.*, policy.*, users.id'.
1449    ' FROM users LEFT JOIN policy ON users.policy_id=policy.id'.
1450    ' WHERE users.email IN (%k) ORDER BY users.priority DESC';
1451
1452  # Btw, MySQL and PostgreSQL are happy with 'SELECT *, users.id',
1453  # but Oracle wants 'SELECT users.*, policy.*, users.id', which is
1454  # also acceptable to MySQL and PostgreSQL.
1455
1456  # The SQL select clause to check sender in per-recipient whitelist/blacklist.
1457  # The first SELECT argument '?' will be users.id from recipient SQL lookup,
1458  # the %k will be replaced by a comma-separated list of query addresses
1459  # for a sender (e.g. a full address, domain only, catchall), %a will be
1460  # replaced by an exact sender address (same as the first entry in %k,
1461  # suitable for pattern matching), %l by a full unmodified localpart, %u by
1462  # a lowercased username (a localpart without extension), %e by lowercased
1463  # addr extension (which includes a delimiter), and %d for lowercased domain.
1464  # Only the first occurrence of '?' will be replaced by users.id,
1465  # subsequent occurrences of '?' will see empty string as an argument.
1466  # There can be zero or more occurrences of each %k, %a, %l, %u, %e, %d,
1467  # lookup keys will be replicated accordingly.
1468  # This is a separate legacy variable for upwards compatibility, now only
1469  # referenced by the program through %sql_clause entry 'sel_wblist' - newer
1470  # config files may assign directly to $sql_clause{'sel_wblist'} if preferred.
1471  #
1472  $sql_select_white_black_list =
1473    'SELECT wb FROM wblist JOIN mailaddr ON wblist.sid=mailaddr.id'.
1474    ' WHERE wblist.rid=? AND mailaddr.email IN (%k)'.
1475    ' ORDER BY mailaddr.priority DESC';
1476
1477  %sql_clause = (
1478    'sel_policy' => \$sql_select_policy,
1479    'sel_wblist' => \$sql_select_white_black_list,
1480    'sel_adr' =>
1481      'SELECT id FROM maddr WHERE partition_tag=? AND email=?',
1482    'ins_adr' =>
1483      'INSERT INTO maddr (partition_tag, email, domain) VALUES (?,?,?)',
1484    'ins_msg' =>
1485      'INSERT INTO msgs (partition_tag, mail_id, secret_id, am_id,'.
1486      ' time_num, time_iso, sid, policy, client_addr, size, host)'.
1487      ' VALUES (?,?,?,?,?,?,?,?,?,?,?)',
1488    'upd_msg' =>
1489      'UPDATE msgs SET content=?, quar_type=?, quar_loc=?, dsn_sent=?,'.
1490      ' spam_level=?, message_id=?, from_addr=?, subject=?, client_addr=?,'.
1491      ' originating=?'.
1492      ' WHERE partition_tag=? AND mail_id=?',
1493    'ins_rcp' =>
1494      'INSERT INTO msgrcpt (partition_tag, mail_id, rseqnum, rid, is_local,'.
1495      ' content, ds, rs, bl, wl, bspam_level, smtp_resp)'.
1496      ' VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',
1497    'ins_quar' =>
1498      'INSERT INTO quarantine (partition_tag, mail_id, chunk_ind, mail_text)'.
1499      ' VALUES (?,?,?,?)',
1500    'sel_msg' =>  # obtains partition_tag if missing in a release request
1501      'SELECT partition_tag FROM msgs WHERE mail_id=?',
1502    'sel_quar' =>
1503      'SELECT mail_text FROM quarantine'.
1504      ' WHERE partition_tag=? AND mail_id=?'.
1505      ' ORDER BY chunk_ind',
1506    'sel_penpals' =>  # no message-id references list
1507      "SELECT msgs.time_num, msgs.mail_id, subject".
1508      " FROM msgs JOIN msgrcpt USING (partition_tag,mail_id)".
1509      " WHERE sid=? AND rid=? AND msgs.content!='V' AND ds='P'".
1510      " ORDER BY msgs.time_num DESC",  # LIMIT 1
1511    'sel_penpals_msgid' =>  # with a nonempty list of message-id references
1512      "SELECT msgs.time_num, msgs.mail_id, subject, message_id, rid".
1513      " FROM msgs JOIN msgrcpt USING (partition_tag,mail_id)".
1514      " WHERE sid=? AND msgs.content!='V' AND ds='P' AND message_id IN (%m)".
1515        " AND rid!=sid".
1516      " ORDER BY rid=? DESC, msgs.time_num DESC",  # LIMIT 1
1517  );
1518  # NOTE on $sql_clause{'upd_msg'}: MySQL clobbers timestamp on update
1519  # (unless DEFAULT 0 is used) setting it to a current local time and
1520  # losing the cherishly preserved and prepared timestamp of mail reception.
1521  # From the MySQL 4.1 documentation:
1522  # * With neither DEFAULT nor ON UPDATE clauses, it is the same as
1523  #   DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP.
1524  # * suppress the automatic initialization and update behaviors for the first
1525  #   TIMESTAMP column by explicitly assigning it a constant DEFAULT value
1526  #   (for example, DEFAULT 0)
1527  # * The first TIMESTAMP column in table row automatically is updated to
1528  #   the current timestamp when the value of any other column in the row is
1529  #   changed, unless the TIMESTAMP column explicitly is assigned a value
1530  #   other than NULL.
1531
1532  # maps full string as returned by a file(1) utility into a short string;
1533  # the first match wins, more specific entries should precede general ones!
1534  # the result may be a string or a ref to a list of strings;
1535  # see also sub decompose_part()
1536
1537  # prepare an arrayref, later to be converted to an Amavis::Lookup::RE object
1538  $map_full_type_to_short_type_re = [
1539    [qr/^empty\z/                          => 'empty'],
1540    [qr/^directory\z/                      => 'dir'],
1541    [qr/^can't (stat|read)\b/              => 'dat'],  # file(1) diagnostics
1542    [qr/^cannot open\b/                    => 'dat'],  # file(1) diagnostics
1543    [qr/^ERROR:/                           => 'dat'],  # file(1) diagnostics
1544    [qr/can't read magic file|couldn't find any magic files/ => 'dat'],
1545    [qr/^data\z/                           => 'dat'],
1546
1547    [qr/^ISO-8859.*\btext\b/               => 'txt'],
1548    [qr/^Non-ISO.*ASCII\b.*\btext\b/       => 'txt'],
1549    [qr/^Unicode\b.*\btext\b/i             => 'txt'],
1550    [qr/^UTF.* Unicode text\b/i            => 'txt'],
1551    [qr/^'diff' output text\b/             => 'txt'],
1552    [qr/^GNU message catalog\b/            => 'mo'],
1553
1554    [qr/^PGP message [Ss]ignature\b/       => ['pgp','pgp.asc'] ],
1555    [qr/^PGP message.*[Ee]ncrypted\b/      => ['pgp','pgp.enc'] ],
1556    [qr/^PGP message\z/                    => ['pgp','pgp.enc'] ],
1557    [qr/^(?:PGP|GPG) encrypted data\b/     => ['pgp','pgp.enc'] ],
1558    [qr/^PGP public key\b/                 => ['pgp','pgp.asc'] ],
1559    [qr/^PGP armored data( signed)? message\b/ => ['pgp','pgp.asc'] ],
1560    [qr/^PGP armored\b/                    => ['pgp','pgp.asc'] ],
1561    [qr/^PGP\b/                            => 'pgp' ],
1562
1563  ### 'file' is a bit too trigger happy to claim something is 'mail text'
1564  # [qr/^RFC 822 mail text\b/              => 'mail'],
1565    [qr/^(ASCII|smtp|RFC 822) mail text\b/ => 'txt'],
1566
1567    [qr/^JPEG image data\b/                => ['image','jpg'] ],
1568    [qr/^GIF image data\b/                 => ['image','gif'] ],
1569    [qr/^PNG image data\b/                 => ['image','png'] ],
1570    [qr/^TIFF image data\b/                => ['image','tif'] ],
1571    [qr/^PCX\b.*\bimage data\b/            => ['image','pcx'] ],
1572    [qr/^PC bitmap data\b/                 => ['image','bmp'] ],
1573    [qr/^SVG Scalable Vector Graphics image\b/ => ['image','svg'] ],
1574
1575    [qr/^MP2\b/                            => ['audio','mpa','mp2'] ],
1576    [qr/^MP3\b/                            => ['audio','mpa','mp3'] ],
1577    [qr/\bMPEG ADTS, layer III\b/          => ['audio','mpa','mp3'] ],
1578    [qr/^ISO Media, MPEG v4 system, 3GPP\b/=> ['audio','mpa','3gpp'] ],
1579    [qr/^ISO Media, MPEG v4 system\b/      => ['audio','mpa','m4a','m4b'] ],
1580    [qr/^FLAC audio bitstream data\b/      => ['audio','flac'] ],
1581    [qr/^Ogg data, FLAC audio\b/           => ['audio','oga'] ],
1582    [qr/^Ogg data\b/                       => ['audio','ogg'] ],
1583
1584    [qr/^MPEG video stream data\b/         => ['movie','mpv'] ],
1585    [qr/^MPEG system stream data\b/        => ['movie','mpg'] ],
1586    [qr/^MPEG\b/                           => ['movie','mpg'] ],
1587    [qr/^Matroska data\b/                  => ['movie','mkv'] ],
1588    [qr/^Microsoft ASF\b/                  => ['movie','wmv'] ],
1589    [qr/^RIFF\b.*\bAVI\b/                  => ['movie','avi'] ],
1590    [qr/^RIFF\b.*\banimated cursor\b/      => ['movie','ani'] ],
1591    [qr/^RIFF\b.*\bWAVE audio\b/           => ['audio','wav'] ],
1592
1593    [qr/^Macromedia Flash data\b/          => 'swf'],
1594    [qr/^HTML document text\b/             => 'html'],
1595    [qr/^XML document text\b/              => 'xml'],
1596    [qr/^exported SGML document text\b/    => 'sgml'],
1597    [qr/^PostScript document text\b/       => 'ps'],
1598    [qr/^PDF document\b/                   => 'pdf'],
1599    [qr/^Rich Text Format data\b/          => 'rtf'],
1600    [qr/^Microsoft Office Document\b/i     => 'doc'], # OLE2: doc, ppt, xls,...
1601    [qr/^Microsoft Word\b/i                => 'doc'],
1602    [qr/^Microsoft Installer\b/i           => 'doc'], # file(1) may misclassify
1603    [qr/^ms-windows meta(file|font)\b/i    => 'wmf'],
1604    [qr/^LaTeX\b.*\bdocument text\b/       => 'lat'],
1605    [qr/^TeX DVI file\b/                   => 'dvi'],
1606    [qr/\bdocument text\b/                 => 'txt'],
1607    [qr/^compiled Java class data\b/       => 'java'],
1608    [qr/^MS Windows 95 Internet shortcut text\b/ => 'url'],
1609    [qr/^Compressed Google KML Document\b/ => 'kmz'],
1610
1611    [qr/^frozen\b/                         => 'F'],
1612    [qr/^gzip compressed\b/                => 'gz'],
1613    [qr/^bzip compressed\b/                => 'bz'],
1614    [qr/^bzip2 compressed\b/               => 'bz2'],
1615    [qr/^xz compressed\b/                  => 'xz'],
1616    [qr/^lzma compressed\b/                => 'lzma'],
1617    [qr/^lrz compressed\b/                 => 'lrz'],  #***(untested)
1618    [qr/^lzop compressed\b/                => 'lzo'],
1619    [qr/^LZ4 compressed\b/                 => 'lz4'],
1620    [qr/^compress'd/                       => 'Z'],
1621    [qr/^Zip archive\b/i                   => 'zip'],
1622    [qr/^7-zip archive\b/i                 => '7z'],
1623    [qr/^RAR archive\b/i                   => 'rar'],
1624    [qr/^LHa.*\barchive\b/i                => 'lha'],  # (also known as .lzh)
1625    [qr/^ARC archive\b/i                   => 'arc'],
1626    [qr/^ARJ archive\b/i                   => 'arj'],
1627    [qr/^Zoo archive\b/i                   => 'zoo'],
1628    [qr/^(\S+\s+)?tar archive\b/i          => 'tar'],
1629    [qr/^(\S+\s+)?cpio archive\b/i         => 'cpio'],
1630    [qr/^StuffIt Archive\b/i               => 'sit'],
1631    [qr/^Debian binary package\b/i         => 'deb'],  # std. Unix archive (ar)
1632    [qr/^current ar archive\b/i            => 'a'],    # std. Unix archive (ar)
1633    [qr/^RPM\b/                            => 'rpm'],
1634    [qr/^(Transport Neutral Encapsulation Format|TNEF)\b/i => 'tnef'],
1635    [qr/^Microsoft Cabinet (file|archive)\b/i => 'cab'],
1636    [qr/^InstallShield Cabinet file\b/     => 'installshield'],
1637    [qr/^ISO 9660 CD-ROM filesystem\b/i    => 'iso'],
1638
1639    [qr/^(uuencoded|xxencoded)\b/i         => 'uue'],
1640    [qr/^binhex\b/i                        => 'hqx'],
1641    [qr/^(ASCII|text)\b/i                  => 'asc'],
1642    [qr/^Emacs.*byte-compiled Lisp data/i  => 'asc'],  # BinHex with empty line
1643    [qr/\bscript\b.* text executable\b/    => 'txt'],
1644
1645    [qr/^MS Windows\b.*\bDLL\b/                 => ['exe','dll'] ],
1646    [qr/\bexecutable for MS Windows\b.*\bDLL\b/ => ['exe','dll'] ],
1647    [qr/^MS-DOS executable \(built-in\)/        => 'asc'],  # starts with LZ
1648    [qr/^(MS-)?DOS executable\b.*\bDLL\b/       => ['exe','dll'] ],
1649    [qr/^MS Windows\b.*\bexecutable\b/          => ['exe','exe-ms'] ],
1650    [qr/\bexecutable\b.*\bfor MS Windows\b/     => ['exe','exe-ms'] ],
1651    [qr/^COM executable for DOS\b/              => 'asc'],  # misclassified?
1652    [qr/^DOS executable \(COM\)/                => 'asc'],  # misclassified?
1653    [qr/^(MS-)?DOS executable\b(?!.*\(COM\))/   => ['exe','exe-ms'] ],
1654    [qr/^PA-RISC.*\bexecutable\b/          => ['exe','exe-unix'] ],
1655    [qr/^ELF .*\bexecutable\b/             => ['exe','exe-unix'] ],
1656    [qr/^COFF format .*\bexecutable\b/     => ['exe','exe-unix'] ],
1657    [qr/^executable \(RISC System\b/       => ['exe','exe-unix'] ],
1658    [qr/^VMS\b.*\bexecutable\b/            => ['exe','exe-vms'] ],
1659    [qr/\bexecutable\b/i                   => 'exe'],
1660
1661    [qr/\bshared object, /i                => 'so'],
1662    [qr/\brelocatable, /i                  => 'o'],
1663    [qr/\btext\b/i                         => 'asc'],
1664    [qr/^/                                 => 'dat'],  # catchall
1665  ];
1666
1667  # MS Windows PE 32-bit Intel 80386 GUI executable not relocatable
1668  # MS-DOS executable (EXE), OS/2 or MS Windows
1669  # MS-DOS executable PE  for MS Windows (DLL) (GUI) Intel 80386 32-bit
1670  # MS-DOS executable PE  for MS Windows (DLL) (GUI) Alpha 32-bit
1671  # MS-DOS executable, NE for MS Windows 3.x (driver)
1672  # MS-DOS executable (built-in)  (any file starting with LZ!)
1673  # PE executable for MS Windows (DLL) (GUI) Intel 80386 32-bit
1674  # PE executable for MS Windows (GUI) Intel 80386 32-bit
1675  # NE executable for MS Windows 3.x
1676  # PA-RISC1.1 executable dynamically linked
1677  # PA-RISC1.1 shared executable dynamically linked
1678  # ELF 64-bit LSB executable, Alpha (unofficial), version 1 (FreeBSD),
1679  #   for FreeBSD 5.0.1, dynamically linked (uses shared libs), stripped
1680  # ELF 64-bit LSB executable, Alpha (unofficial), version 1 (SYSV),
1681  #   for GNU/Linux 2.2.5, dynamically linked (uses shared libs), stripped
1682  # ELF 64-bit MSB executable, SPARC V9, version 1 (FreeBSD),
1683  #   for FreeBSD 5.0, dynamically linked (uses shared libs), stripped
1684  # ELF 64-bit MSB shared object, SPARC V9, version 1 (FreeBSD), stripped
1685  # ELF 32-bit LSB executable, Intel 80386, version 1, dynamically`
1686  # ELF 32-bit MSB executable, SPARC, version 1, dynamically linke`
1687  # COFF format alpha executable paged stripped - version 3.11-10
1688  # COFF format alpha executable paged dynamically linked stripped`
1689  # COFF format alpha demand paged executable or object module
1690  #   stripped - version 3.11-10
1691  # COFF format alpha paged dynamically linked not stripped shared`
1692  # executable (RISC System/6000 V3.1) or obj module
1693  # VMS VAX executable
1694
1695
1696  # A list of pairs or n-tuples: [short-type, code_ref, optional-args...].
1697  # Maps short types to a decoding routine, the first match wins.
1698  # Arguments beyond the first two can be a program path string (or a listref
1699  # of paths to be searched) or a reference to a variable containing such
1700  # path - which allows for lazy evaluation, making possible to assign values
1701  # to legacy configuration variables even after the assignment to @decoders.
1702  #
1703  @decoders = (
1704    ['mail', \&Amavis::Unpackers::do_mime_decode],
1705#   [[qw(asc uue hqx ync)], \&Amavis::Unpackers::do_ascii],  # not safe
1706    ['F',    \&Amavis::Unpackers::do_uncompress, \$unfreeze],
1707             # ['unfreeze', 'freeze -d', 'melt', 'fcat'] ],
1708    ['Z',    \&Amavis::Unpackers::do_uncompress, \$uncompress],
1709             # ['uncompress', 'gzip -d', 'zcat'] ],
1710    ['gz',   \&Amavis::Unpackers::do_uncompress, \$gunzip],
1711    ['gz',   \&Amavis::Unpackers::do_gunzip],
1712    ['bz2',  \&Amavis::Unpackers::do_uncompress, \$bunzip2],
1713    ['xz',   \&Amavis::Unpackers::do_uncompress,
1714             ['xzdec', 'xz -dc', 'unxz -c', 'xzcat'] ],
1715    ['lzma', \&Amavis::Unpackers::do_uncompress,
1716             ['lzmadec', 'xz -dc --format=lzma',
1717              'lzma -dc', 'unlzma -c', 'lzcat', 'lzmadec'] ],
1718    ['lrz',  \&Amavis::Unpackers::do_uncompress,
1719             ['lrzip -q -k -d -o -', 'lrzcat -q -k'] ],
1720    ['lzo',  \&Amavis::Unpackers::do_uncompress, \$unlzop],
1721    ['lz4',  \&Amavis::Unpackers::do_uncompress, ['lz4c -d'] ],
1722    ['rpm',  \&Amavis::Unpackers::do_uncompress, \$rpm2cpio],
1723             # ['rpm2cpio.pl', 'rpm2cpio'] ],
1724    [['cpio','tar'], \&Amavis::Unpackers::do_pax_cpio, \$pax],
1725             # ['/usr/local/heirloom/usr/5bin/pax', 'pax', 'gcpio', 'cpio'] ],
1726#   ['tar',  \&Amavis::Unpackers::do_tar],  # no longer supported
1727    ['deb',  \&Amavis::Unpackers::do_ar, \$ar],
1728#   ['a',    \&Amavis::Unpackers::do_ar, \$ar], #unpacking .a seems an overkill
1729    ['rar',  \&Amavis::Unpackers::do_unrar, \$unrar],  # ['unrar', 'rar']
1730    ['arj',  \&Amavis::Unpackers::do_unarj, \$unarj],  # ['unarj', 'arj']
1731    ['arc',  \&Amavis::Unpackers::do_arc,   \$arc],    # ['nomarch', 'arc']
1732    ['zoo',  \&Amavis::Unpackers::do_zoo,   \$zoo],    # ['zoo', 'unzoo']
1733    ['doc',  \&Amavis::Unpackers::do_ole,   \$ripole],
1734    ['cab',  \&Amavis::Unpackers::do_cabextract, \$cabextract],
1735    ['tnef', \&Amavis::Unpackers::do_tnef_ext, \$tnef],
1736    ['tnef', \&Amavis::Unpackers::do_tnef],
1737#   ['lha',  \&Amavis::Unpackers::do_lha,   \$lha],  # not safe, use 7z instead
1738#   ['sit',  \&Amavis::Unpackers::do_unstuff, \$unstuff],  # not safe
1739    [['zip','kmz'], \&Amavis::Unpackers::do_7zip,  ['7za', '7z'] ],
1740    [['zip','kmz'], \&Amavis::Unpackers::do_unzip],
1741    ['7z',   \&Amavis::Unpackers::do_7zip,  ['7zr', '7za', '7z'] ],
1742    [[qw(gz bz2 Z tar)],
1743             \&Amavis::Unpackers::do_7zip,  ['7za', '7z'] ],
1744    [[qw(xz lzma jar cpio arj rar swf lha iso cab deb rpm)],
1745             \&Amavis::Unpackers::do_7zip,  '7z' ],
1746    ['exe',  \&Amavis::Unpackers::do_executable, \$unrar, \$lha, \$unarj],
1747  );
1748
1749  # build_default_maps
1750
1751  @local_domains_maps = (
1752    \%local_domains, \@local_domains_acl, \$local_domains_re);
1753  @mynetworks_maps = (\@mynetworks);
1754  @client_ipaddr_policy = map(($_,'MYNETS'), @mynetworks_maps);
1755  @ip_repu_ignore_maps = (\@ip_repu_ignore_networks);
1756
1757  @bypass_virus_checks_maps = (
1758    \%bypass_virus_checks, \@bypass_virus_checks_acl, \$bypass_virus_checks_re);
1759  @bypass_spam_checks_maps = (
1760    \%bypass_spam_checks, \@bypass_spam_checks_acl, \$bypass_spam_checks_re);
1761  @bypass_banned_checks_maps = (
1762    \%bypass_banned_checks, \@bypass_banned_checks_acl, \$bypass_banned_checks_re);
1763  @bypass_header_checks_maps = (
1764    \%bypass_header_checks, \@bypass_header_checks_acl, \$bypass_header_checks_re);
1765  @virus_lovers_maps = (
1766    \%virus_lovers, \@virus_lovers_acl, \$virus_lovers_re);
1767  @spam_lovers_maps = (
1768    \%spam_lovers, \@spam_lovers_acl, \$spam_lovers_re);
1769  @banned_files_lovers_maps = (
1770    \%banned_files_lovers, \@banned_files_lovers_acl, \$banned_files_lovers_re);
1771  @bad_header_lovers_maps = (
1772    \%bad_header_lovers, \@bad_header_lovers_acl, \$bad_header_lovers_re);
1773# @unchecked_lovers_maps = ();  # empty, new setting, no need for backw compat.
1774  @warnvirusrecip_maps  = (\$warnvirusrecip);
1775  @warnbannedrecip_maps = (\$warnbannedrecip);
1776  @warnbadhrecip_maps   = (\$warnbadhrecip);
1777  @newvirus_admin_maps  = (\$newvirus_admin);
1778  @virus_admin_maps     = (\%virus_admin, \$virus_admin);
1779  @banned_admin_maps    = (\$banned_admin, \%virus_admin, \$virus_admin);
1780  @bad_header_admin_maps= (\$bad_header_admin);
1781  @spam_admin_maps      = (\%spam_admin, \$spam_admin);
1782  @virus_quarantine_to_maps = (\$virus_quarantine_to);
1783  @banned_quarantine_to_maps = (\$banned_quarantine_to);
1784  @unchecked_quarantine_to_maps = (\$unchecked_quarantine_to);
1785  @spam_quarantine_to_maps = (\$spam_quarantine_to);
1786  @spam_quarantine_bysender_to_maps = (\$spam_quarantine_bysender_to);
1787  @bad_header_quarantine_to_maps = (\$bad_header_quarantine_to);
1788  @clean_quarantine_to_maps = (\$clean_quarantine_to);
1789  @archive_quarantine_to_maps = (\$archive_quarantine_to);
1790  @keep_decoded_original_maps = (\$keep_decoded_original_re);
1791  @map_full_type_to_short_type_maps = (\$map_full_type_to_short_type_re);
1792# @banned_filename_maps = ( {'.' => [$banned_filename_re]} );
1793# @banned_filename_maps = ( {'.' => 'DEFAULT'} );#names mapped by %banned_rules
1794  @banned_filename_maps = ( 'DEFAULT' );  # same as above, but shorter
1795  @viruses_that_fake_sender_maps = (\$viruses_that_fake_sender_re, 1);
1796  @spam_tag_level_maps  = (\$sa_tag_level_deflt);     # CC_CLEAN,1
1797  @spam_tag2_level_maps = (\$sa_tag2_level_deflt);    # CC_SPAMMY
1798  @spam_tag3_level_maps = (\$sa_tag3_level_deflt);    # CC_SPAMMY,1
1799  @spam_kill_level_maps = (\$sa_kill_level_deflt);    # CC_SPAM
1800  @spam_dsn_cutoff_level_maps = (\$sa_dsn_cutoff_level);
1801  @spam_dsn_cutoff_level_bysender_maps = (\$sa_dsn_cutoff_level);
1802  @spam_crediblefrom_dsn_cutoff_level_maps =
1803    (\$sa_crediblefrom_dsn_cutoff_level);
1804  @spam_crediblefrom_dsn_cutoff_level_bysender_maps =
1805    (\$sa_crediblefrom_dsn_cutoff_level);
1806  @spam_quarantine_cutoff_level_maps = (\$sa_quarantine_cutoff_level);
1807  @spam_subject_tag_maps  = (\$sa_spam_subject_tag1); # note: inconsistent name
1808  @spam_subject_tag2_maps = (\$sa_spam_subject_tag);  # note: inconsistent name
1809# @spam_subject_tag3_maps = ();    # new variable, no backward compatib. needed
1810  @whitelist_sender_maps = (
1811    \%whitelist_sender, \@whitelist_sender_acl, \$whitelist_sender_re);
1812  @blacklist_sender_maps = (
1813    \%blacklist_sender, \@blacklist_sender_acl, \$blacklist_sender_re);
1814  @addr_extension_virus_maps  = (\$addr_extension_virus);
1815  @addr_extension_spam_maps   = (\$addr_extension_spam);
1816  @addr_extension_banned_maps = (\$addr_extension_banned);
1817  @addr_extension_bad_header_maps = (\$addr_extension_bad_header);
1818  @debug_sender_maps = (\@debug_sender_acl);
1819# @debug_recipient_maps = ();
1820  @remove_existing_spam_headers_maps = (\$remove_existing_spam_headers);
1821
1822  # new variables, no backward compatibility needed, empty by default:
1823  # @score_sender_maps, @author_to_policy_bank_maps, @signer_reputation_maps,
1824  # @message_size_limit_maps
1825
1826  # build backward-compatible settings hashes
1827  #
1828  %final_destiny_maps_by_ccat = (
1829    # value is normally a list of by-recipient lookup tables, but for compa-
1830    # tibility with old %final_destiny_by_ccat a value may also be a scalar
1831    CC_VIRUS,       sub { c('final_virus_destiny') },
1832    CC_BANNED,      sub { c('final_banned_destiny') },
1833    CC_UNCHECKED,   sub { c('final_unchecked_destiny') },
1834    CC_SPAM,        sub { c('final_spam_destiny') },
1835    CC_BADH,        sub { c('final_bad_header_destiny') },
1836    CC_MTA.',1',    D_TEMPFAIL,  # MTA response was 4xx
1837    CC_MTA.',2',    D_REJECT,    # MTA response was 5xx
1838    CC_MTA,         D_TEMPFAIL,
1839    CC_OVERSIZED,   D_BOUNCE,
1840    CC_CATCHALL,    D_PASS,
1841  );
1842  %forward_method_maps_by_ccat = (
1843    CC_CATCHALL,    sub { ca('forward_method_maps') },
1844  );
1845  %smtp_reason_by_ccat = (
1846    # currently only used for blocked messages only, status 5xx
1847    # a multiline message will produce a valid multiline SMTP response
1848    CC_VIRUS,       'id=%n - INFECTED: %V',
1849    CC_BANNED,      'id=%n - BANNED: %F',
1850    CC_UNCHECKED.',1', 'id=%n - UNCHECKED: encrypted',
1851    CC_UNCHECKED.',2', 'id=%n - UNCHECKED: over limits',
1852    CC_UNCHECKED,      'id=%n - UNCHECKED',
1853    CC_SPAM,        'id=%n - spam',
1854    CC_SPAMMY.',1', 'id=%n - spammy (tag3)',
1855    CC_SPAMMY,      'id=%n - spammy',
1856    CC_BADH.',1',   'id=%n - BAD HEADER: MIME error',
1857    CC_BADH.',2',   'id=%n - BAD HEADER: nonencoded 8-bit character',
1858    CC_BADH.',3',   'id=%n - BAD HEADER: contains invalid control character',
1859    CC_BADH.',4',   'id=%n - BAD HEADER: line made up entirely of whitespace',
1860    CC_BADH.',5',   'id=%n - BAD HEADER: line longer than RFC 5322 limit',
1861    CC_BADH.',6',   'id=%n - BAD HEADER: syntax error',
1862    CC_BADH.',7',   'id=%n - BAD HEADER: missing required header field',
1863    CC_BADH.',8',   'id=%n - BAD HEADER: duplicate header field',
1864    CC_BADH,        'id=%n - BAD HEADER',
1865    CC_OVERSIZED,   'id=%n - Message size exceeds recipient\'s size limit',
1866    CC_MTA.',1',    'id=%n - Temporary MTA failure on relaying',
1867    CC_MTA.',2',    'id=%n - Rejected by next-hop MTA on relaying',
1868    CC_MTA,         'id=%n - Unable to relay message back to MTA',
1869    CC_CLEAN,       'id=%n - CLEAN',
1870    CC_CATCHALL,    'id=%n - OTHER',  # should not happen
1871  );
1872  %lovers_maps_by_ccat = (
1873    CC_VIRUS,       sub { ca('virus_lovers_maps') },
1874    CC_BANNED,      sub { ca('banned_files_lovers_maps') },
1875    CC_UNCHECKED,   sub { ca('unchecked_lovers_maps') },
1876    CC_SPAM,        sub { ca('spam_lovers_maps') },
1877    CC_SPAMMY,      sub { ca('spam_lovers_maps') },
1878    CC_BADH,        sub { ca('bad_header_lovers_maps') },
1879  );
1880  %defang_maps_by_ccat = (
1881    # compatible with legacy %defang_by_ccat: value may be a scalar
1882    CC_VIRUS,       sub { c('defang_virus') },
1883    CC_BANNED,      sub { c('defang_banned') },
1884    CC_UNCHECKED,   sub { c('defang_undecipherable') },
1885    CC_SPAM,        sub { c('defang_spam') },
1886    CC_SPAMMY,      sub { c('defang_spam') },
1887  # CC_BADH.',3',   1,  # NUL or CR character in header section
1888  # CC_BADH.',5',   1,  # header line longer than 998 characters
1889  # CC_BADH.',6',   1,  # header field syntax error
1890    CC_BADH,        sub { c('defang_bad_header') },
1891  );
1892  %subject_tag_maps_by_ccat = (
1893    CC_VIRUS,       [ '***INFECTED*** ' ],
1894    CC_BANNED,      undef,
1895    CC_UNCHECKED,   sub { [ c('undecipherable_subject_tag') ] }, # not by-recip
1896    CC_SPAM,        undef,
1897    CC_SPAMMY.',1', sub { ca('spam_subject_tag3_maps') },
1898    CC_SPAMMY,      sub { ca('spam_subject_tag2_maps') },
1899    CC_CLEAN.',1',  sub { ca('spam_subject_tag_maps') },
1900  );
1901  %quarantine_method_by_ccat = (
1902    CC_VIRUS,       sub { c('virus_quarantine_method') },
1903    CC_BANNED,      sub { c('banned_files_quarantine_method') },
1904    CC_UNCHECKED,   sub { c('unchecked_quarantine_method') },
1905    CC_SPAM,        sub { c('spam_quarantine_method') },
1906    CC_BADH,        sub { c('bad_header_quarantine_method') },
1907    CC_CLEAN,       sub { c('clean_quarantine_method') },
1908  );
1909  %quarantine_to_maps_by_ccat = (
1910    CC_VIRUS,       sub { ca('virus_quarantine_to_maps') },
1911    CC_BANNED,      sub { ca('banned_quarantine_to_maps') },
1912    CC_UNCHECKED,   sub { ca('unchecked_quarantine_to_maps') },
1913    CC_SPAM,        sub { ca('spam_quarantine_to_maps') },
1914    CC_BADH,        sub { ca('bad_header_quarantine_to_maps') },
1915    CC_CLEAN,       sub { ca('clean_quarantine_to_maps') },
1916  );
1917  %admin_maps_by_ccat = (
1918    CC_VIRUS,       sub { ca('virus_admin_maps') },
1919    CC_BANNED,      sub { ca('banned_admin_maps') },
1920    CC_UNCHECKED,   sub { ca('virus_admin_maps') },
1921    CC_SPAM,        sub { ca('spam_admin_maps') },
1922    CC_BADH,        sub { ca('bad_header_admin_maps') },
1923  );
1924  %always_bcc_by_ccat = (
1925    CC_CATCHALL,    sub { c('always_bcc') },
1926  );
1927  %dsn_bcc_by_ccat = (
1928    CC_CATCHALL,    sub { c('dsn_bcc') },
1929  );
1930  %mailfrom_notify_admin_by_ccat = (
1931    CC_SPAM,        sub { c('mailfrom_notify_spamadmin') },
1932    CC_CATCHALL,    sub { c('mailfrom_notify_admin') },
1933  );
1934  %hdrfrom_notify_admin_by_ccat = (
1935    CC_SPAM,        sub { c('hdrfrom_notify_spamadmin') },
1936    CC_CATCHALL,    sub { c('hdrfrom_notify_admin') },
1937  );
1938  %mailfrom_notify_recip_by_ccat = (
1939    CC_CATCHALL,    sub { c('mailfrom_notify_recip') },
1940  );
1941  %hdrfrom_notify_recip_by_ccat = (
1942    CC_CATCHALL,    sub { c('hdrfrom_notify_recip') },
1943  );
1944  %hdrfrom_notify_sender_by_ccat = (
1945    CC_CATCHALL,    sub { c('hdrfrom_notify_sender') },
1946  );
1947  %hdrfrom_notify_release_by_ccat = (
1948    CC_CATCHALL,    sub { c('hdrfrom_notify_release') },
1949  );
1950  %hdrfrom_notify_report_by_ccat = (
1951    CC_CATCHALL,    sub { c('hdrfrom_notify_report') },
1952  );
1953  %notify_admin_templ_by_ccat = (
1954    CC_SPAM,        sub { cr('notify_spam_admin_templ') },
1955    CC_CATCHALL,    sub { cr('notify_virus_admin_templ') },
1956  );
1957  %notify_recips_templ_by_ccat = (
1958    CC_SPAM,        sub { cr('notify_spam_recips_templ') },  #usually empty
1959    CC_CATCHALL,    sub { cr('notify_virus_recips_templ') },
1960  );
1961  %notify_sender_templ_by_ccat = (  # bounce templates
1962    CC_VIRUS,       sub { cr('notify_virus_sender_templ') },
1963    CC_BANNED,      sub { cr('notify_virus_sender_templ') }, #historical reason
1964    CC_SPAM,        sub { cr('notify_spam_sender_templ') },
1965    CC_CATCHALL,    sub { cr('notify_sender_templ') },
1966  );
1967  %notify_release_templ_by_ccat = (
1968    CC_CATCHALL,    sub { cr('notify_release_templ') },
1969  );
1970  %notify_report_templ_by_ccat = (
1971    CC_CATCHALL,    sub { cr('notify_report_templ') },
1972  );
1973  %notify_autoresp_templ_by_ccat = (
1974    CC_CATCHALL,    sub { cr('notify_autoresp_templ') },
1975  );
1976  %warnsender_by_ccat = (  # deprecated use, except perhaps for CC_BADH
1977    CC_VIRUS,       undef,
1978    CC_BANNED,      sub { c('warnbannedsender') },
1979    CC_SPAM,        undef,
1980    CC_BADH,        sub { c('warnbadhsender') },
1981  );
1982  %warnrecip_maps_by_ccat = (
1983    CC_VIRUS,       sub { ca('warnvirusrecip_maps') },
1984    CC_BANNED,      sub { ca('warnbannedrecip_maps') },
1985    CC_SPAM,        undef,
1986    CC_BADH,        sub { ca('warnbadhrecip_maps') },
1987  );
1988  %addr_extension_maps_by_ccat = (
1989    CC_VIRUS,       sub { ca('addr_extension_virus_maps') },
1990    CC_BANNED,      sub { ca('addr_extension_banned_maps') },
1991    CC_SPAM,        sub { ca('addr_extension_spam_maps') },
1992    CC_SPAMMY,      sub { ca('addr_extension_spam_maps') },
1993    CC_BADH,        sub { ca('addr_extension_bad_header_maps') },
1994  # CC_OVERSIZED,   'oversized';
1995  );
1996  %addr_rewrite_maps_by_ccat = ( );
1997  1;
1998} # end BEGIN - init_tertiary
1999
2000
2001# prototypes
2002sub Amavis::Unpackers::do_mime_decode($$);
2003sub Amavis::Unpackers::do_ascii($$);
2004sub Amavis::Unpackers::do_uncompress($$$);
2005sub Amavis::Unpackers::do_gunzip($$);
2006sub Amavis::Unpackers::do_pax_cpio($$$);
2007#sub Amavis::Unpackers::do_tar($$);  # no longer supported
2008sub Amavis::Unpackers::do_ar($$$);
2009sub Amavis::Unpackers::do_unzip($$;$$);
2010sub Amavis::Unpackers::do_7zip($$$;$);
2011sub Amavis::Unpackers::do_unrar($$$;$);
2012sub Amavis::Unpackers::do_unarj($$$;$);
2013sub Amavis::Unpackers::do_arc($$$);
2014sub Amavis::Unpackers::do_zoo($$$);
2015sub Amavis::Unpackers::do_lha($$$;$);
2016sub Amavis::Unpackers::do_ole($$$);
2017sub Amavis::Unpackers::do_cabextract($$$);
2018sub Amavis::Unpackers::do_tnef($$);
2019sub Amavis::Unpackers::do_tnef_ext($$$);
2020sub Amavis::Unpackers::do_unstuff($$$);
2021sub Amavis::Unpackers::do_executable($$@);
2022
2023no warnings 'once';
2024# Define alias names or shortcuts in this module to make it simpler
2025# to call these routines from amavisd.conf
2026*read_l10n_templates = \&Amavis::Util::read_l10n_templates;
2027*read_text       = \&Amavis::Util::read_text;
2028*read_hash       = \&Amavis::Util::read_hash;
2029*read_array      = \&Amavis::Util::read_array;
2030*read_cidr       = \&Amavis::Util::read_cidr;
2031*idn_to_ascii    = \&Amavis::Util::idn_to_ascii;  # RFC 3490: ToASCII
2032*idn_to_utf8     = \&Amavis::Util::idn_to_utf8;   # RFC 3490: ToUnicode
2033*mail_idn_to_ascii = \&Amavis::Util::mail_addr_idn_to_ascii;
2034*dump_hash       = \&Amavis::Util::dump_hash;
2035*dump_array      = \&Amavis::Util::dump_array;
2036*ask_daemon      = \&Amavis::AV::ask_daemon;
2037*ask_clamav      = \&Amavis::AV::ask_clamav;  # deprecated, use ask_daemon
2038*do_mime_decode  = \&Amavis::Unpackers::do_mime_decode;
2039*do_ascii        = \&Amavis::Unpackers::do_ascii;
2040*do_uncompress   = \&Amavis::Unpackers::do_uncompress;
2041*do_gunzip       = \&Amavis::Unpackers::do_gunzip;
2042*do_pax_cpio     = \&Amavis::Unpackers::do_pax_cpio;
2043*do_tar          = \&Amavis::Unpackers::do_tar;  # no longer supported
2044*do_ar           = \&Amavis::Unpackers::do_ar;
2045*do_unzip        = \&Amavis::Unpackers::do_unzip;
2046*do_unrar        = \&Amavis::Unpackers::do_unrar;
2047*do_7zip         = \&Amavis::Unpackers::do_7zip;
2048*do_unarj        = \&Amavis::Unpackers::do_unarj;
2049*do_arc          = \&Amavis::Unpackers::do_arc;
2050*do_zoo          = \&Amavis::Unpackers::do_zoo;
2051*do_lha          = \&Amavis::Unpackers::do_lha;
2052*do_ole          = \&Amavis::Unpackers::do_ole;
2053*do_cabextract   = \&Amavis::Unpackers::do_cabextract;
2054*do_tnef_ext     = \&Amavis::Unpackers::do_tnef_ext;
2055*do_tnef         = \&Amavis::Unpackers::do_tnef;
2056*do_unstuff      = \&Amavis::Unpackers::do_unstuff;
2057*do_executable   = \&Amavis::Unpackers::do_executable;
2058
2059*iso8601_week          = \&Amavis::rfc2821_2822_Tools::iso8601_week;
2060*iso8601_yearweek      = \&Amavis::rfc2821_2822_Tools::iso8601_yearweek;
2061*iso8601_year_and_week = \&Amavis::rfc2821_2822_Tools::iso8601_year_and_week;
2062*iso8601_weekday       = \&Amavis::rfc2821_2822_Tools::iso8601_weekday;
2063*iso8601_timestamp     = \&Amavis::rfc2821_2822_Tools::iso8601_timestamp;
2064*iso8601_utc_timestamp = \&Amavis::rfc2821_2822_Tools::iso8601_utc_timestamp;
2065
2066# a shorthand for creating a regexp-based lookup table
2067sub new_RE    { Amavis::Lookup::RE->new(@_) }
2068
2069# shorthand: construct a query object for a DNSxL query on an IP address
2070sub q_dns_a   { Amavis::Lookup::DNSxL->new(@_) }  # dns zone, expect, resolver
2071
2072# shorthand: construct a query object for an SQL field
2073sub q_sql_s   { Amavis::Lookup::SQLfield->new(undef, $_[0], 'S-') }  # string
2074sub q_sql_n   { Amavis::Lookup::SQLfield->new(undef, $_[0], 'N-') }  # numeric
2075sub q_sql_b   { Amavis::Lookup::SQLfield->new(undef, $_[0], 'B-') }  # boolean
2076
2077# shorthand: construct a query object for an LDAP attribute
2078sub q_ldap_s  { Amavis::Lookup::LDAPattr->new(undef, $_[0], 'S-') }  # string
2079sub q_ldap_n  { Amavis::Lookup::LDAPattr->new(undef, $_[0], 'N-') }  # numeric
2080sub q_ldap_b  { Amavis::Lookup::LDAPattr->new(undef, $_[0], 'B-') }  # boolean
2081
2082sub Opaque    { Amavis::Lookup::Opaque->new(@_) }
2083sub OpaqueRef { Amavis::Lookup::OpaqueRef->new(@_) }
2084#
2085# Opaque provides a wrapper to arbitrary data structures, allowing them to be
2086# treated as 'constant' pseudo-lookups, i.e. preventing arrays and hashes from
2087# being interpreted as lookup lists/tables. In case of $forward_method this
2088# allows for a listref of failover methods. Without the protection of Opaque
2089# the listref would be interpreted by a lookup() as an acl lookup type instead
2090# of a match-always data structure. The Opaque subroutine is not yet available
2091# during a BEGIN phase, so this assignment must come after compiling the rest
2092# of the code.
2093#
2094# This is the only case where both an array @*_maps as well as its default
2095# element are members of a policy bank. Use lazy evaluation through a sub
2096# to make this work as expected.
2097#
2098# @forward_method_maps = ( OpaqueRef(\$forward_method) );
2099@forward_method_maps = ( sub { Opaque(c('forward_method')) } );
2100
2101# retain compatibility with old names
2102use vars qw(%final_destiny_by_ccat %defang_by_ccat
2103            $sql_partition_tag $DO_SYSLOG $LOGFILE);
2104*final_destiny_by_ccat = \%final_destiny_maps_by_ccat;
2105*defang_by_ccat = \%defang_maps_by_ccat;
2106*sql_partition_tag = \$partition_tag;
2107*DO_SYSLOG = \$do_syslog;
2108*LOGFILE = \$logfile;
2109
2110@virus_name_to_spam_score_maps =
2111  (new_RE(  # the order matters, first match wins
2112    [ qr'^Structured\.(SSN|CreditCardNumber)\b'            => 0.1 ],
2113    [ qr'^(Heuristics\.)?Phishing\.'                       => 0.1 ],
2114    [ qr'^(Email|HTML)\.Phishing\.(?!.*Sanesecurity)'      => 0.1 ],
2115    [ qr'^Sanesecurity\.(Malware|Rogue|Trojan)\.' => undef ],# keep as infected
2116    [ qr'^Sanesecurity\.Foxhole\.Zip_exe'                  => 0.1 ], # F.P.
2117    [ qr'^Sanesecurity\.Foxhole\.Zip_bat'                  => 0.1 ], # F.P.
2118    [ qr'^Sanesecurity\.Foxhole\.Mail_gz'                  => 0.1 ], # F.P.
2119    [ qr'^Sanesecurity\.Foxhole\.Mail_ace'                 => 0.1 ], # F.P.
2120    [ qr'^Sanesecurity\.Foxhole\.'                => undef ],# keep as infected
2121    [ qr'^Sanesecurity\.'                                  => 0.1 ],
2122    [ qr'^Sanesecurity_PhishBar_'                          => 0   ],
2123    [ qr'^Sanesecurity.TestSig_'                           => 0   ],
2124    [ qr'^Email\.Spam\.Bounce(\.[^., ]*)*\.Sanesecurity\.' => 0   ],
2125    [ qr'^Email\.Spammail\b'                               => 0.1 ],
2126    [ qr'^MSRBL-(Images|SPAM)\b'                           => 0.1 ],
2127    [ qr'^VX\.Honeypot-SecuriteInfo\.com\.Joke'            => 0.1 ],
2128    [ qr'^VX\.not-virus_(Hoax|Joke)\..*-SecuriteInfo\.com(\.|\z)' => 0.1 ],
2129    [ qr'^Email\.Spam.*-SecuriteInfo\.com(\.|\z)'          => 0.1 ],
2130    [ qr'^Safebrowsing\.'                                  => 0.1 ],
2131    [ qr'^winnow\.(phish|spam)\.'                          => 0.1 ],
2132    [ qr'^INetMsg\.SpamDomain'                             => 0.1 ],
2133    [ qr'^Doppelstern\.(Spam|Scam|Phishing|Junk|Lott|Loan)'=> 0.1 ],
2134    [ qr'^Bofhland\.Phishing'                              => 0.1 ],
2135    [ qr'^ScamNailer\.'                                    => 0.1 ],
2136    [ qr'^HTML/Bankish'                                    => 0.1 ],  # F-Prot
2137    [ qr'^PORCUPINE_JUNK'                                  => 0.1 ],
2138    [ qr'^PORCUPINE_PHISHING'                              => 0.1 ],
2139    [ qr'^Porcupine\.Junk'                                 => 0.1 ],
2140    [ qr'^PhishTank\.Phishing\.'                           => 0.1 ],
2141    [ qr'-SecuriteInfo\.com(\.|\z)'         => undef ],  # keep as infected
2142    [ qr'^MBL_NA\.UNOFFICIAL'               => 0.1 ],    # false positives
2143    [ qr'^MBL_'                             => undef ],  # keep as infected
2144  ));
2145# Sanesecurity       http://www.sanesecurity.co.uk/
2146# MSRBL-             http://www.msrbl.com/site/contact
2147# MBL                http://www.malware.com.br/index.shtml
2148# -SecuriteInfo.com  http://clamav.securiteinfo.com/malwares.html
2149
2150# prepend a lookup table label object for logging purposes
2151#
2152sub label_default_maps() {
2153  for my $varname (qw(
2154    @disclaimer_options_bysender_maps @dkim_signature_options_bysender_maps
2155    @local_domains_maps @mynetworks_maps @ip_repu_ignore_maps
2156    @forward_method_maps @newvirus_admin_maps @banned_filename_maps
2157    @spam_quarantine_bysender_to_maps
2158    @spam_tag_level_maps @spam_tag2_level_maps @spam_tag3_level_maps
2159    @spam_kill_level_maps
2160    @spam_subject_tag_maps @spam_subject_tag2_maps @spam_subject_tag3_maps
2161    @spam_dsn_cutoff_level_maps @spam_dsn_cutoff_level_bysender_maps
2162    @spam_crediblefrom_dsn_cutoff_level_maps
2163    @spam_crediblefrom_dsn_cutoff_level_bysender_maps
2164    @spam_quarantine_cutoff_level_maps @spam_notifyadmin_cutoff_level_maps
2165    @whitelist_sender_maps @blacklist_sender_maps @score_sender_maps
2166    @author_to_policy_bank_maps @signer_reputation_maps
2167    @message_size_limit_maps @debug_sender_maps @debug_recipient_maps
2168    @bypass_virus_checks_maps @bypass_spam_checks_maps
2169    @bypass_banned_checks_maps @bypass_header_checks_maps
2170    @viruses_that_fake_sender_maps
2171    @virus_name_to_spam_score_maps @virus_name_to_policy_bank_maps
2172    @remove_existing_spam_headers_maps
2173    @sa_userconf_maps @sa_username_maps
2174
2175    @keep_decoded_original_maps @map_full_type_to_short_type_maps
2176    @virus_lovers_maps @spam_lovers_maps @unchecked_lovers_maps
2177    @banned_files_lovers_maps @bad_header_lovers_maps
2178    @virus_quarantine_to_maps @banned_quarantine_to_maps
2179    @unchecked_quarantine_to_maps @spam_quarantine_to_maps
2180    @bad_header_quarantine_to_maps @clean_quarantine_to_maps
2181    @archive_quarantine_to_maps
2182    @virus_admin_maps @banned_admin_maps
2183    @spam_admin_maps @bad_header_admin_maps @spam_modifies_subj_maps
2184    @warnvirusrecip_maps @warnbannedrecip_maps @warnbadhrecip_maps
2185    @addr_extension_virus_maps  @addr_extension_spam_maps
2186    @addr_extension_banned_maps @addr_extension_bad_header_maps
2187    ))
2188  {
2189    my $g = $varname; $g =~ s{\@}{Amavis::Conf::};  # qualified variable name
2190    my $label = $varname; $label=~s/^\@//; $label=~s/_maps$//;
2191    { no strict 'refs';
2192      unshift(@$g,  # NOTE: a symbolic reference
2193              Amavis::Lookup::Label->new($label))  if @$g;  # no label if empty
2194    }
2195  }
2196}
2197
2198# return a list of actually read&evaluated configuration files
2199sub get_config_files_read() { @actual_config_files }
2200
2201# read and evaluate a configuration file, some sanity checking and housekeeping
2202#
2203sub read_config_file($$) {
2204  my($config_file,$is_optional) = @_;
2205  my(@stat_list) = stat($config_file);  # symlinks-friendly
2206  my $errn = @stat_list ? 0 : 0+$!;
2207  if ($errn == ENOENT && $is_optional) {
2208    # don't complain if missing
2209  } else {
2210    my $owner_uid = $stat_list[4];
2211    my $msg;
2212    if ($errn == ENOENT) { $msg = "does not exist" }
2213    elsif ($errn)        { $msg = "is inaccessible: $!" }
2214    elsif (-d _)         { $msg = "is a directory" }
2215    elsif (-S _ || -b _ || -c _) { $msg = "is not a regular file or pipe" }
2216    elsif (!$i_know_what_i_am_doing{no_conf_file_writable_check}) {
2217      if    ($> && -o _) { $msg = "should not be owned by EUID $>"}
2218      elsif ($> && -w _) { $msg = "is writable by EUID $>, EGID $)" }
2219      elsif ($owner_uid) { $msg = "should be owned by root (uid 0)" }
2220    }
2221    if (defined $msg)    { die "Config file \"$config_file\" $msg," }
2222    $read_config_files_depth++;  push(@actual_config_files, $config_file);
2223    if ($read_config_files_depth >= 100) {
2224      print STDERR "read_config_files: recursion depth limit exceeded\n";
2225      exit 1;  # avoid unwinding deep recursion, abort right away
2226    }
2227    # avoid magic of searching @INC in do() and reporting unrelated errors
2228    $config_file = './'.$config_file  if $config_file !~ m{^\.{0,2}/};
2229    local($1,$2,$3,$4,$5,$6,$7,$8,$9);
2230    local $/ = $/;  # protect us from a potential change in a config file
2231    $! = 0;
2232    if (defined(do $config_file)) {}
2233    elsif ($@ ne '') { die "Error in config file \"$config_file\": $@" }
2234    elsif ($! != 0)  { die "Error reading config file \"$config_file\": $!" }
2235    $read_config_files_depth--  if $read_config_files_depth > 0;
2236  }
2237  1;
2238}
2239
2240sub include_config_files(@)          { read_config_file($_,0)  for @_;  1 }
2241sub include_optional_config_files(@) { read_config_file($_,1)  for @_;  1 }
2242
2243# supply remaining defaults after config files have already been read/evaluated
2244#
2245sub supply_after_defaults() {
2246  $daemon_chroot_dir = ''
2247    if !defined $daemon_chroot_dir || $daemon_chroot_dir eq '/';
2248  # provide some sensible defaults for essential settings (post-defaults)
2249  $TEMPBASE     = $MYHOME                   if !defined $TEMPBASE;
2250  $helpers_home = $MYHOME                   if !defined $helpers_home;
2251  $db_home      = "$MYHOME/db"              if !defined $db_home;
2252  @zmq_sockets  = ( "ipc://$MYHOME/amavisd-zmq.sock" )  if !@zmq_sockets;
2253  $pid_file     = "$MYHOME/amavisd.pid"     if !defined $pid_file && $daemonize;
2254# just keep $lock_file undefined by default, a temp file (File::Temp::tmpnam)
2255# will be provided by Net::Server for 'flock' serialization on a socket accept()
2256# $lock_file    = "$MYHOME/amavisd.lock"    if !defined $lock_file;
2257  local($1,$2);
2258  $X_HEADER_LINE = $myproduct_name . ' at ' .
2259    Amavis::Util::idn_to_ascii($mydomain)  if !defined $X_HEADER_LINE;
2260  $X_HEADER_TAG = 'X-Virus-Scanned' if !defined $X_HEADER_TAG;
2261  if ($X_HEADER_TAG =~ /^[!-9;-\176]+\z/) {
2262    # implicitly add to %allowed_added_header_fields for compatibility,
2263    # unless the hash entry already exists
2264    my $allowed_hdrs = cr('allowed_added_header_fields');
2265    $allowed_hdrs->{lc($X_HEADER_TAG)} = 1
2266      if $allowed_hdrs && !exists($allowed_hdrs->{lc($X_HEADER_TAG)});
2267  }
2268  $gunzip  = "$gzip -d"   if !defined $gunzip  && $gzip  ne '';
2269  $bunzip2 = "$bzip2 -d"  if !defined $bunzip2 && $bzip2 ne '';
2270  $unlzop  = "$lzop -d"   if !defined $unlzop  && $lzop  ne '';
2271
2272  # substring "${myhostname}" will be expanded later, just before use
2273  my $pname = '"Content-filter at ${myhostname_utf8}"';
2274  $hdrfrom_notify_sender = $pname . ' <postmaster@${myhostname_ascii}>'
2275    if !defined $hdrfrom_notify_sender;
2276  $hdrfrom_notify_recip = $mailfrom_notify_recip eq ''
2277    ? $hdrfrom_notify_sender
2278    : sprintf("%s <%s>", $pname,
2279              Amavis::Util::mail_addr_idn_to_ascii($mailfrom_notify_recip))
2280    if !defined $hdrfrom_notify_recip;
2281  $hdrfrom_notify_admin = $mailfrom_notify_admin eq ''
2282    ? $hdrfrom_notify_sender
2283    : sprintf("%s <%s>", $pname,
2284              Amavis::Util::mail_addr_idn_to_ascii($mailfrom_notify_admin))
2285    if !defined $hdrfrom_notify_admin;
2286  $hdrfrom_notify_spamadmin = $mailfrom_notify_spamadmin eq ''
2287    ? $hdrfrom_notify_sender
2288    : sprintf("%s <%s>", $pname,
2289              Amavis::Util::mail_addr_idn_to_ascii($mailfrom_notify_spamadmin))
2290    if !defined $hdrfrom_notify_spamadmin;
2291  $hdrfrom_notify_release = $hdrfrom_notify_sender
2292    if !defined $hdrfrom_notify_release;
2293  $hdrfrom_notify_report = $hdrfrom_notify_sender
2294    if !defined $hdrfrom_notify_report;
2295
2296  if ($final_banned_destiny == D_DISCARD && c('warnbannedsender') )
2297    { $final_banned_destiny = D_BOUNCE }
2298  if ($final_bad_header_destiny == D_DISCARD && c('warnbadhsender') )
2299    { $final_bad_header_destiny = D_BOUNCE }
2300  if (!%banned_rules) {
2301    # an associative array mapping a rule name
2302    # to a single 'banned names/types' lookup table
2303    %banned_rules = ('DEFAULT'=>$banned_filename_re);  # backward compatible
2304  }
2305  1;
2306}
2307
23081;
2309
2310#
2311package Amavis::JSON;
2312use strict;
2313use re 'taint';
2314
2315# serialize a data structure to JSON, RFC 7159
2316
2317BEGIN {
2318  require Exporter;
2319  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
2320  $VERSION = '2.412';
2321  @ISA = qw(Exporter);
2322  @EXPORT_OK = qw(&boolean &numeric);
2323}
2324use subs @EXPORT_OK;
2325
2326our %jesc = (  # JSON escaping
2327  "\x22" => '\\"', "\x5C" => '\\\\',
2328  "\x08" => '\\b', "\x09" => '\\t',
2329  "\x0A" => '\\n', "\x0C" => '\\f', "\x0D" => '\\r',
2330  "\x{2028}" => '\\u2028', "\x{2029}" => '\\u2029' );
2331  # escape also the Line Separator (U+2028) and Paragraph Separator (U+2029)
2332  # http://timelessrepo.com/json-isnt-a-javascript-subset
2333
2334our($FALSE, $TRUE) = ('false', 'true');
2335sub boolean { bless($_[0] ? \$TRUE : \$FALSE) }
2336sub numeric { my $value = $_[0]; bless(\$value) }
2337
2338# serialize a data structure to JSON, RFC 7159
2339# expects logical characters in scalars, returns a string of logical chars
2340#
2341sub encode($);  # prototype
2342sub encode($) {
2343  my $val = $_[0];
2344  my $ref = ref $val;
2345  local $1;
2346  if ($ref) {
2347    if ($ref eq 'ARRAY') {
2348      return '[' . join(',', map(encode($_), @$val)) . ']';
2349    } elsif ($ref eq 'HASH') {
2350      return '{' .
2351        join(',',
2352          map {
2353            my $k = $_;
2354            $k =~ s{ ([\x00-\x1F\x7F\x{2028}\x{2029}"\\]) }
2355                   { $jesc{$1} || sprintf('\\u%04X',ord($1)) }xgse;
2356            '"' . $k . '":' . encode($val->{$_});
2357          } sort keys %$val
2358        ) . '}';
2359    } elsif ($ref->isa('Amavis::JSON')) {  # numeric or boolean type
2360      return defined $$val ? $$val : 'null';
2361    }
2362    # fall through, encode other refs as strings, helps debugging
2363  }
2364  return 'null' if !defined $val;
2365  { # concession on a perl 5.20.0 bug [perl #122148] (fixed in 5.20.1)
2366    # - just warn, do not abort
2367    use warnings NONFATAL => qw(utf8);
2368    $val =~ s{ ([\x00-\x1F\x7F\x{2028}\x{2029}"\\]) }
2369             { $jesc{$1} || sprintf('\\u%04X',ord($1)) }xgse;
2370  };
2371  '"' . $val . '"';
2372}
2373
23741;
2375
2376#
2377package Amavis::Log;
2378use strict;
2379use re 'taint';
2380
2381BEGIN {
2382  require Exporter;
2383  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
2384  $VERSION = '2.412';
2385  @ISA = qw(Exporter);
2386  @EXPORT_OK = qw(&init &amavis_log_id &collect_log_stats
2387                  &log_to_stderr &log_fd &open_log &close_log &write_log);
2388  import Amavis::Conf qw(:platform $DEBUG $TEMPBASE c cr ca
2389                         $myversion $logline_maxlen $daemon_user);
2390# import Amavis::Util qw(untaint idn_to_utf8);
2391}
2392use subs @EXPORT_OK;
2393
2394use POSIX qw(locale_h strftime);
2395use Fcntl qw(:flock F_GETFL F_SETFL FD_CLOEXEC);
2396use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
2397use Unix::Syslog qw(:macros :subs);
2398use Time::HiRes ();
2399
2400# since IO::File 1.10 (comes with perl 5.8.1):
2401#   If "IO::File::open" is given a mode that includes the ":" character,
2402#   it passes all the three arguments to a three-argument "open" operator.
2403
2404use vars qw($loghandle);  # log file handle when logging to a file
2405use vars qw($log_to_stderr $log_to_syslog $logfile_name $within_write_log);
2406use vars qw($current_amavis_log_id);  # tracks am_id() / $msginfo->log_id
2407use vars qw($current_actual_syslog_ident $current_actual_syslog_facility);
2408use vars qw($log_lines $log_retries %log_entries_by_level %log_status_counts);
2409use vars qw($log_prio_debug $log_prio_info $log_prio_notice
2410            $log_prio_warning $log_prio_err $log_prio_crit);
2411
2412BEGIN {  # saves a few ms by avoiding a subroutine call later
2413  $log_prio_debug   = LOG_DEBUG;
2414  $log_prio_info    = LOG_INFO;
2415  $log_prio_notice  = LOG_NOTICE;
2416  $log_prio_warning = LOG_WARNING;
2417  $log_prio_err     = LOG_ERR;
2418  $log_prio_crit    = LOG_CRIT;
2419  $log_to_stderr = 1;  # default until config files have been read
2420}
2421
2422sub init($$) {
2423  ($log_to_syslog, $logfile_name) = @_;
2424  $log_lines = 0; %log_entries_by_level = ();
2425  $log_retries = 0; %log_status_counts = ();
2426  $log_to_stderr =
2427    $log_to_syslog || (defined $logfile_name && $logfile_name ne '') ? 0 : 1;
2428  open_log();
2429}
2430
2431sub collect_log_stats() {
2432  my(@result) = ($log_lines, {%log_entries_by_level},
2433                 $log_retries, {%log_status_counts});
2434  $log_lines = 0; %log_entries_by_level = ();
2435  $log_retries = 0; %log_status_counts = ();
2436  @result;
2437}
2438
2439# task id as shown in the log, also known as am_id, tracks $msginfo->log_id
2440#
2441sub amavis_log_id(;$) {
2442  $current_amavis_log_id = $_[0]  if @_;
2443  $current_amavis_log_id;
2444}
2445
2446# turn debug logging to STDERR on or off
2447#
2448sub log_to_stderr(;$) {
2449  $log_to_stderr = $_[0]  if @_;
2450  $log_to_stderr;
2451}
2452
2453# try to obtain file descriptor used by write_log, undef if unknown
2454#
2455sub log_fd() {
2456  $log_to_stderr ? fileno(STDERR)
2457  : $log_to_syslog ? undef  # no fd for syslog
2458  : defined $loghandle ? $loghandle->fileno : fileno(STDERR);
2459}
2460
2461sub open_log() {
2462  if ($log_to_syslog && !$log_to_stderr) {
2463    my $id = c('syslog_ident'); my $fac = c('syslog_facility');
2464    $fac =~ /^[A-Za-z0-9_]+\z/
2465      or die "Suspicious syslog facility name: $fac";
2466    my $syslog_facility_num = eval("LOG_\U$fac");
2467    $syslog_facility_num =~ /^\d+\z/
2468      or die "Unknown syslog facility name: $fac";
2469    # man syslog(3) on Linux: The argument 'ident' in the call of openlog()
2470    # is probably stored as-is. Thus, if the string it points to is changed,
2471    # syslog() may start prepending the changed string, and if the string
2472    # it points to ceases to exist, the results are undefined. Most portable
2473    # is to use a string constant.  (we use a static variable here)
2474    $current_actual_syslog_ident = $id; $current_actual_syslog_facility = $fac;
2475    openlog($id, LOG_PID | LOG_NDELAY, $syslog_facility_num);
2476
2477  } elsif ($log_to_stderr || $logfile_name eq '') {  # logging to STDERR
2478    STDERR->autoflush(1);  # just in case (should already be on by default)
2479    STDERR->fcntl(F_SETFL, O_APPEND)
2480      or warn "Error setting O_APPEND on STDERR: $!";
2481
2482  } elsif ($logfile_name ne '') {
2483    $loghandle = IO::File->new;
2484    # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
2485    $loghandle->open($logfile_name,
2486                     Amavis::Util::untaint(O_CREAT|O_APPEND|O_WRONLY), 0640)
2487      or die "Failed to open log file $logfile_name: $!";
2488    binmode($loghandle,':bytes') or die "Can't cancel :utf8 mode: $!";
2489    $loghandle->autoflush(1);
2490    if (defined $daemon_user && $daemon_user ne '' && $> == 0) {
2491      local($1);
2492      my $uid = $daemon_user=~/^(\d+)$/ ? $1 : (getpwnam($daemon_user))[2];
2493      if ($uid) {
2494        chown($uid,-1,$logfile_name)
2495          or die "Can't chown logfile $logfile_name to $uid: $!";
2496      }
2497    }
2498  }
2499}
2500
2501sub close_log() {
2502  if ($log_to_syslog) {
2503    closelog();
2504    $current_actual_syslog_ident = $current_actual_syslog_facility = undef;
2505  } elsif (defined($loghandle) && $logfile_name ne '') {
2506    $loghandle->close or die "Error closing log file $logfile_name: $!";
2507    undef $loghandle;
2508  }
2509}
2510
2511# Log either to syslog or to a file
2512#
2513sub write_log($$) {
2514  my($level,$errmsg) = @_;
2515  return  if $within_write_log;
2516  $within_write_log++;
2517  my $am_id = !defined $current_amavis_log_id ? ''
2518                                              : "($current_amavis_log_id) ";
2519# my $old_locale = POSIX::setlocale(LC_TIME,'C');  # English dates required!
2520  my $alert_mark = $level >= 0 ? '' : $level >= -1 ? '(!)' : '(!!)';
2521# $alert_mark .= '*'  if $> == 0;
2522  $log_entries_by_level{"$level"}++;
2523
2524  my $prio = $level >=  3 ? $log_prio_debug  # most frequent first
2525         # : $level >=  2 ? $log_prio_info
2526           : $level >=  1 ? $log_prio_info
2527           : $level >=  0 ? $log_prio_notice
2528           : $level >= -1 ? $log_prio_warning
2529           : $level >= -2 ? $log_prio_err
2530           :                $log_prio_crit;
2531
2532  if ($log_to_syslog && !$log_to_stderr) {
2533    if ($Amavis::Util::current_config_syslog_ident
2534          ne $current_actual_syslog_ident ||
2535        $Amavis::Util::current_config_syslog_facility
2536          ne $current_actual_syslog_facility) {
2537      close_log()  if defined $current_actual_syslog_ident ||
2538                      defined $current_actual_syslog_facility;
2539      open_log();
2540    }
2541    my $pre = $alert_mark;
2542    # $logline_maxlen should be less than (1023 - prefix) for a typical syslog,
2543    # 980 is a suitable length to avoid truncations by the syslogd daemon
2544    my $logline_size = $logline_maxlen;
2545    $logline_size = 50  if $logline_size < 50;  # let at least something out
2546    while (length($am_id)+length($pre)+length($errmsg) > $logline_size) {
2547      my $avail = $logline_size - length($am_id . $pre . '...');
2548      $log_lines++; $! = 0;
2549    # syslog($prio, '%s', $am_id . $pre . substr($errmsg,0,$avail) . '...');
2550      Unix::Syslog::_isyslog($prio,
2551                             $am_id . $pre . substr($errmsg,0,$avail) . '...');
2552      if ($! != 0) { $log_retries++; $log_status_counts{"$!"}++ }
2553      $pre = $alert_mark . '...';  $errmsg = substr($errmsg,$avail);
2554    }
2555    $log_lines++; $! = 0;
2556  # syslog($prio, '%s', $am_id . $pre . $errmsg);
2557    Unix::Syslog::_isyslog($prio, $am_id . $pre . $errmsg);
2558    if ($! != 0) { $log_retries++; $log_status_counts{"$!"}++ }
2559
2560  } elsif ($log_to_stderr || !defined $loghandle) {
2561    $log_lines++;
2562    my $prefix;
2563    if ($DEBUG) {
2564      my $now = Time::HiRes::time;  # timestamp with milliseconds
2565      $prefix = sprintf('%s:%06.3f %s %s[%s]: ',  # syslog-like prefix
2566        strftime('%b %e %H:%M',localtime($now)), $now-int($now/60)*60,
2567        Amavis::Util::idn_to_utf8(c('myhostname')), c('myprogram_name'), $$);
2568    } else {
2569      $prefix = "<$prio>";  # sd-daemon(3), SyslogLevelPrefix=true
2570    }
2571    # avoid multiple calls to write(2), join the string first!
2572    my $s = $prefix . $am_id . $alert_mark . $errmsg . "\n";
2573    #
2574    # IEEE Std 1003.1, 2013: Write requests to a pipe or FIFO shall be handled
2575    # in the same way as a regular file with the following exceptions: [...]
2576    # - There is no file offset associated with a pipe, hence each write
2577    # request shall append to the end of the pipe.
2578    # - Write requests of {PIPE_BUF} bytes or less shall not be interleaved
2579    # with data from other processes doing writes on the same pipe.
2580    # Writes of greater than {PIPE_BUF} bytes may have data interleaved, on
2581    # arbitrary boundaries, with writes by other processes, whether or not
2582    # the O_NONBLOCK flag of the file status flags is set.
2583    #
2584    # PIPE_BUF is 512 on *BSD, 4096 on Linux.
2585    print STDERR ($s)  or die "Error writing to STDERR: $!";
2586
2587  } else {
2588    $log_lines++;
2589    my $now = Time::HiRes::time;
2590    my $prefix = sprintf('%s %s %s[%s]: ',  # prepare a syslog-like prefix
2591      strftime('%b %e %H:%M:%S',localtime($now)),
2592      Amavis::Util::idn_to_utf8(c('myhostname')), c('myprogram_name'), $$);
2593    my $s = $prefix . $am_id . $alert_mark . $errmsg . "\n";
2594    # NOTE: a lock is on a file, not on a file handle
2595    flock($loghandle,LOCK_EX)  or die "Can't lock a log file: $!";
2596    # seek() seems redundant with O_APPEND:
2597    # IEEE Std 1003.1, 2013: If the O_APPEND flag of the file status flags is
2598    # set, the file offset shall be set to the end of the file prior to each
2599    # write and no intervening file modification operation shall occur between
2600    # changing the file offset and the write operation.
2601    seek($loghandle,0,2)   or die "Can't position log file to its tail: $!";
2602    $loghandle->print($s)  or die "Error writing to log file: $!";
2603    # we have autoflush on, so unlocking here is safe
2604    flock($loghandle,LOCK_UN)  or die "Can't unlock a log file: $!";
2605  }
2606# POSIX::setlocale(LC_TIME, $old_locale);
2607  $within_write_log = 0;
2608}
2609
26101;
2611
2612#
2613package Amavis::DbgLog;
2614use strict;
2615use re 'taint';
2616
2617BEGIN {
2618  use vars qw(@ISA $VERSION);
2619  $VERSION = '2.412';
2620  import Amavis::Conf qw(:platform $TEMPBASE);
2621  import Amavis::Log qw(write_log);
2622}
2623
2624use POSIX qw(locale_h strftime);
2625use IO::File ();
2626use Time::HiRes ();
2627# use File::Temp ();
2628
2629sub new {
2630  my $class = $_[0];
2631  my($self,$fh);
2632# eval {  # calls croak() if an error occurs
2633#   $fh = File::Temp->new(DIR => $TEMPBASE, SUFFIX => '.log',
2634#                         TEMPLATE => sprintf('dbg-%05d-XXXXXXXX',$my_pid));
2635#   $fh  or warn "Can't create a temporary debug log file: $!";
2636#   1;
2637# } or do {
2638#   my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
2639#   warn "Can't create a temporary debug log file: $eval_stat";
2640# };
2641  $fh = IO::File->new_tmpfile;
2642  $fh  or warn "Can't create a temporary debug log file: $!";
2643  $self = bless { fh => $fh }, $class  if $fh;
2644  $self;
2645}
2646
2647sub DESTROY {
2648  my $self = $_[0];
2649  undef $self->{fh};
2650};
2651
2652sub flush {
2653  my $self = $_[0];
2654  my $fh = $self->{fh};
2655  !$fh ? 1 : $fh->flush;
2656}
2657
2658sub reposition_to_end {
2659  my $self = $_[0];
2660  my $fh = $self->{fh};
2661  !$fh ? 1 : seek($fh,0,2);
2662}
2663
2664# Log to a temporary file, to be retrieved later by dump_captured_log()
2665#
2666sub write_dbg_log {
2667  my($self, $level,$errmsg) = @_;
2668  my $fh = $self->{fh};
2669  # ignoring failures
2670  $fh->printf("%06.3f %d %s\n", Time::HiRes::time, $level, $errmsg)  if $fh;
2671  1;
2672}
2673
2674sub dump_captured_log {
2675  my($self, $dump_log_level,$enable_log_capture_dump) = @_;
2676  my $fh = $self->{fh};
2677  if ($fh) {
2678    # copy the captured temporary log to a real log if requested
2679    if ($enable_log_capture_dump) {
2680      $fh->flush or die "Can't flush debug log file: $!";
2681      $fh->seek(0,0) or die "Can't rewind debug log file: $!";
2682      my($ln,$any_logged);
2683      for ($! = 0; defined($ln=<$fh>); $! = 0) {
2684        chomp($ln);
2685        my($timestamp,$level,$errmsg) = split(/ /,$ln,3);
2686        if (!$any_logged) {
2687          write_log($dump_log_level, 'CAPTURED DEBUG LOG DUMP BEGINS');
2688          $any_logged = 1;
2689        }
2690        write_log($dump_log_level,
2691                  sprintf('%s:%06.3f %s',
2692                          strftime('%H:%M', localtime($timestamp)),
2693                          $timestamp - int($timestamp/60)*60,  $errmsg));
2694      }
2695      defined $ln || $! == 0  or die "Error reading from debug log file: $!";
2696      write_log($dump_log_level, 'CAPTURED DEBUG LOG DUMP ENDS')
2697        if $any_logged;
2698    }
2699    # clear the temporary file, prepare it for re-use
2700    $fh->seek(0,0) or die "Can't rewind debug log file: $!";
2701    $fh->truncate(0) or die "Can't truncate debug log file: $!";
2702  }
2703  1;
2704}
2705
27061;
2707
2708#
2709package Amavis::Timing;
2710use strict;
2711use re 'taint';
2712
2713BEGIN {
2714  require Exporter;
2715  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
2716  $VERSION = '2.412';
2717  @ISA = qw(Exporter);
2718  @EXPORT_OK = qw(&init &section_time &report &get_time_so_far
2719                  &get_rusage &rusage_report);
2720}
2721use subs @EXPORT_OK;
2722use vars qw(@timing $rusage_self_initial $rusage_children_initial);
2723
2724use Time::HiRes ();
2725
2726sub get_rusage() {
2727  my($rusage_self, $rusage_children);
2728  $rusage_self = Unix::Getrusage::getrusage()
2729    if Unix::Getrusage->UNIVERSAL::can("getrusage");
2730  $rusage_children = Unix::Getrusage::getrusage_children()
2731    if $rusage_self && Unix::Getrusage->UNIVERSAL::can("getrusage_children");
2732  # ru_minflt   no. of page faults serviced without I/O activity
2733  # ru_majflt   no. of page faults that required I/O activity
2734  # ru_nswap    no. of times a process was swapped out
2735  # ru_inblock  no. of times a file system had to perform input
2736  # ru_oublock  no. of times a file system had to perform output
2737  # ru_msgsnd   no. of IPC messages sent
2738  # ru_msgrcv   no. of IPC messages received
2739  # ru_nsignals no. of signals delivered
2740  # ru_nvcsw    no. of voluntary context switches
2741  # ru_nivcsw   no. of involuntary context switches
2742  # ru_maxrss   [kB] maximum resident set size utilized
2743  # ru_ixrss    [kBtics] integral of mem used by the shared text segment
2744  # ru_idrss    [kBtics] integral of unshared mem in the data segment
2745  # ru_isrss    [kBtics] integral of unshared mem in the stack segment
2746  # ru_utime    [s] time spent executing in user mode
2747  # ru_stime    [s] time spent in the system on behalf of the process
2748  ($rusage_self, $rusage_children);
2749}
2750
2751# clear array @timing and enter start time
2752#
2753sub init() {
2754  @timing = (); section_time('init');
2755  ($rusage_self_initial, $rusage_children_initial) = get_rusage();
2756}
2757
2758# enter current time reading into array @timing
2759#
2760sub section_time($) {
2761  push(@timing, $_[0], Time::HiRes::time);
2762}
2763
2764# returns a string - a report of elapsed time by section
2765#
2766sub report() {
2767  my($rusage_self, $rusage_children);
2768  ($rusage_self, $rusage_children) = get_rusage()  if $rusage_self_initial;
2769  section_time('rundown');
2770  my($notneeded, $t0) = (shift(@timing), shift(@timing));
2771  my $total = $t0 <= 0 ? 0 : $timing[-1] - $t0;
2772  if ($total < 0.0000001) { $total = 0.0000001 }
2773  my(@sections); my $t00 = $t0;
2774  while (@timing) {
2775    my($section, $t) = (shift(@timing), shift(@timing));
2776    my $dt   = $t <= $t0  ? 0 : $t-$t0;   # handle possible clock jumps
2777    my $dt_c = $t <= $t00 ? 0 : $t-$t00;  # handle possible clock jumps
2778    my $dtp   = $dt   >= $total ? 100 : $dt*100.0/$total;    # this event
2779    my $dtp_c = $dt_c >= $total ? 100 : $dt_c*100.0/$total;  # cumulative
2780    my $fmt = $dt >= 0.005 ? "%.0f" : "%.1f";
2781    push(@sections, sprintf("%s: $fmt (%.0f%%)%.0f",
2782                            $section, $dt*1000, $dtp, $dtp_c));
2783    $t0 = $t;
2784  }
2785  my $cpu_usage_sum;
2786  if ($rusage_self && $rusage_children) {
2787    $cpu_usage_sum =
2788      ($rusage_self->{ru_utime}     - $rusage_self_initial->{ru_utime}) +
2789      ($rusage_self->{ru_stime}     - $rusage_self_initial->{ru_stime}) +
2790      ($rusage_children->{ru_utime} - $rusage_children_initial->{ru_utime}) +
2791      ($rusage_children->{ru_stime} - $rusage_children_initial->{ru_stime});
2792  }
2793  !$cpu_usage_sum ?
2794    sprintf('TIMING [total %.0f ms] - %s', $total*1000, join(', ',@sections))
2795  : sprintf('TIMING [total %.0f ms, cpu %.0f ms] - %s',
2796            $total*1000, $cpu_usage_sum*1000, join(', ',@sections));
2797}
2798
2799# returns a string - getrusage(2) counters deltas and gauges
2800#
2801sub rusage_report() {
2802  my($rusage_self, $rusage_children) = get_rusage();
2803  my(@msg);
2804  if ($rusage_self && $rusage_children) {
2805    my(@fields) = qw(minflt majflt nswap inblock oublock
2806                     msgsnd msgrcv nsignals nvcsw nivcsw
2807                     maxrss ixrss idrss isrss utime stime);
2808    for (@fields) {
2809      my $cn = 'ru_' . $_;
2810      my $f = '%d';
2811      if ($_ eq 'maxrss') {
2812        # this one is a gauge, not a counter
2813      } else {  # is a counter
2814        $rusage_self->{$cn} -= $rusage_self_initial->{$cn};
2815        $rusage_children->{$cn} -= $rusage_children_initial->{$cn};
2816        $f = '%.3f'  if /time\z/;
2817      }
2818      push(@msg, sprintf("%s=$f+$f", $_, $rusage_self->{$cn},
2819                                         $rusage_children->{$cn}));
2820    }
2821  }
2822  !@msg ? undef : join(', ',@msg);
2823}
2824
2825# returns value in seconds of elapsed time for processing of this mail so far
2826#
2827sub get_time_so_far() {
2828  my($notneeded, $t0) = @timing;
2829  my $total = $t0 <= 0 ? 0 : Time::HiRes::time - $t0;
2830  $total < 0 ? 0 : $total;
2831}
2832
2833use vars qw($t_was_busy $t_busy_cum $t_idle_cum $t0);
2834
2835sub idle_proc(@) {
2836  my $t1 = Time::HiRes::time;
2837  if (defined $t0) {
2838    ($t_was_busy ? $t_busy_cum : $t_idle_cum) += $t1 - $t0;
2839    Amavis::Util::ll(5) && Amavis::Util::do_log(5,
2840        'idle_proc, %s: was %s, %.1f ms, total idle %.3f s, busy %.3f s',
2841        $_[0],  $t_was_busy ? 'busy' : 'idle',  1000*($t1 - $t0),
2842        $t_idle_cum, $t_busy_cum);
2843  }
2844  $t0 = $t1;
2845}
2846
2847sub go_idle(@) {
2848  if ($t_was_busy) { idle_proc(@_); $t_was_busy = 0 }
2849}
2850
2851sub go_busy(@) {
2852  if (!$t_was_busy) { idle_proc(@_); $t_was_busy = 1 }
2853}
2854
2855sub report_load() {
2856  $t_busy_cum + $t_idle_cum <= 0 ? undef
2857  : sprintf('load: %.0f %%, total idle %.3f s, busy %.3f s',
2858      100*$t_busy_cum / ($t_busy_cum + $t_idle_cum), $t_idle_cum, $t_busy_cum);
2859}
2860
28611;
2862
2863#
2864package Amavis::Util;
2865use strict;
2866use re 'taint';
2867
2868BEGIN {
2869  require Exporter;
2870  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
2871  $VERSION = '2.412';
2872  @ISA = qw(Exporter);
2873  @EXPORT_OK = qw(&untaint &untaint_inplace &min &max &minmax
2874                  &unique_list &unique_ref &format_time_interval
2875                  &is_valid_utf_8 &truncate_utf_8
2876                  &safe_encode &safe_encode_utf8 &safe_encode_utf8_inplace
2877                  &safe_decode &safe_decode_utf8 &safe_decode_latin1
2878                  &safe_decode_mime &q_encode &orcpt_encode &orcpt_decode
2879                  &xtext_encode &xtext_decode &proto_encode &proto_decode
2880                  &idn_to_ascii &idn_to_utf8 &clear_idn_cache
2881                  &mail_addr_decode &mail_addr_idn_to_ascii
2882                  &ll &do_log &do_log_safe &snmp_count &snmp_count64
2883                  &snmp_counters_init &snmp_counters_get &snmp_initial_oids
2884                  &debug_oneshot &update_current_log_level
2885                  &flush_captured_log &reposition_captured_log_to_end
2886                  &dump_captured_log &log_capture_enabled
2887                  &am_id &new_am_id &stir_random
2888                  &add_entropy &fetch_entropy_bytes
2889                  &generate_mail_id &make_password
2890                  &crunching_start_time &prolong_timer &get_deadline
2891                  &waiting_for_client &switch_to_my_time &switch_to_client_time
2892                  &sanitize_str &fmt_struct &freeze &thaw
2893                  &ccat_split &ccat_maj &cmp_ccat &cmp_ccat_maj
2894                  &setting_by_given_contents_category_all
2895                  &setting_by_given_contents_category &rmdir_recursively
2896                  &read_file &read_text &read_l10n_templates
2897                  &read_hash &read_array &dump_hash &dump_array
2898                  &dynamic_destination &collect_equal_delivery_recips);
2899
2900  import Amavis::Conf qw(:platform $DEBUG c cr ca $mail_id_size_bits
2901                  $myversion $snmp_contact $snmp_location
2902                  $trim_trailing_space_in_lookup_result_fields);
2903
2904  import Amavis::Log qw(amavis_log_id write_log);
2905  import Amavis::Timing qw(section_time);
2906}
2907use subs @EXPORT_OK;
2908
2909use Errno qw(ENOENT EACCES EAGAIN ESRCH EBADF);
2910use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
2911use Digest::MD5;  # 2.22 provides 'clone' method, no longer needed since 2.7.0
2912use MIME::Base64;
2913use Encode ();  # Perl 5.8  UTF-8 support
2914use Scalar::Util qw(tainted);
2915use Net::LibIDN ();
2916
2917use vars qw($enc_ascii $enc_utf8 $enc_latin1 $enc_w1252 $enc_tainted
2918            $enc_taintsafe $enc_is_utf8_buggy);
2919BEGIN {
2920  $enc_ascii  = Encode::find_encoding('ascii');
2921  $enc_utf8   = Encode::find_encoding('UTF-8');  # same as utf-8-strict
2922  $enc_latin1 = Encode::find_encoding('ISO-8859-1');
2923  $enc_w1252  = Encode::find_encoding('Windows-1252');
2924  $enc_ascii  or die  "Amavis::Util: unknown encoding 'ascii'";
2925  $enc_utf8   or die  "Amavis::Util: unknown encoding 'UTF-8'";
2926  $enc_latin1 or die  "Amavis::Util: unknown encoding 'ISO-8859-1'";
2927  $enc_w1252  or warn "Amavis::Util: unknown encoding 'Windows-1252'";
2928  $enc_tainted = substr($ENV{PATH}.$ENV{HOME}, 0,0);  # tainted empty string
2929  $enc_taintsafe = 1;  # guessing
2930  if (!tainted($enc_tainted)) {
2931    warn "Amavis::Util: can't obtain a tainted string";
2932  } else {
2933    # NOTE: [rt.cpan.org #85489] - Encode::encode turns on the UTF8 flag
2934    # on a passed argument. Give it a copy to avoid turning $enc_tainted
2935    # or $enc_ps into a UTF-8 string!
2936
2937    # Encode::is_utf8 is always false on tainted in Perl 5.8, Perl bug #32687
2938    my $enc_ps = "\x{2029}";  # Paragraph Separator, utf8 flag on
2939    if (!Encode::is_utf8("$enc_ps $enc_tainted")) {
2940      $enc_is_utf8_buggy = 1;
2941      warn "Amavis::Util, Encode::is_utf8() fails to detect utf8 on tainted";
2942    }
2943    # test for Encode taint laundering bug [rt.cpan.org #84879], fixed in 2.50
2944    if (!tainted($enc_ascii->encode("$enc_ps $enc_tainted"))) {
2945      $enc_taintsafe = 0;
2946      warn "Amavis::Util, Encode::encode() taint laundering bug, ".
2947           "fixed in Encode 2.50";
2948    } elsif (!tainted($enc_ascii->decode("xx $enc_tainted"))) {
2949      $enc_taintsafe = 0;
2950      warn "Amavis::Util, Encode::decode() taint laundering bug, ".
2951           "fixed in Encode 2.50";
2952    }
2953    utf8::is_utf8("$enc_ps $enc_tainted")
2954      or die "Amavis::Util, utf8::is_utf8() fails to detect utf8 on tainted";
2955    !utf8::is_utf8("\xA0   $enc_tainted")
2956      or die "Amavis::Util, utf8::is_utf8() claims utf8 on tainted";
2957    my $t = "$enc_ps $enc_tainted";
2958    utf8::encode($t);
2959    tainted($t)
2960      or die "Amavis::Util, utf8::encode() taint laundering bug";
2961    !utf8::is_utf8($t)
2962      or die "Amavis::Util, utf8::encode() failed to clear utf8 flag";
2963  }
2964  1;
2965}
2966
2967# Return untainted copy of a string (argument can be a string or a string ref)
2968#
2969sub untaint($) {
2970  return undef  if !defined $_[0];  # must return undef even in a list context!
2971  no re 'taint';
2972  local $1;  # avoids Perl taint bug: tainted global $1 propagates taintedness
2973  (ref($_[0]) ? ${$_[0]} : $_[0]) =~ /^(.*)\z/s;
2974  $1;
2975}
2976
2977sub untaint_inplace($) {
2978  return undef  if !defined $_[0];  # must return undef even in a list context!
2979  no re 'taint';
2980  local $1;  # avoid Perl taint bug: tainted global $1 propagates taintedness
2981  $_[0] =~ /^(.*)\z/s;
2982  $_[0] = $1;
2983}
2984
2985# Returns the smallest defined number from the list, or undef
2986#
2987sub min(@) {
2988  my $r = @_ == 1 && ref($_[0]) ? $_[0] : \@_;  # accept list, or a list ref
2989  my $m;  defined $_ && (!defined $m || $_ < $m) && ($m = $_)  for @$r;
2990  $m;
2991}
2992
2993# Returns the largest defined number from the list, or undef
2994#
2995sub max(@) {
2996  my $r = @_ == 1 && ref($_[0]) ? $_[0] : \@_;  # accept list, or a list ref
2997  my $m;  defined $_ && (!defined $m || $_ > $m) && ($m = $_)  for @$r;
2998  $m;
2999}
3000
3001# Returns a pair of the smallest and the largest defined number from the list
3002#
3003sub minmax(@) {
3004  my $r = @_ == 1 && ref($_[0]) ? $_[0] : \@_;  # accept list, or a list ref
3005  my $min; my $max;
3006  for (@$r) {
3007    if (defined $_) {
3008      $min = $_  if !defined $min || $_ < $min;
3009      $max = $_  if !defined $max || $_ > $max;
3010    }
3011  }
3012  ($min,$max);
3013}
3014
3015# Returns a sublist of the supplied list of elements in an unchanged order,
3016# where only the first occurrence of each defined element is retained
3017# and duplicates removed
3018#
3019sub unique_list(@) {
3020  my $r = @_ == 1 && ref($_[0]) ? $_[0] : \@_;  # accepts list, or a list ref
3021  my %seen;  my(@result) = grep(defined($_) && !$seen{$_}++, @$r);
3022  @result;
3023}
3024
3025# same as unique, except that it returns a ref to the resulting list
3026#
3027sub unique_ref(@) {
3028  my $r = @_ == 1 && ref($_[0]) ? $_[0] : \@_;  # accepts list, or a list ref
3029  my %seen;  my(@result) = grep(defined($_) && !$seen{$_}++, @$r);
3030  \@result;
3031}
3032
3033sub format_time_interval($) {
3034  my $t = $_[0];
3035  return 'undefined'  if !defined $t;
3036  my $sign = '';  if ($t < 0) { $sign = '-'; $t = - $t };
3037  my $dd = int($t / (24*3600));  $t = $t - $dd*(24*3600);
3038  my $hh = int($t / 3600);       $t = $t - $hh*3600;
3039  my $mm = int($t / 60);         $t = $t - $mm*60;
3040  sprintf("%s%d %d:%02d:%02d", $sign, $dd, $hh, $mm, int($t+0.5));
3041}
3042
3043# returns true if the provided string of octets represents a syntactically
3044# valid UTF-8 string, otherwise a false is returned
3045#
3046sub is_valid_utf_8($) {
3047# my $octets = $_[0];
3048  return undef if !defined $_[0];
3049  #
3050  # RFC 6532: UTF8-non-ascii = UTF8-2 / UTF8-3 / UTF8-4
3051  # RFC 3629 section 4: Syntax of UTF-8 Byte Sequences
3052  #   UTF8-char   = UTF8-1 / UTF8-2 / UTF8-3 / UTF8-4
3053  #   UTF8-1      = %x00-7F
3054  #   UTF8-2      = %xC2-DF UTF8-tail
3055  #   UTF8-3      = %xE0 %xA0-BF UTF8-tail /
3056  #                 %xE1-EC 2( UTF8-tail ) /
3057  #                 %xED %x80-9F UTF8-tail /
3058  #                   # U+D800..U+DFFF are utf16 surrogates, not legal utf8
3059  #                 %xEE-EF 2( UTF8-tail )
3060  #   UTF8-4      = %xF0 %x90-BF 2( UTF8-tail ) /
3061  #                 %xF1-F3 3( UTF8-tail ) /
3062  #                 %xF4 %x80-8F 2( UTF8-tail )
3063  #   UTF8-tail   = %x80-BF
3064  #
3065  # loose variant:
3066  #   [\x00-\x7F] |
3067  #   [\xC0-\xDF][\x80-\xBF] |
3068  #   [\xE0-\xEF][\x80-\xBF]{2} |
3069  #   [\xF0-\xF4][\x80-\xBF]{3}
3070  #
3071  $_[0] =~ /^ (?: [\x00-\x7F] |
3072                  [\xC2-\xDF] [\x80-\xBF] |
3073                  \xE0 [\xA0-\xBF] [\x80-\xBF] |
3074                  [\xE1-\xEC] [\x80-\xBF]{2} |
3075                  \xED [\x80-\x9F] [\x80-\xBF] |
3076                  [\xEE-\xEF] [\x80-\xBF]{2} |
3077                  \xF0 [\x90-\xBF] [\x80-\xBF]{2} |
3078                  [\xF1-\xF3] [\x80-\xBF]{3} |
3079                  \xF4 [\x80-\x8F] [\x80-\xBF]{2} )* \z/xs ? 1 : 0;
3080}
3081
3082# cleanly chop a UTF-8 byte sequence to $max_len or less, RFC 3629;
3083# if $max_len is undefined just chop off any partial last character
3084#
3085sub truncate_utf_8($;$) {
3086  my($octets, $max_len) = @_;
3087  return $octets if !defined $octets;
3088  return ''      if defined $max_len && $max_len <= 0;
3089  substr($octets,$max_len) = ''
3090                 if defined $max_len && length($octets) > $max_len;
3091  # missing one or more UTF8-tail octets? chop the entire last partial char
3092  if ($octets =~ tr/\x00-\x7F//c) {  # triage - is non-ASCII
3093    $octets =~      s/[\xC0-\xDF]\z//s
3094      or $octets =~ s/[\xE0-\xEF][\x80-\xBF]{0,1}\z//s
3095      or $octets =~ s/[\xF0-\xF7][\x80-\xBF]{0,2}\z//s
3096      or $octets =~ s/[\xF8-\xFB][\x80-\xBF]{0,3}\z//s   # not strictly valid
3097      or $octets =~ s/[\xFC-\xFD][\x80-\xBF]{0,4}\z//s   # not strictly valid
3098      or $octets =~ s/ \xFE      [\x80-\xBF]{0,5}\z//sx; # not strictly valid
3099  }
3100  $octets;
3101}
3102
3103# A wrapper for Encode::encode, avoiding a bug in Perl 5.8.0 which causes
3104# Encode::encode to loop and fill memory when given a tainted string.
3105# Also works around a CPAN bug #64642 in module Encode:
3106#   Tainted values have the taint flag cleared when encoded or decoded.
3107#   https://rt.cpan.org/Public/Bug/Display.html?id=64642
3108# Fixed in Encode 2.50 [rt.cpan.org #84879].
3109#
3110sub safe_encode($$;$) {
3111# my($encoding,$str,$check) = @_;
3112  my $encoding = shift;
3113  return undef  if !defined $_[0];  # must return undef even in a list context!
3114  my $enc = Encode::find_encoding($encoding);
3115  $enc  or die "safe_encode: unknown encoding '$encoding'";
3116  # the resulting UTF8 flag is always off
3117  return $enc->encode(@_)  if $enc_taintsafe || !tainted($_[0]);
3118  # Work around a taint laundering bug in Encode [rt.cpan.org #84879].
3119  # Propagate taintedness across taint-related bugs in module Encode
3120  # ( Encode::encode in Perl 5.8.0 fills up all available memory
3121  #   when given a tainted string with a non-encodeable character. )
3122  $enc_tainted . $enc->encode(untaint($_[0]), $_[1]);
3123}
3124
3125# Encodes logical characters to UTF-8 octets, or returns a string of octets
3126# (with utf8 flag off) unchanged. Ensures the result is always a string of
3127# octets (utf8 flag off). Unlike safe_encode(), a non-ASCII string with
3128# utf8 flag off will be returned unchanged, so the result may not be a
3129# valid UTF-8 string!
3130#
3131sub safe_encode_utf8($) {
3132  my $str = $_[0];
3133  return undef  if !defined $str;  # must return undef even in a list context!
3134  utf8::encode($str)  if utf8::is_utf8($str);
3135  $str;
3136}
3137
3138sub safe_encode_utf8_inplace($) {
3139  return undef  if !defined $_[0];  # must return undef even in a list context!
3140  utf8::encode($_[0])  if utf8::is_utf8($_[0]);
3141}
3142
3143sub safe_decode_latin1($) {
3144  my $str = $_[0];
3145  return undef  if !defined $str;  # must return undef even in a list context!
3146  #
3147  # ->  http://en.wikipedia.org/wiki/Windows-1252
3148  # Windows-1252 character encoding is a superset of ISO 8859-1, but differs
3149  # from the IANA's ISO-8859-1 by using displayable characters rather than
3150  # control characters in the 80 to 9F (hex) range. [...]
3151  # It is very common to mislabel Windows-1252 text with the charset label
3152  # ISO-8859-1. A common result was that all the quotes and apostrophes
3153  # (produced by "smart quotes" in word-processing software) were replaced
3154  # with question marks or boxes on non-Windows operating systems, making
3155  # text difficult to read. Most modern web browsers and e-mail clients
3156  # treat the MIME charset ISO-8859-1 as Windows-1252 to accommodate
3157  # such mislabeling. This is now standard behavior in the draft HTML 5
3158  # specification, which requires that documents advertised as ISO-8859-1
3159  # actually be parsed with the Windows-1252 encoding.
3160  #
3161  if ($enc_taintsafe || !tainted($str)) {
3162    return ($enc_w1252||$enc_latin1)->decode($str);
3163  } else {  # work around bugs in Encode
3164    untaint_inplace($str);
3165    return $enc_tainted . ($enc_w1252||$enc_latin1)->decode($str);
3166  }
3167}
3168
3169sub safe_decode_utf8($;$) {
3170  my($str,$check) = @_;
3171  return undef  if !defined $str;  # must return undef even in a list context!
3172  if ($enc_taintsafe || !tainted($str)) {
3173    return utf8::is_utf8($str) ? $str : $enc_utf8->decode($str, $check||0);
3174  } else {
3175    # Work around a taint laundering bug in Encode [rt.cpan.org #84879].
3176    # Propagate taintedness across taint-related bugs in module Encode.
3177    untaint_inplace($str);
3178    return $enc_tainted .
3179           (utf8::is_utf8($str) ? $str : $enc_utf8->decode($str, $check||0));
3180  }
3181}
3182
3183sub safe_decode($$;$) {
3184  my($encoding,$str,$check) = @_;
3185  return undef  if !defined $str;  # must return undef even in a list context!
3186  my $enc = Encode::find_encoding($encoding);
3187  return $str  if !$enc;
3188  # if the $check argument in a call to Encode::decode() is present it must be
3189  # defined to avoid warning "Use of uninitialized value in subroutine entry"
3190  return $enc->decode($str, $check||0)  if $enc_taintsafe || !tainted($str);
3191  # Work around a taint laundering bug in Encode [rt.cpan.org #84879].
3192  # Propagate taintedness across taint-related bugs in module Encode.
3193  untaint_inplace($str);
3194  $enc_tainted . $enc->decode($str, $check||0);
3195}
3196
3197# Handle Internationalized Domain Names according to IDNA: RFC 5890, RFC 5891.
3198# Similar to ToASCII (RFC 3490), but does not fail on garbage.
3199# Takes a domain name (possibly with utf8 flag on) consisting of U-labels
3200# or A-labels or NR-LDH labels, converting each label to A-label, lowercased.
3201# Non- IDNA-valid strings are only encoded to UTF-8 octets but are otherwise
3202# unchanged. Result is in octets regardless of input, taintedness of the
3203# argument is propagated to the result.
3204#
3205my %idn_encode_cache;
3206sub clear_idn_cache() { %idn_encode_cache = () }
3207sub idn_to_ascii($) {
3208  # propagate taintedness of the argument, but not its utf8 flag
3209  return tainted($_[0]) ? $idn_encode_cache{$_[0]} . $enc_tainted
3210                        : $idn_encode_cache{$_[0]}
3211    if exists $idn_encode_cache{$_[0]};
3212  my $s = $_[0];
3213  my $t = tainted($s);  # taintedness of the argument
3214  return undef  if !defined $s;
3215  untaint_inplace($s)  if $t;
3216  # to octets if needed, not necessarily valid UTF-8
3217  utf8::encode($s)  if utf8::is_utf8($s);
3218  if ($s !~ tr/\x00-\x7F//c) {  # is all-ASCII (including IP address literal)
3219    $s = lc $s;
3220  } else {
3221    # Net::LibIDN does not like a leading dot (or '@') in a valid domain name,
3222    # but we need it (e.g. in lookups, meaning subdomains are included), so
3223    # we have to carry a prefix across the call to Net::LibIDN::idn_to_ascii().
3224    my $prefix; local($1);
3225    $prefix = $1  if $s =~ s/^([.\@])//s;  # strip a leading dot or '@'
3226    # to ASCII-compatible encoding (ACE)
3227    my $sa = Net::LibIDN::idn_to_ascii($s, 'UTF-8');
3228    $s = lc $sa  if defined $sa;
3229    $s = $prefix.$s  if $prefix;
3230  }
3231  $idn_encode_cache{$_[0]} = $s;
3232  $t ? $s.$enc_tainted : $s;  # propagate taintedness of the argument
3233}
3234
3235# Handle Internationalized Domain Names according to IDNA: RFC 5890, RFC 5891.
3236# Implements ToUnicode (RFC 3490). ToUnicode always succeeds, because it just
3237# returns the original string if decoding fails. In particular, this means that
3238# ToUnicode has no effect on a label that does not begin with the ACE prefix.
3239# Takes a domain name (as a string of octets or logical characters)
3240# of "Internationalized labels" (A-labels, U-labels, or NR-LDH labels),
3241# converting each label to U-label. Result is a string of octets encoded
3242# as UTF-8 if input was valid.
3243#
3244sub idn_to_utf8($) {
3245  my $s = $_[0];
3246  return undef  if !defined $s;
3247  safe_encode_utf8_inplace($s);  # to octets (if not already)
3248  if ($s =~ /(?: ^  | \. ) xn-- [\x00-\x2D\x2F-\xFF]{0,58} [\x00-\x2C\x2F-\xFF]
3249             (?: \z | \. )/xsi) {  # contains XN-label
3250    my $su = Net::LibIDN::idn_to_unicode(lc $s, 'UTF-8');
3251    return $su  if defined $su;
3252  }
3253  $s;
3254}
3255
3256# decode octets found in a mail header field body to a logical chars string
3257#
3258sub safe_decode_mime($) {
3259  my $str = $_[0];  # octets
3260  return undef  if !defined $str;
3261  my $chars;  # logical characters
3262
3263  if ($str !~ tr/\x00-\x7F//c) {  # is all-ASCII
3264    # test for any RFC 2047 encoded-words
3265    # encoded-text: Any printable ASCII character other than "?" or SPACE
3266    # permissive: SPACE and other characters can be observed in Q encoded-word
3267    if ($str !~ m{ =\? [^?]* \? (?: [Bb] \? [A-Za-z0-9+/=]*? |
3268                                    [Qq] \? .*? ) \?= }xs) {
3269      return $str;  # good, keep as-is, all-ASCII with no encoded-words
3270    }
3271    # normal, all-ASCII with some encoded-words, try to decode encoded-words
3272    # using Encode::MIME::Header
3273    eval { $chars = safe_decode('MIME-Header',$str); 1 }  # RFC 2047
3274      and return $chars;
3275    # give up, is all-ASCII but not MIME, just return as-is
3276    return $str;
3277  }
3278
3279  # contains at least some non-ASCII
3280
3281  if ($str =~ m{ =\? [^?]* \? (?: [Bb] \? [A-Za-z0-9+/=]* |
3282                                  [Qq] \? [\x20-\x3E\x40-\x7F]* ) \?= }xs) {
3283    # strange/rare, non-ASCII, but also contains RFC 2047 encoded-words !?
3284    # decode any RFC 2047 encoded-words, attempt to decode the rest
3285    # as UTF-8 if valid, or as Windows-1252 (or ISO-8859-1) otherwise
3286    local($1);
3287    $str =~ s{ ( =\? [^?]* \? (?: [Bb] \? [A-Za-z0-9+/=]* |
3288                                  [Qq] \? [\x20-\x3E\x40-\x7F]* ) \?= ) |
3289               ( [^=]* | . )
3290             }{ my $s;
3291                if (defined $1) {
3292                  $s = $1;  # using Encode::MIME::Header
3293                  eval { $s = safe_decode('MIME-Header',$s) };
3294                } else {
3295                  $s = $2;
3296                  eval { $s = safe_decode_utf8($s, 1|8); 1 }
3297                  or do { $s = safe_decode_latin1($s) };
3298                }
3299                $s;
3300             }xgse;
3301    return $str;
3302  }
3303
3304  # contains at least some non-ASCII and no RFC 2047 encoded-words
3305
3306  # non-MIME-encoded KOI8 seems to be pretty common, attempt some guesswork
3307  if (length($str) >= 4 &&
3308      $str !~ tr/\x80-\xA2\xA5\xA8-\xAC\xAE-\xB2\xB5\xB8-\xBC\xBE-\xBF//) {
3309    # does *not* contain UTF8-tail octets (sans KOI8-U letters in that range)
3310    my $koi8_cyr_lett_cnt =  # count cyrillic letters
3311      $str =~ tr/\xA3\xA4\xA6\xA7\xAD\xB3\xB4\xB6\xB7\xBD\xC0-\xFF//;
3312    if ($koi8_cyr_lett_cnt >= length($str)*2/3 &&  # mostly cyrillic letters
3313        ($str =~ tr/A-Za-z//) <= 5 &&  # not many ASCII letters
3314        !is_valid_utf_8($str) ) {
3315      # try decoding as KOI8-U (like KOI8-R but with 8 extra letters)
3316      eval { $chars = safe_decode('KOI8-U',$str,1|8); 1; }
3317        and return $chars;  # hopefully the result makes sense
3318    }
3319  }
3320
3321  # contains at least some non-ASCII, no RFC 2047 encoded-words, not KOI8
3322
3323  if ($enc_taintsafe || !tainted($str)) {
3324    # FB_CROAK | LEAVE_SRC
3325    eval { $chars = $enc_utf8->decode($str,1|8); 1; }  # try strict UTF-8
3326      and return $chars;
3327    # fallback, assume Windows-1252 or ISO-8859-1
3328    # note that Windows-1252 is a proper superset of ISO-8859-1
3329    return ($enc_w1252||$enc_latin1)->decode($str);
3330  } else {  # work around bugs in Encode
3331    untaint_inplace($str);
3332    eval { $chars = $enc_utf8->decode($str,1|8); 1; }  # try strict UTF-8
3333      and return $enc_tainted . $chars;
3334    return $enc_tainted . ($enc_w1252||$enc_latin1)->decode($str);
3335  }
3336}
3337
3338# Do the Q-encoding manually, the MIME::Words::encode_mimeword does not
3339# encode spaces and does not limit to 75 ch, which violates the RFC 2047
3340#
3341sub q_encode($$$) {
3342  my($octets,$encoding,$charset) = @_;
3343  my $prefix = '=?' . $charset . '?' . $encoding . '?';
3344  my $suffix = '?='; local($1,$2,$3);
3345  # FWS | utext (= NO-WS-CTL|rest of US-ASCII)
3346  $octets =~ /^ ( [\001-\011\013\014\016-\177]* [ \t] )?  (.*?)
3347                ( [ \t] [\001-\011\013\014\016-\177]* )? \z/xs;
3348  my($head,$rest,$tail) = ($1,$2,$3);
3349  # Q-encode $rest according to RFC 2047 (not for use in comments or phrase)
3350  $rest =~ s{([\000-\037\177\200-\377=?_])}{sprintf('=%02X',ord($1))}gse;
3351  $rest =~ tr/ /_/;   # turn spaces into _ (RFC 2047 allows it)
3352  my $s = $head; my $len = 75 - (length($prefix)+length($suffix)) - 2;
3353  while ($rest ne '') {
3354    $s .= ' '  if $s !~ /[ \t]\z/;  # encoded words must be separated by FWS
3355    $rest =~ /^ ( .{0,$len} [^=] (?: [^=] | \z ) ) (.*) \z/xs;
3356    $s .= $prefix.$1.$suffix; $rest = $2;
3357  }
3358  $s.$tail;
3359}
3360
3361# encode "+", "=" and any character outside the range "!" (33) .. "~" (126)
3362#
3363sub xtext_encode($) {  # RFC 3461
3364  my $str = $_[0]; local($1);
3365  safe_encode_utf8_inplace($str);  # to octets (if not already)
3366  $str =~ s/([^\041-\052\054-\074\076-\176])/sprintf('+%02X',ord($1))/gse;
3367  $str;
3368}
3369
3370# decode xtext-encoded string as per RFC 3461
3371#
3372sub xtext_decode($) {
3373  my $str = $_[0]; local($1);
3374  $str =~ s/\+([0-9a-fA-F]{2})/pack('C',hex($1))/gse;
3375  $str;
3376}
3377
3378sub proto_encode($@) {
3379  my($attribute_name,@strings) = @_; local($1);
3380  for ($attribute_name,@strings) {
3381    # just in case, handle non-octet characters:
3382    s/([^\000-\377])/sprintf('\\x{%04x}',ord($1))/gse and
3383      do_log(-1,'proto_encode: non-octet character encountered: %s', $_);
3384  }
3385  $attribute_name =~    # encode all but alfanumerics, . _ + -
3386    s/([^0-9a-zA-Z._+-])/sprintf('%%%02x',ord($1))/gse;
3387  for (@strings) {      # encode % and nonprintables
3388    s/([^\041-\044\046-\176])/sprintf('%%%02x',ord($1))/gse;
3389  }
3390  $attribute_name . '=' . join(' ',@strings);
3391}
3392
3393sub proto_decode($) {
3394  my $str = $_[0]; local($1);
3395  $str =~ s/%([0-9a-fA-F]{2})/pack('C',hex($1))/gse;
3396  $str;
3397}
3398
3399# Expects an e-mail address as a string of octets, where a local part
3400# may be encoded as UTF-8, and the domain part may be an international
3401# domain name (IDN) consisting either of U-labels or A-labels or NR-LDH
3402# labels. Decodes A-labels to U-labels in domain name. If $result_as_octets
3403# is false decodes the resulting UTF-8 octets from previous step and returns
3404# a string of characters. If $result_as_octets is true the subroutine skips
3405# decoding of UTF-8 octets, the result will be a string of octets, only valid
3406# as UTF-8 if the provided $addr was a valid UTF-8 (garbage-in/garbage-out).
3407#
3408sub mail_addr_decode($;$) {
3409  my($addr, $result_as_octets) = @_;
3410  return undef  if !defined $addr;
3411  safe_encode_utf8_inplace($addr);  # to octets (if not already)
3412  local($1); my $domain;
3413  my $bracketed = $addr =~ s/^<(.*)>\z/$1/s;
3414  if ($addr =~ s{ \@ ( [^\@]* ) \z}{}xs) {
3415    $domain = $1;
3416    $domain = idn_to_utf8($domain)  if $domain =~ /(?:^|\.)xn--/si;
3417    if ($domain !~ tr/\x00-\x7F//c) {  # all-ASCII
3418      $domain = lc $domain;
3419    } elsif (!$result_as_octets) {  # non-ASCII, attempt decoding UTF-8
3420      # attempt decoding as strict UTF-8, otherwise fall back to Latin1
3421      # Not lowercased.
3422      eval { $domain = safe_decode_utf8($domain, 1|8); 1 }
3423      or do { $domain = safe_decode_latin1($domain) };
3424    }
3425  }
3426  # deal with localpart
3427  if (!$result_as_octets && $addr =~ tr/\x00-\x7F//c) {  # non-ASCII
3428    # attempt decoding as strict UTF-8, otherwise fall back to Latin1
3429    eval { $addr = safe_decode_utf8($addr, 1|8); 1 }
3430    or do { $addr = safe_decode_latin1($addr) };
3431  }
3432  $addr .= '@'.$domain  if defined $domain;  # put back the domain part
3433  $bracketed ? '<'.$addr.'>' : $addr;
3434}
3435
3436# Expects an e-mail address as a string of octets or as logical characters
3437# (with utf8 flag on), where a local part may be encoded as UTF-8, and the
3438# domain part may be an international domain name (IDN) consisting either
3439# of U-labels or A-labels or NR-LDH. Leaves the localpart unchanged, encodes
3440# the domain name to ASCII-compatible encoding (ACE) if it is non-ASCII.
3441# The result is always in octets (UTF-8), domain part is lowercased.
3442#
3443sub mail_addr_idn_to_ascii($) {
3444  my $addr = $_[0];
3445  return undef  if !defined $addr;
3446  safe_encode_utf8_inplace($addr);  # to octets (if not already)
3447  local($1);
3448  my $bracketed = $addr =~ s/^<(.*)>\z/$1/s;
3449  $addr =~ s{ (\@ [^\@]*) \z }{ idn_to_ascii($1) }xse;
3450  $bracketed ? '<'.$addr.'>' : $addr;
3451}
3452
3453# RFC 6533: encode an ORCPT mail address (as obtained from orcpt_decode,
3454# logical characters (utf8 flag may be on)) into one of the forms:
3455# utf-8-address, utf-8-addr-unitext, utf-8-addr-xtext, or as a legacy
3456# xtext (RFC 3461), returning a string of octets
3457#
3458sub orcpt_encode($;$$) {
3459  my($str, $smtputf8, $encode_for_smtp) = @_;
3460  return (undef,undef)  if !defined $str;
3461
3462  # "Original-Recipient" ":" address-type ";" generic-address
3463  # address-type = atom
3464  # atom = [CFWS] 1*atext [CFWS]
3465
3466  # RFC 3461: Due to limitations in the Delivery Status Notification format,
3467  # the value of the original recipient address prior to encoding as "xtext"
3468  # MUST consist entirely of printable (graphic and white space) characters
3469  # from the US-ASCII [4] repertoire.
3470
3471  my $addr_type = '';  # expected 'rfc822' or 'utf-8', possibly empty
3472  local($1);  # get address-type (atom, up to a semicolon) and remove it
3473  if ($str =~ s{^[ \t]*([0-9A-Za-z!\#\$%&'*/=?^_`{|}~+-]*)[ \t]*;[ \t]*}{}s) {
3474    $addr_type = lc $1;
3475  }
3476  ll(5) && do_log(5, 'orcpt_encode %s, %s%s%s%s',
3477                  $addr_type, $str,
3478                  $smtputf8 ? ', smtputf8' : '',
3479                  $encode_for_smtp ? ', encode_for_smtp' : '',
3480                  utf8::is_utf8($str) ? ', is_utf8' : '');
3481  $str = $1  if $str =~ /^<(.*)>\z/s;
3482
3483  if ($smtputf8 && utf8::is_utf8($str) &&
3484      ($addr_type eq 'utf-8' || $str =~ tr/\x00-\x7F//c)) {
3485    # for use in SMTPUTF8 (RCPT TO) or in message/global-delivery-status
3486    if ($encode_for_smtp && $str =~ tr{\x00-\x20+=\\}{}) {
3487      # contains +,=,\,SP,ctrl -> encode as utf-8-addr-unitext
3488      # HEXPOINT in EmbeddedUnicodeChar is 2 to 6 hexadecimal digits.
3489      $str =~ s{ ( [^\x21-\x2A\x2C-\x3C\x3E-\x5B\x5D-\x7E\x80-\xF4] ) }
3490               { sprintf('\\x{%02X}', ord($1)) }xgse;  # 2..6 uppercase hex!
3491    } else {
3492      # no restricted characters or not for SMTP -> keep as utf-8-address
3493      #
3494      # The utf-8-address form MAY be used in the ORCPT parameter when the
3495      # SMTP server also advertises support for SMTPUTF8 and the address
3496      # doesn't contain any ASCII characters not permitted in the ORCPT
3497      # parameter.  It SHOULD be used in a message/global-delivery-status
3498      # "Original-Recipient:" or "Final-Recipient:" DSN field, or in an
3499      # "Original-Recipient:" header field [RFC3798] if the message is a
3500      # SMTPUTF8 message.
3501    }
3502    safe_encode_utf8_inplace($str);  # to octets (if not already)
3503    $addr_type = 'utf-8';
3504
3505  } else {
3506    # RFC 6533: utf-8-addr-xtext MUST be used in the ORCPT parameter
3507    # when the SMTP server doesn't advertise support for SMTPUTF8
3508    if ($str =~ tr/\x00-\x7F//c && utf8::is_utf8($str)) {
3509      # non-ASCII UTF-8, encode as utf-8-addr-xtext
3510      # RFC 6533: QCHAR = %x21-2a / %x2c-3c / %x3e-5b / %x5d-7e
3511      # HEXPOINT in EmbeddedUnicodeChar is 2 to 6 hexadecimal digits.
3512      $str =~ s{ ( [^\x21-\x2A\x2C-\x3C\x3E-\x5B\x5D-\x7E] ) }
3513               { sprintf('\\x{%02X}', ord($1)) }xgse;  # 2..6 uppercase hex!
3514      safe_encode_utf8_inplace($str);  # to octets (if not already)
3515      $addr_type = 'utf-8';
3516    } else {  # encode as legacy RFC 3461 xtext
3517      # encode +, =, \, SP, controls
3518      safe_encode_utf8_inplace($str);  # encode to octets first!
3519      $str =~ s{ ( [^\x21-\x2A\x2C-\x3C\x3E-\x5B\x5D-\x7E] ) }
3520               { sprintf('+%02X', ord($1)) }xgse;  # exactly two uppercase hex
3521      $addr_type = 'rfc822';
3522    }
3523  }
3524  ($addr_type, $str);
3525}
3526
3527# Decode an encoded ORCPT e-mail address (a string of octets, encoded as
3528# xtext, utf-8-addr-xtext, utf-8-addr-unitext, or utf-8-address) as per
3529# RFC 3461 and RFC 6533. Result is presumably an RFC 5322 -encoded mail
3530# address, possibly as utf8-flagged characters string (if valid UTF-8),
3531# no angle brackets.
3532#
3533sub orcpt_decode($;$) {
3534  my($str, $smtputf8) = @_;
3535  return (undef,undef)  if !defined $str;
3536
3537  my $addr_type = ''; local($1);
3538  # get address-type (atom, up to a semicolon) and remove it
3539  if ($str =~ s{^[ \t]*([0-9A-Za-z!\#\$%&'*/=?^_`{|}~+-]*)[ \t]*;[ \t]*}{}s) {
3540    $addr_type = lc $1;
3541  }
3542
3543  if ($addr_type eq '') {
3544    # assumed not encoded (e.g. internally generated)
3545    if ($str =~ tr/\x00-\x7F//c && is_valid_utf_8($str) &&
3546        eval { $str = safe_decode_utf8($str, 1|8); 1 }) {
3547      $addr_type = 'utf-8';
3548    } else {
3549      $addr_type = 'rfc822';
3550    }
3551
3552  } elsif ($addr_type ne 'utf-8') {  # presumably 'rfc822'
3553    # decode xtext-encoded string as per RFC 3461,
3554    # hexchar = ASCII "+" immediately followed by two UPPER CASE hex digits
3555    $str =~ s{ \+ ( [0-9A-F]{2} ) }{ pack('C',hex($1)) }xgse;
3556    # now have a string of octets, possibly with (invalid) 8bit characters
3557
3558    # we may have a legacy encoding which should really be a utf-8 addr_type
3559    if ($smtputf8 && lc $addr_type eq 'rfc822' &&
3560        $str =~ tr/\x00-\x7F//c && is_valid_utf_8($str) &&
3561        eval { $str = safe_decode_utf8($str, 1|8); 1 }) {
3562      $addr_type = 'utf-8';
3563    }
3564
3565  } elsif ($str !~ tr/\x00-\x7F//c) {  # address-type is 'utf-8', is all-ASCII
3566    # Looks like utf-8-addr-xtext or utf-8-addr-unitext.
3567    # Permissive decoding of EmbeddedUnicodeChar, as well as a legacy xtext:
3568    # RFC 6533: UTF-8 address type has 3 forms:
3569    #   utf-8-addr-xtext, utf-8-addr-unitext, and utf-8-address.
3570    $str =~ s{ \\ x \{ ( [0-9A-Fa-f]{2,6} ) \} |
3571               \+      ( [0-9A-F]{2} ) }
3572               { pack('U', hex(defined $1 ? $1 : $2)) }xgse;
3573    # RFC 6533 prohibits <NUL> and surrogates in EmbeddedUnicodeChar,
3574    # as well as encoded printable ASCII chars except xtext-specials +, =, \
3575
3576  } elsif (is_valid_utf_8($str) &&
3577           eval { $str = safe_decode_utf8($str, 1|8); 1 }) {
3578    # Looks like a utf-8-address. Successfully decoded UTF-8 octets to chars.
3579    # permissive decoding of EmbeddedUnicodeChar, as well as a legacy xtext
3580    $str =~ s{ \\ x \{ ( [0-9A-Fa-f]{2,6} ) \} |
3581               \+      ( [0-9A-F]{2} ) }
3582               { pack('U', hex(defined $1 ? $1 : $2)) }xgse;
3583
3584  } else {  # address-type is 'utf-8', non-ASCII, invalid UTF-8 string
3585    # RFC 6533: if an address is labeled with the UTF-8 address type
3586    # but does not conform to utf-8 syntax, then it MUST be copied into
3587    # the message/global-delivery-status field without alteration.
3588    # --> just leave $str unchanged as octets
3589  }
3590
3591  # result in $str is presumably an RFC 5322 -encoded addr,
3592  # possibly as utf8-flagged characters, no angle brackets
3593  ($addr_type, $str);
3594}
3595
3596# Mostly for debugging and reporting purposes:
3597# Convert nonprintable characters in the argument
3598# to \[rnftbe], or hex code, ( and '\' to '\\' ???),
3599# and Unicode characters to UTF-8, returning a sanitized string.
3600#
3601use vars qw(%quote_controls_map);
3602BEGIN {
3603  %quote_controls_map =
3604    ("\r" => '\\r', "\n" => '\\n', "\t" => '\\t', "\\" => '\\\\');
3605
3606# leave out the <FF>, <BS> and <ESC>, these are too confusing in the log,
3607# better to just hand them over to hex quoting ( \xHH )
3608#   ("\r" => '\\r', "\n" => '\\n', "\f" => '\\f', "\t" => '\\t',
3609#    "\b" => '\\b', "\e" => '\\e', "\\" => '\\\\');
3610
3611}
3612sub sanitize_str {
3613  my($str, $keep_eol) = @_;
3614  return ''  if !defined $str;
3615  safe_encode_utf8_inplace($str);  # to octets (if not already)
3616  # $str is now in octets, UTF8 flag is off
3617  local($1);
3618  if ($keep_eol) {
3619    # controls except LF, DEL, backslash
3620    $str =~ s/([\x00-\x09\x0B-\x1F\x7F\\])/
3621              $quote_controls_map{$1} || sprintf('\\x%02X', ord($1))/gse;
3622  } else {
3623    # controls, DEL, backslash
3624    $str =~ s/([\x00-\x1F\x7F\\])/
3625              $quote_controls_map{$1} || sprintf('\\x%02X', ord($1))/gse;
3626  }
3627  $str;
3628}
3629
3630# Set or get Amavis internal task id (also called: log id).
3631# This task id performs a similar function as queue-id in MTA responses.
3632# It may only be used in generating text part of SMTP responses,
3633# or in generating log entries. It is only unique within a limited timespan.
3634use vars qw($amavis_task_id);  # internal task id
3635#                   (accessible via am_id() and later also as $msginfo->log_id)
3636
3637sub am_id(;$) {
3638  if (@_) {         # set, if argument is present
3639    $amavis_task_id = $_[0];
3640    amavis_log_id($amavis_task_id);
3641    $0 = c('myprogram_name') .
3642         (!defined $amavis_task_id ? '' : " ($amavis_task_id)");
3643  }
3644  $amavis_task_id;  # return current value
3645}
3646
3647sub new_am_id($;$$) {
3648  my($str, $cnt, $seq) = @_;
3649  my $id = defined $str ? $str : sprintf('%05d', $$);
3650  $id .= sprintf('-%02d', $cnt)  if defined $cnt;
3651  $id .= '-'.$seq  if defined $seq && $seq > 1;
3652  am_id($id);
3653}
3654
3655use vars qw($entropy);  # MD5 ctx (128 bits, 32 hex digits or 22 base64 chars)
3656sub add_entropy(@) {  # arguments may be strings or array references
3657  $entropy = Digest::MD5->new  if !defined $entropy;
3658  my $s = join(',', map((!defined $_ ? 'U' : ref eq 'ARRAY' ? @$_ : $_), @_));
3659  utf8::encode($s)  if utf8::is_utf8($s);
3660# do_log(5,'add_entropy: %s',$s);
3661  $entropy->add($s);
3662}
3663
3664sub fetch_entropy_bytes($) {
3665  my $n = $_[0];  # number of bytes to collect
3666  my $result = '';
3667  for (; $n > 0; $n--) {
3668    # collect as few bits per MD5 iteration as possible (RFC 4086 sect 6.2.1)
3669    # let's settle for 8 bits for practical reasons; fewer would be better
3670    my $digest = $entropy->digest;  # 16 bytes; also destroys accumulator
3671    $result .= substr($digest,0,1);  # take 1 byte
3672    $entropy->reset; $entropy->add($digest);  # cycle it back
3673  }
3674# ll(5) && do_log(5,'fetch_entropy_bytes %s',
3675#                  join(' ', map(sprintf('%02x',$_), unpack('C*',$result))));
3676  $result;
3677}
3678
3679# read number of bytes from a /dev/urandom device
3680#
3681sub read_random_bytes($$) {
3682  # my($buff,$required_bytes) = @_;
3683  $_[0] = '';
3684  my $required_bytes = $_[1];
3685  my $fname = '/dev/urandom';  # nonblocking device!
3686  if ($required_bytes > 0) {
3687    my $fh = IO::File->new;
3688    $fh->open($fname,O_RDONLY)  # does a sysopen()
3689      or die "Can't open $fname: $!";
3690    $fh->binmode or die "Can't set $fname to binmode: $!";
3691    my $nbytes = $fh->sysread($_[0], $required_bytes);
3692    defined $nbytes  or die "Error reading from $fname: $!";
3693    $nbytes >= $required_bytes  or die "Less data read than requested: $!";
3694    $fh->close or die "Error closing $fname: $!";
3695  }
3696  undef;
3697}
3698
3699# stir/initialize perl's random generator and our entropy pool;
3700# to be called at startup of the main process and each child processes
3701#
3702sub stir_random() {
3703  my $random_bytes;
3704  eval {
3705    read_random_bytes($random_bytes,16);  1;
3706  } or do {
3707    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
3708    do_log(0, 'read_random_bytes error: %s', $eval_stat);
3709    undef $random_bytes;
3710  };
3711  srand();  # let perl give it a try first, then stir-in some additional bits
3712  add_entropy($random_bytes, Time::HiRes::gettimeofday, $$, rand());
3713  #
3714  # must prevent all child processes working with the same inherited random
3715  # seed, otherwise modules like File::Temp will step on each other's toes
3716  my $r = unpack('L', fetch_entropy_bytes(4)) ^ int(rand(0xffffffff));
3717  srand($r & 0x7fffffff);
3718}
3719
3720# generate a reasonably unique (long-term) id based on collected entropy.
3721# The result is a pair of a (mostly public) mail_id, and a secret id,
3722# where mail_id == b64(md5(secret_bin)). The secret id could be used to
3723# authorize releasing quarantined mail. Both the mail_id and secret id are
3724# strings of characters [A-Za-z0-9-_], with an additional restriction
3725# for mail_id which must begin and end with an alphanumeric character.
3726# The number of bits in a mail_id is configurable through $mail_id_size_bits
3727# and defaults to 72, yielding a 12-character base64url-encoded string.
3728# The number of bits must be an integral multiple of 24, so that no base64
3729# trailing padding characters '=' are needed (RFC 4648).
3730# Note the difference in base64-like encodings:
3731#   amavisd almost-base64: 62 +, 63 -   (old, no longer used since 2.7.0)
3732#   RFC 4648 base64:       62 +, 63 /   (not used here)
3733#   RFC 4648 base64url:    62 -, 63 _
3734# Generally, RFC 5322 controls, SP and specials must be avoided: ()<>[]:;@\,."
3735# With version 2.7.0 of amavisd we switched from almost-base64 to base64url
3736# to avoid having to quote a '+' in regular expressions and in URL.
3737#
3738sub generate_mail_id() {
3739  my($id_b64, $secret_bin);
3740  # 72 bits =  9 bytes = 12 b64 chars
3741  # 96 bits = 12 bytes = 16 b64 chars
3742  $mail_id_size_bits > 0 &&
3743  $mail_id_size_bits == int $mail_id_size_bits &&
3744  $mail_id_size_bits % 24 == 0
3745    or die "\$mail_id_size_bits ($mail_id_size_bits) must be a multiple of 24";
3746  for (my $j=0; $j<100; $j++) {  # provide some sanity loop limit just in case
3747    $secret_bin = fetch_entropy_bytes($mail_id_size_bits/8);
3748    # mail_id is computed as md5(secret), rely on unidirectionality of md5
3749    $id_b64 = Digest::MD5->new->add($secret_bin)->b64digest;  # b64(md5(sec))
3750    add_entropy($id_b64,$j);  # fold it back into accumulator
3751    substr($id_b64, $mail_id_size_bits/6) = '';  # b64, crop to size
3752    # done if it starts and ends with an alfanumeric character
3753    last  if $id_b64 =~ /^[A-Za-z0-9].*[A-Za-z0-9]\z/s;
3754    # retry on less than 7% of cases
3755    do_log(5,'generate_mail_id retry: %s', $id_b64);
3756  }
3757  $id_b64 =~ tr{+/}{-_};  # base64 -> RFC 4648 base64url [A-Za-z0-9-_]
3758  if (!wantarray) {  # not interested in secret
3759    $secret_bin = 'X' x length($secret_bin);  # can't hurt to wipe out
3760    return $id_b64;
3761  }
3762  my $secret_b64 = encode_base64($secret_bin,''); # $mail_id_size_bits/6 chars
3763  $secret_bin = 'X' x length($secret_bin);  # can't hurt to wipe out
3764  $secret_b64 =~ tr{+/}{-_};  # base64 -> RFC 4648 base64url [A-Za-z0-9-_]
3765# do_log(5,'generate_mail_id: %s %s', $id_b64, $secret_b64);
3766  ($id_b64, $secret_b64);
3767}
3768
3769# Returns a password that may be used for scrambling of a message being
3770# released from a quarantine or mangled, with intention of preventing an
3771# automatic or undesired implicit opening of a potentially dangerous message.
3772# The first argument may be: a plain string, which is simply passed on
3773# to the result, or: a code reference (to be evaluated in a scalar context),
3774# allowing for lazy evaluation of a supplied password generating code,
3775# or: undef, which causes a generation of a simple 4-digit PIN-like random
3776# password. The second argument is just passed on unchanged to the supplied
3777# subroutine and is expected to be a $msginfo object.
3778#
3779sub make_password($$) {
3780  my($password,$msginfo) = @_;
3781  if (ref $password eq 'CODE') {
3782    eval {
3783      $password = &$password($msginfo);
3784      chomp $password; $password =~ s/^[ \t]+//; $password =~ s/[ \t]+\z//;
3785      untaint_inplace($password)  if $password =~ /^[A-Za-z0-9:._=+-]*\z/;
3786      1;
3787    } or do {
3788      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
3789      do_log(-1, 'password generating subroutine failed, '.
3790                 'supplying a default: %s', $@);
3791      $password = undef;
3792    };
3793  }
3794  if (!defined $password) {  # create a 4-digit random string
3795    my $r;
3796    do {
3797      $r = unpack('S',fetch_entropy_bytes(2));  # 0 .. 65535
3798      # ditch useless samples beyond 60000
3799    } until $r < 65536 - (65536 % 10000);
3800    $password = sprintf('%04d', $r % 10000);
3801    $r = 0;  # clear the IV field of a scalar (the undef() doesn't do so)
3802  }
3803  $password;
3804}
3805
3806use vars qw(@counter_names);
3807# elements may be counter names (increment is 1), or pairs: [name,increment],
3808# or triples: [name,value,type], where type can be: C32, C64, INT, TIM or OID
3809sub snmp_counters_init() { @counter_names = () }
3810sub snmp_count(@) { push(@counter_names, @_) }
3811sub snmp_count64(@) { push(@counter_names, map(ref $_ ?$_ :[$_,1,'C64'], @_)) }
3812sub snmp_counters_get() { \@counter_names }
3813
3814sub snmp_initial_oids() {
3815  return [
3816    ['sysDescr',    'STR', $myversion],                       # 0..255 octets
3817    ['sysObjectID', 'OID', '1.3.6.1.4.1.15312.2'],
3818  # iso.org.dod.internet.private.enterprise.ijs.amavisd-new
3819    ['sysUpTime',   'INT', int(time)],  # to be converted to TIM
3820  # later it must be converted to timeticks (10ms units since start)
3821    ['sysContact',  'STR', safe_encode_utf8($snmp_contact)],  # 0..255 octets
3822  # Network Unicode format (Net-Unicode) RFC 5198, instead of NVT ASCII
3823    ['sysName',     'STR', idn_to_utf8(c('myhostname'))],     # 0..255 octets
3824    ['sysLocation', 'STR', safe_encode_utf8($snmp_location)], # 0..255 octets
3825    ['sysServices', 'INT', 64],  # application
3826  ];
3827}
3828
3829use vars qw($debug_oneshot);
3830sub debug_oneshot(;$$) {
3831  if (@_) {
3832    my $new_debug_oneshot = shift;
3833    if (($new_debug_oneshot ? 1 : 0) != ($debug_oneshot ? 1 : 0)) {
3834      do_log(0, 'DEBUG_ONESHOT: TURNED '.($new_debug_oneshot ? 'ON' : 'OFF'));
3835      do_log(0, shift)  if @_;  # caller-provided extra log entry, usually
3836                                # the one that caused debug_oneshot call
3837    }
3838    $debug_oneshot = $new_debug_oneshot;
3839  }
3840  $debug_oneshot;
3841}
3842
3843use vars qw($dbg_log);
3844sub log_capture_enabled(;$) {
3845  if (@_) {
3846    my $new_state = $_[0];
3847    if (!$dbg_log && $new_state) {
3848      $dbg_log = Amavis::DbgLog->new;
3849    } elsif ($dbg_log && !$new_state) {
3850      undef $dbg_log;  # calls its destructor
3851    }
3852  }
3853  $dbg_log ? 1 : 0;
3854}
3855
3856use vars qw($current_config_log_level
3857            $current_config_syslog_ident
3858            $current_config_syslog_facility);
3859# keeping current settings avoids the most frequent calls to c()
3860sub update_current_log_level() {
3861  $current_config_log_level       = c('log_level') || 0;
3862  $current_config_syslog_ident    = c('syslog_ident');
3863  $current_config_syslog_facility = c('syslog_facility');
3864}
3865
3866# is message log level below the current log level (i.e. eligible for logging)?
3867#
3868sub ll($) {
3869  (($DEBUG || $debug_oneshot) && $_[0] > 0 ? 0 : $_[0])
3870     <= $current_config_log_level
3871  || $dbg_log;
3872}
3873
3874# write a log entry (optimized, called often)
3875#
3876sub do_log($$;@) {
3877# my($level,$errmsg,@args) = @_;
3878  my $level = $_[0];
3879# if (ll($level)) {  # inlined and reorderd the ll() call for speed
3880  if ( $level <= $current_config_log_level ||
3881       ( ($DEBUG || $debug_oneshot) && $level > 0
3882         && 0 <= $current_config_log_level ) ||
3883       $dbg_log ) {
3884    my $errmsg;  # the $_[1] is expected to be ASCII or UTF-8 octets (not char)
3885    if (@_ <= 2) {  # no arguments to sprintf
3886      $errmsg = $_[1];
3887    } elsif (@_ == 3) {  # a single argument to sprintf, optimized common case
3888      if (utf8::is_utf8($_[2])) {
3889        my $arg1 = $_[2]; utf8::encode($arg1);
3890        $errmsg = sprintf($_[1], $arg1);
3891      } else {
3892        $errmsg = sprintf($_[1], $_[2]);
3893      }
3894    } else {
3895      # treat $errmsg as sprintf format string if additional args are provided;
3896      # encode arguments individually to avoid mojibake when UTF8-flagged and
3897      # non- UTF8-flagged strings are concatenated;
3898      my @args = @_[2..$#_];
3899      for (@args) { utf8::encode($_) if utf8::is_utf8($_) }
3900      $errmsg = sprintf($_[1], @args);
3901    }
3902    local($1);
3903    # protect controls, DEL, and backslash; make sure to leave UTF-8 untouched
3904    $errmsg =~ s/([\x00-\x1F\x7F\\])/
3905                 $quote_controls_map{$1} || sprintf('\\x%02X', ord($1))/gse;
3906    $dbg_log->write_dbg_log($level,$errmsg)  if $dbg_log;
3907    $level = 0  if ($DEBUG || $debug_oneshot) && $level > 0;
3908    if ($level <= $current_config_log_level) {
3909      write_log($level,$errmsg);
3910###   $Amavis::zmq_obj->write_log($level,$errmsg)  if $Amavis::zmq_obj;
3911    }
3912  }
3913  1;
3914}
3915
3916# equivalent to do_log, but protected by eval so that it can't bail out
3917#
3918sub do_log_safe($$;@) {
3919  # ignore failures while keeping perlcritic happy
3920  eval { do_log(shift,shift,@_) } or 1;
3921  1;
3922}
3923
3924sub flush_captured_log() {
3925  $dbg_log->flush
3926    or die "Can't flush debug log file: $!"  if $dbg_log;
3927}
3928
3929sub reposition_captured_log_to_end() {
3930  $dbg_log->reposition_to_end
3931    or die "Can't reposition debug log file to its end: $!"  if $dbg_log;
3932}
3933
3934sub dump_captured_log($$) {
3935  my($dump_log_level, $enable_log_capture_dump) = @_;
3936  $dbg_log->dump_captured_log($dump_log_level,
3937                 $enable_log_capture_dump && ll($dump_log_level))  if $dbg_log;
3938}
3939
3940# $timestamp_of_last_reception: a Unix time stamp when an MTA client send the
3941# last command to us, the most important of which is the reception of a final
3942# dot in SMTP session, which is a time when a client started to wait for our
3943# response;  this timestamp, along with a c('child_timeout'), make a deadline
3944# time for our processing
3945#
3946# $waiting_for_client: which timeout is running:
3947#   false: processing is in our courtyard,  true: waiting for a client
3948#
3949use vars qw($timestamp_of_last_reception $waiting_for_client);
3950
3951sub waiting_for_client(;$) {
3952  $waiting_for_client = $_[0]  if @_;
3953  $waiting_for_client;
3954}
3955
3956sub get_deadline(@) {
3957  my($which_section, $allowed_share, $reserve, $max_time) = @_;
3958  # $allowed_share ... factor between 0 and 1 of the remaining time till a
3959  #                    deadline, to be allocated to the task that follows
3960  # $reserve  ... try finishing up $reserve seconds before the deadline;
3961  # $max_time ... upper limit in seconds for the timer interval
3962  my($timer_interval, $timer_deadline, $time_to_deadline);
3963  my $child_t_o = c('child_timeout');
3964  if (!$child_t_o) {
3965    do_log(2, 'get_deadline %s - ignored, child_timeout not set',
3966              $which_section);
3967  } elsif (!defined $timestamp_of_last_reception) {
3968    do_log(2, 'get_deadline %s - ignored, master deadline not known',
3969              $which_section);
3970  } else {
3971    my $now = Time::HiRes::time;
3972    $time_to_deadline = $timestamp_of_last_reception + $child_t_o - $now;
3973    $timer_interval = $time_to_deadline;
3974    if (!defined $allowed_share) {
3975      $allowed_share = 0.6;
3976      $timer_interval *= $allowed_share;
3977    } elsif ($allowed_share <= 0) {
3978      $timer_interval = 0;
3979    } elsif ($allowed_share >= 1) {
3980      # leave it unchanged
3981    } else {
3982      $timer_interval *= $allowed_share;
3983    }
3984    $reserve = 4  if !defined $reserve;
3985    if ($reserve > 0 && $timer_interval > $time_to_deadline - $reserve) {
3986      $timer_interval = $time_to_deadline - $reserve;
3987    }
3988    if ($timer_interval < 8) {  # be generous, allow at least 6 seconds
3989      $timer_interval = max(6, min(8,$time_to_deadline));
3990    }
3991    my $j = int($timer_interval);
3992    $timer_interval = $timer_interval > $j ? $j+1 : $j;  # ceiling
3993    if (defined $max_time && $max_time > 0 && $timer_interval > $max_time) {
3994      $timer_interval = $max_time;
3995    }
3996    ll(5) && do_log(5,'get_deadline %s - deadline in %.1f s, set to %.3f s',
3997                      $which_section, $time_to_deadline, $timer_interval);
3998    $timer_deadline = $now + $timer_interval;
3999  }
4000  !wantarray ? $timer_interval
4001             : ($timer_interval, $timer_deadline, $time_to_deadline);
4002}
4003
4004sub prolong_timer($;$$$) {
4005  my($which_section, $allowed_share, $reserve, $max_time) = @_;
4006  my($timer_interval, $timer_deadline, $time_to_deadline) = get_deadline(@_);
4007  if (defined $timer_interval) {
4008    my $prev_timer = alarm($timer_interval);  # restart/prolong the timer
4009    ll(5) && do_log(5,'prolong_timer %s: timer %d, was %d, deadline in %.1f s',
4010              $which_section, $timer_interval, $prev_timer, $time_to_deadline);
4011  }
4012  !wantarray ? $timer_interval
4013             : ($timer_interval, $timer_deadline, $time_to_deadline);
4014}
4015
4016sub switch_to_my_time($) {      # processing is in our courtyard
4017  my $msg = $_[0];
4018  $waiting_for_client = 0;
4019  $timestamp_of_last_reception = Time::HiRes::time;
4020  my $child_t_o = c('child_timeout');
4021  if (!$child_t_o) {
4022    alarm(0);
4023  } else {
4024    prolong_timer( 'switch_to_my_time(' . $msg . ')' );
4025  }
4026}
4027
4028sub switch_to_client_time($) {  # processing is now in client's hands
4029  my $msg = $_[0];
4030  my $interval = c('smtpd_timeout');
4031  $interval = 5  if $interval < 5;
4032  ll(5) && do_log(5, 'switch_to_client_time %d s, %s', $interval,$msg);
4033  undef $timestamp_of_last_reception;
4034  alarm($interval); $waiting_for_client = 1;
4035}
4036
4037# pretty-print a structure for logging purposes: returns a string
4038#
4039sub fmt_struct($);  # prototype
4040sub fmt_struct($) {
4041  my $arg = $_[0];
4042  my $r = ref $arg;
4043  !$r ?
4044    (defined($arg) ? '"'.$arg.'"' : 'undef')
4045  : $r eq 'ARRAY' ?
4046      '[' . join(',', map(fmt_struct($_), @$arg)) . ']'
4047  : $r eq 'HASH' ?
4048      '{' . join(',', map($_.'=>'.fmt_struct($arg->{$_}), keys %$arg)) . '}'
4049  : $arg;
4050};
4051
4052# used by freeze: protect % and ~, as well as NUL and \200 for good measure
4053#
4054sub st_encode($) {
4055  my $str = $_[0]; local($1);
4056  { # concession on a perl 5.20.0 bug [perl #122148] (fixed in 5.20.1)
4057    # - just warn, do not abort
4058    use warnings NONFATAL => qw(utf8);
4059    $str =~ s/([%~\000\200])/sprintf('%%%02X',ord($1))/gse;
4060  };
4061  $str;
4062}
4063
4064# simple Storable::freeze lookalike
4065#
4066sub freeze($);  # prototype
4067sub freeze($) {
4068  my $obj = $_[0]; my $ty = ref($obj);
4069  if (!defined($obj))     { 'U' }
4070  elsif (!$ty)            { join('~', '',  st_encode($obj))  }  # string
4071  elsif ($ty eq 'SCALAR') { join('~', 'S', st_encode(freeze($$obj))) }
4072  elsif ($ty eq 'REF')    { join('~', 'R', st_encode(freeze($$obj))) }
4073  elsif ($ty eq 'ARRAY')  { join('~', 'A', map(st_encode(freeze($_)),@$obj)) }
4074  elsif ($ty eq 'HASH') {
4075    join('~', 'H',
4076         map {(st_encode($_),st_encode(freeze($obj->{$_})))} sort keys %$obj)
4077  } else { die "Can't freeze object type $ty" }
4078}
4079
4080# simple Storable::thaw lookalike
4081#
4082sub thaw($);  # prototype
4083sub thaw($) {
4084  my $str = $_[0];
4085  return undef  if !defined $str;  # must return undef even in a list context!
4086  my($ty,@val) = split(/~/,$str,-1);
4087  s/%([0-9a-fA-F]{2})/pack('C',hex($1))/gse  for @val;
4088  if    ($ty eq 'U') { undef }
4089  elsif ($ty eq '')  { $val[0] }
4090  elsif ($ty eq 'S') { my $obj = thaw($val[0]); \$obj }
4091  elsif ($ty eq 'R') { my $obj = thaw($val[0]); \$obj }
4092  elsif ($ty eq 'A') { [map(thaw($_),@val)] }
4093  elsif ($ty eq 'H') {
4094    my $hr = {};
4095    while (@val) { my $k = shift @val; $hr->{$k} = thaw(shift @val) }
4096    $hr;
4097  } else { die "Can't thaw object type $ty" }
4098}
4099
4100# accepts either a single contents category (a string: "maj,min" or "maj"),
4101# or a list of contents categories, in which case only the first element
4102# is considered; returns a passed pair: (major_ccat, minor_ccat)
4103#
4104sub ccat_split($) {
4105  my $ccat = $_[0]; my $major; my $minor;
4106  $ccat = $ccat->[0]  if ref $ccat;  # pick the first element if given a list
4107  ($major,$minor) = split(/,/,$ccat,-1)  if defined $ccat;
4108  !wantarray ? $major : ($major,$minor);
4109}
4110
4111# accepts either a single contents category (a string: "maj,min" or "maj"),
4112# or a list of contents categories, in which case only the first element
4113# is considered; returns major_ccat
4114#
4115sub ccat_maj($) {
4116  my $ccat = $_[0]; my $major; my $minor;
4117  $ccat = $ccat->[0]  if ref $ccat;  # pick the first element if given a list
4118  ($major,$minor) = split(/,/,$ccat,-1)  if defined $ccat;
4119  $major;
4120}
4121
4122# compare numerically two strings of the form "maj,min" or just "maj", where
4123# maj and min are numbers, representing major and minor contents category
4124#
4125sub cmp_ccat($$) {
4126  my($a_maj,$a_min) = split(/,/, $_[0], -1);
4127  my($b_maj,$b_min) = split(/,/, $_[1], -1);
4128  $a_maj == $b_maj ? $a_min <=> $b_min : $a_maj <=> $b_maj;
4129}
4130
4131# similar to cmp_ccat, but consider only the major category of both arguments
4132#
4133sub cmp_ccat_maj($$) {
4134  my($a_maj,$a_min) = split(/,/, $_[0], -1);
4135  my($b_maj,$b_min) = split(/,/, $_[1], -1);
4136  $a_maj <=> $b_maj;
4137}
4138
4139# get a list of settings corresponding to all listed contents categories,
4140# ordered from the most important category to the least;  @ccat is a list of
4141# relevant contents categories for which a query is made, it MUST already be
4142# sorted in descending order;  this is a classical subroutine, not a method!
4143#
4144sub setting_by_given_contents_category_all($@) {
4145  my($ccat,@settings_href_list) = @_; my(@r);
4146  if (@settings_href_list) {
4147    for my $e ((!defined $ccat ? () : ref $ccat ?@$ccat :$ccat), CC_CATCHALL) {
4148      if (grep(defined($_) && exists($_->{$e}), @settings_href_list)) {
4149        # supports lazy evaluation (a setting may be a subroutine)
4150        my(@slist) = map { !defined($_) || !exists($_->{$e}) ? undef :
4151                           do { my $s = $_->{$e}; ref($s) eq 'CODE' ? &$s : $s}
4152                         } @settings_href_list;
4153        push(@r, [$e,@slist]);  # a tuple: [corresponding ccat, settings list]
4154      }
4155    }
4156  }
4157  @r;  # a list of tuples
4158}
4159
4160# similar to setting_by_given_contents_category_all(), but only the first
4161# (the most relevant) setting is returned, without a corresponding ccat
4162#
4163sub setting_by_given_contents_category($@) {
4164  my($ccat,@settings_href_list) = @_; my(@slist);
4165  if (@settings_href_list) {
4166    for my $e ((!defined $ccat ? () : ref $ccat ?@$ccat :$ccat), CC_CATCHALL) {
4167      if (grep(defined($_) && exists($_->{$e}), @settings_href_list)) {
4168        # supports lazy evaluation (setting may be a subroutine)
4169        @slist = map { !defined($_) || !exists($_->{$e}) ? undef :
4170                       do { my $s = $_->{$e}; ref($s) eq 'CODE' ? &$s : $s }
4171                     } @settings_href_list;
4172        last;
4173      }
4174    }
4175  }
4176  !wantarray ? $slist[0] : @slist;  # only the first entry
4177}
4178
4179# Removes a directory, along with its contents
4180#
4181# The readdir() is entitled to fail if the directory changes underneath,
4182# so do the deletions by chunks: read a limited set of filenames into
4183# memory, close directory, delete these files, and repeat.
4184# The current working directory must not be within directories which are
4185# to be deleted, otherwise rmdir can fail with 'Invalid argument' (e.g.
4186# on Solaris 10).
4187#
4188sub rmdir_recursively($;$);  # prototype
4189sub rmdir_recursively($;$) {
4190  my($dir, $exclude_itself) = @_;
4191  ll(4) && do_log(4, 'rmdir_recursively: %s, excl=%s', $dir,$exclude_itself);
4192  my($f, @rmfiles, @rmdirs);  my $more = 1;  my $dir_chmoded = 0;
4193  while ($more) {
4194    local(*DIR);  $more = 0;
4195    my $errn = opendir(DIR,$dir) ? 0 : 0+$!;
4196    if ($errn == EACCES && !$dir_chmoded) {
4197      # relax protection on directory, then try again
4198      do_log(3,'rmdir_recursively: enabling read access to directory %s',$dir);
4199      chmod(0750,$dir)
4200        or do_log(-1, "Can't change protection-1 on dir %s: %s", $dir, $!);
4201      $dir_chmoded = 1;
4202      $errn = opendir(DIR,$dir) ? 0 : 0+$!;  # try again
4203    }
4204    if ($errn) { die "Can't open directory $dir: $!" }
4205    my $cnt = 0;
4206    # avoid slurping the whole directory contents into memory
4207    while (defined($f = readdir(DIR))) {
4208      next  if $f eq '.' || $f eq '..';
4209      my $fname = $dir . '/' . $f;
4210      $errn = lstat($fname) ? 0 : 0+$!;
4211      if ($errn == EACCES && !$dir_chmoded) {
4212        # relax protection on the directory and retry
4213        do_log(3,'rmdir_recursively: enabling access to files in dir %s',$dir);
4214        chmod(0750,$dir)
4215          or do_log(-1, "Can't change protection-2 on dir %s: %s", $dir, $!);
4216        $dir_chmoded = 1;
4217        $errn = lstat($fname) ? 0 : 0+$!;  # try again
4218      }
4219      if ($errn) { do_log(-1, "Can't access file \"%s\": $!", $fname,$!) }
4220      if (-d _) { push(@rmdirs,$f) } else { push(@rmfiles,$f) }
4221      $cnt++;
4222      if ($cnt >= 1000) {
4223        do_log(3,'rmdir_recursively: doing %d files and %d dirs for now in %s',
4224                 scalar(@rmfiles), scalar(@rmdirs), $dir);
4225        $more = 1;
4226        last;
4227      }
4228    }
4229    # fixed by perl5.20: readdir() now only sets $! on error.  $! is no longer
4230    # set to EBADF when then terminating undef is read from the directory
4231    # unless the system call sets $!. [perl #118651]
4232    closedir(DIR) or die "Error closing directory $dir: $!";
4233    my $cntf = scalar(@rmfiles);
4234    for my $f (@rmfiles) {
4235      my $fname = $dir . '/' . untaint($f);
4236      if (unlink($fname)) {
4237        # ok
4238      } elsif ($! == EACCES && !$dir_chmoded) {
4239        # relax protection on the directory, then try again
4240        do_log(3,'rmdir_recursively: enabling write access to dir %s',$dir);
4241        my $what = -l _ ? 'symlink' :-d _ ? 'directory' :'non-regular file';
4242        chmod(0750,$dir)
4243          or do_log(-1, "Can't change protection-3 on dir %s: %s", $dir, $!);
4244        $dir_chmoded = 1;
4245        unlink($fname) or die "Can't remove $what $fname: $!";
4246      }
4247    }
4248    undef @rmfiles;
4249    section_time("unlink-$cntf-files")  if $cntf > 0;
4250    for my $d (@rmdirs) {
4251      rmdir_recursively($dir . '/' . untaint($d));
4252    }
4253    undef @rmdirs;
4254  }
4255  if (!$exclude_itself) {
4256    rmdir($dir) or die "rmdir_recursively: Can't remove directory $dir: $!";
4257    section_time('rmdir');
4258  }
4259  1;
4260}
4261
4262# efficiently read a file (in binmode) into a provided string;
4263# either an open file handle may be given, or a filename
4264#
4265sub read_file($$) {
4266  my($fname,$strref) = @_;
4267  my($fh, $file_size, $nbytes);
4268  if (ref $fname) {
4269    $fh = $fname;  # assume a file handle was given
4270  } else {  # a filename
4271    $fh = IO::File->new;
4272    $fh->open($fname,O_RDONLY)  # does a sysopen
4273      or die "Can't open file $fname for reading: $!";
4274    $fh->binmode or die "Can't set file $fname to binmode: $!";
4275  }
4276  my(@stat_list) = stat($fh);
4277  @stat_list or die "Failed to access file: $!";
4278  $file_size = -s _  if -f _;
4279  if ($file_size) {
4280    # preallocate exact storage size, avoids realloc/copying while growing
4281    $$strref = ''; vec($$strref, $file_size + 32768, 8) = 0;
4282  }
4283  $$strref = '';
4284#*** handle EINTR
4285  while ( $nbytes=sysread($fh, $$strref, 32768, length $$strref) ) { }
4286  defined $nbytes or die "Error reading from $fname: $!";
4287  if (!ref $fname) { $fh->close or die "Error closing $fname: $!" }
4288  $strref;
4289}
4290
4291# read a text file, returning its contents as a string - suitable for
4292# calling from amavisd.conf
4293#
4294sub read_text($;$) {
4295  my($fname, $encoding) = @_;
4296  my $fh = IO::File->new;
4297  $fh->open($fname,'<') or die "Can't open file $fname for reading: $!";
4298  if (defined($encoding) && $encoding ne '') {
4299    binmode($fh, ":encoding($encoding)")
4300      or die "Can't set :encoding($encoding) on file $fname: $!";
4301  }
4302  my $nbytes; my $str = '';
4303  while (($nbytes = $fh->read($str, 16384, length($str))) > 0) { }
4304  defined $nbytes or die "Error reading from $fname: $!";
4305  $fh->close or die "Error closing $fname: $!";
4306  my $result = $str; undef $str;  # shrink allocated storage to actual size
4307  $result;
4308}
4309
4310# attempt to read all user-visible replies from a l10n dir
4311# This function auto-fills $notify_sender_templ, $notify_virus_sender_templ,
4312# $notify_virus_admin_templ, $notify_virus_recips_templ,
4313# $notify_spam_sender_templ and $notify_spam_admin_templ from files named
4314# template-dsn.txt, template-virus-sender.txt, template-virus-admin.txt,
4315# template-virus-recipient.txt, template-spam-sender.txt,
4316# template-spam-admin.txt.  If this is available, it uses the charset
4317# file to do automatic charset conversion. Used by the Debian distribution.
4318#
4319sub read_l10n_templates($;$) {
4320  my $dir = $_[0];
4321  if (@_ > 1)  # compatibility with Debian
4322    { my($l10nlang, $l10nbase) = @_; $dir = "$l10nbase/$l10nlang" }
4323  my $file_chset = Amavis::Util::read_text("$dir/charset");
4324  local($1,$2);
4325  if ($file_chset =~ m{^(?:\#[^\n]*\n)*([^./\n\s]+)(\s*[\#\n].*)?$}s) {
4326    $file_chset = untaint("$1");
4327  } else {
4328    die "Invalid charset $file_chset\n";
4329  }
4330  $Amavis::Conf::notify_sender_templ =
4331    Amavis::Util::read_text("$dir/template-dsn.txt", $file_chset);
4332  $Amavis::Conf::notify_virus_sender_templ =
4333    Amavis::Util::read_text("$dir/template-virus-sender.txt", $file_chset);
4334  $Amavis::Conf::notify_virus_admin_templ =
4335    Amavis::Util::read_text("$dir/template-virus-admin.txt", $file_chset);
4336  $Amavis::Conf::notify_virus_recips_templ =
4337    Amavis::Util::read_text("$dir/template-virus-recipient.txt", $file_chset);
4338  $Amavis::Conf::notify_spam_sender_templ =
4339    Amavis::Util::read_text("$dir/template-spam-sender.txt", $file_chset);
4340  $Amavis::Conf::notify_spam_admin_templ =
4341    Amavis::Util::read_text("$dir/template-spam-admin.txt", $file_chset);
4342}
4343
4344# # attempt to read a list of config files to use instead of the default one,
4345# # using an external helper script. Used by the Debian/Ubuntu distribution.
4346# sub find_config_files(@) {
4347#   my(@dirs) = @_;
4348#   local $ENV{PATH} = '/bin:/usr/bin';
4349#   my(@config_files) = map { `run-parts --list "$_"` } @dirs;
4350#   chomp(@config_files);
4351#   # untaint - this data is secure as we check the files themselves later
4352#   map { untaint($_) } @config_files;
4353# }
4354
4355#use CDB_File;
4356#sub tie_hash($$) {
4357# my($hashref, $filename) = @_;
4358# CDB_File::create(%$hashref, $filename, "$filename.tmp$$")
4359#   or die "Can't create cdb $filename: $!";
4360# my $cdb = tie(%$hashref,'CDB_File',$filename)
4361#   or die "Tie to $filename failed: $!";
4362# $hashref;
4363#}
4364
4365# read an associative array (=Perl hash) (as used in lookups) from a file;
4366# may be called from amavisd.conf
4367#
4368# Format: one key per line, anything from '#' to the end of line
4369# is considered a comment, but '#' within correctly quoted RFC 5321
4370# addresses is not treated as a comment introducer (e.g. a hash sign
4371# within "strange # \"foo\" address"@example.com is part of the string).
4372# Lines may contain a pair: key value, separated by whitespace,
4373# or key only, in which case a value 1 is implied. Trailing whitespace
4374# is discarded (iff $trim_trailing_space_in_lookup_result_fields),
4375# empty lines (containing only whitespace or comment) are ignored.
4376# Addresses (lefthand-side) are converted from RFC 5321 -quoted form
4377# into internal (raw) form and inserted as keys into a given hash.
4378# International domain names (IDN) in UTF-8 are encoded to ASCII.
4379# NOTE: the format is partly compatible with Postfix maps (not aliases):
4380#   no continuation lines are honoured, Postfix maps do not allow
4381#   RFC 5321 -quoted addresses containing whitespace, Postfix only allows
4382#   comments starting at the beginning of a line.
4383#
4384# The $hashref argument is returned for convenience, so that one can do
4385# for example:
4386#   $per_recip_whitelist_sender_lookup_tables = {
4387#     '.my1.example.com' => read_hash({},'/var/amavis/my1-example-com.wl'),
4388#     '.my2.example.com' => read_hash({},'/var/amavis/my2-example-com.wl') }
4389# or even simpler:
4390#   $per_recip_whitelist_sender_lookup_tables = {
4391#     '.my1.example.com' => read_hash('/var/amavis/my1-example-com.wl'),
4392#     '.my2.example.com' => read_hash('/var/amavis/my2-example-com.wl') }
4393#
4394sub read_hash(@) {
4395  unshift(@_,{})  if !ref $_[0];  # first argument is optional, defaults to {}
4396  my($hashref, $filename, $keep_case) = @_;
4397  my $lpcs = c('localpart_is_case_sensitive');
4398  my $inp = IO::File->new;
4399  $inp->open($filename,'<') or die "Can't open file $filename for reading: $!";
4400  my $ln;
4401  for ($! = 0; defined($ln=$inp->getline); $! = 0) {
4402    chomp($ln);
4403    # carefully handle comments, '#' within "" does not count as a comment
4404    my $lhs = ''; my $rhs = ''; my $at_rhs = 0; my $trailing_comment = 0;
4405    for my $t ( $ln =~ /\G ( " (?: \\. | [^"\\] )* " |
4406                             [^#" \t]+ | [ \t]+ | . )/xgs) {
4407      if ($t eq '#') { $trailing_comment = 1; last }
4408      if (!$at_rhs && $t =~ /^[ \t]+\z/) { $at_rhs = 1 }
4409      else { ($at_rhs ? $rhs : $lhs) .= $t }
4410    }
4411    $rhs =~ s/[ \t]+\z//  if $trailing_comment ||
4412                             $trim_trailing_space_in_lookup_result_fields;
4413    next  if $lhs eq '' && $rhs eq '';
4414    my($source_route, $localpart, $domain) =
4415      Amavis::rfc2821_2822_Tools::parse_quoted_rfc2821($lhs,1);
4416    $localpart = lc($localpart)  if !$lpcs;
4417    my $addr = $localpart . idn_to_ascii($domain);
4418    $hashref->{$addr} = $rhs eq '' ? 1 : $rhs;
4419    # do_log(5, 'read_hash: address: <%s>: %s', $addr, $hashref->{$addr});
4420  }
4421  defined $ln || $! == 0  or   # returning EBADF at EOF is a perl bug
4422    $! == EBADF ? do_log(0,'Error reading from %s: %s', $filename,$!)
4423                : die "Error reading from $filename: $!";
4424  $inp->close or die "Error closing $filename: $!";
4425  $hashref;
4426}
4427
4428sub read_array(@) {
4429  unshift(@_,[])  if !ref $_[0];  # first argument is optional, defaults to []
4430  my($arrref, $filename, $keep_case) = @_;
4431  my $inp = IO::File->new;
4432  $inp->open($filename,'<') or die "Can't open file $filename for reading: $!";
4433  my $ln;
4434  for ($! = 0; defined($ln=$inp->getline); $! = 0) {
4435    chomp($ln); my $lhs = '';
4436    # carefully handle comments, '#' within "" does not count as a comment
4437    for my $t ( $ln =~ /\G ( " (?: \\. | [^"\\] )* " |
4438                             [^#" \t]+ | [ \t]+ | . )/xgs) {
4439      last  if $t eq '#';
4440      $lhs .= $t;
4441    }
4442    $lhs =~ s/[ \t]+\z//;  # trim trailing whitespace
4443    push(@$arrref, Amavis::rfc2821_2822_Tools::unquote_rfc2821_local($lhs))
4444      if $lhs ne '';
4445  }
4446  defined $ln || $! == 0  or   # returning EBADF at EOF is a perl bug
4447    $! == EBADF ? do_log(0,'Error reading from %s: %s', $filename,$!)
4448                : die "Error reading from $filename: $!";
4449  $inp->close or die "Error closing $filename: $!";
4450  $arrref;
4451}
4452
4453# The read_cidr() reads a Postfix style CIDR file, (see cidr_table(5) man
4454# page), with postfix-style interpretation of comments and line continuations,
4455# returning a ref to an array or a ref to a hash (associative array ref).
4456#
4457# Empty or whitespace-only lines are ignored, as are lines whose first
4458# non-whitespace character is a '#'. A logical line starts with non-whitespace
4459# text. A line that starts with whitespace continues a logical line.
4460# The general form is:  network_address/network_mask  result
4461# where 'network_address' is an IPv4 address in a dot-quad form, or an IPv6
4462# address optionally enclosed in square brackets. The 'network_mask' along
4463# with a preceding slash is optional, as is the 'result' argument.
4464#
4465# If a network mask is omitted, a host address (not a network address)
4466# is assumed (i.e. a mask defaults to /32 for an IPv4 address, and
4467# to /128 for an IPv6 address).
4468#
4469# The read_cidr() returns a ref to an array or a ref to an hash (associative
4470# array) of network specifications, directly suitable for use as a lookup
4471# table in @client_ipaddr_policy and @mynetworks_maps, or for copying the
4472# array into @inet_acl or @mynetworks.
4473#
4474# When returned as an array the 'result' arguments are ignored, just the
4475# presence of a network specification matters. A '!' may precede the network
4476# specification, which will be interpreted as by lookup_ip_acl() as a negation,
4477# i.e. a match on such entry will return a false.
4478#
4479# When returned as a hash, the network specification is lowercased and used
4480# as a key, and the 'result' is stored as a value of a hash entry. A missing
4481# 'result' is replaced by 1.
4482#
4483# See also the lookup_ip_acl() for details on allowed IP address syntax
4484# and on the interpretation of array and hash type IP lookup tables.
4485#
4486sub read_cidr($;$) {
4487  my($filename, $result) = @_;
4488  # the $result arg may be a ref to an existing array or hash, in which case
4489  # data will be added there - either as key/value pairs, or as array elements;
4490  $result = [] if !defined $result;  # missing $results arg yields an array
4491  my $have_arry = ref $result eq 'ARRAY';
4492  my $inp = IO::File->new;
4493  $inp->open($filename,'<') or die "Can't open file $filename for reading: $!";
4494  my($ln, $curr_line);
4495  for ($! = 0; defined($ln=$inp->getline); $! = 0) {
4496    next  if $ln =~ /^ [ \t]* (?: \# | $ )/xs;
4497    chomp($ln);
4498    if ($ln =~ /^[ \t]/) {  # a continuation line
4499      $curr_line = ''  if !defined $curr_line;  # first line a continuation??
4500      $curr_line .= $ln;
4501    } else {  # a new logical line starts
4502      if (defined $curr_line) {  # deal with the previous logical line
4503        my($key,$val) = split(' ',$curr_line,2);
4504        # $val is always defined, it is an empty string if missing
4505        if ($have_arry) { push(@$result,$key) }
4506        else { $result->{lc $key} = $val eq '' ? 1 : $val }
4507      }
4508      $curr_line = $ln;
4509    }
4510  }
4511  if (defined $curr_line) {  # deal with the last logical line
4512    my($key,$val) = split(' ',$curr_line,2);
4513    if ($have_arry) { push(@$result,$key) }
4514    else { $result->{lc $key} = $val eq '' ? 1 : $val }
4515  }
4516  defined $ln || $! == 0  or   # returning EBADF at EOF is a perl bug
4517    $! == EBADF ? do_log(0,'Error reading from %s: %s', $filename,$!)
4518                : die "Error reading from $filename: $!";
4519  $inp->close or die "Error closing $filename: $!";
4520  $result;
4521}
4522
4523sub dump_hash($) {
4524  my $hr = $_[0];
4525  do_log(0, 'dump_hash: %s => %s', $_, $hr->{$_})  for (sort keys %$hr);
4526}
4527
4528sub dump_array($) {
4529  my $ar = $_[0];
4530  do_log(0, 'dump_array: %s', $_)  for @$ar;
4531}
4532
4533# use Devel::Symdump;
4534# sub dump_subs() {
4535#   my $obj = Devel::Symdump->rnew;
4536#   # list of all subroutine names and their memory addresses
4537#   my @a = map([$_, \&$_], $obj->functions, $obj->scalars,
4538#                           $obj->arrays, $obj->hashes);
4539#   open(SUBLIST, ">/tmp/1.log") or die "Can't create a file: $!";
4540#   for my $s (sort { $a->[1] <=> $b->[1] } @a) {  # sorted by memory address
4541#     printf SUBLIST ("%s %s\n", $s->[1], $s->[0]);
4542#   }
4543#   close(SUBLIST) or die "Can't close a file: $!";
4544# }
4545
4546# (deprecated, only still used with Amavis::OS_Fingerprint)
4547sub dynamic_destination($$) {
4548  my($method,$conn) = @_;
4549  if ($method =~ /^(?:[a-z][a-z0-9.+-]*)?:/si) {
4550    my(@list); $list[0] = ''; my $j = 0;
4551    for ($method =~ /\G \[ (?: \\. | [^\]\\] )* \] | " (?: \\. | [^"\\] )* "
4552                        | : | [ \t]+ | [^:"\[ \t]+ | . /xgs) {  # real parsing
4553      if ($_ eq ':') { $list[++$j] = '' } else { $list[$j] .= $_ }
4554    };
4555    if ($list[1] =~ m{^/}) {
4556      # presumably the second field is a Unix socket name, keep unchanged
4557    } else {
4558      my $new_method; my($proto,$relayhost,$relayport) = @list;
4559      if ($relayhost eq '*') {
4560        my $client_ip;  $client_ip = $conn->client_ip if $conn;
4561        $relayhost = "[$client_ip]"  if defined $client_ip && $client_ip ne '';
4562      }
4563      if ($relayport eq '*') {
4564        my $socket_port;  $socket_port = $conn->socket_port if $conn;
4565        $relayport = $socket_port + 1
4566          if defined $socket_port && $socket_port ne '';
4567      }
4568      if ($relayhost eq '*' || $relayport eq '*') {
4569        do_log(0,'dynamic destination expected, no client addr/port info: %s',
4570                  $method);
4571      }
4572      $list[1] = $relayhost;  $list[2] = $relayport;
4573      $new_method = join(':',@list);
4574      if ($new_method ne $method) {
4575        do_log(3, 'dynamic destination: %s -> %s', $method,$new_method);
4576        $method = $new_method;
4577      }
4578    }
4579  }
4580  $method;
4581}
4582
4583# collect unfinished recipients matching a $filter sub and a delivery
4584# method regexp;  assumes all list elements of a delivery_method list
4585# use the same protocol name, hence only the first one is inspected
4586#
4587sub collect_equal_delivery_recips($$$) {
4588  my($msginfo, $filter, $deliv_meth_regexp) = @_;
4589  my(@per_recip_data_subset, $proto_sockname);
4590
4591  my(@per_recip_data) =
4592    grep(!$_->recip_done && (!$filter || &$filter($_)) &&
4593         grep(/$deliv_meth_regexp/,
4594              (ref $_->delivery_method ? $_->delivery_method->[0]
4595                                       : $_->delivery_method)),
4596         @{$msginfo->per_recip_data});
4597  if (@per_recip_data) {
4598    # take the first remaining recipient as a model
4599    $proto_sockname = $per_recip_data[0]->delivery_method;
4600    defined $proto_sockname  or die "undefined recipient's delivery_method";
4601    my $proto_sockname_key = !ref $proto_sockname ? $proto_sockname
4602                                                : join("\n", @$proto_sockname);
4603    # collect recipients with the same delivery method as the first one
4604    $per_recip_data_subset[0] = shift(@per_recip_data);  # always equals self
4605    push(@per_recip_data_subset,
4606         grep((ref $_->delivery_method ? join("\n", @{$_->delivery_method})
4607                                       : $_->delivery_method)
4608               eq $proto_sockname_key,  @per_recip_data) );
4609  }
4610  # return a ref to a filtered list of still-to-be-delivered recipient objects
4611  # and a single string or a ref to a list of delivery methods common to
4612  # these recipients
4613  (\@per_recip_data_subset, $proto_sockname);
4614}
4615
46161;
4617
4618#
4619package Amavis::ProcControl;
4620use strict;
4621use re 'taint';
4622
4623BEGIN {
4624  require Exporter;
4625  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
4626  $VERSION = '2.412';
4627  @ISA = qw(Exporter);
4628  @EXPORT_OK = qw(&exit_status_str &proc_status_ok &kill_proc &cloexec
4629                  &run_command &run_command_consumer &run_as_subprocess
4630                  &collect_results &collect_results_structured);
4631  import Amavis::Conf qw(:platform c cr ca);
4632  import Amavis::Util qw(ll do_log do_log_safe prolong_timer untaint
4633                         flush_captured_log reposition_captured_log_to_end);
4634  import Amavis::Log qw(open_log close_log log_fd);
4635}
4636use subs @EXPORT_OK;
4637
4638use POSIX qw(:sys_wait_h WIFEXITED WIFSIGNALED WIFSTOPPED WEXITSTATUS
4639             WTERMSIG WSTOPSIG);
4640use Errno qw(ENOENT EACCES EAGAIN ESRCH);
4641use IO::File ();
4642use Time::HiRes ();
4643# use Fcntl qw(F_GETFD F_SETFD FD_CLOEXEC);  # used in cloexec, if enabled
4644
4645# map process termination status number to an informative string, and
4646# append optional message (dual-valued errno or a string or a number),
4647# returning the resulting string
4648#
4649sub exit_status_str($;$) {
4650  my($stat,$errno) = @_; my $str;
4651  if (!defined($stat)) {
4652    $str = '(no status)';
4653  } elsif (WIFEXITED($stat)) {
4654    $str = sprintf('exit %d', WEXITSTATUS($stat));
4655  } elsif (WIFSTOPPED($stat)) {
4656    $str = sprintf('stopped, signal %d', WSTOPSIG($stat));
4657  } else {  # WIFSIGNALED($stat)
4658    my $sig = WTERMSIG($stat);
4659    $str = sprintf('%s, signal %d (%04x)',
4660             $sig == 1 ? 'HANGUP' : $sig == 2 ? 'INTERRUPTED' :
4661             $sig == 6 ? 'ABORTED' : $sig == 9 ? 'KILLED' :
4662             $sig == 15 ? 'TERMINATED' : 'DIED',
4663             $sig, $stat);
4664  }
4665  if (defined $errno) {  # deal with dual-valued and plain variables
4666    $str .= ', '.$errno  if (0+$errno) != 0 || ($errno ne '' && $errno ne '0');
4667  }
4668  $str;
4669}
4670
4671# check errno to be 0 and a process exit status to be in the list of success
4672# status codes, returning true if both are ok, and false otherwise
4673#
4674sub proc_status_ok($;$@) {
4675  my($exit_status,$errno,@success) = @_;
4676  my $ok = 0;
4677  if ((!defined $errno || $errno == 0) && WIFEXITED($exit_status)) {
4678    my $j = WEXITSTATUS($exit_status);
4679    if (!@success) { $ok = $j==0 }  # empty list implies only status 0 is good
4680    elsif (grep($_==$j, @success)) { $ok = 1 }
4681  }
4682  $ok;
4683}
4684
4685# kill a process, typically a spawned external decoder or checker
4686#
4687sub kill_proc($;$$$$) {
4688  my($pid,$what,$timeout,$proc_fh,$reason) = @_;
4689  $pid >= 0  or die "Shouldn't be killing process groups: [$pid]";
4690  $pid != 1  or die "Shouldn't be killing process 'init': [$pid]";
4691  $what   = defined $what   ? " running $what"     : '';
4692  $reason = defined $reason ? " (reason: $reason)" : '';
4693  #
4694  # the following order is a must: SIGTERM first, _then_ close a pipe;
4695  # otherwise the following can happen: closing a pipe first (explicitly or
4696  # implicitly by undefining $proc_fh) blocks us so we never send SIGTERM
4697  # until the external process dies of natural death; on the other hand,
4698  # not closing the pipe after SIGTERM does not necessarily let the process
4699  # notice SIGTERM, so SIGKILL is always needed to stop it, which is not nice
4700  #
4701  my $n = kill(0,$pid);  # does the process really exist?
4702  if ($n == 0 && $! != ESRCH) {
4703    die sprintf("Can't send SIG 0 to process [%s]%s: %s", $pid,$what,$!);
4704  } elsif ($n == 0) {
4705    do_log(2, 'no need to kill process [%s]%s, already gone', $pid,$what);
4706  } else {
4707    do_log(-1,'terminating process [%s]%s%s', $pid,$what,$reason);
4708    kill('TERM',$pid) or $! == ESRCH  # be gentle on the first attempt
4709      or die sprintf("Can't send SIGTERM to process [%s]%s: %s",$pid,$what,$!);
4710  }
4711  # close the pipe if still open, ignoring status
4712  $proc_fh->close  if defined $proc_fh;
4713  my $child_stat = defined $pid && waitpid($pid,WNOHANG) > 0 ? $? : undef;
4714  $n = kill(0,$pid);  # is the process still there?
4715  if ($n > 0 && defined($timeout) && $timeout > 0) {
4716    sleep($timeout); $n = kill(0,$pid);  # wait a little and recheck
4717  }
4718  if ($n == 0 && $! != ESRCH) {
4719    die sprintf("Can't send SIG 0 to process [%s]%s: %s", $pid,$what,$!);
4720  } elsif ($n > 0) {  # the process is still there, try a stronger signal
4721    do_log(-1,'process [%s]%s is still alive, using a bigger hammer (SIGKILL)',
4722              $pid,$what);
4723    kill('KILL',$pid) or $! == ESRCH
4724      or die sprintf("Can't send SIGKILL to process [%s]%s: %s",$pid,$what,$!);
4725  }
4726}
4727
4728sub cloexec($;$$) { undef }
4729# sub cloexec($;$$) {  # supposedly not needed for Perl >= 5.6.0
4730#   my($fh,$newsetting,$name) = @_; my $flags;
4731#   $flags = fcntl($fh, F_GETFD, 0)
4732#     or die "Can't get close-on-exec flag for file handle $fh $name: $!";
4733#   $flags = 0 + $flags;  # turn into numeric, avoid: "0 but true"
4734#   if (defined $newsetting) {  # change requested?
4735#     my $newflags = $newsetting ? ($flags|FD_CLOEXEC) : ($flags&~FD_CLOEXEC);
4736#     if ($flags != $newflags) {
4737#       do_log(4,"cloexec: turning %s flag FD_CLOEXEC for file handle %s %s",
4738#              $newsetting ? "ON" : "OFF", $fh, $name);
4739#       fcntl($fh, F_SETFD, $newflags)
4740#         or die "Can't set FD_CLOEXEC for file handle $fh $name: $!";
4741#     }
4742#   }
4743#   ($flags & FD_CLOEXEC) ? 1 : 0;  # returns old setting
4744# }
4745
4746# POSIX::open a file or dup an existing fd (Perl open syntax), with a
4747# requirement that it gets opened on a prescribed file descriptor $fd_target.
4748# Returns a file descriptor number (not a Perl file handle, there is no
4749# associated file handle). Usually called from a forked process prior to exec.
4750#
4751sub open_on_specific_fd($$$$) {
4752  my($fd_target,$fname,$flags,$mode) = @_;
4753  my $fd_got;  # fd directly given as argument, or obtained from POSIX::open
4754  my $logging_safe = 0;
4755  if (ll(5)) {
4756    # crude attempt to prevent a forked process from writing log records
4757    # to its parent process on STDOUT or STDERR
4758    my $log_fd = log_fd();
4759    $logging_safe = 1  if !defined($log_fd) || $log_fd > 2;
4760  }
4761  local($1);
4762  if ($fname =~ /^&=?(\d+)\z/) { $fd_got = $1 }  # fd directly specified
4763  my $flags_displayed = $flags == &POSIX::O_RDONLY ? '<'
4764                      : $flags == &POSIX::O_WRONLY ? '>' : '('.$flags.')';
4765  if (!defined($fd_got) || $fd_got != $fd_target) {
4766    # close whatever is on a target descriptor but don't shoot self in the foot
4767    # with Net::Server <= 0.90 fd0 was main::stdin, but no longer is in 0.91
4768    do_log_safe(5, "open_on_specific_fd: target fd%s closing, to become %s %s",
4769                $fd_target, $flags_displayed, $fname)
4770                if $logging_safe && ll(5);
4771    # it pays off to close explicitly, with some luck open will get a target fd
4772    POSIX::close($fd_target);  # ignore error; we may have just closed a log
4773  }
4774  if (!defined($fd_got)) {  # a file name was given, not a descriptor
4775    $fd_got = POSIX::open($fname,$flags,$mode);
4776    defined $fd_got or die "Can't open $fname ($flags,$mode): $!";
4777    $fd_got = 0 + $fd_got;  # turn into numeric, avoid: "0 but true"
4778  }
4779  if ($fd_got != $fd_target) {  # dup, ensuring we get a requested descriptor
4780    # we may have been left without a log file descriptor, must not die
4781    do_log_safe(5, "open_on_specific_fd: target fd%s dup2 from fd%s %s %s",
4782                $fd_target, $fd_got, $flags_displayed, $fname)
4783                if $logging_safe && ll(5);
4784    # POSIX mandates we got the lowest fd available (but some kernels have
4785    # bugs), let's be explicit that we require a specified file descriptor
4786    defined POSIX::dup2($fd_got,$fd_target)
4787      or die "Can't dup2 from $fd_got to $fd_target: $!";
4788    if ($fd_got > 2) {  # let's get rid of the original fd, unless 0,1,2
4789      my $err; defined POSIX::close($fd_got) or $err = $!;
4790      $err = defined $err ? ": $err" : '';
4791      # we may have been left without a log file descriptor, don't die
4792      do_log_safe(5, "open_on_specific_fd: source fd%s closed%s",
4793                  $fd_got,$err)  if $logging_safe && ll(5);
4794    }
4795  }
4796  $fd_got;
4797}
4798
4799sub release_parent_resources() {
4800  $Amavis::sql_dataset_conn_lookups->dbh_inactive(1)
4801    if $Amavis::sql_dataset_conn_lookups;
4802  $Amavis::sql_dataset_conn_storage->dbh_inactive(1)
4803    if $Amavis::sql_dataset_conn_storage;
4804  $Amavis::zmq_obj->inactivate
4805    if $Amavis::zmq_obj;
4806# undef $Amavis::sql_dataset_conn_lookups;
4807# undef $Amavis::sql_dataset_conn_storage;
4808# undef $Amavis::snmp_db;
4809# undef $Amavis::db_env;
4810}
4811
4812# Run specified command as a subprocess (like qx operator, but more careful
4813# with error reporting and cancels :utf8 mode). If $stderr_to is undef or
4814# an empty string it is converted to '&1', merging stderr to stdout on fd1.
4815# Return a file handle open for reading from the subprocess.
4816#
4817sub run_command($$@) {
4818  my($stdin_from, $stderr_to, $cmd, @args) = @_;
4819  my $cmd_text = join(' ', $cmd, @args);
4820  $stdin_from = '/dev/null'  if !defined $stdin_from || $stdin_from eq '';
4821  $stderr_to = '&1'  if !defined $stderr_to || $stderr_to eq '';  # to stdout
4822  my $msg = join(' ', $cmd, @args, "<$stdin_from", "2>$stderr_to");
4823# $^F == 2  or do_log(-1,"run_command: SYSTEM_FD_MAX not 2: %d", $^F);
4824  my $proc_fh      = IO::File->new;  # parent reading side of the pipe
4825  my $child_out_fh = IO::File->new;  # child writing side of the pipe
4826  pipe($proc_fh,$child_out_fh)
4827    or die "run_command: Can't create a pipe: $!";
4828  flush_captured_log();
4829  my $pid;
4830  eval {
4831    # Avoid using open('-|') which is just too damn smart: possibly waiting
4832    # indefinitely when resources are tight, and not catching fork errors as
4833    # expected but just bailing out of eval; make a pipe explicitly and fork.
4834    # Note that fork(2) returns ENOMEM on lack of swap space, and EAGAIN when
4835    # process limit is reached; we want it to fail in both cases and not obey
4836    # the EAGAIN and keep retrying, as perl open() does.
4837    $pid = fork(); 1;
4838  } or do {
4839    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
4840    die "run_command (forking): $eval_stat";
4841  };
4842  defined($pid) or die "run_command: can't fork: $!";
4843  if (!$pid) {  # child
4844    alarm(0); my $interrupt = '';
4845    my $h1 = sub { $interrupt = $_[0] };
4846    my $h2 = sub { die "Received signal ".$_[0] };
4847    @SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)} = ($h1) x 7;
4848    my $err;
4849    eval {  # die must be caught, otherwise we end up with two running daemons
4850      local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7;
4851#     use Devel::Symdump ();
4852#     my $dumpobj = Devel::Symdump->rnew;
4853#     for my $k ($dumpobj->ios) {
4854#       no strict 'refs';  my $fn = fileno($k);
4855#       if (!defined($fn)) { do_log(2, "not open %s", $k) }
4856#       elsif ($fn == 1 || $fn == 2) { do_log(2, "KEEP %s, fileno=%s",$k,$fn) }
4857#       else { $! = 0;
4858#         close(*{$k}{IO}) and do_log(2, "DID CLOSE %s (fileno=%s)", $k,$fn);
4859#       }
4860#     }
4861      eval { release_parent_resources() };
4862      $proc_fh->close or die "Child can't close parent side of a pipe: $!";
4863      if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i }
4864      # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
4865      my $opt_rdonly = untaint(&POSIX::O_RDONLY);
4866      my $opt_wronly = untaint(&POSIX::O_WRONLY | &POSIX::O_CREAT);
4867      open_on_specific_fd(0, $stdin_from, $opt_rdonly, 0);
4868      open_on_specific_fd(1, '&='.fileno($child_out_fh), $opt_wronly, 0);
4869      open_on_specific_fd(2, $stderr_to, $opt_wronly, 0);
4870    # eval { close_log() };  # may have been closed by open_on_specific_fd
4871      # BEWARE of Perl older that 5.6.0: sockets and pipes were not FD_CLOEXEC
4872      exec {$cmd} ($cmd,@args);
4873      die "run_command: failed to exec $cmd_text: $!";
4874      0;  # paranoia
4875    } or do {
4876      $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
4877    };
4878    eval {
4879      local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7;
4880      if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i }
4881      open_log();  # oops, exec failed, we will need logging after all...
4882      # we're in trouble if stderr was attached to a terminal, but no longer is
4883      do_log_safe(-1,"run_command: child process [%s]: %s", $$,$err);
4884    } or 1;  # ignore failures, make perlcritic happy
4885    { # no warnings;
4886      POSIX::_exit(3);  # SIGQUIT, avoid END and destructor processing
4887    # POSIX::_exit(6);  # SIGABRT, avoid END and destructor processing
4888      kill('KILL',$$); exit 1;   # still kicking? die!
4889    }
4890  }
4891  # parent
4892  ll(5) && do_log(5,"run_command: [%s] %s", $pid,$msg);
4893  $child_out_fh->close
4894    or die "Parent failed to close child side of the pipe: $!";
4895  binmode($proc_fh) or die "Can't set pipe to binmode: $!";  # dflt Perl 5.8.1
4896  ($proc_fh, $pid);  # return pipe file handle to the subprocess and its PID
4897}
4898
4899# Run a specified command as a subprocess. Return a file handle open for
4900# WRITING to the subprocess, utf8 mode canceled and autoflush turned OFF !
4901# If $stderr_to is undef or is an empty string it is converted to '&1',
4902# merging stderr to stdout on fd1.
4903#
4904sub run_command_consumer($$@) {
4905  my($stdout_to, $stderr_to, $cmd, @args) = @_;
4906  my $cmd_text = join(' ', $cmd, @args);
4907  $stdout_to = '/dev/null'  if !defined $stdout_to || $stdout_to eq '';
4908  $stderr_to = '&1'  if !defined $stderr_to || $stderr_to eq '';  # to stdout
4909  my $msg = join(' ', $cmd, @args, ">$stdout_to", "2>$stderr_to");
4910# $^F == 2  or do_log(-1,"run_command_consumer: SYSTEM_FD_MAX not 2: %d", $^F);
4911  my $proc_fh     = IO::File->new;  # parent writing side of the pipe
4912  my $child_in_fh = IO::File->new;  # child reading side of the pipe
4913  pipe($child_in_fh,$proc_fh)
4914    or die "run_command_consumer: Can't create a pipe: $!";
4915  flush_captured_log();
4916  my $pid;
4917  eval {
4918    # Avoid using open('|-') which is just too damn smart: possibly waiting
4919    # indefinitely when resources are tight, and not catching fork errors as
4920    # expected but just bailing out of eval; make a pipe explicitly and fork.
4921    # Note that fork(2) returns ENOMEM on lack of swap space, and EAGAIN when
4922    # process limit is reached; we want it to fail in both cases and not obey
4923    # the EAGAIN and keep retrying, as perl open() does.
4924    $pid = fork(); 1;
4925  } or do {
4926    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
4927    die "run_command_consumer (fork): $eval_stat";
4928  };
4929  defined($pid) or die "run_command_consumer: can't fork: $!";
4930  if (!$pid) {  # child
4931    alarm(0); my $interrupt = '';
4932    my $h1 = sub { $interrupt = $_[0] };
4933    my $h2 = sub { die "Received signal ".$_[0] };
4934    @SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)} = ($h1) x 7;
4935    my $err;
4936    eval {  # die must be caught, otherwise we end up with two running daemons
4937      local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7;
4938      eval { release_parent_resources() };
4939      $proc_fh->close or die "Child can't close parent side of a pipe: $!";
4940      if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i }
4941      # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
4942      my $opt_rdonly = untaint(&POSIX::O_RDONLY);
4943      my $opt_wronly = untaint(&POSIX::O_WRONLY | &POSIX::O_CREAT);
4944      open_on_specific_fd(0, '&='.fileno($child_in_fh), $opt_rdonly, 0);
4945      open_on_specific_fd(1, $stdout_to, $opt_wronly, 0);
4946      open_on_specific_fd(2, $stderr_to, $opt_wronly, 0);
4947    # eval { close_log() };  # may have been closed by open_on_specific_fd
4948      # BEWARE of Perl older that 5.6.0: sockets and pipes were not FD_CLOEXEC
4949      exec {$cmd} ($cmd,@args);
4950      die "run_command_consumer: failed to exec $cmd_text: $!";
4951      0;  # paranoia
4952    } or do {
4953      $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
4954    };
4955    eval {
4956      local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7;
4957      if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i }
4958      open_log();  # oops, exec failed, we will need logging after all...
4959      # we're in trouble if stderr was attached to a terminal, but no longer is
4960      do_log_safe(-1,"run_command_consumer: child process [%s]: %s", $$,$err);
4961    } or 1;  # ignore failures, make perlcritic happy
4962    { # no warnings;
4963      POSIX::_exit(3);  # SIGQUIT, avoid END and destructor processing
4964    # POSIX::_exit(6);  # SIGABRT, avoid END and destructor processing
4965      kill('KILL',$$); exit 1;   # still kicking? die!
4966    }
4967  }
4968  # parent
4969  ll(5) && do_log(5,"run_command_consumer: [%s] %s", $pid,$msg);
4970  $child_in_fh->close
4971    or die "Parent failed to close child side of the pipe: $!";
4972  binmode($proc_fh) or die "Can't set pipe to binmode: $!";  # dflt Perl 5.8.1
4973  $proc_fh->autoflush(0);  # turn it off here, must call ->flush when needed
4974  ($proc_fh, $pid);  # return pipe file handle to the subprocess and its PID
4975}
4976
4977# run a specified subroutine with given arguments as a (forked) subprocess,
4978# collecting results (if any) over a pipe from a subprocess and propagating
4979# them back to a caller; (useful to prevent a potential process crash from
4980# bringing down the main process, and allows cleaner timeout aborts)
4981#
4982sub run_as_subprocess($@) {
4983  my($code,@args) = @_;
4984  alarm(0);  # stop the timer
4985  my $proc_fh      = IO::File->new;  # parent reading side of the pipe
4986  my $child_out_fh = IO::File->new;  # child writing side of the pipe
4987  pipe($proc_fh,$child_out_fh)
4988    or die "run_as_subprocess: Can't create a pipe: $!";
4989  flush_captured_log();
4990  my $pid;
4991  eval {
4992    # Avoid using open('-|') which is just too damn smart: possibly waiting
4993    # indefinitely when resources are tight, and not catching fork errors as
4994    # expected but just bailing out of eval; make a pipe explicitly and fork.
4995    # Note that fork(2) returns ENOMEM on lack of swap space, and EAGAIN when
4996    # process limit is reached; we want it to fail in both cases and not obey
4997    # the EAGAIN and keep retrying, as perl open() does.
4998    $pid = fork(); 1;
4999  } or do {
5000    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
5001    die "run_as_subprocess (forking): $eval_stat";
5002  };
5003  defined($pid) or die "run_as_subprocess: can't fork: $!";
5004  if (!$pid) {  # child
5005    # timeouts will be also be handled by a parent process
5006    my $t0 = Time::HiRes::time; my(@result); my $interrupt = '';
5007    my $h1 = sub { $interrupt = $_[0] };
5008    my $h2 = sub { die "Received signal ".$_[0] };
5009    @SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)} = ($h1) x 7;
5010    $SIG{PIPE} = 'IGNORE';  # don't signal on a write to a widowed pipe
5011    my $myownpid = $$;  # fetching $$ is a syscall
5012    $0 = 'sub-' . c('myprogram_name');  # let it show in ps(1)
5013    my $eval_stat;
5014    eval {  # die must be caught, otherwise we end up with two running daemons
5015      local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7;
5016      eval { release_parent_resources() };
5017      $proc_fh->close or die "Child can't close parent side of a pipe: $!";
5018      if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i }
5019      prolong_timer("child[$myownpid]");  # restart the timer
5020      binmode($child_out_fh) or die "Can't set pipe to binmode: $!";
5021      # we don't really need STDOUT here, but just in case the supplied code
5022      # happens to write there, let's make STDOUT a dup of a pipe
5023      close STDOUT;  # ignoring status
5024      # prefer dup(2) here instead of fdopen, with some luck this gives us fd1
5025      open(STDOUT, '>&'.fileno($child_out_fh))
5026        or die "Child can't dup pipe to STDOUT: $!";
5027      binmode(STDOUT) or die "Can't set STDOUT to binmode: $!";
5028      #*** should re-establish ZMQ sockets here without clobbering parent
5029      ll(5) && do_log(5,'[%s] run_as_subprocess: running as child, '.
5030                        'stdin=%s, stdout=%s, pipe=%s',  $myownpid,
5031                        fileno(STDIN), fileno(STDOUT), fileno($child_out_fh));
5032      @result = &$code(@args);  # invoke a caller-specified subroutine
5033      1;
5034    } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
5035    my $dt = Time::HiRes::time - $t0;
5036    eval {  # must not use die in forked process, or we end up with two daemons
5037      local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7;
5038      if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i }
5039      my $status; my $ll = 3;
5040      if (defined $eval_stat) {  # failure
5041        chomp $eval_stat; $ll = -2;
5042        $status = sprintf("STATUS: FAILURE %s", $eval_stat);
5043      } else {  # success
5044        $status = sprintf("STATUS: SUCCESS, %d results", scalar(@result));
5045      };
5046      my $frozen = Amavis::Util::freeze([$status,@result]);
5047      ll($ll) && do_log($ll, '[%s] run_as_subprocess: child done (%.1f ms), '.
5048                             'sending results: res_len=%d, %s',
5049                             $myownpid, $dt*1000, length($frozen), $status);
5050      # write results back to a parent process over a pipe as a frozen struct.
5051      # writing to broken pipe must return an error, not throw a signal
5052      local $SIG{PIPE} = sub { die "Broken pipe\n" };  # locale-independent err
5053      $child_out_fh->print($frozen) or die "Can't write result to pipe: $!";
5054      $child_out_fh->close or die "Child can't close its side of a pipe: $!";
5055      flush_captured_log();
5056      close STDOUT or die "Child can't close its STDOUT: $!";
5057      POSIX::_exit(0); # normal completion, avoid END and destructor processing
5058    } or 1;  # ignore failures, make perlcritic happy
5059    my $eval2_stat = $@ ne '' ? $@ : "errno=$!";
5060    eval {
5061      chomp $eval2_stat;
5062      if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i }
5063      # broken pipe is common when parent process is shutting down
5064      my $ll = $eval2_stat =~ /^Broken pipe\b/ ? 1 : -1;
5065      do_log_safe($ll, 'run_as_subprocess: child process [%s]: %s',
5066                       $myownpid, $eval2_stat);
5067    } or 1;  # ignore failures, make perlcritic happy
5068    POSIX::_exit(3);  # SIGQUIT, avoid END and destructor processing
5069  # POSIX::_exit(6);  # SIGABRT, avoid END and destructor processing
5070  }
5071  # parent
5072  ll(5) && do_log(5,"run_as_subprocess: spawned a subprocess [%s]", $pid);
5073  $child_out_fh->close
5074    or die "Parent failed to close child side of the pipe: $!";
5075  binmode($proc_fh) or die "Can't set pipe to binmode: $!";  # dflt Perl 5.8.1
5076  prolong_timer('run_as_subprocess');  # restart the timer
5077  ($proc_fh, $pid);  # return pipe file handle to the subprocess and its PID
5078}
5079
5080# read results from a subprocess over a pipe, returns a ref to a results string
5081# and a subprocess exit status;  close the pipe and dismiss the subprocess,
5082# by force if necessary; if $success_list_ref is defined, check also the
5083# subprocess exit status against the provided list and log results
5084#
5085sub collect_results($$;$$$) {
5086  my($proc_fh,$pid, $what,$results_max_size,$success_list_ref) = @_;
5087  # $results_max_size is interpreted as follows:
5088  #   undef .. no limit, read and return all data;
5089  #      0 ... no limit, read and discard all data, returns ref to empty string
5090  #   >= 1 ... read all data, but truncate results string at limit
5091  my $child_stat; my $close_err = 0; my $pid_orig = $pid;
5092  my $result = ''; my $result_l = 0; my $skipping = 0; my $eval_stat;
5093  eval {  # read results; could be aborted by a read error or a timeout
5094    my($nbytes,$buff);
5095    while (($nbytes=$proc_fh->read($buff,16384)) > 0) {
5096      if (!defined($results_max_size)) { $result .= $buff }  # keep all data
5097      elsif ($results_max_size == 0 || $skipping)  {}        # discard data
5098      elsif ($result_l < $results_max_size) { $result .= $buff }
5099      else {
5100        $skipping = 1;  # sanity limit exceeded
5101        do_log(-1,'collect_results from [%s] (%s): results size limit '.
5102                  '(%d bytes) exceeded', $pid_orig,$what,$results_max_size);
5103      }
5104      $result_l += $nbytes;
5105    }
5106    defined $nbytes or die "Error reading from a subprocess [$pid_orig]: $!";
5107    ll(5) && do_log(5,'collect_results from [%s] (%s), %d bytes, (limit %s)',
5108                      $pid_orig,$what,$result_l,$results_max_size);
5109    1;
5110  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
5111  if (defined($results_max_size) && $results_max_size > 0 &&
5112      length($result) > $results_max_size) {
5113    substr($result, $results_max_size) = '...';
5114  }
5115  if (defined $eval_stat) {  # read error or timeout; abort the subprocess
5116    chomp $eval_stat;
5117    undef $_[0];  # release the caller's copy of $proc_fh
5118    kill_proc($pid,$what,1,$proc_fh, "on reading: $eval_stat") if defined $pid;
5119    undef $proc_fh; undef $pid;
5120    die "collect_results - reading aborted: $eval_stat";
5121  }
5122  # normal subprocess exit, close pipe, collect exit status
5123  $eval_stat = undef;
5124  eval {
5125    $proc_fh->close or $close_err = $!;
5126    $child_stat = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
5127    undef $proc_fh; undef $pid;
5128    undef $_[0];  # release also the caller's copy of $proc_fh
5129    1;
5130  } or do {  # just in case a close itself timed out
5131    $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
5132    undef $_[0];  # release the caller's copy of $proc_fh
5133    kill_proc($pid,$what,1,$proc_fh, "on closing: $eval_stat") if defined $pid;
5134    undef $proc_fh; undef $pid;
5135    die "collect_results - closing aborted: $eval_stat";
5136  };
5137  reposition_captured_log_to_end();
5138  if (defined $success_list_ref) {
5139    proc_status_ok($child_stat,$close_err, @$success_list_ref)
5140      or do_log(-2, 'collect_results from [%s] (%s): %s %s', $pid_orig, $what,
5141                    exit_status_str($child_stat,$close_err), $result);
5142  } elsif ($close_err != 0) {
5143    die "Can't close pipe to subprocess [$pid_orig]: $close_err";
5144  }
5145  (\$result,$child_stat);
5146}
5147
5148# read results from a subprocess over a pipe as a frozen data structure;
5149# close the pipe and dismiss the subprocess; returns results as a ref to a list
5150#
5151sub collect_results_structured($$;$$) {
5152  my($proc_fh,$pid, $what,$results_max_size) = @_;
5153  my($result_ref,$child_stat) =
5154    collect_results($proc_fh,$pid, $what,$results_max_size,[0]);
5155  my(@result);
5156  $result_ref = Amavis::Util::thaw($$result_ref);
5157  @result = @$result_ref  if $result_ref;
5158  @result
5159    or die "collect_results_structured: no results from subprocess [$pid]";
5160  my $status = shift(@result);
5161  $status =~ /^STATUS: (?:SUCCESS|FAILURE)\b/
5162    or die "collect_results_structured: subprocess [$pid] returned: $status";
5163  (\@result,$child_stat);
5164}
5165
51661;
5167
5168#
5169package Amavis::rfc2821_2822_Tools;
5170use strict;
5171use re 'taint';
5172
5173BEGIN {
5174  require Exporter;
5175  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
5176  $VERSION = '2.412';
5177  @ISA = qw(Exporter);
5178  @EXPORT = qw(
5179    &rfc2822_timestamp &rfc2822_utc_timestamp
5180    &iso8601_timestamp &iso8601_utc_timestamp
5181    &iso8601_week &iso8601_yearweek &iso8601_year_and_week &iso8601_weekday
5182    &make_received_header_field &parse_received
5183    &fish_out_ip_from_received &parse_message_id
5184    &split_address &split_localpart &replace_addr_fields
5185    &clear_query_keys_cache &make_query_keys
5186    &quote_rfc2821_local &qquote_rfc2821_local
5187    &parse_quoted_rfc2821 &unquote_rfc2821_local &parse_address_list
5188    &wrap_string &wrap_smtp_resp &one_response_for_all
5189    &EX_OK &EX_NOUSER &EX_UNAVAILABLE &EX_TEMPFAIL &EX_NOPERM);
5190  import Amavis::Conf qw(:platform c cr ca $myproduct_name);
5191  import Amavis::Util qw(ll do_log unique_ref unique_list
5192                         safe_encode_utf8_inplace
5193                         idn_to_ascii idn_to_utf8 mail_addr_idn_to_ascii);
5194}
5195use subs @EXPORT;
5196
5197use POSIX qw(locale_h strftime);
5198
5199BEGIN {
5200  # try to use the installed version
5201  eval { require 'sysexits.ph' } or 1;  # ignore failure, make perlcritic happy
5202  # define the most important constants if undefined
5203  do { sub EX_OK()           {0} } unless defined(&EX_OK);
5204  do { sub EX_NOUSER()      {67} } unless defined(&EX_NOUSER);
5205  do { sub EX_UNAVAILABLE() {69} } unless defined(&EX_UNAVAILABLE);
5206  do { sub EX_TEMPFAIL()    {75} } unless defined(&EX_TEMPFAIL);
5207  do { sub EX_NOPERM()      {77} } unless defined(&EX_NOPERM);
5208}
5209
5210# Given a Unix time, return the local time zone offset at that time
5211# as a string +HHMM or -HHMM, appropriate for the RFC 5322 date format.
5212# Works also for non-full-hour zone offsets, and on systems where strftime
5213# cannot return TZ offset as a number;  (c) Mark Martinec, GPL
5214#
5215sub get_zone_offset($) {
5216  my $t = int($_[0]);
5217  my $d = 0;   # local zone offset in seconds
5218  for (1..3) {  # match the date (with a safety loop limit just in case)
5219    my $r = sprintf("%04d%02d%02d", (localtime($t))[5, 4, 3]) cmp
5220            sprintf("%04d%02d%02d", (gmtime($t + $d))[5, 4, 3]);
5221    if ($r == 0) { last } else { $d += $r * 24 * 3600 }
5222  }
5223  my($sl,$su) = (0,0);
5224  for ((localtime($t))[2,1,0])   { $sl = $sl * 60 + $_ }
5225  for ((gmtime($t + $d))[2,1,0]) { $su = $su * 60 + $_ }
5226  $d += $sl - $su;  # add HMS difference (in seconds)
5227  my $sign = $d >= 0 ? '+' : '-';
5228  $d = -$d  if $d < 0;
5229  $d = int(($d + 30) / 60.0);  # give minutes, rounded
5230  sprintf("%s%02d%02d", $sign, int($d / 60), $d % 60);
5231}
5232
5233# Given a Unix time, provide date-time timestamp as specified in RFC 5322
5234# (local time), to be used in header fields such as 'Date:' and 'Received:'
5235# See also RFC 3339.
5236#
5237sub rfc2822_timestamp($) {
5238  my $t = $_[0];
5239  my(@lt) = localtime(int($t));
5240  # can't use %z because some systems do not support it (is treated as %Z)
5241# my $old_locale = POSIX::setlocale(LC_TIME,'C');  # English dates required!
5242  my $zone_name = strftime("%Z",@lt);
5243  my $s = strftime("%a, %e %b %Y %H:%M:%S ", @lt);
5244  $s .= get_zone_offset($t);
5245  $s .= " (" . $zone_name . ")"  if $zone_name !~ /^\s*\z/;
5246# POSIX::setlocale(LC_TIME, $old_locale);  # restore the locale
5247  $s;
5248}
5249
5250# Given a Unix time, provide date-time timestamp as specified in RFC 5322
5251# in a UTC time zone. See also RFC 3339 and RFC 6692.
5252#
5253sub rfc2822_utc_timestamp($) {
5254  my $t = $_[0];
5255  strftime("%a, %e %b %Y %H:%M:%S +0000 (UTC)", gmtime(int($t)));
5256}
5257
5258# Given a Unix numeric time (seconds since 1970-01-01T00:00Z),
5259# provide date-time timestamp (local time) as specified in ISO 8601 (EN 28601)
5260# RFC 3339 is a subset of ISO 8601 and requires field separators "-" and ":".
5261#
5262sub iso8601_timestamp($;$$$) {
5263  my($t, $suppress_zone, $dtseparator, $with_field_separators) = @_;
5264  # can't use %z because some systems do not support it (is treated as %Z)
5265  my $fmt = $with_field_separators ? "%Y-%m-%dT%H:%M:%S" : "%Y%m%dT%H%M%S";
5266  $fmt =~ s/T/$dtseparator/  if defined $dtseparator;
5267  my $s = strftime($fmt,localtime(int($t)));
5268  $s .= get_zone_offset($t)  unless $suppress_zone;
5269  $s;
5270}
5271
5272# Given a Unix numeric time (seconds since 1970-01-01T00:00Z),
5273# provide date-time timestamp (UTC) as specified in ISO 8601 (EN 28601)
5274#
5275sub iso8601_utc_timestamp($;$$$$) {
5276  my($t, $suppress_zone, $dtseparator,
5277     $with_field_separators, $with_fraction) = @_;
5278  my $fmt = $with_field_separators ? "%Y-%m-%dT%H:%M:%S" : "%Y%m%dT%H%M%S";
5279  $fmt =~ s/T/$dtseparator/  if defined $dtseparator;
5280  my $s = strftime($fmt, gmtime(int($t)));
5281  $s .= sprintf(".%03d", int(1000*($t-int($t))+0.5)) if $with_fraction;
5282  $s .= 'Z'  unless $suppress_zone;
5283  $s;
5284}
5285
5286# Does the given year have 53 weeks?  Using a formula by Simon Cassidy.
5287#
5288sub iso8601_year_is_long($) {
5289  my $y = $_[0];
5290  my $p = $y + int($y/4) - int($y/100) + int($y/400);
5291  if (($p % 7) == 4) { return 1 }
5292  $y--;  $p = $y + int($y/4) - int($y/100) + int($y/400);
5293  if (($p % 7) == 3) { return 1 } else { return 0 }
5294}
5295
5296# Given a Unix numeric time (seconds since 1970-01-01T00:00Z),
5297# provide a week number 1..53 (local time) as specified in ISO 8601 (EN 28601)
5298# ( equivalent to PostgreSQL extract(week from ...), and MySQL week(date,3) )
5299#
5300sub iso8601_year_and_week($) {
5301  my $unix_time = $_[0];
5302  my($y,$dowm0,$doy0) = (localtime($unix_time))[5,6,7];
5303  $y += 1900; $dowm0--; $dowm0=6 if $dowm0<0;  # normalize, Monday==0
5304  my $dow0101 = ($dowm0 - $doy0 + 53*7) % 7;  # dow Jan 1
5305  my $wn = int(($doy0 + $dow0101) / 7);
5306  if ($dow0101 < 4) { $wn++ }
5307  if ($wn == 0) { $y--; $wn = iso8601_year_is_long($y) ? 53 : 52 }
5308  elsif ($wn == 53 && !iso8601_year_is_long($y)) { $y++; $wn = 1 }
5309  ($y,$wn);
5310}
5311
5312sub iso8601_week($) {  # 1..53
5313  my($y,$wn) = iso8601_year_and_week($_[0]);  $wn;
5314}
5315
5316sub iso8601_yearweek($) {
5317  my($y,$wn) = iso8601_year_and_week($_[0]);  $y*100+$wn;
5318}
5319
5320# Given a Unix numeric time (seconds since 1970-01-01T00:00Z), provide a
5321# weekday number (based on local time): a number from 1 through 7, beginning
5322# with Monday and ending with Sunday, as specified in ISO 8601 (EN 28601)
5323#
5324sub iso8601_weekday($) {  # 1..7, Mo=1
5325  my $unix_time = $_[0]; ((localtime($unix_time))[6] + 6) % 7 + 1;
5326}
5327
5328sub make_received_header_field($$) {
5329  my($msginfo, $folded) = @_;
5330  my $conn = $msginfo->conn_obj;
5331  my $id = $msginfo->mail_id;
5332  my($smtp_proto, $recips) = ($conn->appl_proto, $msginfo->recips);
5333  my($client_ip, $socket_ip) = ($conn->client_ip, $conn->socket_ip);
5334  for ($client_ip, $socket_ip) {
5335    $_ = '' if !defined($_);
5336    # RFC 5321 (ex RFC 2821), section 4.1.3
5337    $_ = 'IPv6:'.$_  if /:[0-9a-f]*:/i && !/^IPv6:/is;
5338  }
5339  my $myhost = c('myhostname');      # my FQDN (DNS) name, UTF-8 octets
5340  my $myhelo = c('localhost_name');  # my EHLO/HELO/LHLO name, UTF-8 octets
5341  $myhelo = 'localhost'  if $myhelo eq '';
5342  if ($msginfo->smtputf8) {
5343    $myhost = idn_to_utf8($myhost);  $myhelo = idn_to_utf8($myhelo);
5344  } else {
5345    $myhost = idn_to_ascii($myhost); $myhelo = idn_to_ascii($myhelo);
5346  }
5347  my $tls = $msginfo->tls_cipher;
5348  my $s = sprintf("from %s%s%s\n by %s%s (%s, %s)",
5349    $conn->smtp_helo eq '' ? 'unknown' : $conn->smtp_helo,
5350    $client_ip eq '' ? '' : " ([$client_ip])",
5351    !defined $tls    ? '' : " (using TLS with cipher $tls)",
5352    $myhelo,
5353    $socket_ip eq '' ? '' : sprintf(" (%s [%s])", $myhost, $socket_ip),
5354    $myproduct_name,
5355    $conn->socket_port eq '' ? 'unix socket' : "port ".$conn->socket_port);
5356  # RFC 3848, RFC 6531
5357  # http://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml
5358  $s .= "\n with $smtp_proto"
5359    if $smtp_proto =~ /^ (?: SMTP | (?: ES|L|UTF8S|UTF8L) MTP S? A? ) \z/xsi;
5360  $s .= "\n id $id"  if defined $id && $id ne '';
5361  if (@$recips == 1) {  # do not disclose recipients if more than one
5362    my $recip = $recips->[0];
5363    $recip = mail_addr_idn_to_ascii($recip)  if !$msginfo->smtputf8;
5364    $s .= "\n for " . qquote_rfc2821_local($recip);
5365  }
5366  $s .= ";\n " . rfc2822_timestamp($msginfo->rx_time);
5367  $s =~ s/\n//g  if !$folded;
5368  $s;
5369}
5370
5371# parse Received header field according to RFC 5321, somewhat loosened syntax
5372#   Stamp = From-domain By-domain [Via] [With] [ID] [For] datetime
5373#   From-domain = "FROM" FWS Extended-Domain           CFWS
5374#   By-domain   = "BY"   FWS Extended-Domain           CFWS
5375#   Via         = "VIA"  FWS ("TCP"            / Atom) CFWS
5376#   With        = "WITH" FWS ("ESMTP" / "SMTP" / Atom) CFWS
5377#   ID          = "ID"   FWS (Atom / DQUOTE *qcontent DQUOTE / msg-id) CFWS
5378#   For         = "FOR"  FWS 1*( Path / Mailbox )      CFWS
5379#     Path = "<" [ A-d-l ":" ] Mailbox ">"
5380#   datetime    = ";"    FWS [ day-of-week "," ] date FWS time [CFWS]
5381#   Extended-Domain =
5382#    (Domain / Address-literal) [ FWS "(" [ Domain FWS ] Address-literal ")" ]
5383# Avoid regexps like ( \\. | [^"\\] )* which cause recursion trouble / crashes!
5384#
5385sub parse_received($) {
5386  local($_) = $_[0]; my(%fld);
5387  local($1); tr/\n//d;  # unfold, chomp
5388  my $comm_lvl = 0; my $in_option = '';
5389  my $in_ext_dom = 0; my $in_tcp_info = 0;
5390  my $in_qcontent = 0; my $in_literal = 0; my $in_angle = 0;
5391  my $str_l = length($_); my $new_pos;
5392  for (my $pos=-1;  $new_pos=pos($_), $new_pos<$str_l;  $pos=$new_pos) {
5393    $new_pos > $pos or die "parse_received PANIC1 $new_pos"; # just in case
5394    # comment (may be nested: RFC 5322 section 3.2.2)
5395    if ($comm_lvl > 0 && /\G( \) )/gcsx) {
5396      if ($comm_lvl >  1 ||  $in_tcp_info) { $fld{$in_option} .= $1 }  # nested
5397      if ($comm_lvl == 1 && !$in_tcp_info) { $in_option =~ s/-com\z// }
5398      $comm_lvl--; next;  # pop up one level of comments
5399    }
5400    if ($in_tcp_info && /\G( \) )/gcsx)  # leaving TCP-info
5401      { $in_option =~ s/-tcp\z//; $in_tcp_info = 0; $in_ext_dom = 4; next }
5402    if (!$in_qcontent && !$in_literal && !$comm_lvl &&
5403        !$in_tcp_info && $in_ext_dom==1 && /\G( \( )/gcsx) {
5404      # entering TCP-info part, only once after 'from' or 'by'
5405      $in_option .= '-tcp'; $in_tcp_info = 1; $in_ext_dom = 2; next;
5406    }
5407    if (!$in_qcontent && !$in_literal && /\G( \( )/gcsx) {
5408      $comm_lvl++;  # push one level of comments
5409      if ($comm_lvl >  1 ||  $in_tcp_info) { $fld{$in_option} .= $1 }  # nested
5410      if ($comm_lvl == 1 && !$in_tcp_info) {  # comment starts here
5411        $in_option .= '-com';
5412        $fld{$in_option} .= ' ' if defined $fld{$in_option};  # looks better
5413      }
5414      next;
5415    }
5416    if ($comm_lvl > 0 && /\G( \\.      )/gcsx) { $fld{$in_option} .= $1; next }
5417    if ($comm_lvl > 0 && /\G( [^()\\]+ )/gcsx) { $fld{$in_option} .= $1; next }
5418    # quoted content
5419    if ($in_qcontent && /\G( " )/gcsx)  # normal exit from qcontent
5420      { $in_qcontent = 0; $fld{$in_option} .= $1; next }
5421    if ($in_qcontent && /\G( > )/gcsx)  # bail out of qcontent
5422      { $in_qcontent = 0; $in_angle = 0; $fld{$in_option} .= $1; next }
5423    if ($in_qcontent && /\G( \\.      )/gcsx) { $fld{$in_option} .= $1; next }
5424    if ($in_qcontent && /\G( [^"\\>]+ )/gcsx) { $fld{$in_option} .= $1; next }
5425    # address literal
5426    if ($in_literal && /\G( \] )/gcsx)
5427      { $in_literal = 0; $fld{$in_option} .= $1; next }
5428    if ($in_literal && /\G( > )/gcsx)  # bail out of address literal
5429      { $in_literal = 0; $in_angle = 0; $fld{$in_option} .= $1; next }
5430    if (!$comm_lvl && !$in_qcontent && /\G( \[ )/gcsx)
5431      { $in_literal = 1; $fld{$in_option} .= $1; next }
5432    if ($in_literal && /\G( \\.       )/gcsx) { $fld{$in_option} .= $1; next }
5433    if ($in_literal && /\G( [^\]\\>]+ )/gcsx) { $fld{$in_option} .= $1; next }
5434
5435    if (!$comm_lvl && !$in_qcontent && !$in_literal && !$in_tcp_info) {  # top
5436      if (!$in_angle && /\G( < )/gcsx)
5437        { $in_angle = 1; $fld{$in_option} .= $1; next }
5438      if ( $in_angle && /\G( > )/gcsx)
5439        { $in_angle = 0; $fld{$in_option} .= $1; next }
5440      if (!$in_angle && /\G (from|by)       (?:[ \t]+|\z|(?=[\[\(",;<]))/gcsxi)
5441        { $in_option = lc($1); $in_ext_dom = 1; next }
5442      if (!$in_angle && /\G(via|with|id|for)(?:[ \t]+|\z|(?=[\[\(",;<]))/gcsxi)
5443        { $in_option = lc($1); $in_ext_dom = 0; next }
5444      if (!$in_angle && /\G( ; )/gcsxi)
5445        { $in_option = lc($1); $in_ext_dom = 0; next }
5446      if (/\G( [ \t]+ )/gcsx)                  { $fld{$in_option} .= $1; next }
5447      if (/\G( [^ \t,:;\@<>()"\[\]\\]+ )/gcsx) { $fld{$in_option} .= $1; next }
5448    }
5449    if (/\G( . )/gcsx) { $fld{$in_option} .= $1; next }  # other junk
5450    die "parse_received PANIC2 $new_pos";  # just in case
5451  }
5452  for my $f ('from-tcp','by-tcp') {
5453    # a tricky part is handling the syntax:
5454    #   (Domain/Addr-literal) [ FWS "(" [ Domain FWS ] Addr-literal ")" ] CFWS
5455    # where absence of Address-literal in TCP-info means that what looked
5456    # like a domain in the optional TCP-info, is actually a comment in CFWS
5457    local($_) = $fld{$f};
5458    if (!defined($_)) {}
5459    elsif (/\[ \d{1,3} (?: \. \d{1,3} ){3} \] /x) {}
5460    elsif (/\[ .* : .* : /x &&  # triage, contains at least two colons
5461           /\[ (?: IPv6: )?  [0-9a-f]{0,4}
5462               (?: : [0-9a-f]{0,4} | \. [0-9]{1,3} ){2,9}
5463               (?: % [A-Z0-9_-]+ )?
5464            \] /xi) {}
5465  # elsif (/ (?: ^ | \D ) ( \d{1,3} (?: \. \d{1,3}){3}) (?! [0-9.] ) /x) {}
5466    elsif (/^(?: localhost |
5467                 (?: [\x{80}-\x{F4}a-zA-Z0-9_\/+-]{1,63} \. )+
5468                 [\x{80}-\x{F4}a-zA-Z0-9-]{2,} ) \b/xs) {}
5469    else {
5470      my $fc = $f;  $fc =~ s/-tcp\z/-com/;
5471      $fld{$fc} = ''  if !defined $fld{$fc};
5472      $fld{$fc} = $_ . (/[ \t]\z/||$fld{$fc}=~/^[ \t]/?'':' ') . $fld{$fc};
5473      delete $fld{$f};
5474    }
5475  }
5476  for (values %fld) { s/[ \t]+\z//; s/^[ \t]+// }
5477  delete $fld{""}  if exists $fld{""} && $fld{""} eq "";
5478# for my $f (sort {$fld{$a} cmp $fld{$b}} keys %fld)
5479#   { do_log(5, "RECVD: %-8s -> /%s/", $f,$fld{$f}) }
5480  \%fld;
5481}
5482
5483sub fish_out_ip_from_received($;$) {
5484  my($received,$fields_ref) = @_;
5485  $fields_ref = parse_received($received)  if !defined $fields_ref;
5486  my $ip; local($1);
5487  for (@$fields_ref{qw(from-tcp from from-com)}) {
5488    next  if !defined($_);
5489    if (/ \[ (\d{1,3} (?: \. \d{1,3}){3}) (?: \. \d{4,5} )? \] /xs) {
5490      $ip = $1;
5491    } elsif (/:.*:/) {  # triage - IPv6 address contain at least two colons
5492      if (tr/././ == 3) {  # triage - alternative form contains three dots
5493        $ip = $1  if / \[ ( (?: IPv6: )?
5494                            [0-9a-f]{0,4}  (?: : [0-9a-f]{0,4} ){1,5}
5495                            : \d{1,3} (?: \. \d{1,3} ){3}
5496                            (?: % [A-Z0-9_-]+ )?
5497                          ) \] /xsi;
5498      } else {
5499        $ip = $1  if / \[ ( (?: IPv6: )?
5500                            [0-9a-f]{0,4}  (?: : [0-9a-f]{0,4} ){2,7}
5501                            (?: % [A-Z0-9_-]+ )?
5502                           ) \] /xsi;
5503      }
5504    } elsif (/ (?: ^ | \D ) ( \d{1,3} (?: \. \d{1,3}){3}) (?! [0-9.] ) /xs) {
5505      $ip = $1;
5506    }
5507    last if defined $ip;
5508  }
5509  if (!defined $ip) {
5510    do_log(5, "ip_from_received: no IP address in: %s", $received);
5511    # must return undef even in a list context!
5512  } else {
5513    do_log(5, "ip_from_received: %s", $ip);
5514    $ip =~ s/^IPv6://i;  # discard 'IPv6:' prefix if any
5515  }
5516  $ip;
5517}
5518
5519# Splits unquoted fully qualified e-mail address, or an address
5520# with a missing domain part. Returns a pair: (localpart, domain).
5521# The domain part (if nonempty) includes the '@' as the first character.
5522# If the syntax is badly broken, everything ends up as a localpart.
5523# The domain part can be an address literal, as specified by RFC 5322.
5524# Does not handle explicit route paths, use parse_quoted_rfc2821 for that.
5525#
5526sub split_address($) {
5527  my $mailbox = $_[0];  local($1,$2);
5528  $mailbox =~ /^ (.*?) ( \@ (?:  \[  (?: \\. | [^\]\\] ){0,999} (?: \] | \z)
5529                              |  [^\[\@] )*
5530                       ) \z/xs ? ($1, $2) : ($mailbox, '');
5531}
5532
5533# split_localpart() splits localpart of an e-mail address at the first
5534# occurrence of the address extension delimiter character. (based on
5535# equivalent routine in Postfix)
5536#
5537# Reserved addresses are not split: postmaster, mailer-daemon,
5538# double-bounce. Addresses that begin with owner-, or addresses
5539# that end in -request are not split when the owner_request_special
5540# parameter is set.
5541#
5542sub split_localpart($$) {
5543  my($localpart, $delimiter) = @_;
5544  my $owner_request_special = 1;  # configurable ???
5545  my $extension; local($1,$2);
5546  if ($localpart =~ /^(postmaster|mailer-daemon|double-bounce)\z/i) {
5547    # do not split these, regardless of what the delimiter is
5548  } elsif (index($delimiter,'-') >= 0 && $owner_request_special &&
5549           $localpart =~ /^owner-.|.-request\z/si) {
5550    # don't split owner-foo or foo-request
5551  } elsif ($localpart =~ /^(.+?)([\Q$delimiter\E].*)\z/s) {
5552    ($localpart, $extension) = ($1, $2);  # extension includes a delimiter
5553    # do not split the address if the result would have a null localpart
5554  }
5555  ($localpart, $extension);
5556}
5557
5558# replace localpart/extension/domain fields of an original email address
5559# with nonempty fields of a replacement
5560#
5561sub replace_addr_fields($$;$) {
5562  my($orig_addr, $repl_addr, $delim) = @_;
5563  my($localpart_o, $domain_o, $ext_o, $localpart_r, $domain_r, $ext_r);
5564  ($localpart_o,$domain_o) = split_address($orig_addr);
5565  ($localpart_r,$domain_r) = split_address($repl_addr);
5566  $localpart_r = $localpart_o  if $localpart_r eq '';
5567  $domain_r    = $domain_o     if $domain_r    eq '';
5568  if (defined $delim && $delim ne '') {
5569    ($localpart_o,$ext_o) = split_localpart($localpart_o,$delim);
5570    ($localpart_r,$ext_r) = split_localpart($localpart_r,$delim);
5571    $ext_r = $ext_o  if !defined $ext_r;
5572  }
5573  $localpart_r . (defined $ext_r ? $ext_r : '') . $domain_r;
5574}
5575
5576# given a (potentially multiline) header field Message-ID, Resent-Message-ID.
5577# In-Reply-To, or References, parse the RFC 5322 (RFC 2822) syntax extracting
5578# all message IDs while ignoring comments, and return them as a list
5579# Note: currently does not handle nested comments.
5580# See also: RFC 2392 - Content-ID and Message-ID Uniform Resource Locators
5581#
5582sub parse_message_id($) {
5583  my $str = $_[0];
5584  $str =~ tr/\n//d; my(@message_id); my $garbage = 0;
5585  $str =~ s/[ \t]+/ /g;  # compress whitespace as a band aid for regexp trouble
5586  for my $t ( $str =~ /\G ( [ \t]+ | \( (?: \\. | [^()\\] ){0,999} \) |
5587                             <  (?:  "  (?: \\. | [^"\\>] ){0,999} "  |
5588                                     \[ (?: \\. | [^\]\\>]){0,999} \] |
5589                                     [^"<>\[\]\\]+ )*  >  |
5590                             [^<( \t]+ | . )/xgs ) {
5591    if    ($t =~ /^<.*>\z/) { push(@message_id,$t) }
5592    elsif ($t =~ /^[ \t]*\z/) {}   # ignore FWS
5593    elsif ($t =~ /^\(.*\)\z/)      # ignore CFWS
5594      { do_log(2, "parse_message_id ignored comment: /%s/ in %s", $t,$str) }
5595    else { $garbage = 1 }
5596  }
5597  if (@message_id > 1) {
5598    @message_id = unique_list(\@message_id);  # remove possible duplicates
5599  } elsif ($garbage && !@message_id) {
5600    local($_) = $str; s/^[ \t]+//; s/[ \t\n]+\z//;  # trim and sanitize <...>
5601    s/^<//; s/>\z//; s/>/_/g; $_ = '<'.$_.'>'; @message_id = ($_);
5602    do_log(5, "parse_message_id sanitizing garbage: /%s/ to %s", $str,$_);
5603  }
5604  @message_id;
5605}
5606
5607# For a given email address (e.g. for User+Foo@sub.exAMPLE.CoM)
5608# prepare and return a list of lookup keys in the following order:
5609#   User+Foo@sub.exAMPLE.COM   (as-is, no lowercasing, no ToASCII)
5610#   user+foo@sub.example.com
5611#   user@sub.example.com (only if $recipient_delimiter nonempty)
5612#   user+foo(@) (only if $include_bare_user)
5613#   user(@)     (only if $include_bare_user and $recipient_delimiter nonempty)
5614#   (@)sub.example.com
5615#   (@).sub.example.com
5616#   (@).example.com
5617#   (@).com
5618#   (@).
5619# Another example with EAI and international domain names (IDN):
5620#   Pingüino@Pájaro.Niño.exAMPLE.COM  (as-is, no lowercasing, no ToASCII)
5621#   pingüino@xn--pjaro-xqa.xn--nio-8ma.example.com
5622#   pingüino(@)                       (only if $include_bare_user)
5623#   (@)xn--pjaro-xqa.xn--nio-8ma.example.com
5624#   (@).xn--pjaro-xqa.xn--nio-8ma.example.com
5625#   (@).xn--pjaro-xqa.example.com
5626#   (@).example.com
5627#   (@).com
5628#   (@).
5629#
5630# Note about (@): if $at_with_user is true the user-only keys (without domain)
5631# get an '@' character appended (e.g. 'user+foo@'). Usual for lookup_hash.
5632# If $at_with_user is false the domain-only (without localpart) keys
5633# get a '@' prepended (e.g. '@.example.com'). Usual for SQL and LDAP lookups.
5634#
5635# The domain part is lowercased and IDN converted to ASCII in all but
5636# the first item in the resulting list; the localpart is lowercased
5637# iff $localpart_is_case_sensitive is true. The $addr may be a string
5638# of octets (assumed to be UTF-8 encoded), or a string of characters.
5639#
5640my %query_keys_cache;
5641sub clear_query_keys_cache() { %query_keys_cache = () }
5642sub make_query_keys($$$;$) {
5643  my($addr, $at_with_user, $include_bare_user, $append_string) = @_;
5644  safe_encode_utf8_inplace($addr);  # to octets (if not already)
5645  my $query_keys_slot = join("\x00",
5646                             $at_with_user?1:0, $include_bare_user?1:0,
5647                             $append_string, $addr);
5648  if (exists $query_keys_cache{$query_keys_slot}) {
5649    do_log(5,'query_keys: cached '.$addr);  # concat, knowing it's in octets
5650    return @{$query_keys_cache{$query_keys_slot}};  # ($keys_ref, $rhs)
5651  }
5652  my($localpart, $domain) = split_address($addr);
5653  my $saved_full_localpart = $localpart;
5654  $localpart = lc($localpart)  if !c('localpart_is_case_sensitive');
5655  # chop off leading @, and trailing dots
5656  local($1);
5657  $domain = $1  if $domain =~ /^\@?(.*?)\.*\z/s;
5658  $domain = idn_to_ascii($domain)  if $domain ne '';  # lowercase, ToASCII
5659  my $extension; my $delim = c('recipient_delimiter');
5660  if ($delim ne '') {
5661    ($localpart,$extension) = split_localpart($localpart,$delim);
5662    # extension includes a delimiter since amavisd-new-2.5.0!
5663  }
5664  $extension = ''  if !defined $extension;   # mute warnings
5665  my($append_to_user,$prepend_to_domain) = $at_with_user ? ('@','') : ('','@');
5666  my(@keys);  # a list of query keys
5667  push(@keys, $addr);                        # as is
5668  push(@keys, $localpart.$extension.'@'.$domain)
5669    if $extension ne '';                     # user+foo@example.com
5670  push(@keys, $localpart.'@'.$domain);       # user@example.com
5671  if ($include_bare_user) {  # typically enabled for local users only
5672    push(@keys, $localpart.$extension.$append_to_user)
5673      if $extension ne '';                   # user+foo(@)
5674    push(@keys, $localpart.$append_to_user); # user(@)
5675  }
5676  push(@keys, $prepend_to_domain.$domain);   # (@)sub.example.com
5677  if ($domain =~ /\[/) {  # don't split address literals
5678    push(@keys, $prepend_to_domain.'.');     # (@).
5679  } else {
5680    my(@dkeys); my $d = $domain;
5681    for (;;) {            # (@).sub.example.com (@).example.com (@).com (@).
5682      push(@dkeys, $prepend_to_domain.'.'.$d);
5683      last  if $d eq '';
5684      $d = ($d =~ /^([^.]*)\.(.*)\z/s) ? $2 : '';
5685    }
5686    @dkeys = @dkeys[$#dkeys-19 .. $#dkeys]  if @dkeys > 20;  # sanity limit
5687    push(@keys, @dkeys);
5688  }
5689  if (defined $append_string && $append_string ne '') {
5690    $_ .= $append_string  for @keys;
5691  }
5692  my $keys_ref = unique_ref(\@keys);  # remove duplicates
5693  ll(5) && do_log(5,"query_keys: %s", join(', ',@$keys_ref));
5694  # the rhs replacement strings are similar to what would be obtained
5695  # by lookup_re() given the following regular expression:
5696  # /^( ( ( [^\@]*? ) ( \Q$delim\E [^\@]* )? ) (?: \@ (.*) ) )$/xs
5697  my $rhs = [   # a list of right-hand side replacement strings
5698    $addr,                  # $1 = User+Foo@Sub.Example.COM
5699    $saved_full_localpart,  # $2 = User+Foo
5700    $localpart,             # $3 = user  (lc if localpart_is_case_sensitive)
5701    $extension,             # $4 = +foo  (lc if localpart_is_case_sensitive)
5702    $domain,                # $5 = sub.example.com (lowercase, ToASCII)
5703  ];
5704  $query_keys_cache{$query_keys_slot} = [$keys_ref, $rhs];
5705  ($keys_ref, $rhs);
5706}
5707
5708# quote_rfc2821_local() quotes the local part of a mailbox address
5709# (given in internal (unquoted) form), and returns external (quoted)
5710# mailbox address, as per RFC 5321 (ex RFC 2821).
5711#
5712# internal (unquoted) form is used internally by amavisd-new and other mail sw,
5713# external (quoted) form is used in SMTP commands and in message header section
5714#
5715# To re-insert message back via SMTP, the local-part of the address needs
5716# to be quoted again if it contains reserved characters or otherwise
5717# does not obey the dot-atom syntax, as specified in RFC 5321 and RFC 6531.
5718#
5719sub quote_rfc2821_local($) {
5720  my $mailbox = $_[0];
5721  # RFC 5321/RFC 5322: atext: any character except controls, SP, and specials
5722  # RFC 6531 section 3.3: The definition of <atext> is extended to permit
5723  # both the RFC 5321 definition and a UTF-8 string.  That string MUST NOT
5724  # contain any of the ASCII graphics or control characters.
5725  # RFC 6531: atext     =/ UTF8-non-ascii
5726  #           qtextSMTP =/ UTF8-non-ascii
5727  # RFC 6532: UTF8-non-ascii = UTF8-2 / UTF8-3 / UTF8-4
5728  # RFC 3629 section 4: Syntax of UTF-8 Byte Sequences
5729  # non-atext: [\x00-\x20"(),.:;<>@\[\]\\\x7F]
5730  my $atext = "a-zA-Z0-9!\#\$%&'*/=?^_`{|}~+-";
5731  # my $specials = '()<>\[\]\\\\@:;,."';
5732  # HTML5 - 4.10.5.1.5 E-mail state (type=email):
5733  #   email = 1*( atext / "." ) "@" label *( "." label )
5734  #   i.e. localpart is: [a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+
5735  my($localpart,$domain) = split_address($mailbox);
5736  if ($localpart =~ /^[$atext]+(?:\.[$atext]+)*\z/so) {
5737    # plain RFC 5321 dot-atom, no need for quoting
5738  } elsif ($localpart =~ /[\x80-\xBF\xC2-\xF4]/s &&  # triage, RFC 3629
5739           $localpart =~ /^ ( [$atext] |
5740                              [\xC2-\xDF][\x80-\xBF]{1} |
5741                              [\xE0-\xEF][\x80-\xBF]{2} |
5742                              [\xF0-\xF4][\x80-\xBF]{3}
5743                            )+
5744                            ( \. ( [$atext] |
5745                                   [\xC2-\xDF][\x80-\xBF]{1} |
5746                                   [\xE0-\xEF][\x80-\xBF]{2} |
5747                                   [\xF0-\xF4][\x80-\xBF]{3}
5748                                 )+
5749                            )* \z/xso) {
5750    # Extended RFC 6531 UTF-8 atext / dot-atom, no need for quoting.
5751    # The \xC0 and \xC1 could only be used for overlong encoding of basic
5752    # ASCII characters. Tolerate other non-shortest UTF-8 encodings here.
5753    # UTF-8 is restricted by RFC 3629 to end at U+10FFFF, this removed
5754    # all 5- and 6-byte sequences, and about half of the 4-byte sequences.
5755    # The RFC 5198 also prohibits "C1 Controls" (U+0080 through U+009F)
5756    # (i.e. in UTF-8: C2 80 .. C2 9F) for Net-Unicode.
5757  } else {  # needs quoting or is invalid
5758    local($1);  # qcontent = qtext / quoted-pair
5759    $localpart =~ s{ ( ["\\] ) }{\\$1}xgs;
5760    $localpart = '"'.$localpart.'"';  # non-qtext, make it a qcontent
5761#   Postfix hates  ""@domain  but is not so harsh on  @domain
5762#   Late breaking news: don't bother, both forms are rejected by Postfix
5763#   when strict_rfc821_envelopes=yes, and both are accepted otherwise
5764  }
5765  # we used to strip off empty domain (just '@') unconditionally, but this
5766  # leads Postfix to interpret an address with a '@' in the quoted local part
5767  # e.g. <"h@example.net"@> as <hhh@example.net> (subject to Postfix setting
5768  # 'resolve_dequoted_address'), which is not what the sender requested;
5769  # we no longer do that if localpart contains an '@':
5770  $domain = ''  if $domain eq '@' && $localpart =~ /\@/;
5771  $localpart . $domain;
5772}
5773
5774# wraps the result of quote_rfc2821_local into angle brackets <...> ;
5775# If given a list, it returns a list (possibly converted to
5776# comma-separated scalar if invoked in scalar context), quoting each element;
5777#
5778sub qquote_rfc2821_local(@) {
5779  my(@r) = map($_ eq '' ? '<>' : ('<'.quote_rfc2821_local($_).'>'), @_);
5780  wantarray ? @r : join(', ', @r);
5781}
5782
5783sub parse_quoted_rfc2821($$) {
5784  my($addr,$unquote) = @_;
5785  # the angle-bracket stripping is not really a duty of this subroutine,
5786  # as it should have been already done elsewhere, but we allow it here anyway:
5787  $addr =~ s/^\s*<//s;  $addr =~ s/>\s*\z//s;  # tolerate unmatched angle brkts
5788  local($1,$2); my($source_route,$localpart,$domain) = ('','','');
5789  # RFC 5321: so-called "source route" MUST BE accepted,
5790  #           SHOULD NOT be generated, and SHOULD be ignored.
5791  #           Path = "<" [ A-d-l ":" ] Mailbox ">"
5792  #           A-d-l = At-domain *( "," A-d-l )
5793  #           At-domain = "@" domain
5794  if (index($addr,':') >= 0 &&  # triage before more testing for source route
5795      $addr=~m{^(    [ \t]* \@ (?: [\x{80}-\x{F4}A-Za-z0-9.!\#\$%&*/^{}=_+-]* |
5796                              \[ (?: \\. | [^\]\\] ){0,999} \] ) [ \t]*
5797                (?: ,[ \t]* \@ (?: [\x{80}-\x{F4}A-Za-z0-9.!\#\$%&*/^{}=_+-]* |
5798                              \[ (?: \\. | [^\]\\] ){0,999} \] ) [ \t]*
5799                )* : [ \t]* ) (.*) \z }xs)
5800  { # NOTE: we are quite liberal on allowing whitespace around , and : here,
5801    # and liberal in allowed character set and syntax of domain names,
5802    # we mainly avoid stop-characters in the domain names of source route
5803    $source_route = $1; $addr = $2;
5804  }
5805  if ($addr =~ m{^ ( .*? )
5806                   ( \@ (?: [^\@\[\]]+ | \[ (?: \\. | [^\]\\] ){0,999} \]
5807                            | [^\@] )* )
5808                 \z}xs) {
5809    ($localpart,$domain) = ($1,$2);
5810  } else {
5811    ($localpart,$domain) = ($addr,'');
5812  }
5813  $localpart =~ s/ " | \\ (.) | \\ \z /$1/xgs  if $unquote; # undo quoted-pairs
5814  ($source_route, $localpart, $domain);
5815}
5816
5817# unquote_rfc2821_local() strips away the quoting from the local part
5818# of an external (quoted) mailbox address, and returns internal (unquoted)
5819# mailbox address, as per RFC 5321 (ex RFC 2821).
5820# Internal (unquoted) form is used internally by amavisd-new and other mail sw,
5821# external (quoted) form is used in SMTP commands and in message header section
5822#
5823sub unquote_rfc2821_local($) {
5824  my $mailbox = $_[0];
5825  my($source_route,$localpart,$domain) = parse_quoted_rfc2821($mailbox,1);
5826  # make address with '@' in the localpart but no domain (like <"aa@bb.com"> )
5827  # distinguishable from <aa@bb.com> by representing it as aa@bb.com@ in
5828  # unquoted form; (it still obeys all regular rules, it is not a dirty trick)
5829  $domain = '@'  if $domain eq '' && $localpart ne '' && $localpart =~ /\@/;
5830  $localpart . $domain;
5831}
5832
5833# Parse an rfc2822.address-list, returning a list of RFC 5322 (quoted)
5834# addresses. Properly deals with group addresses, nested comments, address
5835# literals, qcontent, addresses with source route, discards display
5836# names and comments. The following header fields accept address-list:
5837# To, Cc, Bcc, Reply-To, (and since RFC 6854 also:) From and Sender.
5838#
5839# RFC 6854 relaxed the syntax on 'From' and 'Sender', where the group syntax
5840# is now allowed. Prior to RFC 6854 the 'From' accepted a 'mailbox-list'
5841# syntax (does not allow groups), and 'Sender' accepted a 'mailbox' syntax,
5842# i.e. only one address and not a group.
5843#
5844use vars qw($s $p @addresses);
5845sub flush_a() {
5846  $s =~ s/^[ \t]+//s; $s =~ s/[ \t]\z//s;  # trim
5847  $p =~ s/^[ \t]+//s; $p =~ s/[ \t]\z//s;
5848  if ($p ne '') { $p =~ s/^<//; $p =~ s/>\z//; push(@addresses,$p) }
5849  elsif ($s ne '') { push(@addresses,$s) }
5850  $p = ''; $s = '';
5851}
5852sub parse_address_list($) {
5853  local($_) = $_[0];
5854  local($1); s/\n(?=[ \t])//gs; s/\n+\z//s;  # unfold, chomp
5855  my $str_l = length($_); $p = ''; $s = ''; @addresses = ();
5856  my($comm_lvl, $in_qcontent, $in_literal,
5857     $in_group, $in_angle, $after_at) = (0) x 6;
5858  my $new_pos;
5859  for (my $pos=-1;  $new_pos=pos($_), $new_pos<$str_l;  $pos=$new_pos) {
5860    $new_pos > $pos or die "parse_address_list PANIC1 $new_pos"; # just in case
5861    # comment (may be nested: RFC 5322 section 3.2.2)
5862    if ($comm_lvl > 0 && /\G( \) )/gcsx) { $comm_lvl--; next }
5863    if (!$in_qcontent && !$in_literal && /\G( \( )/gcsx) { $comm_lvl++; next }
5864    if ($comm_lvl > 0 && /\G( \\.      )/gcsx) { next }
5865    if ($comm_lvl > 0 && /\G( [^()\\]+ )/gcsx) { next }
5866    # quoted content
5867    if ($in_qcontent && /\G( " )/gcsx)  # normal exit from qcontent
5868      { $in_qcontent = 0; ($in_angle?$p:$s) .= $1; next }
5869    if ($in_qcontent && /\G( > )/gcsx)  # bail out of qcontent
5870      { $in_qcontent = 0; $in_angle = 0; $after_at = 0;
5871        ($in_angle?$p:$s) .= $1; next }
5872    if (!$comm_lvl && !$in_qcontent && !$in_literal && /\G( " )/gcsx)
5873      { $in_qcontent = 1; ($in_angle?$p:$s) .= $1; next }
5874    if ($in_qcontent && /\G( \\.      )/gcsx) { ($in_angle?$p:$s) .= $1; next }
5875    if ($in_qcontent && /\G( [^"\\>]+ )/gcsx) { ($in_angle?$p:$s) .= $1; next }
5876    # address literal
5877    if ($in_literal && /\G( \] )/gcsx)
5878      { $in_literal = 0; ($in_angle?$p:$s) .= $1; next }
5879    if ($in_literal && /\G( > )/gcsx)  # bail out of address literal
5880      { $in_literal = 0; $in_angle = 0; $after_at = 0;
5881        ($in_angle?$p:$s) .= $1; next }
5882    if (!$comm_lvl && !$in_qcontent && /\G( \[ )/gcsx)
5883      { $in_literal = 1 if $after_at; ($in_angle?$p:$s) .= $1; next }
5884    if ($in_literal && /\G( \\.       )/gcsx) { ($in_angle?$p:$s) .= $1; next }
5885    if ($in_literal && /\G( [^\]\\>]+ )/gcsx) { ($in_angle?$p:$s) .= $1; next }
5886    # normal content
5887    if (!$comm_lvl && !$in_qcontent && !$in_literal) {
5888      if (!$in_angle && /\G( < )/gcsx)
5889        { $in_angle = 1; $after_at = 0; flush_a() if $p ne ''; $p .= $1; next }
5890      if ( $in_angle && /\G( > )/gcsx)
5891        { $in_angle = 0; $after_at = 0; $p .= $1; next }
5892      if (/\G( , )/gcsx)  # top-level addr separator or source route delimiter
5893        { !$in_angle ? flush_a() : ($p.=$1); $after_at = 0; next }
5894      if (!$in_angle && !$in_group && /\G( : )/gcsx)  # group name terminator
5895        { $in_group = 1; $s .= $1; $p=$s=''; next }   # discard group name
5896      if ($after_at && /\G( : )/gcsx)                 # source route terminator
5897        { $after_at = 0; ($in_angle?$p:$s) .= $1; next }
5898      if ( $in_group && /\G( ; )/gcsx)                # group terminator
5899        { $in_group = 0; $after_at = 0; next }
5900      if (!$in_group && /\G( ; )/gcsx)                # out of place special
5901        { ($in_angle?$p:$s) .= $1; $after_at = 0; next }
5902      if (/\G( \@ )/gcsx)    { $after_at = 1; ($in_angle?$p:$s) .= $1; next }
5903      if (/\G( [ \t]+ )/gcsx)               { ($in_angle?$p:$s) .= $1; next }
5904      if (/\G( [^,:;\@<>()"\[\]\\]+ )/gcsx) { ($in_angle?$p:$s) .= $1; next }
5905    }
5906    if (/\G( . )/gcsx) { ($in_angle?$p:$s) .= $1; next }  # other junk
5907    die "parse_address_list PANIC2 $new_pos";  # just in case
5908  }
5909  flush_a(); @addresses;
5910}
5911
5912# compute a total displayed line size if a string (possibly containing TAB
5913# characters) would be displayed at the given character position (0-based)
5914#
5915sub displayed_length($$) {
5916  my($str,$ind) = @_;
5917  for my $t ($str =~ /\G ( \t | [^\t]+ )/xgs)
5918    { $ind += $t ne "\t" ? length($t) : 8 - $ind % 8 }
5919  $ind;
5920}
5921
5922# Wrap a string into a multiline string, inserting \n as appropriate to keep
5923# each line length at $max_len or shorter (not counting \n). A string $prefix
5924# is prepended to each line. Continuation lines get their first space or TAB
5925# character replaced by a string $indent (unless $indent is undefined, which
5926# keeps the leading whitespace character unchanged). Both the $prefix and
5927# $indent are included in line size calculation, and for the purpose of line
5928# size calculations TABs are treated as an appropriate number of spaces.
5929# Parameter $structured indicates where line breaks are permitted: true
5930# indicates that line breaks may only occur where a \n character is already
5931# present in the source line, indicating possible (tentative) line breaks.
5932# If $structured is false, permitted line breaks are chosen within existing
5933# whitespace substrings so that all-whitespace lines are never generated
5934# (even at the expense of producing longer than allowed lines if necessary),
5935# and that each continuation line starts by at least one whitespace character.
5936# Whitespace is neither added nor removed, but simply spliced into trailing
5937# and leading whitespace of subsequent lines. Typically leading whitespace
5938# is a single character, but may include part of the trailing whitespace of
5939# the preceding line if it would otherwise be too long. This is appropriate
5940# and required for wrapping of mail header fields. An exception to preservation
5941# of whitespace is when $indent string is defined but is an empty string,
5942# causing leading and trailing whitespace to be trimmed, producing a classical
5943# plain text wrapping results. Intricate!
5944#
5945sub wrap_string($;$$$$) {
5946  my($str,$max_len,$prefix,$indent,$structured) = @_;
5947  $max_len = 78    if !defined $max_len;
5948  $prefix = ''     if !defined $prefix;
5949  $structured = 0  if !defined $structured;
5950  my(@chunks);
5951  # split a string into chunks where each chunk starts with exactly one SP or
5952  # TAB character (except possibly the first chunk), followed by an unbreakable
5953  # string (consisting typically entirely of non-whitespace characters, at
5954  # least one character must be non-whitespace), followed by an all-whitespace
5955  # string consisting of only SP or TAB characters.
5956  if ($structured) {
5957    local($1);
5958    # unfold all-whitespace chunks, just in case
5959    1 while $str =~ s/^([ \t]*)\n/$1/;  # prefixed?
5960    $str =~ s/\n(?=[ \t]*(\n|\z))//g;   # within and at end
5961    $str =~ s/\n(?![ \t])/\n /g;  # insert a space at line folds if missing
5962    # unbreakable parts are substrings between newlines, determined by caller
5963    @chunks = split(/\n/,$str,-1);
5964  } else {
5965    $str =~ s/\n(?![ \t])/\n /g;  # insert a space at line folds if missing
5966    $str =~ s/\n//g;  # unfold (knowing a space at folds is not missing)
5967    # unbreakable parts are non- all-whitespace substrings
5968    @chunks = $str =~ /\G ( (?: ^ .*? | [ \t]) [^ \t]+ [ \t]* )
5969                          (?=  \z | [ \t]  [^ \t] )/xgs;
5970  }
5971  # do_log(5,"wrap_string chunk: <%s>", $_)  for @chunks;
5972  my $result = '';  # wrapped multiline string will accumulate here
5973  my $s = '';       # collects partially assembled single line
5974  my $s_displ_ind = # display size of string in $s, including $prefix
5975    displayed_length($prefix,0);
5976  my $contin_line = 0;  # are we assembling a continuation line?
5977  while (@chunks) {  # walk through input substrings and join shorter sections
5978    my $chunk = shift(@chunks);
5979    # replace leading space char with $indent if starting a continuation line
5980    $chunk =~ s/^[ \t]/$indent/ if defined $indent && $contin_line && $s eq '';
5981    my $s_displ_l = displayed_length($chunk, $s_displ_ind);
5982    if ($s_displ_l <= $max_len  # collecting in $s while still fits
5983        || (@chunks==0 && $s =~ /^[ \t]*\z/)) {  # or we are out of options
5984      $s .= $chunk; $s_displ_ind = $s_displ_l;  # absorb entire chunk
5985    } else {
5986      local($1,$2);
5987      $chunk =~ /^ ( .* [^ \t] ) ( [ \t]* ) \z/xs  # split to head and allwhite
5988        or die "Assert 1 failed in wrap: /$result/, /$chunk/";
5989      my($solid,$white_tail) = ($1,$2);
5990      my $min_displayed_s_len = displayed_length($solid, $s_displ_ind);
5991      if (@chunks > 0  # not being at the last chunk gives a chance to shove
5992                       # part of the trailing whitespace off to the next chunk
5993          && ($min_displayed_s_len <= $max_len  # non-whitespace part fits
5994              || $s =~ /^[ \t]*\z/) ) {    # or still allwhite even if too long
5995        $s .= $solid; $s_displ_ind = $min_displayed_s_len;  # take nonwhite
5996        if (defined $indent && $indent eq '') {
5997          # discard leading whitespace in continuation lines on a plain wrap
5998        } else {
5999          # preserve all original whitespace
6000          while ($white_tail ne '') {
6001            # stash-in as much trailing whitespace as it fits to the curr. line
6002            my $c = substr($white_tail,0,1);  # one whitespace char. at a time
6003            my $dlen = displayed_length($c, $s_displ_ind);
6004            if ($dlen > $max_len) { last }
6005            else {
6006              $s .= $c; $s_displ_ind = $dlen;  # absorb next whitespace char.
6007              $white_tail = substr($white_tail,1); # one down, more to go...
6008            }
6009          }
6010          # push remaining trailing whitespace characters back to input
6011          $chunks[0] = $white_tail . $chunks[0]  if $white_tail ne '';
6012        }
6013      } elsif ($s =~ /^[ \t]*\z/) {
6014        die "Assert 2 failed in wrap: /$result/, /$chunk/";
6015      } else {  # nothing more fits to $s, flush it to $result
6016        if ($contin_line) { $result .= "\n" } else { $contin_line = 1  }
6017        # trim trailing whitespace when wrapping as a plain text (not headers)
6018        $s =~ s/[ \t]+\z//  if defined $indent && $indent eq '';
6019        $result .= $prefix.$s; $s = '';
6020        $s_displ_ind = displayed_length($prefix,0);
6021        unshift(@chunks,$chunk);  # reprocess the chunk
6022      }
6023    }
6024  }
6025  if ($s !~ /^[ \t]*\z/) {  # flush last chunk if nonempty
6026    if ($contin_line) { $result .= "\n" } else { $contin_line = 1  }
6027    $s =~ s/[ \t]+\z//  if defined $indent && $indent eq '';  # trim plain text
6028    $result .= $prefix.$s; $s = '';
6029  }
6030  $result;
6031}
6032
6033# wrap an SMTP response at each \n char according to RFC 5321 (ex RFC 2821),
6034# returning resulting lines as a listref
6035#
6036sub wrap_smtp_resp($) {
6037  my $resp = $_[0];
6038  # RFC 5321 section 4.5.3.1.5: The maximum total length of a
6039  # reply line including the reply code and the <CRLF> is 512 octets.
6040  # More information may be conveyed through multiple-line replies.
6041  my $max_len = 512-2; my(@result_list); local($1,$2,$3,$4);
6042  if ($resp !~ /^ ([1-5]\d\d) (\ |-|\z)
6043                ([245] \. \d{1,3} \. \d{1,3} (?: \ |\z) )?
6044                (.*) \z/xs)
6045    { die "wrap_smtp_resp: bad SMTP response code: '$resp'" }
6046  my($resp_code,$more,$enhanced,$tail) = ($1,$2,$3,$4);
6047  my $lead_len = length($resp_code) + 1 + length($enhanced);
6048  while (length($tail) > $max_len-$lead_len || $tail =~ /\n/) {
6049    # RFC 2034: When responses are continued across multiple lines
6050    # the same status code must appear at the beginning of the text
6051    # in each line of the response.
6052    my $head = substr($tail, 0, $max_len-$lead_len);
6053    if ($head =~ /^([^\n]*\n)/s) { $head = $1 }
6054    $tail = substr($tail,length($head)); chomp($head);
6055    push(@result_list, $resp_code.'-'.$enhanced.$head);
6056  }
6057  push(@result_list, $resp_code.' '.$enhanced.$tail);
6058  \@result_list;
6059}
6060
6061# Prepare a single SMTP response and an exit status as per sysexits.h
6062# from individual per-recipient response codes, taking into account
6063# sendmail milter specifics. Returns a triple: (smtp response, exit status,
6064# an indication whether a non delivery notification (NDN, a form of DSN)
6065# is needed).
6066#
6067sub one_response_for_all($$;$) {
6068  my($msginfo, $dsn_per_recip_capable, $suppressed) = @_;
6069  do_log(5, 'one_response_for_all, per_recip_capable: %s, suppressed: %s',
6070            $dsn_per_recip_capable?'Y':'N', $suppressed?'Y':'N');
6071  my($smtp_resp, $exit_code, $ndn_needed);
6072  my $am_id          = $msginfo->log_id;
6073  my $sender         = $msginfo->sender;
6074  my $per_recip_data = $msginfo->per_recip_data;
6075  my $any_not_done   = scalar(grep(!$_->recip_done, @$per_recip_data));
6076  if (!@$per_recip_data) {  # no recipients, nothing to do
6077    $smtp_resp = "250 2.5.0 Ok, id=$am_id"; $exit_code = EX_OK;
6078    do_log(5, "one_response_for_all <%s>: no recipients, '%s'",
6079              $sender, $smtp_resp);
6080  }
6081  if (!defined $smtp_resp) {
6082    for my $r (@$per_recip_data) {  # any 4xx code ?
6083      if ($r->recip_smtp_response =~ /^4/)  # pick the first 4xx code
6084        { $smtp_resp = $r->recip_smtp_response; last }
6085    }
6086  }
6087  if (!defined $smtp_resp) {
6088    for my $r (@$per_recip_data) {
6089      my $fwd_m = $r->delivery_method;
6090      if (!defined $fwd_m) {
6091        die "one_response_for_all: delivery_method not defined";
6092      } elsif ($fwd_m ne '' && $any_not_done) {
6093        die "Explicit forwarding, but not all recips done";
6094      }
6095    }
6096    for my $r (@$per_recip_data) {        # any invalid code ?
6097      if ($r->recip_done && $r->recip_smtp_response !~ /^[245]/) {
6098        $smtp_resp = '451 4.5.0 Bad SMTP response code??? "'
6099                     . $r->recip_smtp_response . '"';
6100        last;                             # pick the first
6101      }
6102    }
6103    if (defined $smtp_resp) {
6104      $exit_code = EX_TEMPFAIL;
6105      do_log(5, "one_response_for_all <%s>: 4xx found, '%s'",
6106                $sender,$smtp_resp);
6107    }
6108  }
6109  # NOTE: a 2xx SMTP response code is set both by internal Discard
6110  # and by a genuine successful delivery. To distinguish between the two
6111  # we need to check $r->recip_destiny as well.
6112  #
6113  if (!defined $smtp_resp) {
6114    # if destiny for _all_ recipients is D_DISCARD, give Discard
6115    my $notall;
6116    for my $r (@$per_recip_data) {
6117      if ($r->recip_destiny == D_DISCARD)  # pick the first DISCARD code
6118        { $smtp_resp = $r->recip_smtp_response  if !defined $smtp_resp }
6119      else { $notall=1; last }  # one is not a discard, nogood
6120    }
6121    if ($notall) { $smtp_resp = undef }
6122    if (defined $smtp_resp) {
6123      $exit_code = 99;  # helper program will interpret 99 as discard
6124      do_log(5, "one_response_for_all <%s>: all DISCARD, '%s'",
6125                $sender,$smtp_resp);
6126    }
6127  }
6128  if (!defined $smtp_resp) {
6129    # destiny for _all_ recipients is Discard or Reject, give 5xx
6130    # (and there is at least one Reject)
6131    my($notall, $done_level);
6132    my $bounce_cnt = 0;
6133    for my $r (@$per_recip_data) {
6134      my($dest, $resp) = ($r->recip_destiny, $r->recip_smtp_response);
6135      if ($dest == D_DISCARD) {
6136        # ok, this one is a discard, let's see the rest
6137      } elsif ($resp =~ /^5/ && $dest != D_BOUNCE) {
6138        # prefer to report SMTP response code of genuine rejects
6139        # from MTA, over internal rejects by content filters
6140        if (!defined $smtp_resp || $r->recip_done > $done_level)
6141          { $smtp_resp = $resp; $done_level = $r->recip_done }
6142      } else {
6143        $notall=1; last;  # one is a Pass or Bounce, nogood
6144      }
6145    }
6146    if ($notall) { $smtp_resp = undef }
6147    if (defined $smtp_resp) {
6148      $exit_code = EX_UNAVAILABLE;
6149      do_log(5, "one_response_for_all <%s>: REJECTs, '%s'",$sender,$smtp_resp);
6150    }
6151  }
6152  if (!defined $smtp_resp) {
6153    # mixed destiny => 2xx, but generate dsn for bounces and rejects
6154    my($rej_cnt, $bounce_cnt, $drop_cnt) = (0,0,0);
6155    for my $r (@$per_recip_data) {
6156      my($dest, $resp) = ($r->recip_destiny, $r->recip_smtp_response);
6157      if ($resp =~ /^2/ && $dest == D_PASS)  # genuine successful delivery
6158        { $smtp_resp = $resp  if !defined $smtp_resp }
6159      $drop_cnt++  if $dest == D_DISCARD;
6160      if ($resp =~ /^5/)
6161        { if ($dest == D_BOUNCE) { $bounce_cnt++ } else { $rej_cnt++ } }
6162    }
6163    $exit_code = EX_OK;
6164    if (!defined $smtp_resp) {                 # no genuine Pass/2xx
6165        # declare success, we'll handle bounce
6166      $smtp_resp = "250 2.5.0 Ok, id=$am_id";
6167      if ($any_not_done) { $smtp_resp .= ", continue delivery" }
6168      else { $exit_code = 99 }  # helper program DISCARD (e.g. milter)
6169    }
6170    if ($rej_cnt + $bounce_cnt + $drop_cnt > 0) {
6171      $smtp_resp .= ", ";
6172      $smtp_resp .= "but "  if $rej_cnt+$bounce_cnt+$drop_cnt<@$per_recip_data;
6173      $smtp_resp .= join ", and ",
6174        map { my($cnt, $nm) = @$_;
6175              !$cnt ? () : $cnt == @$per_recip_data ? $nm : "$cnt $nm"
6176        } ([$rej_cnt,  'REJECT'],
6177           [$bounce_cnt, $suppressed ? 'DISCARD(bounce.suppressed)' :'BOUNCE'],
6178           [$drop_cnt, 'DISCARD']);
6179    }
6180    $ndn_needed =
6181      ($bounce_cnt > 0 || ($rej_cnt > 0 && !$dsn_per_recip_capable)) ? 1 : 0;
6182    ll(5) && do_log(5,
6183          "one_response_for_all <%s>: %s, r=%d,b=%d,d=%s, ndn_needed=%s, '%s'",
6184             $sender,
6185             $rej_cnt + $bounce_cnt + $drop_cnt > 0 ? 'mixed' : 'success',
6186             $rej_cnt, $bounce_cnt, $drop_cnt, $ndn_needed, $smtp_resp);
6187  }
6188  ($smtp_resp, $exit_code, $ndn_needed);
6189}
6190
61911;
6192
6193#
6194package Amavis::Lookup::RE;
6195use strict;
6196use re 'taint';
6197
6198BEGIN {
6199  require Exporter;
6200  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
6201  $VERSION = '2.412';
6202  @ISA = qw(Exporter);
6203  import Amavis::Util qw(ll do_log fmt_struct);
6204}
6205
6206# Make an object out of the supplied lookup list
6207# to make it distinguishable from simple ACL array
6208sub new($$) { my $class = shift; bless [@_], $class }
6209
6210# lookup_re() performs a lookup for an e-mail address or other key string
6211# against a list of regular expressions.
6212#
6213# A full unmodified e-mail address is always used, so splitting to localpart
6214# and domain or lowercasing is NOT performed. The regexp is powerful enough
6215# that this can be accomplished by its own mechanisms. The routine is useful
6216# for other RE tests besides the usual e-mail addresses, such as looking for
6217# banned file names.
6218#
6219# Each element of the list can be a ref to a pair, or directly a regexp
6220# ('Regexp' object created by a qr operator, or just a (less efficient)
6221# string containing a regular expression). If it is a pair, the first
6222# element is treated as a regexp, and the second provides a value in case
6223# the regexp matches. If not a pair, the implied result of a match is 1.
6224#
6225# The regular expression is taken as-is, no implicit anchoring or setting
6226# case insensitivity is done, so do use a qr'(?i)^user\@example\.com$',
6227# and not a sloppy qr'user@example.com', which can easily backfire.
6228# Also, if qr is used with a delimiter other than ' (apostrophe), make sure
6229# to quote the @ and $ when they are not introducing a variable name.
6230#
6231# The pattern allows for capturing of parenthesized substrings, which can
6232# then be referenced from the result string using the $1, $2, ... notation,
6233# as with a Perl m// operator. The number after a $ may be a multi-digit
6234# decimal number. To avoid possible ambiguity a ${n} or $(n) form may be used
6235# Substring numbering starts with 1. Nonexistent references evaluate to empty
6236# strings. If any substitution is done, the result inherits the taintedness
6237# of $addr. Keep in mind that $ and @ characters needs to be backslash-quoted
6238# in qq() strings. Example:
6239#   $virus_quarantine_to = new_RE(
6240#     [ qr'^(.*)\@example\.com$'i => 'virus-${1}@example.com' ],
6241#     [ qr'^(.*)(\@[^\@]*)?$'i    => 'virus-${1}${2}' ] );
6242#
6243# Example (equivalent to the example in lookup_acl):
6244#    $acl_re = Amavis::Lookup::RE->new(
6245#                     qr'\@me\.ac\.uk$'i, [qr'[\@.]ac\.uk$'i=>0], qr'\.uk$'i );
6246#    ($r,$k) = $acl_re->lookup_re('user@me.ac.uk');
6247# or $r = lookup(0, 'user@me.ac.uk', $acl_re);
6248#
6249# 'user@me.ac.uk'   matches me.ac.uk, returns true and search stops
6250# 'user@you.ac.uk'  matches .ac.uk, returns false (because of =>0)
6251#                   and search stops
6252# 'user@them.co.uk' matches .uk, returns true and search stops
6253# 'user@some.com'   does not match anything, falls through and
6254#                   returns false (undef)
6255#
6256# As a special allowance, the $addr argument may be a ref to a list of search
6257# keys. At each step in traversing the supplied regexp list, all elements of
6258# @$addr are tried. If any of them matches, the search stops. This is currently
6259# used in banned names lookups, where all attributes of a part are given as a
6260# list @$addr, as a loop on attributes must be an inner loop.
6261#
6262sub lookup_re($$;$%) {
6263  my($self, $addr,$get_all,%options) = @_;
6264  local($1,$2,$3,$4); my(@matchingkey,@result);
6265  $addr .= $options{AppendStr}  if defined $options{AppendStr};
6266  for my $e (@$self) {  # try each regexp in the list
6267    my($key,$r);
6268    if (ref($e) eq 'ARRAY') {  # a pair: (regexp,result)
6269      ($key,$r) = ($e->[0], @$e < 2 ? 1 : $e->[1]);
6270    } else {  # a single regexp (not a pair), implies result 1
6271      ($key,$r) = ($e, 1);
6272    }
6273    # braindamaged Perl: empty string implies the last successfully
6274    # matched regular expression; we must avoid this:
6275    $key = qr{(?:)}  if !defined $key || $key eq '';
6276    my(@rhs);    # match, capturing parenthesized subpatterns into @rhs
6277    if (!ref($addr)) { @rhs = $addr =~ /$key/ }
6278    else { for (@$addr) { @rhs = /$key/; last if @rhs } }  # inner loop
6279    if (@rhs) {  # regexp matches
6280      # do the righthand side replacements if any $n, ${n} or $(n) is specified
6281      if (defined($r) && !ref($r) && index($r,'$') >= 0) {  # triage
6282        my $any = $r =~ s{ \$ ( (\d+) | \{ (\d+) \} | \( (\d+) \) ) }
6283                         { my $j=$2+$3+$4; $j<1 ? '' : $rhs[$j-1] }xgse;
6284        # bring taintedness of input to the result
6285        $r .= substr($addr,0,0)  if $any;
6286      }
6287      push(@result,$r); push(@matchingkey,$key);
6288      last  if !$get_all;
6289    }
6290  }
6291  if (!ll(5)) {
6292    # don't bother preparing log report which will not be printed
6293  } elsif (!@result) {
6294    do_log(5, "lookup_re(%s), no matches", fmt_struct($addr));
6295  } else {  # pretty logging
6296    if (!$get_all) {  # first match wins
6297      do_log(5, 'lookup_re(%s) matches key "%s", result=%s',
6298                fmt_struct($addr), $matchingkey[0], fmt_struct($result[0]));
6299    } else {  # want all matches
6300      do_log(5, "lookup_re(%s) matches keys: %s", fmt_struct($addr),
6301          join(', ', map { sprintf('"%s"=>%s',
6302                                   $matchingkey[$_], fmt_struct($result[$_]))
6303                         } (0..$#result)));
6304    }
6305  }
6306  if (!$get_all) { !wantarray ? $result[0] : ($result[0], $matchingkey[0]) }
6307  else           { !wantarray ? \@result   : (\@result,   \@matchingkey)   }
6308}
6309
63101;
6311
6312#
6313package Amavis::Lookup::IP;
6314use strict;
6315use re 'taint';
6316
6317BEGIN {
6318  require Exporter;
6319  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION $have_patricia);
6320  $VERSION = '2.412';
6321  @ISA = qw(Exporter);
6322  @EXPORT_OK = qw(&lookup_ip_acl &ip_to_vec &normalize_ip_addr);
6323  import Amavis::Util qw(ll do_log);
6324}
6325use subs @EXPORT_OK;
6326
6327BEGIN {
6328  eval {
6329    require Net::Patricia;
6330    Net::Patricia->VERSION(1.015);  # need AF_INET6 support
6331    import Net::Patricia;
6332    $have_patricia = 1;
6333  } or do {
6334    undef $have_patricia;
6335  };
6336}
6337
6338# ip_to_vec() takes an IPv6 or IPv4 address with optional prefix length
6339# (or an IPv4 mask), parses and validates it, and returns it as a 128-bit
6340# vector string that can be used as operand to Perl bitwise string operators.
6341# Syntax and other errors in the argument throw exception (die).
6342# If the second argument $allow_mask is 0, the prefix length or mask
6343# specification is not allowed as part of the IP address.
6344#
6345# The IPv6 syntax parsing and validation adheres to RFC 4291 (ex RFC 3513).
6346# All the following IPv6 address forms are supported:
6347#   x:x:x:x:x:x:x:x        preferred form
6348#   x:x:x:x:x:x:d.d.d.d    alternative form
6349#   ...::...               zero-compressed form
6350#   addr/prefix-length     prefix length may be specified (defaults to 128)
6351# Optionally an "IPv6:" prefix may be prepended to an IPv6 address
6352# as specified by RFC 5321 (ex RFC 2821). Brackets enclosing the address
6353# are optional, e.g. [::1]/128 .
6354#
6355# The following IPv4 forms are allowed:
6356#   d.d.d.d
6357#   d.d.d.d/prefix-length  CIDR mask length is allowed (defaults to 32)
6358#   d.d.d.d/m.m.m.m        network mask (gets converted to prefix-length)
6359# If prefix-length or a mask is specified with an IPv4 address, the address
6360# may be shortened to d.d.d/n or d.d/n or d/n. Such truncation is allowed
6361# for compatibility with earlier version, but is deprecated and is not
6362# allowed for IPv6 addresses.
6363#
6364# IPv4 addresses and masks are converted to IPv4-mapped IPv6 addresses
6365# of the form ::FFFF:d.d.d.d,  The CIDR mask length (0..32) is converted
6366# to an IPv6 prefix-length (96..128). The returned vector strings resulting
6367# from IPv4 and IPv6 forms are indistinguishable.
6368#
6369# NOTE:
6370#   d.d.d.d is equivalent to ::FFFF:d.d.d.d (IPv4-mapped IPv6 address)
6371#   which is not the same as ::d.d.d.d      (IPv4-compatible IPv6 address)
6372#
6373# A quadruple is returned:
6374#  - an IP address represented as a 128-bit vector (a string)
6375#  - network mask derived from prefix length, a 128-bit vector (string)
6376#  - prefix length as an integer (0..128)
6377#  - zone_id, e.g. an interface scope for link-local addresses,
6378#      undef if not specified (implies a default zone_id 0, RFC 4007 sect. 11)
6379#
6380sub ip_to_vec($;$) {
6381  my($ip,$allow_mask) = @_;
6382  my($ip_len, @ip_fields, $scope);
6383  local($1,$2,$3,$4,$5,$6);
6384  $ip =~ s/^[ \t]+//; $ip =~ s/[ \t\r\n]+\z//s;  # trim
6385  my $ipa = $ip;
6386  ($ipa,$ip_len) = ($1,$2)  if $allow_mask && $ip =~ m{^ ([^/]*) / (.*) \z}xs;
6387  $ipa = $1  if $ipa =~ m{^ \[ (.*) \] \z}xs;  # discard optional brackets
6388  my $have_ipv6;
6389  if    ($ipa =~ s/^IPv6://i)    { $have_ipv6 = 1 }
6390  elsif ($ipa =~ /:[0-9a-f]*:/i) { $have_ipv6 = 1 }
6391
6392  # RFC 4007: IPv6 Scoped Address Architecture, sect 11: textual representation
6393  # RFC 6874  A <zone_id> SHOULD contain only ASCII characters
6394  #   classified as "unreserved" for use in URIs [RFC 3986]
6395  # RFC 3986: unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
6396  $scope = $1  if $ipa =~ s/ ( % [A-Z0-9._~-]* ) \z//xsi;  # scoped address
6397
6398  if ($have_ipv6 &&
6399      $ipa =~ m{^(.*:) (\d{1,3}) \. (\d{1,3}) \. (\d{1,3}) \. (\d{1,3})\z}xsi){
6400    # IPv6 alternative form x:x:x:x:x:x:d.d.d.d
6401    my(@d) = ($2,$3,$4,$5);
6402    !grep($_ > 255, @d)
6403      or die "Invalid decimal field value in IPv6 address: [$ip]\n";
6404    $ipa = $1 . sprintf('%02x%02x:%02x%02x', @d);
6405  } elsif (!$have_ipv6 &&
6406           $ipa =~ m{^ \d{1,3} (?: \. \d{1,3}){0,3} \z}xs) {  # IPv4
6407    my(@d) = split(/\./,$ipa,-1);
6408    !grep($_ > 255, @d)
6409      or die "Invalid field value in IPv4 address: [$ip]\n";
6410    defined($ip_len) || @d==4
6411      or die "IPv4 address [$ip] contains fewer than 4 fields\n";
6412    $ipa = '::ffff:' . sprintf('%02x%02x:%02x%02x', @d);  # IPv4-mapped IPv6
6413    if (!defined($ip_len)) { $ip_len = 32;  # no length, defaults to /32
6414    } elsif ($ip_len =~ /^\d{1,9}\z/) {     # /n, IPv4 CIDR notation
6415    } elsif ($ip_len =~ /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/) {
6416      my(@d) = ($1,$2,$3,$4);
6417      !grep($_ > 255, @d)
6418        or die "Illegal field value in IPv4 mask: [$ip]\n";
6419      my $mask1 = pack('C4', @d);           # /m.m.m.m
6420      my $len = unpack('%b*', $mask1);      # count ones
6421      my $mask2 = pack('B32', '1' x $len);  # reconstruct mask from count
6422      $mask1 eq $mask2
6423        or die "IPv4 mask not representing a valid CIDR mask: [$ip]\n";
6424      $ip_len = $len;
6425    } else {
6426      die "Invalid IPv4 network mask or CIDR prefix length: [$ip]\n";
6427    }
6428    $ip_len<=32 or die "IPv4 network prefix length greater than 32: [$ip]\n";
6429    $ip_len += 128-32;  # convert IPv4 net mask length to IPv6 prefix length
6430  }
6431  # now we presumably have an IPv6 compressed or preferred form x:x:x:x:x:x:x:x
6432  if ($ipa !~ /^(.*?)::(.*)\z/s) {  # zero-compressing form used?
6433    @ip_fields = split(/:/,$ipa,-1);  # no, have preferred form
6434  } else {                          # expand zero-compressing form
6435    my($before,$after) = ($1,$2);
6436    my(@bfr) = split(/:/,$before,-1); my(@aft) = split(/:/,$after,-1);
6437    my $missing_cnt = 8-(@bfr+@aft);  $missing_cnt = 1  if $missing_cnt<1;
6438    @ip_fields = (@bfr, ('0') x $missing_cnt, @aft);
6439  }
6440  @ip_fields >= 8  or die "IPv6 address [$ip] contains fewer than 8 fields\n";
6441  @ip_fields <= 8  or die "IPv6 address [$ip] contains more than 8 fields\n";
6442  !grep(!/^[0-9a-fA-F]{1,4}\z/, @ip_fields)  # this is quite slow
6443    or die "Invalid syntax of IPv6 address: [$ip]\n";
6444  my $vec = pack('n8', map(hex($_),@ip_fields));
6445  if (!defined($ip_len)) {
6446    $ip_len = 128;
6447  } elsif ($ip_len !~ /^\d{1,3}\z/) {
6448    die "Invalid prefix length syntax in IP address: [$ip]\n";
6449  } elsif ($ip_len > 128) {
6450    die "IPv6 network prefix length greater than 128: [$ip]\n";
6451  }
6452  my $mask = pack('B128', '1' x $ip_len);
6453# do_log(5, "ip_to_vec: %s => %s/%d\n",     # unpack('B*',$vec)
6454#           $ip, join(':',unpack('(H4)*',$vec)), $ip_len);
6455  ($vec, $mask, $ip_len, $scope);
6456}
6457
6458use vars qw($ip_mapd_vec $ip_mapd_mask  $ip_xlat_vec $ip_xlat_mask
6459            $ip_6to4_vec $ip_6to4_mask  $ip_nat64_vec $ip_nat64_mask);
6460BEGIN {
6461  # RFC 4291: IPv4-mapped
6462  ($ip_mapd_vec, $ip_mapd_mask) = ip_to_vec('::ffff:0:0/96',1);  # IPv4-mapped
6463  # RFC 2765 (SIIT): IPv4-translated
6464  ($ip_xlat_vec, $ip_xlat_mask) = ip_to_vec('::ffff:0:0:0/96',1); # IPv4-xlat
6465  # RFC 3056 (6to4)
6466  ($ip_6to4_vec, $ip_6to4_mask) = ip_to_vec('2002::/16',1);      # 6to4
6467  # RFC 6052, RFC 6146 (NAT64)
6468  ($ip_nat64_vec, $ip_nat64_mask) = ip_to_vec('64:ff9b::/96',1); # NAT64
6469  # check, just in case
6470  $ip_mapd_vec  = $ip_mapd_vec  & $ip_mapd_mask;
6471  $ip_xlat_vec  = $ip_xlat_vec  & $ip_xlat_mask;
6472  $ip_6to4_vec  = $ip_6to4_vec  & $ip_6to4_mask;
6473  $ip_nat64_vec = $ip_nat64_vec & $ip_nat64_mask;
6474}
6475
6476# strip an optional 'IPv6:' prefix, lowercase hex digits,
6477# convert an IPv4-mapped IPv6 address into a plain IPv4 dot-quad form;
6478# leave unchanged if syntactically incorrect
6479#
6480sub normalize_ip_addr($) {
6481  my $ip = $_[0];
6482  my($have_ipv6, $scope);
6483  if    ($ip =~ s/^IPv6://i)    { $have_ipv6 = 1 }
6484  elsif ($ip =~ /:[0-9a-f]*:/i) { $have_ipv6 = 1 }
6485  if ($have_ipv6) {
6486    local($1);
6487    $scope = $1  if $ip =~ s/ % ( [A-Z0-9._~-]* ) \z//xsi;  # scoped address
6488    if ($ip !~ /^[0:]+:ffff:/i) {  # triage for IPv4-mapped
6489      $ip = lc $ip;  # lowercase: RFC 5952
6490    } else {  # looks like an IPv4-mapped address
6491      my($ip_vec,$ip_mask);
6492      if (!eval { ($ip_vec,$ip_mask) = ip_to_vec($ip,0); 1 }) {
6493        do_log(3, "normalize_ip_addr: bad IP address: %s", $_[0]);
6494      } elsif (($ip_vec & $ip_mapd_mask) ne $ip_mapd_vec) {
6495        $ip = lc $ip;  # lowercase: RFC 5952
6496        # RFC 5952 - Recommendation for IPv6 Text Representation
6497        # TODO: apply suppression of leading zeroes, zero compression
6498      } else {  # IPv4-mapped address
6499        my $ip_dq = join('.', unpack('C4',substr($ip_vec,12,4)));  # 32 bits
6500        do_log(5, "IPv4-mapped: %s -> %s", $ip, $ip_dq);
6501        $ip = $ip_dq;
6502      }
6503    }
6504  }
6505  $ip .= '%'.$scope  if $scope;  # defined, nonempty and nonzero
6506  $ip;
6507}
6508
6509# lookup_ip_acl() performs a lookup for an IPv4 or IPv6 address against a list
6510# of lookup tables, each may be a constant, or a ref to an access control
6511# list or a ref to an associative array (hash) of network or host addresses.
6512# Interface zone_id (e.g. scope for link-local addresses) is ignored.
6513#
6514# IP address is compared to each member of an access list in turn,
6515# the first match wins (terminates the search), and its value decides
6516# whether the result is true (yes, permit, pass) or false (no, deny, drop).
6517# Falling through without a match produces a false (undef).
6518#
6519# For lookup tables which are a ref to a an array (a traditional ACL),
6520# the presence of a character '!' prepended to a list member decides
6521# whether the result will be true (without a '!') or false (with a '!')
6522# in case this list member matches and terminates the search.
6523#
6524# Because search stops at the first match, it only makes sense
6525# to place more specific patterns before the more general ones.
6526#
6527# For IPv4 a network address can be specified in classless notation
6528# n.n.n.n/k, or using a mask n.n.n.n/m.m.m.m . Missing mask implies /32,
6529# i.e. a host address. For IPv6 addresses all RFC 4291 forms are allowed
6530# and the /k specifies a prefix length. See also comments at ip_to_vec().
6531#
6532# Although not a special case, it is good to remember that '::/0'
6533# always matches any IPv4 or IPv6 address (even syntactically invalid address).
6534#
6535# The '0/0' is equivalent to '::ffff:0:0/96' and matches any syntactically
6536# valid IPv4 address (including IPv4-mapped IPv6 addresses), but not other
6537# IPv6 addresses!
6538#
6539# Example
6540#   given: @acl = qw( !192.168.1.12 172.16.3.3 !172.16.3.0/255.255.255.0
6541#                     10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
6542#                     !0.0.0.0/8 !:: 127.0.0.0/8 ::1 );
6543#   matches RFC 1918 private address space except host 192.168.1.12
6544#   and net 172.16.3/24 (but host 172.16.3.3 within 172.16.3/24 still matches).
6545#   In addition, the 'unspecified' (null, i.e. all zeros) IPv4 and IPv6
6546#   addresses return false, and IPv4 and IPv6 loopback addresses match
6547#   and return true.
6548#
6549# If the supplied lookup table is a hash reference, match a canonical
6550# IP address: dot-quad IPv4, or a preferred IPv6 form, against hash keys.
6551# For IPv4 addresses a simple classful subnet specification is allowed in
6552# hash keys by truncating trailing bytes from the looked up IPv4 address.
6553# A syntactically invalid IP address cannot match any hash entry.
6554#
6555sub lookup_ip_acl($@) {
6556  my($ip, @nets_ref) = @_;
6557  my($ip_vec,$ip_mask);  my $eval_stat;
6558  eval { ($ip_vec,$ip_mask) = ip_to_vec($ip,0);  1 }
6559    or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
6560  my($label,$fullkey,$result,$lookup_type); my $found = 0;
6561  for my $tb (@nets_ref) {
6562    my $t = ref($tb) eq 'REF' ? $$tb : $tb;  # allow one level of indirection
6563    if (!ref($t) || ref($t) eq 'SCALAR') {   # a scalar always matches
6564      my $r = ref($t) ? $$t : $t;  # allow direct or indirect reference
6565      $result = $r; $fullkey = "(constant:$r)"; $lookup_type = 'const';
6566      $found=1  if defined $result;
6567    } elsif (ref($t) eq 'HASH') {
6568      $lookup_type = 'hash';
6569      if (!defined $ip_vec) {  # syntactically invalid IP address
6570        $fullkey = undef; $result = $t->{$fullkey};  # only matches undef key
6571        $found=1  if defined $result;
6572      } else {     # valid IP address
6573        # match a canonical IP address: dot-quad IPv4, or preferred IPv6 form
6574        my $ip_c;  # IP address in a canonical form: x:x:x:x:x:x:x:x
6575        $ip_c = join(':', map(sprintf('%04x',$_), unpack('n8',$ip_vec)));
6576        if (($ip_vec & $ip_mapd_mask) ne $ip_mapd_vec) {
6577          do_log(5, 'lookup_ip_acl keys: "%s"', $ip_c);
6578        } else {  # is an IPv4-mapped addr
6579          my $ip_dq;  # IPv4 in dotted-quad form
6580          $ip_dq = join('.', unpack('C4',substr($ip_vec,12,4)));  # 32 bits
6581          # try dot-quad, stripping off trailing bytes repeatedly
6582          do_log(5, 'lookup_ip_acl keys: "%s", "%s"', $ip_dq, $ip_c);
6583          for (my(@f)=split(/\./,$ip_dq); @f && !$found; $#f--) {
6584            $fullkey = join('.',@f); $result = $t->{$fullkey};
6585            $found=1  if defined $result;
6586          }
6587        }
6588      # test for 6to4 too? not now
6589      # if ($ip_vec & $ip_6to4_mask) eq $ip_6to4_vec) {
6590      #   # yields an IPv4 address of a client's 6to4 router
6591      #   $ip_dq = join('.', unpack('C4',substr($ip_vec,2,4)));
6592      # }
6593        if (!$found) {  # try the 'preferred IPv6 form', lowercase hex letters
6594          $fullkey = lc $ip_c; $result = $t->{$fullkey};
6595          $found=1  if defined $result;
6596        }
6597      }
6598    } elsif (ref($t) eq 'ARRAY') {
6599      $lookup_type = 'array';
6600      my($key,$acl_ip_vec,$acl_mask,$acl_mask_len); local($1,$2);
6601      for my $net (@$t) {
6602        $fullkey = $key = $net; $result = 1;
6603        if ($key =~ /^(!+)(.*)\z/s) {  # starts with exclamation mark(s)
6604          $key = $2;
6605          $result = 1 - $result  if (length($1) & 1);  # negate if odd
6606        }
6607        ($acl_ip_vec, $acl_mask, $acl_mask_len) = ip_to_vec($key,1);
6608        if ($acl_mask_len == 0) { $found=1 } #even an invalid addr matches ::/0
6609        elsif (!defined($ip_vec)) {}     # no other matches for invalid address
6610        elsif (($ip_vec & $acl_mask) eq ($acl_ip_vec & $acl_mask)) { $found=1 }
6611        last  if $found;
6612      }
6613    } elsif ($t->isa('Net::Patricia::AF_INET6')) {  # Patricia Trie
6614      $lookup_type = 'patricia';
6615      local($1,$2,$3,$4); local($_) = $ip;
6616      $_ = $1  if /^ \[ ( [^\]]* ) \] \z/xs;  # discard optional brackets
6617      s/%[A-Z0-9:._-]+\z//si;           # discard interface specification
6618      if (m{^ (\d+) \. (\d+) \. (\d+) \. (\d+) \z}x) {
6619        $_ = sprintf('::ffff:%d.%d.%d.%d', $1,$2,$3,$4);
6620      } else {
6621        s/^IPv6://i;  # discard optional 'IPv6:' prefix
6622      }
6623      eval { $result = $t->match_string($_); 1 }  or $result=undef;
6624      if (defined $result) {
6625        $fullkey = $result;
6626        if ($fullkey =~ s/^!//) { $result = 0 }
6627        else { $result = 1; $found = 1 }
6628      }
6629    } elsif ($t->isa('Amavis::Lookup::IP')) {  # pre-parsed IP lookup array obj
6630      $lookup_type = 'arr.obj';
6631      my($acl_ip_vec, $acl_mask, $acl_mask_len);
6632      for my $e (@$t) {
6633        ($fullkey, $acl_ip_vec, $acl_mask, $acl_mask_len, $result) = @$e;
6634        if ($acl_mask_len == 0) { $found=1 } #even an invalid addr matches ::/0
6635        elsif (!defined($ip_vec)) {}     # no other matches for invalid address
6636        elsif (($ip_vec & $acl_mask) eq ($acl_ip_vec & $acl_mask)) { $found=1 }
6637        last  if $found;
6638      }
6639    } elsif ($t->isa('Amavis::Lookup::DNSxL')) {  # DNSxL lookup obj, RFC 5782
6640      $lookup_type = 'dns';
6641      ($result, $fullkey) = $t->lookup_ip($ip);
6642      $found = $result;
6643    } elsif ($t->isa('Amavis::Lookup::Label')) {  # logging label
6644      # just a convenience for logging purposes, not a real lookup method
6645      $label = $t->display;  # grab the name, and proceed with the next table
6646    } else {
6647      die "TROUBLE: lookup table is an unknown object: " . ref($t);
6648    }
6649    last  if $found;
6650  }
6651  $fullkey = $result = undef  if !$found;
6652  if ($label ne '') { $label = " ($label)" }
6653  ll(4) && do_log(4, 'lookup_ip_acl%s %s: key="%s"%s',
6654                     $label, $lookup_type, $ip,
6655                     !$found ? ", no match"
6656                             : " matches \"$fullkey\", result=$result");
6657  if (defined $eval_stat) {
6658    chomp $eval_stat;
6659    die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
6660    $eval_stat = "lookup_ip_acl$label: $eval_stat";
6661    do_log(2, "%s", $eval_stat);
6662  }
6663  !wantarray ? $result : ($result, $fullkey, $eval_stat);
6664}
6665
6666# Create a pre-parsed object from a list of IP networks, which
6667# may be used as an argument to lookup_ip_acl to speed up its searches.
6668# Interface zone_id (e.g. scope for link-local addresses) is ignored.
6669#
6670sub new($@) {
6671  my($class,@nets) = @_;
6672  my $build_patricia_trie = $have_patricia && (@nets > 20);
6673  if (!$build_patricia_trie) {
6674    # build a traditional pre-parsed search list for a small number of entries
6675    my(@list); local($1,$2);
6676    for my $net (@nets) {
6677      my $key = $net; my $result = 1;
6678      if ($key =~ /^(!+)(.*)\z/s) {  # starts with exclamation mark(s)
6679        $key = $2;
6680        $result = 1 - $result  if (length($1) & 1);  # negate if odd
6681      }
6682      my($ip_vec, $ip_mask, $ip_mask_len) = ip_to_vec($key,1);
6683      push(@list, [$net, $ip_vec, $ip_mask, $ip_mask_len, $result]);
6684    }
6685    return bless(\@list, $class);
6686  } else {
6687    # build a patricia trie, it offers more efficient searching in large sets
6688    my $pt = Net::Patricia->new(&AF_INET6);
6689    do_log(5, "building a patricia trie out of %d nets", scalar(@nets));
6690    for my $net (@nets) {
6691      local $_ = $net;
6692      local($1,$2,$3,$4); my $masklen;
6693      if (s{ / ([0-9.]+) \z }{}x) {
6694        $masklen = $1;
6695        $masklen =~ /^\d{1,3}\z/
6696          or die "Network mask not supported, use a CIDR syntax: $net";
6697      }
6698      s/^!//;  # strip a negation from a key, it will be retained in data
6699      $_ = $1  if /^ \[ ( [^\]]* ) \] \z/xs;  # discard optional brackets
6700      s/%[A-Z0-9:._-]+\z//si;                 # discard interface specification
6701      if (/^ \d+ (?: \. | \z) /x) {  # triage for an IPv4 network address
6702        if (/^ (\d+) \. (\d+) \. (\d+) \. (\d+) \z/x) {
6703          $_ = sprintf('::ffff:%d.%d.%d.%d', $1,$2,$3,$4);
6704          $masklen = 32  if !defined $masklen;
6705        } elsif (/^ (\d+) \. (\d+) \. (\d+) \.? \z/x) {
6706          $_ = sprintf('::ffff:%d.%d.%d.0', $1,$2,$3);
6707          $masklen = 24  if !defined $masklen;
6708        } elsif (/^ (\d+) \. (\d+) \.? \z/x) {
6709          $_ = sprintf('::ffff:%d.%d.0.0', $1,$2);
6710          $masklen = 16  if !defined $masklen;
6711        } elsif (/^ (\d+) \.? \z/x) {
6712          $_ = sprintf('::ffff:%d.0.0.0', $1);
6713          $masklen = 8  if !defined $masklen;
6714        }
6715        $masklen += 96  if defined $masklen;
6716      } else {  # looks like an IPv6 network
6717        s/^IPv6://i;  # discard optional 'IPv6:' prefix
6718      }
6719      $masklen = 128  if !defined $masklen;
6720      $_ .= '/' . $masklen;
6721      eval { $pt->add_string($_, $net); 1 }
6722        or die "Adding a network $net to a patricia trie failed: $@";
6723    }
6724  # ll(5) && $pt->climb(sub { do_log(5,"patricia trie, node $_[0]") });
6725    return $pt;  # a Net::Patricia::AF_INET6 object
6726  }
6727}
6728
67291;
6730
6731#
6732package Amavis::Lookup::Opaque;
6733use strict;
6734use re 'taint';
6735
6736# Make an object out of the supplied argument, pretocting it
6737# from being interpreted as an acl- or a hash- type lookup.
6738#
6739sub new($$) { my($class,$obj) = @_; bless \$obj, $class }
6740sub get($) { ${$_[0]} }
6741
67421;
6743
6744#
6745package Amavis::Lookup::OpaqueRef;
6746use strict;
6747use re 'taint';
6748
6749# Make an object out of the supplied argument, pretocting it
6750# from being interpreted as an acl- or a hash- type lookup.
6751# The argument to new() is expected to be a ref to a variable,
6752# which will be dereferenced by a method get().
6753#
6754sub new($$) { my($class,$obj) = @_; bless \$obj, $class }
6755sub get($) { ${${$_[0]}} }
6756
67571;
6758
6759#
6760package Amavis::Lookup::Label;
6761use strict;
6762use re 'taint';
6763
6764# Make an object out of the supplied string, to serve as label
6765# in log messages generated by sub lookup
6766#
6767sub new($$) { my($class,$str) = @_; bless \$str, $class }
6768sub display($) { ${$_[0]} }
6769
67701;
6771
6772#
6773package Amavis::Lookup::SQLfield;
6774use strict;
6775use re 'taint';
6776
6777sub new($$$;$$) {
6778  my($class, $sql_query, $fieldname, $fieldtype, $implied_args) = @_;
6779  my $self =
6780    bless { fieldname => $fieldname, fieldtype => $fieldtype }, $class;
6781  $self->{sql_query} = $sql_query  if defined $sql_query;
6782  $self->{args} = ref($implied_args) eq 'ARRAY' ? [@$implied_args]  # copy
6783                  : [$implied_args]  if defined $implied_args;
6784  $self;
6785}
6786
67871;
6788
6789#
6790package Amavis::Lookup::LDAPattr;
6791use strict;
6792use re 'taint';
6793
6794sub new($$$;$) {
6795  my($class, $ldap_query, $attrname, $attrtype) = @_;
6796  my $self =
6797    bless { attrname => $attrname, attrtype => $attrtype }, $class;
6798  $self->{ldap_query} = $ldap_query  if defined $ldap_query;
6799  $self;
6800}
6801
68021;
6803
6804#
6805package Amavis::Lookup::DNSxL;
6806use strict;
6807use re 'taint';
6808
6809BEGIN {
6810  import Amavis::Conf qw(:platform);
6811  import Amavis::Util qw(ll do_log);
6812  use vars qw($dns_resolver);  # implicit persistent Net::DNS::Resolver object
6813}
6814
6815sub new {
6816  my($class, $zone, $expected, $resolver) = @_;
6817  # $zone is either a DNSxL zone name, or a template where an %a is a
6818  # place-holder for the IP address to be queried.
6819  # The result of a type-A DNS query is matched against $expected, which is
6820  # either a scalar string, or a ref to an array of strings, or a regexp obj.
6821  require NetAddr::IP or die "Can't load module NetAddr::IP";
6822  NetAddr::IP->VERSION(4.010);  # need a method full6()
6823  if ($resolver) {
6824    # DNS resolver object provided by a caller, use that
6825  } elsif ($dns_resolver) {
6826    # reuse previously created internal resolver object
6827    $resolver = $dns_resolver;
6828  } else {  # create a new internal resolver object with some sensible defaults
6829    require Net::DNS or die "Can't load module Net::DNS";
6830    $dns_resolver = Net::DNS::Resolver->new(
6831      config_file => '/etc/resolv.conf', force_v4 => !$have_inet6,
6832      defnames => 0, retry => 1, persistent_udp => 1,
6833      tcp_timeout => 2, udp_timeout => 2, retrans => 1);  # seconds
6834    $dns_resolver or die "Failed to create a Net::DNS::Resolver object";
6835    $dns_resolver->udppacketsize(1220);
6836    $resolver = $dns_resolver;
6837  }
6838  defined $zone && $zone ne ''
6839    or die "DNS zone name must not be empty, in Amavis::Lookup::DNSxL";
6840  $expected = '127.0.0.2'  if !defined $expected;  # an RFC 5782 convention
6841  my $self = {
6842    zone => $zone,          # DNSxL zone name (a base DNS domain name)
6843    resolver => $resolver,  # a Net::DNS::Resolver object or equivalent
6844    expected => $expected,  # a set of replies that qualify as a match
6845  };
6846  bless $self, $class;
6847}
6848
6849# Query a DNSxL list given an IPv4 or IPv6 address, according to RFC 5782.
6850# Returns an IPv4 address in the 127.0.0.0/8 subnet as returned by a DNS
6851# type-A query when the result matches the provided expected value, or a
6852# zero when a query succeeded (NOERROR or NXDOMAIN) but there was no match.
6853# The argument $expected may be a string, a ref to array, or a regexp object.
6854# Returns undef on DNS failures (like a timeout, or no Net::DNS module).
6855#
6856sub lookup_ip {
6857  my($self, $ipaddr) = @_;
6858  my $result;   # result of a DNS query, undef indicates a lookup failure
6859  my $fullkey;  # matching (expected) key
6860  return ($result,$fullkey)  if !$self->{resolver};
6861  my $revip;
6862  local($1,$2,$3,$4);
6863  if ($ipaddr =~ /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/) {
6864    $revip = "$4.$3.$2.$1";
6865  } elsif ($ipaddr =~ /:[0-9a-f]*:/i) {  # triage
6866    # looks like an IPv6 address, let NetAddr::IP check the details
6867    my $ip_obj = NetAddr::IP->new6($ipaddr);
6868    if (defined $ip_obj) {  # a valid IPv6 address, apply RFC 5782 section 2.4
6869      $revip = lc $ip_obj->network->full6;  # string in a canonical form
6870      $revip =~ s/://gs;  $revip = join('.', reverse split(//,$revip));
6871    }
6872  }
6873  if (!defined $revip) {
6874    do_log(4,'invalid IP address for a DNSxL query: %s', $ipaddr);
6875    return ($result,$fullkey);
6876  }
6877  my $query = $self->{zone};
6878  $query =~ s/%a/$revip/gs  or  ($query = $revip . '.' .$query);
6879  my $pkt = $self->{resolver}->send($query, 'A');
6880
6881  my $ll5 = ll(5);
6882  $result = 0;  # defined but false
6883  if (!$pkt || !$pkt->header) {
6884    undef $result;
6885    $ll5 && do_log(5,'DNSxL query %s, no result', $query);
6886  } elsif ($pkt->header->rcode eq 'NXDOMAIN') {
6887    $ll5 && do_log(5,'DNSxL query %s, domain does not exist', $query);
6888  } elsif ($pkt->header->rcode ne 'NOERROR') {
6889    $ll5 && do_log(5,'DNSxL query %s, rcode %s', $query, $pkt->header->rcode);
6890  } elsif ($pkt->header->ancount) {
6891    my $expected = $self->{expected};
6892    $expected = [ $expected ]  if !ref $expected;
6893    for my $rr ($pkt->answer) {
6894      next if $rr->type ne 'A';
6895      my $returned_addr = $rr->address;
6896      $ll5 && do_log(5,'DNSxL query %s, DNS answer: %s',$query,$returned_addr);
6897      # RFC 5782 section 2.3: values SHOULD be in the 127.0.0.0/8 range
6898      next if $returned_addr !~ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/s;
6899      if (ref $expected eq 'ARRAY') {
6900        # $expected is an array of strings: IPv4 addresses in dotted-quad
6901        # form, with bytes possibly omitted from the left
6902        for (@$expected) {
6903          if ( ( /^\d+\z/           ? "127.0.0.$_"
6904               : /^\d+\.\d+\z/      ? "127.0.$_"
6905               : /^\d+\.\d+\.\d+\z/ ? "127.$_" : $_) eq $returned_addr) {
6906            $fullkey = $_; $result = $returned_addr;
6907            last;
6908          }
6909        }
6910        last if defined $result;
6911      } elsif (ref $expected eq 'Regexp') {
6912        # $expected is a regular expresion
6913        if ($returned_addr =~ /$expected/s) {
6914          $fullkey = "$expected";  # stringified regexp object
6915          $result = $returned_addr; last;
6916        }
6917      }
6918    }
6919  }
6920  do_log(5,'DNSxL result: %s, matches %s',$result,$fullkey) if $ll5 && $result;
6921  ($result, $fullkey);
6922}
6923
69241;
6925
6926#
6927package Amavis::Lookup;
6928use strict;
6929use re 'taint';
6930
6931BEGIN {
6932  require Exporter;
6933  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
6934  $VERSION = '2.412';
6935  @ISA = qw(Exporter);
6936  @EXPORT_OK = qw(&lookup &lookup2 &lookup_hash &lookup_acl);
6937  import Amavis::Util qw(ll do_log fmt_struct unique_list idn_to_ascii
6938                         safe_encode_utf8_inplace);
6939  import Amavis::Conf qw(:platform c cr ca);
6940  import Amavis::Timing qw(section_time);
6941  import Amavis::rfc2821_2822_Tools qw(split_address make_query_keys);
6942}
6943use subs @EXPORT_OK;
6944
6945# lookup_hash() performs a lookup for an e-mail address against a hash map.
6946# If a match is found (a hash key exists in the Perl hash) the function returns
6947# whatever the map returns, otherwise undef is returned. First match wins,
6948# aborting further search sequence.
6949#
6950# The $addr may be a string of octets (assumed to be UTF-8 encoded)
6951# or a string of characters which gets first encoded to UTF-8 octets.
6952# International domain name (IDN) in $addr will be converted to ACE and
6953# lowercased. Keys of a hash table are expected to be in octets (utf8 flag
6954# off) and their international domain names encoded in ASCII-compatible
6955# encoding (ACE).
6956#
6957sub lookup_hash($$;$%) {
6958  my($addr, $hash_ref,$get_all,%options) = @_;
6959  ref($hash_ref) eq 'HASH'
6960    or die "lookup_hash: arg2 must be a hash ref: $hash_ref";
6961  local($1,$2,$3,$4); my(@matchingkey,@result); my $append_string;
6962  $append_string = $options{AppendStr}  if defined $options{AppendStr};
6963  my($keys_ref,$rhs_ref) = make_query_keys($addr,1,1,$append_string);
6964  for my $key (@$keys_ref) {  # do the search
6965    if (exists $$hash_ref{$key}) {  # got it
6966      push(@result,$$hash_ref{$key}); push(@matchingkey,$key);
6967      last  if !$get_all;
6968    }
6969  }
6970  # do the right-hand side replacements if any $n, ${n} or $(n) is specified
6971  for my $r (@result) {  # $r is just an alias to array elements
6972    if (defined($r) && !ref($r) && index($r,'$') >= 0) { # plain string with $
6973      my $any = $r =~ s{ \$ ( (\d+) | \{ (\d+) \} | \( (\d+) \) ) }
6974                       { my $j = $2+$3+$4; $j<1 ? '' : $rhs_ref->[$j-1] }xgse;
6975      # bring taintedness of input to the result
6976      $r .= substr($addr,0,0)  if $any;
6977    }
6978  }
6979  if (!ll(5)) {
6980    # only bother with logging when needed
6981  } elsif (!@result) {
6982    do_log(5,"lookup_hash(%s), no matches", $addr);
6983  } elsif (!$get_all) {  # first match wins
6984    do_log(5,'lookup_hash(%s) matches key "%s", result=%s',
6985              $addr, $matchingkey[0], !defined($result[0])?'undef':$result[0]);
6986  } else {  # want all matches
6987    do_log(5,"lookup_hash(%s) matches keys: %s", $addr,
6988              join(', ', map {sprintf('"%s"=>%s',$matchingkey[$_],$result[$_])}
6989                             (0..$#result)) );
6990  }
6991  if (!$get_all) { !wantarray ? $result[0] : ($result[0], $matchingkey[0]) }
6992  else           { !wantarray ? \@result   : (\@result,   \@matchingkey)   }
6993}
6994
6995# lookup_acl() performs a lookup for an e-mail address against
6996# access control list.
6997#
6998# The $addr may be a string of octets (assumed to be UTF-8 encoded)
6999# or a string of characters which gets first encoded to UTF-8 octets.
7000# International domain name (IDN) in $addr will be converted to ACE
7001# and lowercased. Array elements are expected to be in octets (utf8
7002# flag off) and their international domain names encoded in
7003# ASCII-compatible encoding (ACE).
7004#
7005# The supplied e-mail address is compared with each member of the
7006# lookup list in turn, the first match wins (terminates the search),
7007# and its value decides whether the result is true (yes, permit, pass)
7008# or false (no, deny, drop). Falling through without a match produces
7009# false (undef). Search is always case-insensitive on domain part,
7010# local part matching depends on $localpart_is_case_sensitive setting.
7011#
7012# NOTE: lookup_acl is not aware of address extensions and they are
7013# not handled specially!
7014#
7015# If a list element contains a '@', the full e-mail address is compared,
7016# otherwise if a list element has a leading dot, the domain name part is
7017# matched only, and the domain as well as its subdomains can match. If there
7018# is no leading dot, the domain must match exactly (subdomains do not match).
7019#
7020# The presence of a character '!' prepended to a list element decides
7021# whether the result will be true (without a '!') or false (with '!')
7022# in case where this list element matches and terminates the search.
7023#
7024# Because search stops at the first match, it only makes sense
7025# to place more specific patterns before the more general ones.
7026#
7027# Although not a special case, it is good to remember that '.' always matches,
7028# so a '.' would stop the search and return true, whereas '!.' would stop the
7029# search and return false (0).
7030#
7031# Examples:
7032#
7033# given: @acl = qw( me.ac.uk !.ac.uk .uk )
7034#   'me.ac.uk' matches me.ac.uk, returns true and search stops
7035#
7036# given: @acl = qw( me.ac.uk !.ac.uk .uk )
7037#   'you.ac.uk' matches .ac.uk, returns false (because of '!') and search stops
7038#
7039# given: @acl = qw( me.ac.uk !.ac.uk .uk )
7040#   'them.co.uk' matches .uk, returns true and search stops
7041#
7042# given: @acl = qw( me.ac.uk !.ac.uk .uk )
7043#   'some.com' does not match anything, falls through and returns false (undef)
7044#
7045# given: @acl = qw( me.ac.uk !.ac.uk .uk !. )
7046#   'some.com' similar to previous, except it returns 0 instead of undef,
7047#   which would only make a difference if this ACL is not the last argument
7048#   in a call to lookup(), because a defined result stops further lookups
7049#
7050# given: @acl = qw( me.ac.uk !.ac.uk .uk . )
7051#   'some.com' matches catchall ".", and returns true. The ".uk" is redundant
7052#
7053# more complex example: @acl = qw(
7054#   !The.Boss@dept1.xxx.com .dept1.xxx.com
7055#   .dept2.xxx.com .dept3.xxx.com lab.dept4.xxx.com
7056#   sub.xxx.com !.sub.xxx.com
7057#   me.d.aaa.com him.d.aaa.com !.d.aaa.com .aaa.com
7058# );
7059#
7060sub lookup_acl($$%) {
7061  my($addr, $acl_ref,%options) = @_;
7062  ref($acl_ref) eq 'ARRAY'
7063    or die "lookup_acl: arg2 must be a list ref: $acl_ref";
7064  return  if !@$acl_ref;  # empty list can't match anything
7065  safe_encode_utf8_inplace($addr);  # to octets (if not already)
7066  my $lpcs = c('localpart_is_case_sensitive');
7067  my($localpart,$domain) = split_address($addr);
7068  $localpart = lc $localpart  if !$lpcs;
7069  local($1,$2);
7070  # chop off leading '@' and trailing dots
7071  $domain = $1  if $domain =~ /^\@?(.*?)\.*\z/s;
7072  $domain = idn_to_ascii($domain) if $domain ne '';  # lowercase, ToASCII
7073  $domain .= $options{AppendStr}  if defined $options{AppendStr};
7074  my($matchingkey, $result); my $found = 0;
7075  for my $e (@$acl_ref) {
7076    $result = 1; $matchingkey = $e; my $key = $e;
7077    if ($key =~ /^(!+)(.*)\z/s) {      # starts with an exclamation mark(s)
7078      $key = $2;
7079      $result = 1-$result  if length($1) & 1;  # negate if odd
7080    }
7081    if ($key =~ /^(.*?)\@([^\@]*)\z/s) {  # contains '@', check full address
7082      $found=1  if $localpart eq ($lpcs?$1:lc($1)) && $domain eq lc($2);
7083    } elsif ($key =~ /^\.(.*)\z/s) {   # leading dot: domain or subdomain
7084      my $key_t = lc($1);
7085      $found=1  if $domain eq $key_t || $domain =~ /(\.|\z)\Q$key_t\E\z/s;
7086    } else {                           # match domain (but not its subdomains)
7087      $found=1  if $domain eq lc($key);
7088    }
7089    last  if $found;
7090  }
7091  $matchingkey = $result = undef  if !$found;
7092  ll(5) && do_log(5, 'lookup_acl(%s)%s', $addr,
7093                  (!$found ? ", no match"
7094                           : " matches key \"$matchingkey\", result=$result"));
7095  !wantarray ? $result : ($result, $matchingkey);
7096}
7097
7098# Perform a lookup for an e-mail address against any number of supplied maps:
7099# - SQL map,
7100# - LDAP map,
7101# - hash map (associative array),
7102# - (access control) list,
7103# - a list of regular expressions (an Amavis::Lookup::RE object),
7104# - a (defined) scalar always matches, and returns itself as the map value
7105#   (useful as a catchall for a final 'pass' or 'fail');
7106# (see lookup_hash, lookup_acl, lookup_sql and lookup_ldap for details).
7107#
7108# when $get_all is 0 (the common usage):
7109#   If a match is found (a defined value), returns whatever the map returns,
7110#   otherwise returns undef. FIRST match aborts further search sequence.
7111# when $get_all is true:
7112#   Collects a list of results from ALL matching tables, and within each
7113#   table from ALL matching key. Returns a ref to a list of results
7114#   (and a ref to a list of matching keys if returning a pair).
7115#   The first element of both lists is supposed to be what lookup() would
7116#   have returned if $get_all were 0. The order of returned elements
7117#   corresponds to the order of the search.
7118#
7119# traditional API, deprecated
7120#
7121sub lookup($$@) {
7122  my($get_all, $addr, @tables) = @_;
7123  lookup2($get_all, $addr, \@tables);
7124}
7125
7126# generalized API
7127#
7128sub lookup2($$$%) {
7129  my($get_all, $addr, $tables_ref, %options) = @_;
7130  (@_ - 3) % 2 == 0 or die "lookup2: options argument not in pairs (not hash)";
7131  my($label, @result, @matchingkey);
7132  for my $tb (!$tables_ref ? () : @$tables_ref) {
7133    my $t = ref($tb) eq 'REF' ? $$tb : $tb;  # allow one level of indirection
7134    my $reft = ref($t);
7135    if ($reft eq 'CODE') {  # lazy evaluation
7136      $t = &$t($addr,$get_all,%options);
7137      $reft = ref($t);
7138    }
7139    if (!$reft || $reft eq 'SCALAR') {  # a scalar always matches
7140      my $r = $reft ? $$t : $t;  # allow direct or indirect reference
7141      if (defined $r) {
7142        ll(5) && do_log(5, 'lookup: (scalar) matches, result="%s"', $r);
7143        push(@result,$r); push(@matchingkey,"(constant:$r)");
7144      }
7145    } elsif ($reft eq 'HASH') {
7146      my($r,$mk);
7147      ($r,$mk) = lookup_hash($addr,$t,$get_all,%options)  if %$t;
7148      if (!defined $r)  {}
7149      elsif (!$get_all) { push(@result,$r);  push(@matchingkey,$mk)  }
7150      elsif (@$r)       { push(@result,@$r); push(@matchingkey,@$mk) }
7151    } elsif ($reft eq 'ARRAY') {
7152      my($r,$mk);
7153      ($r,$mk) = lookup_acl($addr,$t,%options)  if @$t;
7154      if (defined $r)   { push(@result,$r);  push(@matchingkey,$mk)  }
7155    } elsif ($t->isa('Amavis::Lookup::Label')) {  # logging label
7156      # just a convenience for logging purposes, not a real lookup method
7157      $label = $t->display;  # grab the name, and proceed with the next table
7158    } elsif ($t->isa('Amavis::Lookup::Opaque') ||     # a structured constant
7159             $t->isa('Amavis::Lookup::OpaqueRef')) {  # ref to structured const
7160      my $r = $t->get;  # behaves like a constant pseudo-lookup
7161      if (defined $r) {
7162        ll(5) && do_log(5, 'lookup: (opaque) matches, result="%s"', $r);
7163        push(@result,$r); push(@matchingkey,"(opaque:$r)");
7164      }
7165    } elsif ($t->isa('Amavis::Lookup::RE')) {
7166      my($r,$mk);
7167      ($r,$mk) = $t->lookup_re($addr,$get_all,%options)  if @$t;
7168      if (!defined $r)  {}
7169      elsif (!$get_all) { push(@result,$r);  push(@matchingkey,$mk)  }
7170      elsif (@$r)       { push(@result,@$r); push(@matchingkey,@$mk) }
7171    } elsif ($t->isa('Amavis::Lookup::SQL')) {
7172      my($r,$mk) = $t->lookup_sql($addr,$get_all,%options);
7173      if (!defined $r)  {}
7174      elsif (!$get_all) { push(@result,$r);  push(@matchingkey,$mk)  }
7175      elsif (@$r)       { push(@result,@$r); push(@matchingkey,@$mk) }
7176    } elsif ($t->isa('Amavis::Lookup::SQLfield')) {
7177      if ($Amavis::sql_lookups) {  # triage
7178        my($r,$mk) = $t->lookup_sql_field($addr,$get_all,%options);
7179        if (!defined $r)  {}
7180        elsif (!$get_all) { push(@result,$r);  push(@matchingkey,$mk)  }
7181        elsif (@$r)       { push(@result,@$r); push(@matchingkey,@$mk) }
7182      }
7183    } elsif ($t->isa('Amavis::Lookup::LDAP')) {
7184      if ($Amavis::ldap_lookups && c('enable_ldap')) {  # triage
7185        my($r,$mk) = $t->lookup_ldap($addr,$get_all,%options);
7186        if (!defined $r)  {}
7187        elsif (!$get_all) { push(@result,$r);  push(@matchingkey,$mk) }
7188        elsif (@$r)       { push(@result,@$r); push(@matchingkey,@$mk) }
7189      }
7190    } elsif ($t->isa('Amavis::Lookup::LDAPattr')) {
7191      if ($Amavis::ldap_lookups && c('enable_ldap')) {  # triage
7192        my($r,$mk) = $t->lookup_ldap_attr($addr,$get_all,%options);
7193        if (!defined $r)  {}
7194        elsif (!$get_all) { push(@result,$r);  push(@matchingkey,$mk) }
7195        elsif (@$r)       { push(@result,@$r); push(@matchingkey,@$mk) }
7196      }
7197    } else {
7198      die "TROUBLE: lookup table is an unknown object: " . $reft;
7199    }
7200    last  if @result && !$get_all;
7201  }
7202  # pretty logging
7203  if (ll(4)) {  # only bother preparing log report which will be printed
7204    my $opt_label = $options{Label};
7205    my(@lbl) = grep(defined $_ && $_ ne '', ($opt_label,$label));
7206    $label = ' [' . join(',',unique_list(\@lbl)) . ']'  if @lbl;
7207    if (!$tables_ref || !@$tables_ref) {
7208      do_log(4, "lookup%s => undef, %s, no lookup tables",
7209                $label, fmt_struct($addr));
7210    } elsif (!@result) {
7211      do_log(4, "lookup%s => undef, %s does not match",
7212                $label, fmt_struct($addr));
7213    } elsif (!$get_all) {  # first match wins
7214      do_log(4, 'lookup%s => %-6s %s matches, result=%s, matching_key="%s"',
7215                $label, $result[0] ? 'true,' : 'false,',
7216                fmt_struct($addr), fmt_struct($result[0]), $matchingkey[0]);
7217    } else {  # want all matches
7218      do_log(4, 'lookup%s, %d matches for %s, results: %s',
7219                $label, scalar(@result), fmt_struct($addr),
7220                join(', ', map { sprintf('"%s"=>%s',
7221                                   $matchingkey[$_], fmt_struct($result[$_]))
7222                               } (0 .. $#result) ));
7223    }
7224  }
7225  if (!$get_all) { !wantarray ? $result[0] : ($result[0], $matchingkey[0]) }
7226  else           { !wantarray ? \@result   : (\@result,   \@matchingkey)   }
7227}
7228
72291;
7230
7231#
7232package Amavis::Expand;
7233use strict;
7234use re 'taint';
7235
7236BEGIN {
7237  require Exporter;
7238  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
7239  $VERSION = '2.412';
7240  @ISA = qw(Exporter);
7241  @EXPORT_OK = qw(&expand &tokenize);
7242  import Amavis::Util qw(ll do_log);
7243}
7244use subs @EXPORT_OK;
7245
7246# Given a string reference and a hashref of predefined (builtin) macros,
7247# expand() performs a macro expansion and returns a ref to a resulting string.
7248#
7249# This is a simple, yet fully fledged macro processor with proper lexical
7250# analysis, call stack, quoting levels, user supplied and builtin macros,
7251# three builtin flow-control macros: selector, regexp selector and iterator,
7252# a macro-defining macro and a macro '#' that eats input to the next newline.
7253# Also recognized are the usual \c and \nnn forms for specifying special
7254# characters, where c can be any of: r, n, f, b, e, a, t.
7255# Details are described in file README.customize, practical examples of use
7256# are in the supplied notification messages;
7257#   Author: Mark Martinec <Mark.Martinec@ijs.si>, 2002, 2006
7258
7259use vars qw(%builtins_cached %lexmap %esc);
7260use vars qw($lx_lb $lx_lbS $lx_lbT $lx_lbA $lx_lbC $lx_lbE $lx_lbQQ
7261            $lx_rbQQ $lx_rb $lx_sep $lx_h $lx_ph);
7262
7263BEGIN {
7264  no warnings 'qw';  # avoid "Possible attempt to put comments in qw()"
7265  my(@lx_str) = qw( [  [?  [~  [@  [: [=  ["  "]  ]  |  #  %#
7266                    %0 %1 %2 %3 %4 %5 %6 %7 %8 %9);  # lexical elem.
7267  # %lexmap maps string to reference in order to protect lexels
7268  $lexmap{$_} = \$_  for @lx_str;  # maps lexel strings to references
7269  ($lx_lb, $lx_lbS, $lx_lbT, $lx_lbA, $lx_lbC, $lx_lbE, $lx_lbQQ, $lx_rbQQ,
7270   $lx_rb, $lx_sep, $lx_h, $lx_ph) = map($lexmap{$_}, @lx_str);
7271  %esc = (n => \"\n", r => "\r", f => "\f", b => "\b",
7272          e => "\e", a => "\a", t => "\t");
7273  # NOTE that \n is specific, it is represented by a ref to a newline and not
7274  # by a newline itself; this makes it possible for a macro '#' to skip input
7275  # to a true newline from source, making it possible to comment-out entire
7276  # lines even if they contain "\n" tokens
7277  1;
7278}
7279
7280# make an object out of the supplied list of tokens
7281sub newmacro { my $class = shift; bless [@_], $class }
7282
7283# turn a ref to a list of tokens into a single plain string
7284sub tokens_list_to_str($) { join('', map(ref($_) ? $$_ : $_, @{$_[0]})) }
7285
7286sub tokenize($;$) {
7287  my($str_ref,$tokens_ref) = @_;  local($1);
7288  $tokens_ref = []  if !defined $tokens_ref;
7289  # parse lexically, replacing lexical element strings with references,
7290  # unquoting backslash-quoted characters and %%, and dropping \NL and \_
7291  @$tokens_ref = map {
7292    exists $lexmap{$_} ? $lexmap{$_}      # replace with ref
7293    : $_ eq "\\\n" || $_ eq "\\_" ? ''    # drop \NEWLINE and \_
7294    : $_ eq '%%' ? '%'                    # %% -> %
7295    : /^(%\#?.)\z/s ? \"$1"               # unknown builtins
7296    : /^\\([0-7]{1,3})\z/ ? chr(oct($1))  # \nnn
7297    : /^\\(.)\z/s ? (exists($esc{$1}) ? $esc{$1} : $1)  # \r, \n, \f, ...
7298    : /^(_ [A-Z]+ (?: \( [^)]* \) )? _)\z/xs ? \"$1"  # SpamAssassin-compatible
7299    : $_ }
7300    $$str_ref =~ /\G \# | \[ [?~\@:="]? | "\] | \] | \| | % \#? . | \\ [^0-7] |
7301                  \\ [0-7]{1,3} | _ [A-Z]+ (?: \( [^)]* \) )? _ |
7302                  [^\[\]\\|%\n#"_]+ | [^\n]+? | \n /xgs;
7303  $tokens_ref;
7304}
7305
7306sub evalmacro($$;@) {
7307  my($macro_type,$builtins_href,@args) = @_;
7308  my @result; local($1,$2);
7309  if ($macro_type == $lx_lbS) {  # selector built-in macro
7310    my $sel = tokens_list_to_str(shift(@args));
7311    if    ($sel eq '')               { $sel = 0 }  # quick
7312    elsif ($sel =~ /^\s*\z/)         { $sel = 0 }
7313    elsif ($sel =~ /^\s*(\d+)\s*\z/) { $sel = 0+$1 }  # decimal to numeric
7314    else { $sel = 1 }
7315    # provide an empty second alternative if we only have one specified
7316    if (@args < 2) {}  # keep $sel beyond $#args
7317    elsif ($sel > $#args) { $sel = $#args }  # use last alternative
7318    @result = @{$args[$sel]}  if $sel >= 0 && $sel <= $#args;
7319  } elsif ($macro_type == $lx_lbT) {  # regexp built-in macro
7320    # args: string, regexp1, then1, regexp2, then2, ... regexpN, thenN[, else]
7321    my $str = tokens_list_to_str(shift(@args));  # collect the first argument
7322    my($match,@repl);
7323    while (@args >= 2) {  # at least a regexp and a 'then' argument still there
7324      @repl = ();
7325      my $regexp = tokens_list_to_str(shift(@args));  # collect a regexp arg
7326      if ($regexp eq '') {
7327        # braindamaged Perl: empty string implies the last successfully
7328        # matched regular expression; we must avoid this
7329        $match = 1;
7330      } else {
7331        eval {  # guard against invalid regular expression
7332          local($1,$2,$3,$4,$5,$6,$7,$8,$9);
7333          $match = $str=~/$regexp/ ? 1 : 0;
7334          @repl = ($1,$2,$3,$4,$5,$6,$7,$8,$9)  if $match;
7335          1;
7336        } or do {
7337          my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
7338          die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
7339          do_log(2,"invalid macro regexp arg: %s", $eval_stat);
7340          $match = 0; @repl = ();
7341        };
7342      }
7343      if ($match) { last } else { shift(@args) }  # skip 'then' arg if no match
7344    }
7345    if (@args > 0) {
7346      unshift(@repl,$str);  # prepend the whole string as a %0
7347      # formal arg lexels %0, %1, ... %9 are replaced by captured substrings
7348      @result = map(!ref || $$_!~/^%([0-9])\z/ ? $_ : $repl[$1], @{$args[0]});
7349    }
7350  } elsif ($macro_type == $lx_lb) {    # iterator macro
7351    my($cvar_r,$sep_r,$body_r); my $cvar;  # give meaning to arguments
7352    if (@args >= 3) { ($cvar_r,$body_r,$sep_r) = @args }
7353    else { ($body_r,$sep_r) = @args;  $cvar_r = $body_r }
7354    # find the iterator name
7355    for (@$cvar_r) { if (ref && $$_ =~ /^%(.)\z/s) { $cvar = $1; last } }
7356    my $name = $cvar;  # macro name is usually the same as the iterator name
7357    if (@args >= 3 && !defined($name)) {
7358      # instead of iterator like %x, the first arg may be a long macro name,
7359      # in which case the iterator name becomes a hard-wired 'x'
7360      $name = tokens_list_to_str($cvar_r);
7361      $name =~ s/^[ \t\n]+//; $name =~ s/[ \t\n]+\z//;  # trim whitespace
7362      if ($name eq '') { $name = undef } else { $cvar = 'x' }
7363    }
7364    if (exists($builtins_href->{$name})) {
7365      my $s = $builtins_href->{$name};
7366      if (UNIVERSAL::isa($s,'Amavis::Expand')) {  # dynamically defined macro
7367        my(@margs) = ($name);  # no arguments beyond %0
7368        my(@res) = map(!ref || $$_ !~ /^%([0-9])\z/ ? $_
7369                         : ref($margs[$1]) ? @{$margs[$1]} : (), @$s);
7370        $s = tokens_list_to_str(\@res);
7371      } elsif (ref($s) eq 'CODE') {
7372        if (exists($builtins_cached{$name})) {
7373          $s = $builtins_cached{$name};
7374        } else {
7375          while (ref($s) eq 'CODE') { $s = &$s($name) }
7376          $builtins_cached{$name} = $s;
7377        }
7378      }
7379      my $ind = 0;
7380      for my $val (ref($s) ? @$s : $s) {  # do substitutions in the body
7381        push(@result, @$sep_r)  if ++$ind > 1 && ref($sep_r);
7382        push(@result, map(ref && $$_ eq "%$cvar" ? $val : $_, @$body_r));
7383      }
7384    }
7385  } elsif ($macro_type == $lx_lbE) {  # define a new macro
7386    my $name = tokens_list_to_str(shift(@args));   # first arg is a macro name
7387    $name =~ s/^[ \t\n]+//; $name =~ s/[ \t\n]+\z//;  # trim whitespace on name
7388    delete $builtins_cached{$name};
7389    $builtins_href->{$name} = Amavis::Expand->newmacro(@{$args[0]});
7390  } elsif ($macro_type == $lx_lbA || $macro_type == $lx_lbC ||     # macro call
7391           $$macro_type =~ /^%(\#)?(.)\z/s) {
7392    my $name; my $cardinality_only = 0;
7393    if ($macro_type == $lx_lbA || $macro_type == $lx_lbC) {
7394      $name = tokens_list_to_str($args[0]);  # arg %0 is a macro name
7395      $name =~ s/^[ \t\n]+//; $name =~ s/[ \t\n]+\z//;  # trim whitespace
7396    } else {  # simple macro call %x or %#x
7397      $name = $2;
7398      $cardinality_only = 1  if defined $1;
7399    }
7400    my $s = $builtins_href->{$name};
7401    if (!ref($s)) {  # macro expands to a plain string
7402      if (!$cardinality_only) { @result = $s }
7403      else { @result = $s !~ /^\s*\z/ ? 1 : 0 };  # %#x => nonwhite=1, other 0
7404    } elsif (UNIVERSAL::isa($s,'Amavis::Expand')) { # dynamically defined macro
7405      $args[0] = $name;  # replace name with a stringified and trimmed form
7406      # expanding a dynamically-defined macro produces a list of tokens;
7407      # formal argument lexels %0, %1, ... %9 are replaced by actual arguments
7408      @result = map(!ref || $$_ !~ /^%([0-9])\z/ ? $_
7409                      : ref($args[$1]) ? @{$args[$1]} : (), @$s);
7410      if ($cardinality_only) {  # macro call form %#x
7411        @result = tokens_list_to_str(\@result) !~ /^\s*\z/ ? 1 : 0;
7412      }
7413    } else {  # subroutine or array ref
7414      if (ref($s) eq 'CODE') {
7415        if (exists($builtins_cached{$name}) && @args <= 1) {
7416          $s = $builtins_cached{$name};
7417        } elsif (@args <= 1) {
7418          while (ref($s) eq 'CODE') { $s = &$s($name) }  # callback
7419          $builtins_cached{$name} = $s;
7420        } else {
7421          shift(@args);  # discard original form of a macro name
7422          while (ref($s) eq 'CODE')  # subroutine callback
7423            { $s = &$s($name, map(tokens_list_to_str($_), @args)) }
7424        }
7425      }
7426      if ($cardinality_only) {  # macro call form %#x
7427        # for array: number of elements; for scalar: nonwhite=1, other 0
7428        @result = ref($s) ? scalar(@$s) : $s !~ /^\s*\z/ ? 1 : 0;
7429      } else {  # macro call %x evaluates to the value of macro x
7430        @result = ref($s) ? join(', ',@$s) : $s;
7431      }
7432    }
7433  }
7434  \@result;
7435}
7436
7437sub expand($$) {
7438  my($str_ref,$builtins_href) = @_;
7439  # $str_ref       ... a ref to a source string to be macro expanded;
7440  # $builtins_href ... a hashref, mapping builtin macro names
7441  #                    to macro values: strings or array refs
7442  my(@tokens);
7443  if (ref($str_ref) eq 'ARRAY') { @tokens = @$str_ref }
7444  else { tokenize($str_ref,\@tokens) }
7445  my $call_level = 0; my $quote_level = 0;
7446  my(@arg);  # stack of arguments lists to nested calls, [0] is top of stack
7447  my(@macro_type); # call stack of macro types (leading lexels) of nested calls
7448  my(@implied_q);  # call stack: is implied quoting currently active?
7449                   #   0 (not active) or 1 (active); element [0] stack top
7450  my(@open_quote); # quoting stack: opening quote lexel for each quoting level
7451  %builtins_cached = (); my $whereto; local($1,$2);
7452  # preallocate some storage
7453  my $output_str = ''; vec($output_str,2048,8) = 0; $output_str = '';
7454  while (@tokens) {
7455    my $t = shift(@tokens);
7456    # do_log(5, "TOKEN: %s", ref($t) ? "<$$t>" : "'$t'");
7457    if (!ref($t)) {  # a plain string, no need to check for quoting levels
7458      if (defined $whereto) { push(@$whereto,$t) } else { $output_str .= $t }
7459    } elsif ($quote_level > 0 && substr($$t,0,1) eq '[') {
7460      # go even deeper into quoting
7461      $quote_level += ($t == $lx_lbQQ) ? 2 : 1;  unshift(@open_quote,$t);
7462      if (defined $whereto) { push(@$whereto,$t) } else { $output_str .= $$t }
7463    } elsif ($t == $lx_lbQQ) {  # just entering a [" ... "] quoting context
7464      $quote_level += 2; unshift(@open_quote,$t);
7465      # drop a [" , thus stripping one level of quotes
7466    } elsif (substr($$t,0,1) eq '[') {
7467      # $lx_lb $lx_lbS lx_lbT $lx_lbA $lx_lbC $lx_lbE
7468      $call_level++;  # open a macro call, start collecting arguments
7469      unshift(@arg, [[]]); unshift(@macro_type, $t); unshift(@implied_q, 0);
7470      $whereto = $arg[0][0];
7471      if ($t == $lx_lb) {  # iterator macro implicitly quotes all arguments
7472        $quote_level++; unshift(@open_quote,$t); $implied_q[0] = 1;
7473      }
7474    } elsif ($quote_level <= 1 && $call_level>0 && $t == $lx_sep) {  # next arg
7475      unshift(@{$arg[0]}, []); $whereto = $arg[0][0];
7476      if ($macro_type[0]==$lx_lbS && @{$arg[0]} == 2) {
7477        # selector macro implicitly quotes arguments beyond first argument
7478        $quote_level++; unshift(@open_quote,$macro_type[0]); $implied_q[0] = 1;
7479      }
7480    } elsif ($quote_level > 1 && ($t == $lx_rb || $t == $lx_rbQQ)) {
7481      $quote_level -= ($open_quote[0] == $lx_lbQQ) ? 2 : 1;
7482      shift(@open_quote);  # pop the quoting stack
7483      if ($t == $lx_rb || $quote_level > 0) {  # pass-on if still quoted
7484        if (defined $whereto) { push(@$whereto,$t) } else { $output_str .= $$t}
7485      }
7486    } elsif ($call_level > 0 && ($t == $lx_rb || $t == $lx_rbQQ)) {  # evaluate
7487      $call_level--;  my $m_type = $macro_type[0];
7488      if ($t == $lx_rbQQ) {  # fudge for compatibility: treat "] as two chars
7489        if (defined $whereto) { push(@$whereto,'"') } else { $output_str.='"' }
7490      }
7491      if ($implied_q[0] && $quote_level > 0) {
7492        $quote_level -= ($open_quote[0] == $lx_lbQQ) ? 2 : 1;
7493        shift(@open_quote);  # pop the quoting stack
7494      }
7495      my $result_ref = evalmacro($m_type, $builtins_href, reverse @{$arg[0]});
7496      shift(@macro_type); shift(@arg); shift(@implied_q);  # pop the call stack
7497      $whereto = $call_level > 0 ? $arg[0][0] : undef;
7498      if ($m_type == $lx_lbC) {  # neutral macro call, result implicitly quoted
7499        if (defined $whereto) { push(@$whereto, @$result_ref) }
7500        else { $output_str .= tokens_list_to_str($result_ref) }
7501      } else {  # active macro call, push result back to input for reprocessing
7502        unshift(@tokens, @$result_ref);
7503      }
7504    } elsif ($quote_level > 0 ) {  # still protect %x and # macro calls
7505      if (defined $whereto) { push(@$whereto,$t) } else { $output_str .= $$t }
7506    } elsif ($t == $lx_h) {  # discard tokens up to and including a newline
7507      while (@tokens) { last  if shift(@tokens) eq "\n" }
7508    } elsif ($$t =~ /^%\#?.\z/s) {  # neutral simple macro call %x or %#x
7509      my $result_ref = evalmacro($t, $builtins_href);
7510      if (defined $whereto) { push(@$whereto,@$result_ref) }
7511#     else { $output_str .= tokens_list_to_str($result_ref) }
7512      else { $output_str .= join('', map(ref($_) ? $$_ : $_, @$result_ref)) }
7513    } elsif ($$t =~ /^_ ([A-Z]+) (?: \( ( [^)]* ) \) )? _\z/xs) {
7514      # neutral simple SA-like macro call, $1 is name, $2 is a single! argument
7515      my $result_ref = evalmacro($lx_lbC, $builtins_href, [$1],
7516                                 !defined($2) ? () : [$2] );
7517      if (defined $whereto) { push(@$whereto, @$result_ref) }
7518      else { $output_str .= tokens_list_to_str($result_ref) }
7519    } else {  # misplaced top-level lexical element
7520      if (defined $whereto) { push(@$whereto,$t) } else { $output_str .= $$t }
7521    }
7522  }
7523  %builtins_cached = ();  # clear memory
7524  \$output_str;
7525}
7526
75271;
7528
7529#
7530package Amavis::TempDir;
7531
7532# Handles creation and cleanup of a persistent temporary directory,
7533# a file 'email.txt' therein, and a subdirectory 'parts'
7534
7535use strict;
7536use re 'taint';
7537
7538BEGIN {
7539  require Exporter;
7540  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
7541  $VERSION = '2.412';
7542  @ISA = qw(Exporter);
7543  import Amavis::Conf qw(:platform :confvars c cr ca);
7544  import Amavis::Timing qw(section_time);
7545  import Amavis::Util qw(ll do_log do_log_safe add_entropy rmdir_recursively);
7546  import Amavis::rfc2821_2822_Tools qw(iso8601_timestamp);
7547}
7548
7549use Errno qw(ENOENT EACCES EEXIST);
7550use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
7551use File::Temp ();
7552
7553sub new {
7554  my $class = $_[0];
7555  my $self = bless {}, $class;
7556  $self->{tempdir_path} = undef;
7557  undef $self->{tempdir_dev}; undef $self->{tempdir_ino};
7558  undef $self->{fh_pers}; undef $self->{fh_dev}; undef $self->{fh_ino};
7559  $self->{empty} = 1; $self->{preserve} = 0;
7560  $self;
7561}
7562
7563sub path {      # path to a temporary directory
7564  @_<2 ? shift->{tempdir_path} : ($_[0]->{tempdir_path} = $_[1])
7565}
7566sub fh {        # email.txt file handle
7567  @_<2 ? shift->{fh_pers} : ($_[0]->{fh_pers} = $_[1]);
7568}
7569sub empty {     # whether the directory is empty
7570  @_<2 ? shift->{empty} : ($_[0]->{empty} = $_[1])
7571}
7572sub preserve {  # whether to preserve directory when current task is done
7573  @_<2 ? shift->{preserve} : ($_[0]->{preserve} = $_[1]);
7574}
7575
7576# Clean up the tempdir on shutdown
7577#
7578sub DESTROY {
7579  my $self = $_[0];
7580  local($@,$!,$_); my $myactualpid = $$;
7581  if (defined($my_pid) && $myactualpid != $my_pid) {
7582    do_log_safe(5,"TempDir::DESTROY skip, clone [%s] (born as [%s])",
7583                  $myactualpid, $my_pid);
7584  } else {
7585    do_log_safe(5,"TempDir::DESTROY called");
7586    eval {
7587      # must step out of the directory which is about to be deleted,
7588      # otherwise rmdir can fail (e.g. on Solaris)
7589      chdir($TEMPBASE)
7590        or do_log(-1,"TempDir::DESTROY can't chdir to %s: %s", $TEMPBASE, $!);
7591      if ($self->{fh_pers}) {
7592        $self->{fh_pers}->close
7593          or do_log(-1,"Error closing temp file: %s", $!);
7594      }
7595      undef $self->{fh_pers};
7596      my $dname = $self->{tempdir_path};
7597      my $errn = !defined($dname) || $dname eq '' ? ENOENT
7598                 : lstat($dname) ? 0 : 0+$!;
7599      if (defined($dname) && $errn != ENOENT) {
7600        # this will not be included in the TIMING report,
7601        # but it only occurs infrequently and doesn't take that long
7602        if ($self->{preserve} && !$self->{empty}) {
7603          do_log(-1,"TempDir removal: tempdir is to be PRESERVED: %s", $dname);
7604        } else {
7605          do_log(3, "TempDir removal: %s is being removed: %s%s",
7606                    $self->{empty} ? 'empty tempdir' : 'tempdir',  $dname,
7607                    $self->{preserve} ? ', nothing to preserve' : '');
7608          rmdir_recursively($dname);
7609        }
7610      };
7611      1;
7612    } or do {
7613      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
7614      do_log_safe(1,"TempDir removal: %s",$eval_stat);
7615    };
7616  }
7617}
7618
7619# Creates a temporary directory, or checks that inode did not change on reuse
7620#
7621sub prepare_dir {
7622  my $self = $_[0];
7623  my(@stat_list); my $errn; my $reuse = 0;
7624  my $dname = $self->{tempdir_path};
7625  if (defined $dname) {  # hope to reuse existing directory
7626    @stat_list = lstat($dname);  $errn = @stat_list ? 0 : 0+$!;
7627    if ($errn != ENOENT) {
7628      $reuse = 1;  # good, it exists, try reusing it
7629    } else {
7630      do_log(2,"TempDir::prepare_dir: directory %s no longer exists", $dname);
7631      $self->{tempdir_path} = $dname = undef; $self->{empty} = 1;
7632    }
7633  }
7634  if (!defined $dname) {
7635    # invent a name of a temporary directory for this child
7636    my $dirtemplate = sprintf("amavis-%s-%05d-XXXXXXXX",
7637                              iso8601_timestamp(time,1), $my_pid);
7638    $dname = File::Temp::tempdir($dirtemplate, DIR => $TEMPBASE);
7639    defined $dname && $dname ne ''
7640      or die "Can't create a temporary directory $TEMPBASE/$dirtemplate: $!";
7641    do_log(4,"TempDir::prepare_dir: created directory %s", $dname);
7642    chmod(0750,$dname)
7643      or die "Can't change protection on directory $dname: $!";
7644    @stat_list = lstat($dname);
7645    @stat_list or die "Failed to access directory $dname: $!";
7646    $self->{tempdir_path} = $dname;
7647    ($self->{tempdir_dev}, $self->{tempdir_ino}) = @stat_list;
7648    $self->{empty} = 1; add_entropy($dname, @stat_list);
7649    section_time('mkdir tempdir');
7650  }
7651  $errn = @stat_list ? 0 : 0+$!;
7652  if ($errn != 0) {
7653    die "TempDir::prepare_dir: Can't access temporary directory $dname: $!";
7654  } elsif (! -d _) {  # exists, but is not a directory !?
7655    die "TempDir::prepare_dir: $dname is not a directory!!!";
7656  } elsif ($reuse) {  # existing directory
7657    my($dev,$ino,$mode,$nlink) = @stat_list;
7658    if ($dev != $self->{tempdir_dev} || $ino != $self->{tempdir_ino}) {
7659      do_log(-1,"TempDir::prepare_dir: %s is no longer the same directory!",
7660                $dname);
7661      ($self->{tempdir_dev}, $self->{tempdir_ino}) = ($dev, $ino);
7662    }
7663    if ($nlink > 3) {
7664      # when a directory's link count is > 2, it has "n-2" sub-directories;
7665      # this does not apply to file systems like AFS, FAT, ISO-9660,
7666      # but it also seems it does not apply to Mac OS 10 (Leopard)
7667      do_log(5, "TempDir::prepare_dir: directory %s has %d subdirectories",
7668                $dname, $nlink-2);
7669    }
7670  }
7671}
7672
7673# Prepares the email.txt temporary file for writing (and reading later)
7674#
7675sub prepare_file {
7676  my $self = $_[0];
7677  my $fname = $self->path . '/email.txt';
7678  my(@stat_list) = lstat($fname); my $errn = @stat_list ? 0 : 0+$!;
7679  if ($errn == ENOENT) {  # no file
7680    do_log(0,"TempDir::prepare_file: %s no longer exists, can't re-use it",
7681              $fname)  if $self->{fh_pers};
7682    undef $self->{fh_pers};
7683  } elsif ($errn != 0) {  # some other error
7684    undef $self->{fh_pers};
7685    die "TempDir::prepare_file: can't access temporary file $fname: $!";
7686  } elsif (! -f _) {  # not a regular file !?
7687    undef $self->{fh_pers};
7688    die "TempDir::prepare_file: $fname is not a regular file!!!";
7689  } elsif ($self->{fh_pers}) {
7690    my($dev,$ino) = @stat_list;
7691    if ($dev != $self->{file_dev} || $ino != $self->{file_ino}) {
7692      # may happen if some user code has replaced the file, e.g. by altermime
7693      undef $self->{fh_pers};
7694      do_log(1,"TempDir::prepare_file: %s is no longer the same file, ".
7695               "won't re-use it, deleting", $fname);
7696      unlink($fname) or die "Can't remove file $fname: $!";
7697    }
7698  }
7699  if ($self->{fh_pers} && !$can_truncate) {  # just in case clean() retained it
7700    undef $self->{fh_pers};
7701    do_log(1,"TempDir::prepare_file: unable to truncate temporary file %s, ".
7702             "deleting it", $fname);
7703    unlink($fname) or die "Can't remove file $fname: $!";
7704  }
7705  if ($self->{fh_pers}) {  # rewind and truncate existing file
7706    $self->{fh_pers}->flush or die "Can't flush mail file: $!";
7707    $self->{fh_pers}->seek(0,0) or die "Can't rewind mail file: $!";
7708    $self->{fh_pers}->truncate(0) or die "Can't truncate mail file: $!";
7709  } else {
7710    do_log(4,"TempDir::prepare_file: creating file %s", $fname);
7711  # $^F == 2
7712  #   or do_log(-1,"TempDir::prepare_file: SYSTEM_FD_MAX not 2: %d", $^F);
7713    my $newfh = IO::File->new;
7714    # this can fail if a previous task of this process just recently stumbled
7715    # on some error and preserved its evidence, not deleting a file email.txt
7716    $newfh->open($fname, O_CREAT|O_EXCL|O_RDWR, 0640)
7717      or die "Can't create file $fname: $!";
7718    binmode($newfh,':bytes') or die "Can't cancel :utf8 mode on $fname: $!";
7719    if (ll(5) && $] >= 5.008001) {  # get_layers was added with Perl 5.8.1
7720      my(@layers) = PerlIO::get_layers($newfh);
7721      do_log(5,"TempDir::prepare_file: layers: %s", join(',',@layers));
7722    }
7723    $self->{fh_pers} = $newfh;
7724    @stat_list = lstat($fname);
7725    @stat_list or die "Failed to access temporary file $fname: $!";
7726    add_entropy(@stat_list);
7727    ($self->{file_dev}, $self->{file_ino}) = @stat_list;
7728    section_time('create email.txt');
7729  }
7730}
7731
7732# Cleans the temporary directory for reuse, unless it is set to be preserved
7733#
7734sub clean {
7735  my $self = $_[0];
7736  if ($self->{preserve} && !$self->{empty}) {
7737    # keep evidence in case of trouble
7738    do_log(-1,"PRESERVING EVIDENCE in %s", $self->{tempdir_path});
7739    if ($self->{fh_pers}) {
7740      $self->{fh_pers}->close or die "Error closing mail file: $!"
7741    }
7742    undef $self->{fh_pers}; $self->{tempdir_path} = undef; $self->{empty} = 1;
7743  }
7744  # cleanup, but leave directory (and file handle if possible) for reuse
7745  if ($self->{fh_pers} && !$can_truncate) {
7746    # truncate is not standard across all Unix variants,
7747    # it is not Posix, but is XPG4-UNIX.
7748    # So if we can't truncate a file and leave it open,
7749    # we have to create it anew later, at some cost.
7750    #
7751    $self->{fh_pers}->close or die "Error closing mail file: $!";
7752    undef $self->{fh_pers};
7753    unlink($self->{tempdir_path}.'/email.txt')
7754      or die "Can't delete file ".$self->{tempdir_path}."/email.txt: $!";
7755    section_time('delete email.txt');
7756  }
7757  if (defined $self->{tempdir_path}) {  # prepare for the next one
7758    $self->strip; $self->{empty} = 1;
7759  }
7760  $self->{preserve} = 0;  # reset
7761}
7762
7763# Remove files and subdirectories from the temporary directory, leaving only
7764# the directory itself, file email.txt, and empty subdirectory ./parts .
7765# Leaving directories for reuse can represent an important saving in time,
7766# as directory creation + deletion can be an expensive operation,
7767# requiring atomic file system operation, including flushing buffers
7768# to disk (depending on the file system in use).
7769#
7770sub strip {
7771  my $self = $_[0];
7772  my $dname = $self->{tempdir_path};
7773  do_log(4, "TempDir::strip: %s", $dname);
7774  # must step out of the directory which is about to be deleted,
7775  # otherwise rmdir can fail (e.g. on Solaris)
7776  chdir($TEMPBASE) or die "TempDir::strip: can't chdir to $TEMPBASE: $!";
7777  my(@stat_list) = lstat($dname);
7778  my $errn = @stat_list ? 0 : 0+$!;
7779  if ($errn == ENOENT) {
7780    do_log(-1,"TempDir::strip: directory %s no longer exists", $dname);
7781    $self->{tempdir_path} = $dname = undef; $self->{empty} = 1;
7782  } elsif ($errn != 0) {
7783    die "TempDir::strip: error accessing directory $dname: $!";
7784  } else {
7785    my($dev,$ino) = @stat_list;
7786    if ($dev != $self->{tempdir_dev} || $ino != $self->{tempdir_ino}) {
7787      do_log(-1,"TempDir::strip: %s is no longer the same directory!",
7788                $dname);
7789      ($self->{tempdir_dev}, $self->{tempdir_ino}) = ($dev, $ino);
7790    }
7791    # now deal with the 'parts' subdirectory
7792    my $errn = lstat("$dname/parts") ? 0 : 0+$!;
7793    if ($errn == ENOENT) {}  # fine, no such directory
7794    elsif ($errn!=0) { die "TempDir::strip: error accessing $dname/parts: $!" }
7795    elsif ( -l _) { die "TempDir::strip: $dname/parts is a symbolic link" }
7796    elsif (!-d _) { die "TempDir::strip: $dname/parts is not a directory" }
7797    else { rmdir_recursively("$dname/parts", 1) }
7798    $self->check;  # check for any remains in the top directory just in case
7799  }
7800  1;
7801}
7802
7803# Checks tempdir after being cleaned.
7804# It may only contain subdirectory 'parts' and file email.txt, nothing else.
7805#
7806sub check {
7807  my $self = $_[0];
7808  my $eval_stat; my $dname = $self->{tempdir_path};
7809  local(*DIR); opendir(DIR,$dname) or die "Can't open directory $dname: $!";
7810  eval {
7811    # avoid slurping the whole directory contents into memory
7812    $! = 0; my $f;
7813    while (defined($f = readdir(DIR))) {
7814      next  if $f eq '.' || $f eq '..';
7815      my $fname = $dname . '/' . $f;
7816      my(@stat_list) = lstat($fname);
7817      my $errn = @stat_list ? 0 : 0+$!;
7818      if ($errn) {
7819        die "Inaccessible $fname: $!";
7820      } elsif (-f _) {
7821        warn "Unexpected file $fname"  if $f ne 'email.txt';
7822      } elsif (-l _) {
7823        die "Unexpected link $fname";
7824      } elsif (-d _) {
7825        my $nlink = $stat_list[3];
7826        if ($f ne 'parts') {
7827          die "Unexpected directory $fname";
7828        } elsif ($nlink > 2) {  # number of hard links
7829          # when a directory's link count is > 2, it has "n-2" sub-directories;
7830          # this does not apply to file systems like AFS, FAT, ISO-9660,
7831          # but it also seems it does not apply to Mac OS 10 (Leopard)
7832          do_log(5, "TempDir::check: directory %s has %d subdirectories",
7833                    $dname, $nlink-2);
7834        }
7835      } else {
7836        die "Unexpected non-regular file $fname";
7837      }
7838    }
7839    # checking status on directory read ops doesn't work as expected, Perl bug
7840    # $! == 0 or die "Error reading directory $dname: $!";
7841    1;
7842  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
7843  closedir(DIR) or die "Error closing directory $dname: $!";
7844  if (defined $eval_stat) {
7845    chomp $eval_stat;
7846    die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
7847    die "TempDir::check: $eval_stat\n";
7848  }
7849  1;
7850}
7851
78521;
7853
7854#
7855package Amavis::IO::FileHandle;
7856
7857# Provides a virtual file (a filehandle tie - a TIEHANDLE) representing
7858# a view to a mail message (accessed on an open file handle) prefixed by
7859# a couple of synthesized mail header fields supplied as an array of lines.
7860
7861use strict;
7862use re 'taint';
7863use Errno qw(EAGAIN);
7864
7865sub new { shift->TIEHANDLE(@_) }
7866
7867sub TIEHANDLE {
7868  my $class = shift;
7869  my $self = bless { 'fileno' => undef }, $class;
7870  if (@_) { $self->OPEN(@_) or return }
7871  $self;
7872}
7873
7874sub UNTIE {
7875  my($self,$count) = @_;
7876  $self->CLOSE  if !$count && defined $self->FILENO;
7877  1;
7878}
7879
7880sub DESTROY {
7881  my $self = $_[0]; local($@,$!,$_);
7882  $self->CLOSE  if defined $self->FILENO;
7883  1;
7884}
7885
7886sub BINMODE { 1 }
7887sub FILENO { my $self = $_[0]; $self->{'fileno'} }
7888sub CLOSE  { my $self = $_[0]; undef $self->{'fileno'}; 1 }
7889sub EOF    { my $self = $_[0]; defined $self->{'fileno'} ? $self->{'eof'} : 1 }
7890
7891# creates a view on an already open file, prepended by some text
7892#
7893sub OPEN {
7894  my($self, $filehandle,$prefix_lines_ref,$size_limit) = @_;
7895  # $filehandle is a fh of an already open file;
7896  # $prefix_lines_ref is a ref to an array of lines, to be prepended
7897  #   to a created view on an existing file; these lines must each
7898  #   be terminated by a \n, and must not include other \n characters
7899  $self->CLOSE  if defined $self->FILENO;
7900  $self->{'fileno'} = 9999; $self->{'eof'} = 0;
7901  $self->{'prefix'} = $prefix_lines_ref;
7902  $self->{'prefix_n'} = 0;  # number of lines of a prefix
7903  $self->{'prefix_l'} = 0;  # number of characters of a prefix
7904  $self->{'pos'} = 0; $self->{'rec_ind'} = 0;
7905  $self->{'size_limit'} = $size_limit;  # pretend file ends at the byte limit
7906  if (ref $prefix_lines_ref) {
7907    my $len = 0;
7908    $len += length($_) for @$prefix_lines_ref;
7909    $self->{'prefix_l'} = $len;
7910    $self->{'prefix_n'} = @$prefix_lines_ref;
7911  }
7912  $self->{'handle'} = $filehandle;
7913  seek($filehandle, 0,0);  # also provides a return value and errno
7914};
7915
7916sub SEEK {
7917  my($self,$offset,$whence) = @_;
7918  $whence == 0  or die "Only absolute SEEK is supported on this file";
7919  $offset == 0  or die "Only SEEK(0,0) is supported on this file";
7920  $self->{'eof'} = 0; $self->{'pos'} = 0; $self->{'rec_ind'} = 0;
7921  seek($self->{'handle'}, 0,0);  # also provides a return value and errno
7922}
7923
7924# sub TELL (not implemented)
7925#   Returns the current position in bytes for FILEHANDLE, or -1 on error.
7926
7927# mixing of READ and READLINE is not supported (without rewinding inbetween)
7928#
7929sub READLINE {
7930  my $self = $_[0];
7931  my $size_limit = $self->{'size_limit'};
7932  my $pos = $self->{'pos'};
7933  if ($self->{'eof'}) {
7934    return;
7935  } elsif (defined $size_limit && $pos >= $size_limit) {
7936    $self->{'eof'} = 1;
7937    return;
7938  } elsif (wantarray) {  # return entire file as an array
7939    my $rec_ind = $self->{'rec_ind'};  $self->{'eof'} = 1;
7940    my $fh = $self->{'handle'};
7941    if (!defined $size_limit) {
7942      $self->{'rec_ind'} = $self->{'prefix_n'};  # just an estimate
7943      $self->{'pos'} = $self->{'prefix_l'};      # just an estimate
7944      if ($rec_ind >= $self->{'prefix_n'}) {
7945        return readline($fh);
7946      } elsif ($rec_ind == 0) {  # common case: get the whole thing
7947        return ( @{$self->{'prefix'}}, readline($fh) );
7948      } else {
7949        return ( @{$self->{'prefix'}}[ $rec_ind .. $#{$self->{'prefix'}} ],
7950                 readline($fh) );
7951      }
7952    } else {  # take size limit into account
7953      my(@array);
7954      if ($rec_ind == 0) {
7955        @array = @{$self->{'prefix'}};
7956      } elsif ($rec_ind < $self->{'prefix_n'}) {
7957        @array = @{$self->{'prefix'}}[ $rec_ind .. $#{$self->{'prefix'}} ];
7958      }
7959      for my $j (0..$#array) {
7960        $pos += length($array[$j]);
7961        if ($pos >= $size_limit) {  # truncate at NL past limit
7962          $#array = $j; last;
7963        }
7964      }
7965      my $nread = 0;
7966      if ($pos < $size_limit) {
7967        my($inbuf,$carry); my $beyond_limit = 0;
7968        while ( $nread=read($fh,$inbuf,16384) ) {  # faster than line-by-line
7969          if ($pos+$nread >= $size_limit) {
7970            my $k = index($inbuf, "\n",  # find a clean break at next NL
7971                          $pos >= $size_limit ? 0 : $size_limit-$pos);
7972            substr($inbuf, $k >= 0 ? $k+1 : $size_limit-$pos) = '';
7973            $beyond_limit = 1;
7974          }
7975          $pos += $nread;
7976          my $k = $#array + 1;  # insertion point
7977          push(@array, split(/^/m, $inbuf, -1));
7978          if (defined $carry) { $array[$k] = $carry.$array[$k]; $carry=undef }
7979          $carry = pop(@array)  if substr($array[-1],-1,1) ne "\n";
7980          last  if $beyond_limit;
7981        }
7982        push(@array,$carry)  if defined $carry;
7983      }
7984      $self->{'rec_ind'} = $rec_ind + @array;
7985      $self->{'pos'} = $pos;
7986      if (!defined $nread) {
7987        undef @array;
7988        # errno should still be in $!, caller should be checking it
7989        # die "error reading: $!";
7990      }
7991      return @array;
7992    }
7993  } else {  # read one line
7994    if ($self->{'rec_ind'} < $self->{'prefix_n'}) {
7995      my $line = $self->{'prefix'}->[$self->{'rec_ind'}];
7996      $self->{'rec_ind'}++; $self->{'pos'} += length($line);
7997      return $line;
7998    } else {
7999      my $line = scalar(readline($self->{'handle'}));
8000      if (!defined($line)) { $self->{'eof'} = 1 }  # errno in $!
8001      else { $self->{'rec_ind'}++; $self->{'pos'} += length($line) }
8002      return $line;
8003    }
8004  }
8005}
8006
8007# mixing of READ and READLINE is not supported (without rewinding inbetween)
8008#
8009sub READ {  # SCALAR,LENGTH,OFFSET
8010  my $self = shift; my $len = $_[1]; my $offset = $_[2];
8011  my $str = ''; my $nbytes = 0;
8012  my $pos = $self->{'pos'};
8013  my $beyond_limit = 0;
8014  my $size_limit = $self->{'size_limit'};
8015  if (defined $size_limit && $pos+$len > $size_limit) {
8016    $len = $pos >= $size_limit ? 0 : $size_limit - $pos;
8017    $beyond_limit = 1;
8018  }
8019  if ($len > 0 && $pos < $self->{'prefix_l'}) {
8020    # not efficient, but typically only occurs once
8021    $str = substr(join('',@{$self->{'prefix'}}), $pos, $len);
8022    $nbytes += length($str); $len -= $nbytes;
8023  }
8024  my $msg;  my $buff_directly_accessed = 0;
8025  if ($len > 0) {
8026    # avoid shuffling data through multiple buffers for a common case
8027    $buff_directly_accessed = $nbytes == 0;
8028    my $nb = $buff_directly_accessed
8029               ? read($self->{'handle'}, $_[0], $len, $offset)
8030               : read($self->{'handle'}, $str,  $len, $nbytes);
8031    if (!defined $nb) {
8032      $msg = "Error reading: $!";
8033    } elsif ($nb < 1) {  # read returns 0 at eof
8034      $self->{'eof'} = 1;
8035    } else {
8036      $nbytes += $nb; $len -= $nb;
8037    }
8038  }
8039  if (defined $msg) {
8040    undef $nbytes;  # $! already set by a failed sysread
8041  } elsif ($beyond_limit && $nbytes == 0) {
8042    $self->{'eof'} = 1;
8043  } else {
8044    if (!$buff_directly_accessed) {
8045      ($offset ? substr($_[0],$offset) : $_[0]) = $str;
8046    }
8047    $pos += $nbytes; $self->{'pos'} = $pos;
8048  }
8049  $nbytes;   # eof: 0;  error: undef
8050}
8051
8052sub close    { shift->CLOSE(@_) }
8053sub fileno   { shift->FILENO(@_) }
8054sub binmode  { shift->BINMODE(@_) }
8055sub seek     { shift->SEEK(@_) }
8056#sub tell    { shift->TELL(@_) }
8057sub read     { shift->READ(@_) }
8058sub readline { shift->READLINE(@_) }
8059sub getlines { shift->READLINE(@_) }
8060sub getline  { scalar(shift->READLINE(@_)) }
8061
80621;
8063
8064#
8065package Amavis::IO::Zlib;
8066
8067# A simple IO::File -compatible wrapper around Compress::Zlib,
8068# much like IO::Zlib but simpler: does only what we need and does it carefully
8069
8070use strict;
8071use re 'taint';
8072
8073BEGIN {
8074  require Exporter;
8075  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
8076  $VERSION = '2.412';
8077  @ISA = qw(Exporter);
8078}
8079use Errno qw(EIO);
8080use Compress::Zlib;
8081
8082sub new {
8083  my $class = shift;  my $self = bless {}, $class;
8084  if (@_) { $self->open(@_) or return }
8085  $self;
8086}
8087
8088sub close {
8089  my $self = $_[0];
8090  my $status; my $eval_stat; local($1,$2);
8091  eval { $status = $self->{fh}->gzclose; 1 }
8092    or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
8093  delete $self->{fh};
8094  if (defined $eval_stat) {
8095    chomp $eval_stat;
8096    die $eval_stat  if $eval_stat =~ /^timed out\b/;   # resignal timeout
8097    # can't stash arbitrary text into $!
8098    die "gzclose error: $eval_stat, $gzerrno";
8099    $! = EIO; return;  # not reached
8100  } elsif ($status != Z_OK) {
8101    die "gzclose error: $gzerrno";  # can't stash arbitrary text into $!
8102    $! = EIO; return;  # not reached
8103  }
8104  1;
8105}
8106
8107sub DESTROY {
8108  my $self = $_[0]; local($@,$!,$_);
8109  # ignore failure, make perlcritic happy
8110  if ($self && $self->{fh}) { eval { $self->close } or 1 }
8111}
8112
8113sub open {
8114  my($self,$fname,$mode) = @_;
8115  # ignore failure, make perlcritic happy
8116  if (exists($self->{fh})) { eval { $self->close } or 1; delete $self->{fh} }
8117  $self->{fname} = $fname; $self->{mode} = $mode; $self->{pos} = 0;
8118  my $gz = gzopen($fname,$mode);
8119  if ($gz) {
8120    $self->{fh} = $gz;
8121  } else {
8122    die "gzopen error: $gzerrno";  # can't stash arbitrary text into $!
8123    $! = EIO; undef $gz;  # not reached
8124  }
8125  $gz;
8126}
8127
8128sub seek {
8129  my($self,$pos,$whence) = @_;
8130  $whence == 0  or die "Only absolute seek is supported on gzipped file";
8131  $pos >= 0     or die "Can't seek to a negative absolute position";
8132  $self->{mode} eq 'rb'
8133    or die "Seek to $whence,$pos on gzipped file only supported for 'rb' mode";
8134  if ($pos < $self->{pos}) {
8135    $self->close or die "seek: can't close gzipped file: $!";
8136    $self->open($self->{fname},$self->{mode})
8137      or die "seek: can't reopen gzipped file: $!";
8138  }
8139  my $skip = $pos - $self->{pos};
8140  while ($skip > 0) {
8141    my $s;  my $nbytes = $self->read($s,$skip);  # acceptable for small skips
8142    defined $nbytes && $nbytes > 0
8143      or die "seek: error skipping $skip bytes on gzipped file: $!";
8144    $skip -= $nbytes;
8145  }
8146  1;  # seek is supposed to return 1 upon success, 0 otherwise
8147}
8148
8149sub read {  # SCALAR,LENGTH,OFFSET
8150  my $self = shift; my $len = $_[1]; my $offset = $_[2];
8151  defined $len  or die "Amavis::IO::Zlib::read: length argument undefined";
8152  my $nbytes;
8153  if (!defined($offset) || $offset == 0) {
8154    $nbytes = $self->{fh}->gzread($_[0], $len);
8155  } else {
8156    my $buff;
8157    $nbytes = $self->{fh}->gzread($buff, $len);
8158    substr($_[0],$offset) = $buff;
8159  }
8160  if ($nbytes < 0) {
8161    die "gzread error: $gzerrno";  # can't stash arbitrary text into $!
8162    $! = EIO; undef $nbytes;  # not reached
8163  } else {
8164    $self->{pos} += $nbytes;
8165  }
8166  $nbytes;   # eof: 0;  error: undef
8167}
8168
8169sub getline {
8170  my $self = $_[0];  my($nbytes,$line);
8171  $nbytes = $self->{fh}->gzreadline($line);
8172  if ($nbytes <= 0) {  # eof (0) or error (-1)
8173    $! = 0; $line = undef;
8174    if ($nbytes < 0 && $gzerrno != Z_STREAM_END) {
8175      die "gzreadline error: $gzerrno";  # can't stash arbitrary text into $!
8176      $! = EIO;  # not reached
8177    }
8178  } else {
8179    $self->{pos} += $nbytes;
8180  }
8181  $line;  # eof: undef, $! zero;  error: undef, $! nonzero
8182}
8183
8184sub print {
8185  my $self = shift;
8186  my $buff_ref = @_ == 1 ? \$_[0] : \join('',@_);
8187  my $nbytes; my $len = length($$buff_ref);
8188  if ($len <= 0) {
8189    $nbytes = "0 but true";
8190  } else {
8191    $nbytes = $self->{fh}->gzwrite($$buff_ref);  $self->{pos} += $len;
8192    if ($nbytes <= 0) {
8193      die "gzwrite error: $gzerrno";  # can't stash arbitrary text into $!
8194      $! = EIO; undef $nbytes;  # not reached
8195    }
8196  }
8197  $nbytes;
8198}
8199
8200sub printf { shift->print(sprintf(shift,@_)) }
8201
82021;
8203
8204#
8205package Amavis::IO::RW;
8206use strict;
8207use re 'taint';
8208
8209BEGIN {
8210  require Exporter;
8211  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
8212  $VERSION = '2.412';
8213  @ISA = qw(Exporter);
8214  import Amavis::Conf qw(:platform);
8215  import Amavis::Util qw(ll do_log min max minmax idn_to_ascii);
8216}
8217
8218use Errno qw(EIO EINTR EAGAIN EPIPE ENOTCONN ECONNRESET);
8219use Time::HiRes ();
8220use IO::Socket;
8221use IO::Socket::UNIX;
8222#use IO::Socket::SSL;
8223
8224# Connect to one of the specified sockets. The $socket_specs may be a
8225# simple string ([inet-host]:port, [inet6-host]:port, or a unix socket name),
8226# optionally prefixed by a protocol name (scheme) and a colon (the prefix is
8227# ignored here, just avoids a need for parsing by a caller);  or it can be
8228# a ref to a list of such socket specifications, which are tried one after
8229# another until a connection is successful. In case of a listref, it leaves
8230# a good socket as the first entry in the list so that it will be tried first
8231# on a next call.
8232# The 'Timeout' argument controls both the connect timeout as well as the
8233# timeout of a select() call in rw_loop() - but may be changed through a
8234# timeout() method.
8235#
8236sub new {
8237  my($class, $socket_specs, %arg) = @_;
8238  my $self = bless {}, $class;
8239  $self->timeout($arg{Timeout});
8240  $self->{eol_str} = !defined $arg{Eol} ? "\n" : $arg{Eol};
8241  $self->{inp_sane_size} = !$arg{InpSaneSize} ? 500000 : $arg{InpSaneSize};
8242  $self->{last_event_time} = 0; $self->{last_event_tx_time} = 0;
8243  $self->{inp} = ''; $self->{out} = '';
8244  $self->{inpeof} = 0; $self->{ssl_active} = 0;
8245  $socket_specs = [ $socket_specs ]  if !ref $socket_specs;
8246  my($protocol,$socketname,$sock,$eval_stat);
8247  my $attempts = 0; my(@failures);
8248  my $n_candidates = scalar @$socket_specs;
8249  $n_candidates > 0  or die "Can't connect, no sockets specified!?";  # sanity
8250  for (;;) {
8251    if ($n_candidates > 1) {  # pick one at random, put it to head of the list
8252      my $j = int(rand($n_candidates));
8253      ll(5) && do_log(5, "picking candidate #%d (of %d) in %s",
8254                         $j+1, $n_candidates, join(', ',@$socket_specs));
8255      @$socket_specs[0,$j] = @$socket_specs[$j,0]  if $j != 0;
8256    }
8257    $socketname = $socket_specs->[0];  # try the first on the list
8258    local($1);
8259    $socketname =~ s/^([a-z][a-z0-9.+-]*)?://si;  # strip protocol name
8260    $protocol = lc($1);  # kept for the benefit of a caller
8261    $self->{socketname} = undef;
8262    $attempts++;
8263    eval {
8264      $sock = $self->connect_attempt($socketname, %arg);
8265      $sock  or die "Error connecting to socket $socketname\n";
8266      1;
8267    } or do {
8268      $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
8269      undef $sock;
8270    };
8271    if ($sock) {  # mission accomplished
8272      if (!@failures) {
8273        do_log(5, "connected to %s successfully", $self->{socketname});
8274      } else {
8275        do_log(1, "connected to %s successfully after %d failures on: %s",
8276                 $self->{socketname}, scalar(@failures), join(', ',@failures));
8277      }
8278      last;
8279    } else {  # failure, prepare for a retry with a next entry if any
8280      $n_candidates--;
8281      my $ll = $attempts > 1 || $n_candidates <= 0 ? -1 : 1;
8282      ll($ll) && do_log($ll, "connect to %s failed, attempt #%d: %s%s",
8283                   $socketname, $attempts, $eval_stat,
8284                   $n_candidates <= 0 ? '' : ', trying next');
8285      push(@failures, $socketname);
8286      # circular shift left, move a bad candidate to the end of the list
8287      push(@$socket_specs, shift @$socket_specs)  if @$socket_specs > 1;
8288      last if $n_candidates <= 0;
8289    }
8290  }
8291  $sock  or die("All attempts ($attempts) failed connecting to ".
8292                join(', ',@$socket_specs) . "\n");
8293  $self->{socket} = $sock;
8294  $self->{protocol} = $protocol;
8295  $self;
8296}
8297
8298sub connect_attempt {
8299  my($self, $socketname, %arg) = @_;
8300  my $sock;
8301  my($localaddr, $localport) = ($arg{LocalAddr}, $arg{LocalPort});
8302  my $blocking = 1;  # blocking mode defaults to on
8303  $blocking = 0  if defined $arg{Blocking} && !$arg{Blocking};
8304  my $timeout = $self->timeout;
8305  my $timeout_displ = !defined $timeout ? 'undef'
8306                      : int($timeout) == $timeout ? "$timeout"
8307                      : sprintf("%.3f",$timeout);
8308  my($peeraddress, $peerport, $is_inet); local($1,$2,$3);
8309  if ($socketname =~ m{^/}) {  # simpleminded: unix vs. inet
8310    $is_inet = 0;
8311  } elsif ($socketname =~ /^(?: \[ ([^\]]*) \] | ([^:]*) ) : ([^:]*)/xs) {
8312    # ignore possible further fields after the "proto:addr:port:..." last colon
8313    $peeraddress = defined $1 ? $1 : $2;  $peerport = $3;  $is_inet = 1;
8314  } elsif ($socketname =~ /^(?: \[ ([^\]]*) \] | ([0-9a-fA-F.:]+) ) \z/xs) {
8315    $peeraddress = defined $1 ? $1 : $2;  $is_inet = 1;
8316  } else {  # probably a syntax error, but let's assume it is a Unix socket
8317    $is_inet = 0;
8318  }
8319  if ($is_inet) {
8320    if (defined $peeraddress && $peeraddress eq '*') {
8321      $peeraddress = $arg{WildcardImpliedHost};
8322      defined $peeraddress
8323        or die "Wildcarded host, but client's address not known: $socketname";
8324    }
8325    if (!defined $peeraddress || $peeraddress eq '') {
8326      die "Empty/unknown host address in socket specification: $socketname";
8327    }
8328    $peerport = $arg{Port}  if !defined $peerport || $peerport eq '';
8329    if (defined $peerport && $peerport eq '*') {
8330      $peerport = $arg{WildcardImpliedPort};
8331      defined $peerport
8332        or die "Wildcarded port, but client's port not known: $socketname";
8333    }
8334    if (!defined $peerport || $peerport eq '') {
8335      die "Empty/unknown port number in socket specification: $socketname";
8336    } elsif ($peerport !~ /^\d{1,5}\z/ || $peerport < 1 || $peerport > 65535) {
8337      die "Invalid port number in socket specification: $socketname";
8338    }
8339  }
8340
8341  $self->{last_event_time} = $self->{last_event_tx_time} = Time::HiRes::time;
8342
8343  if (!$is_inet) {
8344    # unix socket
8345    ll(3) && do_log(3, "new socket by IO::Socket::UNIX to %s, ".
8346                       "timeout set to %s", $socketname, $timeout_displ);
8347    $sock = IO::Socket::UNIX->new(
8348      # Domain => AF_UNIX,
8349      Type => SOCK_STREAM, Timeout => $timeout);
8350    $sock or die "Can't create UNIX socket: $!\n";
8351    $sock->connect( pack_sockaddr_un($socketname) )
8352      or die "Can't connect to a UNIX socket $socketname: $!\n";
8353    $self->{last_event} = 'new-unix';
8354
8355  } else {  # inet or inet6
8356    defined $io_socket_module_name
8357      or die "No INET or INET6 socket module is available";
8358    my $local_sock_displ = '';
8359    $peeraddress = idn_to_ascii($peeraddress);
8360    my(%args) = (Type => SOCK_STREAM, Proto => 'tcp', Blocking => $blocking,
8361                 PeerAddr => $peeraddress, PeerPort => $peerport);
8362               # Timeout => $timeout,  # produces: Invalid argument
8363    if (defined $localaddr && $localaddr ne '') {
8364      $args{LocalAddr} = $localaddr;
8365      $local_sock_displ .= '[' . $localaddr . ']';
8366    }
8367    if (defined $localport && $localport ne '') {
8368      $args{LocalPort} = $localport;
8369      $local_sock_displ .= ':' . $localport;
8370    }
8371    ll(3) && do_log(3,"new socket using %s to [%s]:%s, timeout %s%s%s",
8372                      $io_socket_module_name, $peeraddress, $peerport,
8373                      $timeout_displ, $blocking ? '' : ', nonblocking',
8374                      $local_sock_displ eq '' ? ''
8375                                              : ', local '.$local_sock_displ);
8376    $sock = $io_socket_module_name->new(%args);
8377    if (!$sock) {
8378      # note: the IO::Socket::IP constructor provides an error message in $@
8379      die sprintf("Can't connect to socket %s using module %s: %s\n",
8380                  $socketname, $io_socket_module_name,
8381                  $io_socket_module_name eq 'IO::Socket::IP' ? $@ : $!);
8382    }
8383    $self->{last_event} = 'new-' . $io_socket_module_name;
8384  }
8385  if ($sock) {
8386    $self->{socketname} = $is_inet ? "[$peeraddress]:$peerport" : $socketname;
8387  }
8388  $sock;
8389}
8390
8391sub internal_close {
8392  my($self, $destroying) = @_;
8393  my $sock = $self->{socket};
8394  my $status = 1;  # ok
8395  if (!defined($sock)) {
8396    # nothing to do
8397  } elsif (!defined fileno($sock)) {  # not really open
8398    $sock->close;  # ignoring errors
8399  } else {
8400    my $flush_status = 1;  # ok
8401    eval {  # don't let errors during flush prevent us from closing a socket
8402      $flush_status = $self->flush;
8403    } or do {
8404      undef $flush_status;  # false, indicates a signalled failure
8405      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
8406      do_log($destroying ? 5 : 1,
8407             "closing: Error flushing socket in Amavis::IO::RW::%s: %s",
8408             $destroying?'DESTROY':'close', $eval_stat);
8409    };
8410    $self->{last_event} = 'close';
8411    $self->{last_event_time} = $self->{last_event_tx_time} = Time::HiRes::time;
8412    $! = 0; $status = $sock->close;
8413    $status or do_log($destroying ? 5 : 1,
8414                     "closing: Error closing socket in Amavis::IO::RW::%s: %s",
8415                      $destroying?'DESTROY':'close',
8416                      !$self->{ssl_active} ? $! : $sock->errstr.", $!" );
8417    $status = $flush_status  if $status && !$flush_status;
8418  }
8419  $status;
8420}
8421
8422sub close {
8423  my $self = $_[0];
8424  $self->internal_close(0);
8425}
8426
8427sub DESTROY {
8428  my $self = $_[0]; local($@,$!,$_);
8429  # ignore failure, make perlcritic happy
8430  eval { $self->internal_close(1) } or 1;
8431}
8432
8433sub rw_loop {
8434  my($self,$needline,$flushoutput) = @_;
8435#
8436# RFC 2920: Client SMTP implementations MAY elect to operate in a nonblocking
8437# fashion, processing server responses immediately upon receipt, even if
8438# there is still data pending transmission from the client's previous TCP
8439# send operation. If nonblocking operation is not supported, however, client
8440# SMTP implementations MUST also check the TCP window size and make sure that
8441# each group of commands fits entirely within the window. The window size
8442# is usually, but not always, 4K octets.  Failure to perform this check can
8443# lead to deadlock conditions.
8444#
8445# We choose to operate in a nonblocking mode. Responses are read as soon as
8446# they become available and stored for later, but not immediately processed
8447# as they come in. This requires some sanity limiting against rogue servers.
8448#
8449  my $sock = $self->{socket};
8450  my $fd_sock = fileno($sock);
8451  my $timeout = $self->timeout;
8452  my $timeout_displ = !defined $timeout ? 'undef'
8453                      : int($timeout) == $timeout ? "$timeout"
8454                      : sprintf("%.3f",$timeout);
8455  my $eol_str = $self->{eol_str};
8456  my $idle_cnt = 0; my $failed_write_attempts = 0;
8457  local $SIG{PIPE} = 'IGNORE';  # don't signal on a write to a widowed pipe
8458  for (;;) {
8459    $idle_cnt++;
8460    my($rout,$wout,$eout,$rin,$win,$ein); $rin=$win=$ein='';
8461    my $want_to_write = $self->{out} ne '' && ($flushoutput || $needline);
8462    ll(5) && do_log(5, 'rw_loop: needline=%d, flush=%s, wr=%d, timeout=%s',
8463                      $needline, $flushoutput, $want_to_write, $timeout_displ);
8464    if (!defined($fd_sock)) {
8465      do_log(3, 'rw_loop read: got a closed socket');
8466      $self->{inpeof} = 1; last;
8467    }
8468    vec($rin,$fd_sock,1) = 1;
8469    vec($win,$fd_sock,1) = $want_to_write ? 1 : 0;
8470    $ein = $rin | $win;
8471    $self->{last_event} = 'select';
8472    $self->{last_event_time} = Time::HiRes::time;
8473    my($nfound,$timeleft) =
8474      select($rout=$rin, $wout=$win, $eout=$ein, $timeout);
8475    defined $nfound && $nfound >= 0
8476      or die "Select failed: ".
8477             (!$self->{ssl_active} ? $! : $sock->errstr.", $!");
8478    if (vec($rout,$fd_sock,1)) {
8479      ll(5) && do_log(5, 'rw_loop: receiving');
8480      my $inbuf = ''; $! = 0;
8481      my $nread = sysread($sock,$inbuf,16384);
8482      if ($nread) {  # successful read
8483        $self->{last_event} = 'read-ok';
8484        $self->{inpeof} = 0;
8485        ll(5) && do_log(5,'rw_loop read %d chars< %s', length($inbuf),$inbuf);
8486        $self->{inp} .= $inbuf; $idle_cnt = 0;
8487        length($self->{inp}) < $self->{inp_sane_size}
8488          or die "rw_loop: Aborting on a runaway server, inp_len=" .
8489                 length($self->{inp});
8490      } elsif (defined $nread) {  # defined but zero, sysread returns 0 at eof
8491        $self->{last_event} = 'read-eof';
8492        $self->{inpeof} = 1;  do_log(3, 'rw_loop read: got eof');
8493      } elsif ($! == EAGAIN || $! == EINTR) {
8494        $self->{last_event} = 'read-intr'.(0+$!);
8495        $idle_cnt = 0;
8496        do_log(2, 'rw_loop read interrupted: %s',
8497                  !$self->{ssl_active} ? $! : $sock->errstr.", $!");
8498        Time::HiRes::sleep(0.1);  # slow down, just in case
8499        # retry
8500      } else {
8501        $self->{last_event} = 'read-fail';
8502        $self->{inpeof} = 1;
8503        die "Error reading from socket: ".
8504             (!$self->{ssl_active} ? $! : $sock->errstr.", $!");
8505      }
8506      $self->{last_event_time} = Time::HiRes::time;
8507    }
8508    if (vec($wout,$fd_sock,1)) {
8509      my $out_l = length($self->{out});
8510      ll(5) && do_log(5,'rw_loop: sending %d chars', $out_l);
8511      my $nwrite = syswrite($sock, $self->{out});
8512      if (!defined($nwrite)) {
8513        if ($! == EAGAIN || $! == EINTR) {
8514          $self->{last_event} = 'write-intr'.(0+$!);
8515          $idle_cnt = 0; $failed_write_attempts++;
8516          do_log(2, 'rw_loop writing %d bytes interrupted: %s', $out_l,
8517                    !$self->{ssl_active} ? $! : $sock->errstr.", $!");
8518          Time::HiRes::sleep(0.1);   # slow down, just in case
8519        } else {
8520          $self->{last_event} = 'write-fail';
8521          die sprintf('Error writing %d bytes to socket: %s', $out_l,
8522                      !$self->{ssl_active} ? $! : $sock->errstr.", $!");
8523        }
8524      } else {  # successful write
8525        $self->{last_event} = 'write-ok';
8526        my $ll = $nwrite != $out_l ? 4 : 5;
8527        if (ll($ll)) {
8528          my $msg = $nwrite==$out_l ? sprintf("%d", $nwrite)
8529                                    : sprintf("%d (of %d)", $nwrite,$out_l);
8530          my $nlog = min(200,$nwrite);
8531          do_log($ll, 'rw_loop sent %s> %s%s',
8532                $msg, substr($self->{out},0,$nlog), $nlog<$nwrite?' [...]':'');
8533        };
8534        $idle_cnt = 0;
8535        if ($nwrite <= 0) { $failed_write_attempts++ }
8536        elsif ($nwrite < $out_l) { substr($self->{out},0,$nwrite) = '' }
8537        else { $self->{out} = '' }
8538      }
8539      $self->{last_event_time} = $self->{last_event_tx_time} =
8540        Time::HiRes::time;
8541    }
8542    if ( ( !$needline || !defined($eol_str) || $eol_str eq '' ||
8543           index($self->{inp},$eol_str) >= 0 ) &&
8544         ( !$flushoutput || $self->{out} eq '' ) ) {
8545      last;
8546    }
8547    if ($self->{inpeof}) {
8548      if ($self->{out} ne '') {
8549        do_log(2, 'rw_loop: EOF on input, output buffer not yet empty');
8550      }
8551      last;
8552    }
8553    if ($idle_cnt > 0) {  # probably exceeded timeout in select
8554      do_log(-1, 'rw_loop: leaving rw loop, no progress, '.
8555                 'last event (%s) %.3f s ago', $self->{last_event},
8556             Time::HiRes::time - $self->{last_event_time});
8557      last;
8558    }
8559    $failed_write_attempts < 100  or die "rw_loop: Aborting stalled sending";
8560  }
8561}
8562
8563sub socketname
8564  { @_<2 ? shift->{socketname} : ($_[0]->{socketname} = $_[1]) }
8565sub protocol
8566  { @_<2 ? shift->{protocol}   : ($_[0]->{protocol} = $_[1]) }
8567sub timeout
8568  { @_<2 ? shift->{timeout}    : ($_[0]->{timeout} = $_[1]) }
8569sub ssl_active
8570  { @_<2 ? shift->{ssl_active} : ($_[0]->{ssl_active} = $_[1]) }
8571sub eof
8572  { @_<2 ? shift->{client_ip}  : ($_[0]->{client_ip} = $_[1]) }
8573
8574sub last_io_event_timestamp
8575  { my($self,$keyword) = @_; $self->{last_event_time} }
8576
8577sub last_io_event_tx_timestamp
8578  { my($self,$keyword) = @_; $self->{last_event_tx_time} }
8579
8580sub flush
8581  { my $self = $_[0]; $self->rw_loop(0,1) if $self->{out} ne ''; 1 }
8582
8583sub discard_pending_output
8584  { my $self = $_[0]; my $len = length $self->{out}; $self->{out} = ''; $len }
8585
8586sub out_buff_large
8587  { my $self = $_[0]; length $self->{out} > 40000 }
8588
8589sub print {
8590  my $self = shift;
8591  $self->{out} .= $_  for @_;
8592# $self->out_buff_large ? $self->flush : 1;
8593  length $self->{out} > 40000 ? $self->flush : 1;  # inlined out_buff_large()
8594}
8595
8596sub at_line_boundary {
8597  my $self = $_[0];
8598  my $eol_str = $self->{eol_str};
8599  my $eol_str_l = !defined($eol_str) ? 0 : length($eol_str);
8600  !$eol_str_l ? 1
8601    : substr($self->{out}, -$eol_str_l, $eol_str_l) eq $eol_str ? 1 : 0;
8602}
8603
8604# returns true if there is any full line (or last incomplete line)
8605# in the buffer waiting to be read, 0 otherwise, undef on eof or error
8606#
8607sub response_line_available {
8608  my $self = $_[0];
8609  my $eol_str = $self->{eol_str};
8610  if (!defined $eol_str || $eol_str eq '') {
8611    return length($self->{inp});
8612  } elsif (index($self->{inp},$eol_str) >= 0) {
8613    return 1;
8614  } elsif ($self->{inpeof} && $self->{inp} eq '') {
8615    return;  # undef on end-of-file
8616  } elsif ($self->{inpeof}) {  # partial last line
8617    return length($self->{inp});
8618  }
8619}
8620
8621# get one full text line, or last partial line, or undef on eof/error/timeout
8622#
8623sub get_response_line {
8624  my $self = $_[0];
8625  my $ind; my $attempts = 0;
8626  my $eol_str = $self->{eol_str};
8627  my $eol_str_l = !defined($eol_str) ? 0 : length($eol_str);
8628  for (;;) {
8629    if (!$eol_str_l) {
8630      my $str = $self->{inp}; $self->{inp} = ''; return $str;
8631    } elsif (($ind=index($self->{inp},$eol_str)) >= 0) {
8632      return substr($self->{inp},0,$ind+$eol_str_l,'');
8633    } elsif ($self->{inpeof} && $self->{inp} eq '') {
8634      $! = 0; return;  # undef on end-of-file
8635    } elsif ($self->{inpeof}) {  # return partial last line
8636      my $str = $self->{inp}; $self->{inp} = ''; return $str;
8637    } elsif ($attempts > 0) {
8638      $! = EIO; return;  # timeout or error
8639    }
8640    # try reading some more input, one attempt only
8641    $self->rw_loop(1,0); $attempts++;
8642  }
8643}
8644
8645# read whatever is available, up to LENGTH bytes
8646#
8647sub read {  # SCALAR,LENGTH,OFFSET
8648  my $self = shift; my $len = $_[1]; my $offset = $_[2];
8649  defined $len  or die "Amavis::IO::RW::read: length argument undefined";
8650  $len >= 0     or die "Amavis::IO::RW::read: length argument negative";
8651  $self->rw_loop(0,0);
8652  my $nbytes = length($self->{inp});
8653  $nbytes = $len  if $len < $nbytes;
8654  if (!defined($offset) || $offset == 0) {
8655    $_[0] = substr($self->{inp}, 0, $len, '');
8656  } else {
8657    substr($_[0],$offset) = substr($self->{inp}, 0, $len, '');
8658  }
8659  $nbytes;   # eof: 0;  error: undef
8660}
8661
8662use vars qw($ssl_cache);
8663sub ssl_upgrade {
8664  my($self, %tls_options) = @_;
8665  $self->flush;
8666  IO::Socket::SSL->VERSION(1.05);  # required minimal version
8667  $ssl_cache = IO::Socket::SSL::Session_Cache->new(2)  if !defined $ssl_cache;
8668  my $sock = $self->{socket};
8669  IO::Socket::SSL->start_SSL($sock,
8670    SSL_session_cache => $ssl_cache,
8671    SSL_error_trap => sub {
8672      my($sock,$msg) = @_;
8673      do_log(-2,"Upgrading socket to TLS failed (in ssl_upgrade): %s", $msg);
8674    },
8675    %tls_options,
8676  ) or die "Error upgrading output socket to TLS: ".IO::Socket::SSL::errstr();
8677  $self->{last_event} = 'ssl-upgrade';
8678  $self->{last_event_time} = $self->{last_event_tx_time} = Time::HiRes::time;
8679  $self->{ssl_active} = 1;
8680  ll(3) && do_log(3,"TLS cipher: %s", $sock->get_cipher);
8681  ll(5) && do_log(5,"TLS certif: %s", $sock->dump_peer_certificate);
8682  1;
8683}
8684
86851;
8686
8687#
8688package Amavis::In::Connection;
8689
8690# Keeps relevant information about how we received the message:
8691# client connection information, SMTP envelope and SMTP parameters
8692
8693use strict;
8694use re 'taint';
8695
8696BEGIN {
8697  require Exporter;
8698  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
8699  $VERSION = '2.412';
8700  @ISA = qw(Exporter);
8701}
8702
8703sub new
8704  { my $class = $_[0]; bless {}, $class }
8705
8706sub client_ip      # client IP address (immediate SMTP client, i.e. our MTA)
8707  { @_<2 ? shift->{client_ip}   : ($_[0]->{client_ip} = $_[1]) }
8708sub client_port    # TCP source port number (immediate SMTP client)
8709  { @_<2 ? shift->{client_port} : ($_[0]->{client_port} = $_[1]) }
8710sub socket_ip      # IP address of our interface that received connection
8711  { @_<2 ? shift->{socket_ip}   : ($_[0]->{socket_ip} = $_[1]) }
8712sub socket_port    # TCP port of our interface that received connection
8713  { @_<2 ? shift->{socket_port} : ($_[0]->{socket_port} = $_[1]) }
8714sub socket_proto   # TCP/UNIX
8715  { @_<2 ? shift->{socket_proto}: ($_[0]->{socket_proto} = $_[1])}
8716sub socket_path    # socket path, UNIX sockets only
8717  { @_<2 ? shift->{socket_path} : ($_[0]->{socket_path} = $_[1])}
8718# RFC 3848
8719sub appl_proto     # SMTP/ESMTP(A|S|SA)/LMTP(A|S|SA) / AM.PDP/AM.CL/QMQP/QMQPqq
8720  { @_<2 ? shift->{appl_proto}  : ($_[0]->{appl_proto} = $_[1]) }
8721sub smtp_helo      # (E)SMTP HELO/EHLO parameter
8722  { @_<2 ? shift->{smtp_helo}   : ($_[0]->{smtp_helo} = $_[1]) }
8723
87241;
8725
8726#
8727package Amavis::In::Message::PerRecip;
8728
8729use strict;
8730use re 'taint';
8731
8732BEGIN {
8733  require Exporter;
8734  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
8735  $VERSION = '2.412';
8736  @ISA = qw(Exporter);
8737  import Amavis::Conf qw(:platform);
8738  import Amavis::Util qw(setting_by_given_contents_category_all
8739                         setting_by_given_contents_category cmp_ccat);
8740}
8741
8742sub new     # NOTE: this class is a list, not a hash
8743  { my $class = $_[0]; bless [(undef) x 42], $class }
8744
8745# subs to set or access individual elements of a n-tuple by name
8746sub recip_addr       # unquoted recipient envelope e-mail address
8747  { @_<2 ? shift->[0] : ($_[0]->[0] = $_[1]) }
8748sub recip_addr_smtp  # SMTP-encoded recipient envelope e-mail address in <>
8749  { @_<2 ? shift->[1] : ($_[0]->[1] = $_[1]) }
8750sub recip_addr_modified  # recip. addr. with possible addr. extension inserted
8751  { @_<2 ? shift->[2] : ($_[0]->[2] = $_[1]) }
8752sub recip_is_local   # recip_addr matches @local_domains_maps
8753  { @_<2 ? shift->[3] : ($_[0]->[3] = $_[1]) }
8754sub recip_maddr_id   # maddr.id field from SQL corresponding to recip_addr_smtp
8755  { @_<2 ? shift->[4] : ($_[0]->[4] = $_[1]) }
8756sub recip_maddr_id_orig # maddr.id field from SQL corresponding to dsn_orcpt
8757  { @_<2 ? shift->[5] : ($_[0]->[5] = $_[1]) }
8758sub recip_penpals_related  # mail_id of a previous correspondence
8759  { @_<2 ? shift->[6] : ($_[0]->[6] = $_[1]) }
8760sub recip_penpals_age # penpals age in seconds if SQL or Redis is enabled
8761  { @_<2 ? shift->[7] : ($_[0]->[7] = $_[1]) }
8762sub recip_penpals_score # penpals score (info, also added to spam_level)
8763  { @_<2 ? shift->[8] : ($_[0]->[8] = $_[1]) }
8764sub dsn_notify       # ESMTP RCPT command NOTIFY option (DSN-RFC 3461, listref)
8765  { @_<2 ? shift->[9] : ($_[0]->[9] = $_[1]) }
8766sub dsn_orcpt  # ESMTP RCPT command ORCPT option (decoded: RFC 3461, RFC 6533)
8767  { @_<2 ? shift->[10] : ($_[0]->[10] = $_[1]) }
8768sub dsn_suppress_reason  # if defined disable sending DSN and supply a reason
8769  { @_<2 ? shift->[11] : ($_[0]->[11] = $_[1]) }
8770sub recip_destiny    # D_REJECT, D_BOUNCE, D_DISCARD, D_PASS
8771  { @_<2 ? shift->[12] : ($_[0]->[12] = $_[1]) }
8772sub recip_done       # false: not done, true: done (1: faked, 2: truly sent)
8773  { @_<2 ? shift->[13] : ($_[0]->[13] = $_[1]) }
8774sub recip_smtp_response # RFC 5321 response (3-digit + enhanced resp + text)
8775  { @_<2 ? shift->[14] : ($_[0]->[14] = $_[1]) }
8776sub recip_remote_mta_smtp_response  # smtp response as issued by remote MTA
8777  { @_<2 ? shift->[15] : ($_[0]->[15] = $_[1]) }
8778sub recip_remote_mta # remote MTA that issued the smtp response
8779  { @_<2 ? shift->[16] : ($_[0]->[16] = $_[1]) }
8780sub recip_tagged # message was tagged by address extension or Subject or X-Spam
8781  { @_<2 ? shift->[17] : ($_[0]->[17] = $_[1]) }
8782sub recip_mbxname    # mailbox name or file when known (local:, bsmtp: or sql:)
8783  { @_<2 ? shift->[18] : ($_[0]->[18] = $_[1]) }
8784sub recip_whitelisted_sender  # recip considers this sender whitelisted
8785  { @_<2 ? shift->[19] : ($_[0]->[19] = $_[1]) }
8786sub recip_blacklisted_sender  # recip considers this sender blacklisted
8787  { @_<2 ? shift->[20] : ($_[0]->[20] = $_[1]) }
8788sub bypass_virus_checks # boolean: virus checks to be bypassed for this recip
8789  { @_<2 ? shift->[21] : ($_[0]->[21] = $_[1]) }
8790sub bypass_banned_checks # bool: ban checks are to be bypassed for this recip
8791  { @_<2 ? shift->[22] : ($_[0]->[22] = $_[1]) }
8792sub bypass_spam_checks # boolean: spam checks are to be bypassed for this recip
8793  { @_<2 ? shift->[23] : ($_[0]->[23] = $_[1]) }
8794sub banned_parts     # banned part descriptions (ref to a list of banned parts)
8795  { @_<2 ? shift->[24] : ($_[0]->[24] = $_[1]) }
8796sub banned_parts_as_attr  # banned part descriptions - newer syntax (listref)
8797  { @_<2 ? shift->[25] : ($_[0]->[25] = $_[1]) }
8798sub banning_rule_key  # matching banned rules (lookup table keys) (ref to list)
8799  { @_<2 ? shift->[26] : ($_[0]->[26] = $_[1]) }
8800sub banning_rule_comment #comments (or whole expr) from banning_rule_key regexp
8801  { @_<2 ? shift->[27] : ($_[0]->[27] = $_[1]) }
8802sub banning_reason_short  # just one banned part leaf name with a rule comment
8803  { @_<2 ? shift->[28] : ($_[0]->[28] = $_[1]) }
8804sub banning_rule_rhs  # a right-hand side of matching rules (a ref to a list)
8805  { @_<2 ? shift->[29] : ($_[0]->[29] = $_[1]) }
8806sub mail_body_mangle  # mail body is being modified (and how) (e.g. defanged)
8807  { @_<2 ? shift->[30] : ($_[0]->[30] = $_[1]) }
8808sub contents_category # sorted listref of "major,minor" strings(category types)
8809  { @_<2 ? shift->[31] : ($_[0]->[31] = $_[1]) }
8810sub blocking_ccat   # category type most responsible for blocking msg, or undef
8811  { @_<2 ? shift->[32] : ($_[0]->[32] = $_[1]) }
8812sub user_id   # listref of recipient IDs from a lookup, e.g. SQL field users.id
8813  { @_<2 ? shift->[33] : ($_[0]->[33] = $_[1]) }
8814sub user_policy_id  # recipient's policy ID, e.g. SQL field users.policy_id
8815  { @_<2 ? shift->[34] : ($_[0]->[34] = $_[1]) }
8816sub courier_control_file # path to control file containing this recipient
8817  { @_<2 ? shift->[35] : ($_[0]->[35] = $_[1]) }
8818sub courier_recip_index # index of recipient within control file
8819  { @_<2 ? shift->[36] : ($_[0]->[36] = $_[1]) }
8820sub delivery_method # delivery method, or empty for implicit delivery (milter)
8821  { @_<2 ? shift->[37] : ($_[0]->[37] = $_[1]) }
8822sub spam_level  # spam score as returned by spam scanners, ham near 0, spam 5
8823  { @_<2 ? shift->[38] : ($_[0]->[38] = $_[1]) }
8824sub spam_tests      # a listref of r/o stringrefs, each: t1=score1,t2=score2,..
8825  { @_<2 ? shift->[39] : ($_[0]->[39] = $_[1]) }
8826# per-recipient spam info - when undefined consult a per-message counterpart
8827sub spam_report     # SA terse report of tests hit (for header section reports)
8828  { @_<2 ? shift->[40] : ($_[0]->[40] = $_[1]) }
8829sub spam_summary    # SA summary of tests hit for standard body reports
8830  { @_<2 ? shift->[41] : ($_[0]->[41] = $_[1]) }
8831
8832sub recip_final_addr {  # return recip_addr_modified if set, else recip_addr
8833  my $self = shift;
8834  my $newaddr = $self->recip_addr_modified;
8835  defined $newaddr ? $newaddr : $self->recip_addr;
8836}
8837
8838# The contents_category list is a sorted list of strings, each of the form
8839# "major" or "major,minor", where major and minor are numbers, representing
8840# major and minor category type. Sort order is descending by numeric values,
8841# major first, and subordered by a minor value. When an entry "major,minor"
8842# is added, an entry "major" is added automatically (minor implied to be 0).
8843# A string "major" means the same as "major,0". See CC_* constants for major
8844# category types. Minor category types semantics is specific to each major
8845# category, higher number represent more important finding than a lower number.
8846
8847# add new findings to the contents_category list
8848#
8849sub add_contents_category {
8850  my($self, $major,$minor) = @_;
8851  my $aref = $self->contents_category || [];
8852  # major category is always inserted, but "$major,$minor" only if minor>0
8853  if (defined $minor && $minor > 0) {  # straight insertion of "$major,$minor"
8854    my $el = sprintf("%d,%d",$major,$minor); my $j=0;
8855    for (@$aref) { if (cmp_ccat($_,$el) <= 0) { last } else { $j++ } };
8856    if ($j > $#{$aref}) { push(@$aref,$el) }  # append
8857    elsif (cmp_ccat($aref->[$j],$el) != 0) { splice(@$aref,$j,0,$el) }
8858  }
8859  # straight insertion of "$major" into an ordered array (descending order)
8860  my $el = sprintf("%d",$major); my $j=0;
8861  for (@$aref) { if (cmp_ccat($_,$el) <= 0) { last } else { $j++ } };
8862  if ($j > $#{$aref}) { push(@$aref,$el) }  # append
8863  elsif (cmp_ccat($aref->[$j],$el) != 0)
8864    { splice(@$aref,$j,0,$el) }  # insert at index $j
8865  $self->contents_category($aref);
8866}
8867
8868# is the "$major,$minor" category in the list?
8869#
8870sub is_in_contents_category {
8871  my($self, $major,$minor) = @_;
8872  my $el = sprintf('%d,%d', $major,$minor);
8873  my $aref = $self->contents_category;
8874  !defined($aref) ? undef : scalar(grep(cmp_ccat($_,$el) == 0, @$aref));
8875}
8876
8877# get a setting corresponding to the most important contents category;
8878# i.e. the highest entry from the category list for which a corresponding entry
8879# in the associative array of settings exists determines returned setting;
8880#
8881sub setting_by_main_contents_category {
8882  my($self, @settings_href_list) = @_;
8883  return undef  if !@settings_href_list;
8884  my $aref = $self->contents_category;
8885  setting_by_given_contents_category($aref, @settings_href_list);
8886}
8887
8888# get a list of settings corresponding to all relevant contents categories,
8889# sorted from the most important to the least important entry;  entries which
8890# have no corresponding setting are not included in the list
8891#
8892sub setting_by_main_contents_category_all {
8893  my($self, @settings_href_list) = @_;
8894  return undef  if !@settings_href_list;
8895  my $aref = $self->contents_category;
8896  setting_by_given_contents_category_all($aref, @settings_href_list);
8897}
8898
8899sub setting_by_blocking_contents_category {
8900  my($self, @settings_href_list) = @_;
8901  my $blocking_ccat = $self->blocking_ccat;
8902  !defined($blocking_ccat) ? undef
8903    : setting_by_given_contents_category($blocking_ccat, @settings_href_list);
8904}
8905
8906sub setting_by_contents_category {
8907  my($self, @settings_href_list) = @_;
8908  my $blocking_ccat = $self->blocking_ccat;
8909  !defined($blocking_ccat)
8910    ? $self->setting_by_main_contents_category(@settings_href_list)
8911    : setting_by_given_contents_category($blocking_ccat, @settings_href_list);
8912}
8913
89141;
8915
8916#
8917package Amavis::In::Message;
8918# this class keeps information about the message being processed
8919
8920use strict;
8921use re 'taint';
8922
8923BEGIN {
8924  require Exporter;
8925  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
8926  $VERSION = '2.412';
8927  @ISA = qw(Exporter);
8928  import Amavis::Conf qw(:platform);
8929  import Amavis::rfc2821_2822_Tools qw(rfc2822_timestamp quote_rfc2821_local
8930                                       qquote_rfc2821_local);
8931  import Amavis::Util qw(ll do_log orcpt_decode);
8932  import Amavis::In::Message::PerRecip;
8933}
8934
8935sub new
8936  { my $class = $_[0];
8937    my $self = bless({},$class); $self->skip_bytes(0); $self }
8938
8939sub conn_obj        # ref to a connection object Amavis::In::Connection
8940  { @_<2 ? shift->{conn}       : ($_[0]->{conn} = $_[1]) }
8941sub rx_time         # Unix time (s since epoch) of message reception by amavisd
8942  { @_<2 ? shift->{rx_time}    : ($_[0]->{rx_time} = $_[1]) }
8943sub partition_tag   # SQL partition tag (e.g. an ISO week number 1..53, or 0)
8944  { @_<2 ? shift->{partition}  : ($_[0]->{partition} = $_[1]) }
8945sub client_proto    # orig. client protocol, obtained from XFORWARD or milter
8946  { @_<2 ? shift->{cli_proto}  : ($_[0]->{cli_proto} = $_[1]) }
8947sub client_addr     # original client IP addr, obtained from XFORWARD or milter
8948  { @_<2 ? shift->{cli_ip}     : ($_[0]->{cli_ip} = $_[1]) }
8949sub client_name     # orig. client DNS name, obtained from XFORWARD or milter
8950  { @_<2 ? shift->{cli_name}   : (shift->{cli_name} = $_[1]) }
8951sub client_port    # orig client src port num, obtained from XFORWARD or milter
8952  { @_<2 ? shift->{cli_port}   : ($_[0]->{cli_port} = $_[1]) }
8953sub client_source   # LOCAL/REMOTE/undef, local_header_rewrite_clients/XFORWARD
8954  { @_<2 ? shift->{cli_source} : ($_[0]->{cli_source} = $_[1]) }
8955sub client_helo     # orig. client EHLO name, obtained from XFORWARD or milter
8956  { @_<2 ? shift->{cli_helo}   : ($_[0]->{cli_helo} = $_[1]) }
8957sub client_os_fingerprint  # SMTP client's OS fingerprint, obtained from p0f
8958  { @_<2 ? shift->{cli_p0f}    : ($_[0]->{cli_p0f} = $_[1]) }
8959sub originating     # originating from our users, copied from c('originating')
8960  { @_<2 ? shift->{originating}: ($_[0]->{originating} = $_[1]) }
8961sub queue_id # MTA queue ID of message if known (Courier, milter/AM.PDP, XFORW)
8962  { @_<2 ? shift->{queue_id}   : ($_[0]->{queue_id} = $_[1]) }
8963sub log_id          # task id as shown in the log, also known as am_id
8964  { @_<2 ? shift->{log_id}     : ($_[0]->{log_id} = $_[1]) }
8965sub mail_id         # long-term unique id of the message on this system
8966  { @_<2 ? shift->{mail_id}    : ($_[0]->{mail_id} = $_[1]) }
8967sub secret_id       # secret string to grant access to a message with mail_id
8968  { @_<2 ? $_[0]->{secret_id}  : ($_[0]->{secret_id} = $_[1]) }
8969sub parent_mail_id  # original mail_id for msgs generated by amavis (DSN,notif)
8970  { @_<2 ? shift->{parent_mail_id} : ($_[0]->{parent_mail_id} = $_[1]) }
8971sub attachment_password # scrambles a potentially dangerous released mail
8972  { @_<2 ? shift->{release_pwd}: ($_[0]->{release_pwd} = $_[1]) }
8973sub msg_size        # ESMTP SIZE value, later corrected to actual size,RFC 1870
8974  { @_<2 ? shift->{msg_size}   : ($_[0]->{msg_size} = $_[1]) }
8975sub auth_user       # ESMTP AUTH username
8976  { @_<2 ? shift->{auth_user}  : ($_[0]->{auth_user} = $_[1]) }
8977sub auth_pass       # ESMTP AUTH password
8978  { @_<2 ? shift->{auth_pass}  : ($_[0]->{auth_pass} = $_[1]) }
8979sub auth_submitter  # ESMTP MAIL command AUTH option value (addr-spec or "<>")
8980  { @_<2 ? shift->{auth_subm}  : (shift->{auth_subm} = $_[1]) }
8981sub tls_cipher      # defined if TLS was on, e.g. contains cipher alg.,RFC 3207
8982  { @_<2 ? shift->{auth_tlscif}: ($_[0]->{auth_tlscif} = $_[1]) }
8983sub dsn_ret         # ESMTP MAIL command RET option   (DSN-RFC 3461)
8984  { @_<2 ? shift->{dsn_ret}    : ($_[0]->{dsn_ret} = $_[1]) }
8985sub dsn_envid       # ESMTP MAIL command ENVID option (DSN-RFC 3461) xtext enc.
8986  { @_<2 ? shift->{dsn_envid}  : ($_[0]->{dsn_envid} = $_[1]) }
8987sub dsn_passed_on   # obligation to send notification on SUCCESS was relayed
8988  { @_<2 ? shift->{dsn_pass_on}: ($_[0]->{dsn_pass_on} = $_[1]) }
8989sub requested_by    # Resent-From addr who requested release from a quarantine
8990  { @_<2 ? shift->{requested_by}:($_[0]->{requested_by} = $_[1])}
8991sub body_type       # ESMTP BODY param (RFC 6152: 7BIT, 8BITMIME) or BINARYMIME
8992  { @_<2 ? shift->{body_type}  : ($_[0]->{body_type} = $_[1]) }
8993sub smtputf8        # ESMTP SMTPUTF8 param, boolean (RFC 6531)
8994  { @_<2 ? shift->{smtputf8}   : ($_[0]->{smtputf8} = $_[1]) }
8995sub header_8bit     # true if header contains non-ASCII characters
8996  { @_<2 ? shift->{header_8bit}: ($_[0]->{header_8bit} = $_[1]) }
8997sub body_8bit       # true if body contains non-ASCII characters
8998  { @_<2 ? shift->{body_8bit}  : ($_[0]->{body_8bit} = $_[1]) }
8999sub sender          # envelope sender, internal form, e.g.: j doe@example.com
9000  { @_<2 ? $_[0]->{sender}     : ($_[0]->{sender} = $_[1]) }
9001sub sender_smtp     # env sender, SMTP form in <>, e.g.: <"j doe"@example.com>
9002  { @_<2 ? shift->{sender_smtp}: ($_[0]->{sender_smtp} = $_[1]) }
9003sub sender_credible # envelope sender is believed to be valid
9004  { @_<2 ? shift->{sender_cred}: ($_[0]->{sender_cred} = $_[1]) }
9005sub sender_source   # unmangled sender addr. or info from the trace (log/notif)
9006  { @_<2 ? shift->{sender_src} : ($_[0]->{sender_src} = $_[1]) }
9007sub sender_maddr_id # maddr.id field from SQL if logging to SQL is enabled
9008  { @_<2 ? shift->{maddr_id}   : ($_[0]->{maddr_id} = $_[1]) }
9009sub mime_entity     # MIME::Parser entity holding the parsed message
9010  { @_<2 ? shift->{mime_entity}: (shift->{mime_entity} = $_[1])}
9011sub parts_root      # Amavis::Unpackers::Part root object
9012  { @_<2 ? shift->{parts_root} : ($_[0]->{parts_root} = $_[1])}
9013sub skip_bytes      # file offset where mail starts, useful for quar. release
9014  { @_<2 ? shift->{file_ofs}   : ($_[0]->{file_ofs} = $_[1]) }
9015sub mail_text       # RFC 5322 msg: open file handle, or MIME::Entity object
9016  { @_<2 ? shift->{mail_text}  : ($_[0]->{mail_text} = $_[1]) }
9017sub mail_text_str   # RFC 5322 msg: small messages as a stringref, else undef
9018  { @_<2 ? shift->{mailtextstr}: ($_[0]->{mailtextstr} = $_[1]) }
9019sub mail_text_fn    # orig. mail filename or undef, e.g. mail_tempdir/email.txt
9020  { @_<2 ? shift->{mailtextfn} : ($_[0]->{mailtextfn} = $_[1]) }
9021sub mail_tempdir    # work directory, under $TEMPBASE or supplied by client
9022  { @_<2 ? shift->{mailtempdir}: ($_[0]->{mailtempdir} = $_[1])}
9023sub mail_tempdir_obj # Amavis::TempDir obj when non-persistent (quar.release)
9024  { @_<2 ? shift->{tempdirobj}: ($_[0]->{tempdirobj} = $_[1])}
9025sub header_edits    # Amavis::Out::EditHeader object or undef
9026  { @_<2 ? shift->{hdr_edits}  : ($_[0]->{hdr_edits} = $_[1]) }
9027sub rfc2822_from #author addresses list (rfc allows one or more), parsed 'From'
9028  { @_<2 ? $_[0]->{hdr_from}   : ($_[0]->{hdr_from} = $_[1]) }
9029sub rfc2822_sender  # sender address (rfc allows none or one), parsed 'Sender'
9030  { @_<2 ? shift->{hdr_sender} : ($_[0]->{hdr_sender} = $_[1]) }
9031sub rfc2822_resent_from # resending author addresses list, parsed 'Resent-From'
9032  { @_<2 ? shift->{hdr_rfrom}  : ($_[0]->{hdr_rfrom} = $_[1]) }
9033sub rfc2822_resent_sender  # resending sender addresses, parsed 'Resent-Sender'
9034  { @_<2 ? shift->{hdr_rsender}: ($_[0]->{hdr_rsender} = $_[1]) }
9035sub rfc2822_to      # parsed 'To' header field: a list of recipients
9036  { @_<2 ? shift->{hdr_to}     : ($_[0]->{hdr_to} = $_[1]) }
9037sub rfc2822_cc      # parsed 'Cc' header field: a list of Cc recipients
9038  { @_<2 ? shift->{hdr_cc}     : (shift->{hdr_cc} = $_[1]) }
9039sub orig_header_fields # header field indices by h.f. name, hashref of arrays
9040  { @_<2 ? shift->{orig_hdr_f} : ($_[0]->{orig_hdr_f} = $_[1]) }
9041sub orig_header # orig.h.sect, arrayref of h.fields, with folding & trailing LF
9042  { @_<2 ? shift->{orig_header}: ($_[0]->{orig_header} = $_[1]) }
9043sub orig_header_size # size of original header, incl. a separator line,RFC 1870
9044  { @_<2 ? shift->{orig_hdr_s} : ($_[0]->{orig_hdr_s} = $_[1]) }
9045sub references  # References & In-Reply-To message IDs, array
9046  { @_<2 ? shift->{refs}       : ($_[0]->{refs} = $_[1]) }
9047sub orig_body_size  # size of original body (in bytes), RFC 1870
9048  { @_<2 ? shift->{orig_bdy_s} : ($_[0]->{orig_bdy_s} = $_[1]) }
9049sub body_start_pos  # byte offset into a msg where mail body starts (if known)
9050  { @_<2 ? shift->{body_pos}: ($_[0]->{body_pos} = $_[1]) }
9051sub body_digest     # digest of a message body (e.g. MD5, SHA1, SHA256), hex
9052  { @_<2 ? shift->{body_digest}: ($_[0]->{body_digest} = $_[1]) }
9053sub trace  # info from Received header fields, top-down, array of hashrefs
9054  { @_<2 ? shift->{trace}      : ($_[0]->{trace} = $_[1]) }
9055sub ip_addr_trace_public  # public IP addresses in 'Received from' hdr flds
9056  { @_<2 ? shift->{iptracepub} : ($_[0]->{iptracepub} = $_[1]) }
9057sub is_mlist        # mail is from a mailing list (boolean/string)
9058  { @_<2 ? shift->{is_mlist}   : ($_[0]->{is_mlist} = $_[1]) }
9059sub is_auto         # mail is an auto-response (boolean/string)
9060  { @_<2 ? shift->{is_auto}    : ($_[0]->{is_auto} = $_[1]) }
9061sub is_bulk         # mail from a m.list or bulk or auto-response (bool/string)
9062  { @_<2 ? $_[0]->{is_bulk}    : ($_[0]->{is_bulk} = $_[1]) }
9063sub dkim_signatures_all # a ref to a list of DKIM signature objects, or undef
9064  { @_<2 ? shift->{dkim_sall}  : ($_[0]->{dkim_sall} = $_[1]) }
9065sub dkim_signatures_valid # a ref to a list of valid DKIM signature objects
9066  { @_<2 ? shift->{dkim_sval}  : ($_[0]->{dkim_sval} = $_[1]) }
9067sub dkim_author_sig # author domain signature present and valid (bool/domain)
9068  { @_<2 ? shift->{dkim_auth_s}: ($_[0]->{dkim_auth_s} = $_[1]) }
9069sub dkim_thirdparty_sig # third-party signature present and valid (bool/domain)
9070  { @_<2 ? shift->{dkim_3rdp_s}: ($_[0]->{dkim_3rdp_s} = $_[1]) }
9071sub dkim_sender_sig # a sender signature is present and is valid (bool/domain)
9072  { @_<2 ? shift->{dkim_sndr_s}: (shift->{dkim_sndr_s} = $_[1]) }
9073sub dkim_envsender_sig # boolean: envelope sender signature present and valid
9074  { @_<2 ? shift->{dkim_envs_s}: ($_[0]->{dkim_envs_s} = $_[1]) }
9075sub dkim_signatures_new # ref to a list of DKIM signature objects, our signing
9076  { @_<2 ? shift->{dkim_snew}  : ($_[0]->{dkim_snew} = $_[1]) }
9077sub dkim_signwith_sd # ref to a pair [selector,domain] to force signing with
9078  { @_<2 ? shift->{dkim_signsd}: ($_[0]->{dkim_signsd} = $_[1]) }
9079sub quarantined_to  # list of quar mailbox names or addresses if quarantined
9080  { @_<2 ? shift->{quarantine} : ($_[0]->{quarantine} = $_[1]) }
9081sub quar_type  # list of quar types: F/Z/B/Q/M (file/zipfile/bsmtp/sql/mailbox)
9082  { @_<2 ? shift->{quar_type}  : ($_[0]->{quar_type} = $_[1]) }
9083sub dsn_sent        # delivery status notification was sent(1) or suppressed(2)
9084  { @_<2 ? shift->{dsn_sent}   : ($_[0]->{dsn_sent} = $_[1]) }
9085sub client_delete   # don't delete the tempdir, it is a client's responsibility
9086  { @_<2 ? shift->{client_del} :($_[0]->{client_del} = $_[1])}
9087sub contents_category # sorted arrayref CC_VIRUS/CC_BANNED/CC_SPAM../CC_CLEAN
9088  { @_<2 ? shift->{category}   : ($_[0]->{category} = $_[1]) }
9089sub blocking_ccat   # category type most responsible for blocking msg, or undef
9090  { @_<2 ? $_[0]->{bl_ccat}    : ($_[0]->{bl_ccat} = $_[1]) }
9091sub checks_performed  # a hashref of checks done on a msg (for statistics/log)
9092  { @_<2 ? shift->{checks_perf}: ($_[0]->{checks_perf} = $_[1]) }
9093sub actions_performed  # listref, summarized actions & SMTP status, for logging
9094  { @_<2 ? shift->{act_perf}   : ($_[0]->{act_perf} = $_[1]) }
9095sub virusnames      # a ref to a list of virus names detected, or undef
9096  { @_<2 ? shift->{virusnames} : ($_[0]->{virusnames} = $_[1]) }
9097sub spam_report     # SA terse report of tests hit (for header section reports)
9098  { @_<2 ? shift->{spam_report}   : ($_[0]->{spam_report} = $_[1])}
9099sub spam_summary    # SA summary of tests hit for standard body reports
9100  { @_<2 ? shift->{spam_summary}  :($_[0]->{spam_summary} = $_[1])}
9101sub ip_repu_score   # IP reputation score (info, also added to spam_level)
9102  { @_<2 ? shift->{ip_repu_score} :($_[0]->{ip_repu_score} = $_[1])}
9103sub time_elapsed    # elapsed times by section - associative array ref
9104  { @_<2 ? shift->{elapsed}       : ($_[0]->{elapsed} = $_[1])}
9105
9106# new style of providing additional information from checkers
9107sub supplementary_info {  # holds a hash of tag/value pairs, such as SA get_tag
9108  my $self=shift; my $key=shift;
9109  !@_ ? $self->{info_tag}{$key} : ($self->{info_tag}{$key}=shift);
9110}
9111
9112{ no warnings 'once';
9113# the following methods apply on a per-message level as well, summarizing
9114# per-recipient information as far as possible
9115  *add_contents_category =
9116    \&Amavis::In::Message::PerRecip::add_contents_category;
9117  *is_in_contents_category =
9118    \&Amavis::In::Message::PerRecip::is_in_contents_category;
9119  *setting_by_main_contents_category =
9120    \&Amavis::In::Message::PerRecip::setting_by_main_contents_category;
9121  *setting_by_main_contents_category_all =
9122    \&Amavis::In::Message::PerRecip::setting_by_main_contents_category_all;
9123  *setting_by_blocking_contents_category =
9124    \&Amavis::In::Message::PerRecip::setting_by_blocking_contents_category;
9125  *setting_by_contents_category =
9126    \&Amavis::In::Message::PerRecip::setting_by_contents_category;
9127}
9128
9129# The order of entries in a per-recipient list is the original order
9130# in which recipient addresses (e.g. obtained via 'MAIL TO:') were received.
9131# Only the entries that were accepted (via SMTP response code 2xx)
9132# are placed in the list. The ORDER MUST BE PRESERVED and no recipients
9133# may be added or removed from the list (without precaution)! This is vital
9134# to be able to produce correct per-recipient responses to an LMTP client!
9135#
9136sub per_recip_data {  # get or set a listref of envelope recipient objects
9137  my $self = shift;
9138  # store a copy of the a given listref of recip objects
9139  if (@_) { $self->{recips} = [@{$_[0]}] }
9140  # caller may modify data if he knows what he is doing
9141  $self->{recips};    # return a list of recipient objects
9142}
9143
9144sub recips {          # get or set a listref of envelope recipients
9145  my $self = shift;
9146  if (@_) {  # store a copy of a given listref of recipient addresses
9147    my($recips_list_ref, $set_dsn_orcpt_too) = @_;
9148    $self->per_recip_data([ map {
9149      my $per_recip_obj = Amavis::In::Message::PerRecip->new;
9150      $per_recip_obj->recip_addr($_);
9151      $per_recip_obj->recip_addr_smtp(qquote_rfc2821_local($_));
9152      $per_recip_obj->dsn_orcpt(
9153        join(';', orcpt_decode(';'.$per_recip_obj->recip_addr_smtp)))
9154        if $set_dsn_orcpt_too;
9155      $per_recip_obj->recip_destiny(D_PASS);  # default is Pass
9156      $per_recip_obj } @{$recips_list_ref} ]);
9157  }
9158  return  if !defined wantarray;  # don't bother
9159  # return listref of recipient addresses
9160  [ map($_->recip_addr, @{$self->per_recip_data}) ];
9161}
9162
9163# for each header field maintain a list of signature indices which covered it;
9164# returns a list of signature indices for a given header field position
9165#
9166sub header_field_signed_by {
9167  my($self,$header_field_index) = @_;
9168  my $h = $self->{hdr_sig_ind};  my $hf;
9169  if (@_ > 2) {
9170    $self->{hdr_sig_ind} = $h = []  if !$h;
9171    $hf = $h->[$header_field_index];
9172    $h->[$header_field_index] = $hf = []  if !$hf;
9173    # store signature index(es) at a given header position
9174    shift; shift; push(@$hf, @_);
9175  }
9176  $hf = $h->[$header_field_index]  if $h && !$hf;
9177  $hf ? @{$hf} : ();
9178}
9179
9180# return a j-th header field with a given field name, along with its index
9181# in the array of all header fields; if a field name is undef then all
9182# header fields are considered; search proceeds top-down if j >= 0,
9183# or bottom up for negative values (-1=last, -2=next-to-last, ...)
9184#
9185sub get_header_field2 {
9186  my($self, $field_name, $j) = @_;
9187  my $orig_hfields = $self->orig_header_fields;
9188  return if !$orig_hfields;
9189  my($field_ind, $field, $all_fields, $hfield_indices);
9190  # arrayref of header field indices for a given h.field name
9191  $hfield_indices = $orig_hfields->{lc $field_name}  if defined $field_name;
9192  $all_fields = $self->orig_header;
9193  if (defined $field_name) {
9194    if (!defined $hfield_indices) {
9195      # no header field with such name
9196    } elsif (ref $hfield_indices) {
9197      # $hfield_indices is an arrayref
9198      $j = 0  if !defined $j;
9199      $field_ind = $hfield_indices->[$j];
9200    } else {
9201      # optimized: $hfield_indices is a scalar - the only element
9202      $field_ind = $hfield_indices  if !defined($j) || $j == 0 || $j == -1;
9203    }
9204  } elsif (!ref $all_fields) {
9205    # no header section
9206  } elsif ($j >= 0) {  # top-down, 0,1,2,...
9207    $field_ind = $j  if $j <= $#$all_fields;
9208  } else {  # bottom-up, -1,-2,-3,...
9209    $j += @$all_fields;  # turn into an absolute index
9210    $field_ind = $j  if $j >= 0;
9211  }
9212  return $field_ind  if !wantarray;
9213  ($field_ind, !defined $field_ind ? undef : $all_fields->[$field_ind]);
9214}
9215
9216# compatibility wrapper for pre-2.8.0 custom code
9217#
9218sub get_header_field {
9219  my($self, $field_name, $j) = @_;
9220  my($field_ind, $field) = $self->get_header_field2($field_name,$j);
9221  if (defined($field_ind) && wantarray) {
9222    local $1;
9223    $field_name = lc($1)  if $field =~ /^([^:]*?)[ \t]*:/s;
9224  }
9225  !wantarray ? $field_ind : ($field_ind, $field_name, $field);
9226}
9227
9228sub get_header_field_body {
9229  my($self, $field_name, $j) = @_;
9230  my $k;  my($field_ind, $f) = $self->get_header_field2($field_name,$j);
9231  defined $f && ($k=index($f,':')) >= 0 ? substr($f,$k+1) : $f;
9232}
9233
92341;
9235
9236#
9237package Amavis::Out::EditHeader;
9238
9239# Accumulates instructions on what header fields need to be added
9240# to a header section, which deleted, or how to change existing ones.
9241# A call to write_header() then performs these edits on the fly.
9242
9243use strict;
9244use re 'taint';
9245
9246BEGIN {
9247  require Exporter;
9248  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
9249  $VERSION = '2.412';
9250  @ISA = qw(Exporter);
9251  @EXPORT_OK = qw(&hdr);
9252  import Amavis::Conf qw(:platform c cr ca);
9253  import Amavis::Timing qw(section_time);
9254  import Amavis::rfc2821_2822_Tools qw(wrap_string);
9255  import Amavis::Util qw(ll do_log min max q_encode
9256                         safe_encode safe_encode_utf8_inplace);
9257}
9258use Errno qw(EBADF);
9259use Encode ();
9260use MIME::Words;
9261
9262sub new {
9263  my $class = $_[0];
9264  bless { prepend=>[], append=>[], addrcvd=>[], edit=>{} }, $class;
9265}
9266
9267sub prepend_header {
9268  my $self = shift;
9269  unshift(@{$self->{prepend}}, hdr(@_));
9270}
9271
9272sub append_header {
9273  my $self = shift;
9274  push(@{$self->{append}}, hdr(@_));
9275}
9276
9277sub append_header_above_received {
9278  my $self = shift;
9279  push(@{$self->{addrcvd}}, hdr(@_));
9280}
9281
9282# now a synonym for append_header_above_received()  (old semantics: prepend
9283# or append, depending on setting of $append_header_fields_to_bottom)
9284#
9285sub add_header {
9286  my $self = shift;
9287  push(@{$self->{addrcvd}}, hdr(@_));
9288}
9289
9290# delete all header fields with a $field_name
9291#
9292sub delete_header {
9293  my($self, $field_name) = @_;
9294  $self->{edit}{lc $field_name} = [undef];
9295}
9296
9297# all header fields with $field_name will be edited by a supplied subroutine
9298#
9299sub edit_header {
9300  my($self, $field_name, $field_edit_sub) = @_;
9301  # $field_edit_sub will be called with 2 args: a field name and a field body;
9302  # It should return a pair consisting of a replacement field body (no field
9303  # name and no colon, with or without a trailing NL), and a boolean 'verbatim'
9304  # (false in its absence). An undefined replacement field body indicates a
9305  # deletion of the entire header field. A value true in the second returned
9306  # element indicates that a verbatim replacement is desired (i.e. no other
9307  # changes are allowed on a replacement body such as folding or encoding).
9308  !defined($field_edit_sub) || ref($field_edit_sub) eq 'CODE'
9309    or die "edit_header: arg#3 must be undef or a subroutine ref";
9310  $field_name = lc $field_name;
9311  if (!exists($self->{edit}{$field_name})) {
9312    $self->{edit}{$field_name} = [$field_edit_sub];
9313  } else {
9314    do_log(5, "INFO: multiple header edits: %s", $field_name);
9315    push(@{$self->{edit}{$field_name}}, $field_edit_sub);
9316  }
9317}
9318
9319# copy all header edits from another header-edits object into this one
9320#
9321sub inherit_header_edits($$) {
9322  my($self, $other_edits) = @_;
9323  if (defined $other_edits) {
9324    for (qw(prepend addrcvd append)) {
9325      unshift(@{$self->{$_}}, @{$other_edits->{$_}})  if $other_edits->{$_};
9326    }
9327    my $o_edit = $other_edits->{edit};
9328    if ($o_edit) {
9329      for my $fn (keys %$o_edit) {
9330        if (!exists($self->{edit}{$fn})) {
9331          $self->{edit}{$fn} = [ @{$o_edit->{$fn}} ];  # copy list
9332        } else {
9333          unshift(@{$self->{edit}{$fn}}, @{$o_edit->{$fn}});
9334        }
9335      }
9336    }
9337  }
9338}
9339
9340# Conditioning of a header field to be added.
9341# Insert space after colon if not present, RFC 2047 -encode if field body
9342# contains non-ASCII characters, fold long lines if needed, prepend space
9343# before each NL if missing, append NL if missing. Header lines with only
9344# spaces are not allowed. (RFC 5322: Each line of characters MUST be no more
9345# than 998 octets(!) (RFC 6532), and SHOULD be no more than 78 characters(!)
9346# (RFC 6532), excluding the CRLF). $structured==0 indicates an unstructured
9347# header field, folding may be inserted at any existing whitespace character
9348# position; $structured==1 indicates that folding is only allowed at positions
9349# indicated by \n in the provided header body, original \n will be removed.
9350# With $structured==2 folding is preserved, wrapping step is skipped.
9351#
9352sub hdr {
9353  my($field_name, $field_body, $structured, $wrap_char, $smtputf8) = @_;
9354  $wrap_char = "\t"  if !defined $wrap_char;
9355  safe_encode_utf8_inplace($field_name);  # to octets (if not already)
9356  $field_name =~ tr/\x21-\x39\x3B-\x7E/?/c;  # printable ASCII except ':'
9357  my $field_body_is_utf8 = utf8::is_utf8($field_body);
9358  local($1);
9359  if ($field_body !~ tr/\x00-\x7F//c) {  # is all-ASCII
9360    # no encoding necessary, just clear the utf8 flag if set
9361    if ($field_body_is_utf8) {
9362      do_log(5,'header encoded (utf8:Y) (all-ASCII): %s: %s',
9363               $field_name, $field_body);
9364      safe_encode_utf8_inplace($field_body);  # to octets (if not already)
9365    } else {
9366      do_log(5,'header encoded (all-ASCII): %s: %s', $field_name, $field_body);
9367    }
9368  } elsif ($smtputf8) {  # UTF-8 in header field bodies is allowed
9369    safe_encode_utf8_inplace($field_body)  if $field_body_is_utf8;
9370    ll(5) && do_log(5,'header encoded (utf8:%s) to UTF-8 (SMTPUTF8): %s: %s',
9371                      $field_body_is_utf8?'Y':'N', $field_name, $field_body);
9372  } elsif ($field_name =~ /^(?: Subject | Comments |
9373                            (?:Resent-)? (?: From|Sender|To|Cc ) )\z/six &&
9374           $field_body !~ /^[\t\n\x20-\x7F]*\z/  # but printable or HT or LF
9375       # consider also:  | X- (?! Envelope- (?:From|To)\z )
9376  ) {  # encode according to RFC 2047
9377    # actually RFC 2047 also allows encoded-words in rfc822 extension
9378    # message header fields (now: optional header fields), within comments
9379    # in structured header fields, or within 'phrase' (e.g. in From, To, Cc);
9380    # we are being sloppy here!
9381    $field_body =~ s/\n(?=[ \t])//gs;  # unfold
9382    chomp($field_body);
9383    my $chset = c('hdr_encoding');
9384    my $field_body_octets = safe_encode($chset, $field_body);
9385    ll(5) && do_log(5,'header encoded (utf8:%s) to %s, %s: %s -> %s',
9386                      $field_body_is_utf8?'Y':'N', $chset,
9387                      $field_name, $field_body, $field_body_octets);
9388    my $qb = c('hdr_encoding_qb');
9389    my $encoder_func = uc $qb eq 'Q' ? \&q_encode
9390                                     : \&MIME::Words::encode_mimeword;
9391    $field_body = join("\n", map { /^[\001-\011\013\014\016-\177]*\z/ ? $_
9392                                     : &$encoder_func($_,$qb,$chset) }
9393                                 split(/\n/, $field_body_octets, -1));
9394  } else {  # should have been all-ASCII, or UTF-8 with SMTPUTF8 - but anyway:
9395    safe_encode_utf8_inplace($field_body)  if $field_body_is_utf8;
9396    ll(5) && do_log(5,'header encoded (utf8:%s) to UTF-8: %s: %s',
9397                      $field_body_is_utf8?'Y':'N', $field_name, $field_body);
9398  }
9399  my $str = $field_name . ':';
9400  $str .= ' '  if $field_body =~ /^[^ \t]/;  # insert space, looks nicer
9401  $str .= $field_body;
9402  if ($structured == 2) {  # already folded, keep it that way, sanitize
9403    1 while $str =~ s/^([ \t]*)\n/$1/;  # prefixed by whitespace lines?
9404    $str =~ s/\n(?=[ \t]*(\n|\z))//g;   # whitespace lines within or at end
9405    $str =~ s/\n(?![ \t])/\n /g;  # insert a space at line folds if missing
9406  } else {
9407    $wrap_char = "\t"  if !defined $wrap_char;
9408    $str = wrap_string($str, 78, '', $wrap_char, $structured
9409                      )  if $structured==1 || length($str) > 78;
9410  }
9411  if (length($str) > 998) {
9412    my(@lines) = split(/\n/,$str);  my $trunc = 0;
9413    for (@lines) {
9414      if (length($_) > 998) { substr($_,998-3) = '...'; $trunc = 1 }
9415    }
9416    if ($trunc) {
9417      do_log(0, "INFO: truncating long header field (len=%d): %s[...]",
9418             length($str), substr($str,0,100) );
9419      $str = join("\n",@lines);
9420    }
9421  }
9422  $str =~ s{\n*\z}{\n}s;  # ensure a single final NL
9423  ll(5) && do_log(5, 'header: %s', $str);
9424  $str;
9425}
9426
9427# Copy mail header section to the supplied method while adding, removing,
9428# or changing certain header fields as required, and append an empty line
9429# (header/body separator). Returns a number of original 'Received:'
9430# header fields to make a simple loop detection possible (as required
9431# by RFC 5321 (ex RFC 2821) section 6.3).
9432# Leaves input file positioned at the beginning of a body.
9433#
9434sub write_header($$$$) {
9435  my($self, $msginfo, $out_fh, $noninitial_submission) = @_;
9436  my $received_cnt = 0;
9437  my($fix_whitespace_lines, $fix_long_header_lines, $fix_bare_cr) = (0,0,0);
9438  if ($noninitial_submission && c('allow_fixing_improper_header')) {
9439    $fix_bare_cr = 1;
9440    $fix_long_header_lines = 1  if c('allow_fixing_long_header_lines');
9441    $fix_whitespace_lines  = 1  if c('allow_fixing_improper_header_folding');
9442  }
9443  my(@header); my $pos = 0; my $header_in_array = 0;
9444  my $msg = $msginfo->mail_text;
9445  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
9446  $msg = $msg_str_ref  if ref $msg_str_ref;
9447  if (!defined $msg) {
9448    # empty mail
9449    $header_in_array = 1;
9450  } elsif (ref $msg eq 'SCALAR') {
9451    $header_in_array = 1;
9452    $pos = min($msginfo->skip_bytes, length($$msg));
9453    if ($pos >= length($$msg)) {  # empty message
9454      $pos = length($$msg);
9455    } elsif (substr($$msg,$pos,1) eq "\n") {  # empty header section
9456      $pos++;
9457    } else {
9458      my $ind = index($$msg, "\n\n", $pos);  # find header/body separator
9459      if ($ind < 0) {  # no body
9460        @header = split(/^/m, substr($$msg, $pos));
9461        $pos = length($$msg);
9462      } else {  # normal, nonempty header section and nonempty body
9463        @header = split(/^/m, substr($$msg, $pos, $ind+1-$pos));
9464        $pos = $ind+2;
9465      }
9466    }
9467    # $pos now points to the first byte of a body
9468  } elsif ($msg->isa('MIME::Entity')) {
9469    $header_in_array = 1;
9470    $fix_whitespace_lines = 1;  # fix MIME::Entity artifacts
9471    @header = @{$msg->header};
9472  } else {  # a file handle assumed
9473    $pos = $msginfo->skip_bytes;
9474    $msg->seek($pos,0)  or die "Can't rewind mail file: $!";
9475  }
9476  ll(5) && do_log(5, 'write_header: %s, %s', $header_in_array, $out_fh);
9477  # preallocate some storage
9478  my $str = ''; vec($str,8192,8) = 0; $str = '';
9479  $str .= $_  for @{$self->{prepend}};
9480  $str .= $_  for @{$self->{addrcvd}};
9481  my($ill_white_cnt, $ill_long_cnt, $ill_bare_cr) = (0,0,0);
9482  local($1,$2); my $curr_head; my $next_head; my $eof = 0;
9483  for (;;) {
9484    if ($eof) {
9485      $next_head = "\n";  # fake a missing header/body separator line
9486    } elsif ($header_in_array) {
9487      for (;;) {  # get next nonempty line or eof
9488        if (!@header) { $eof = 1; $next_head = "\n"; last }
9489        $next_head = shift @header;
9490        # ensure NL at end, faster than m/\n\z/
9491        $next_head .= "\n"  if substr($next_head,-1,1) ne "\n";
9492        last  if !$fix_whitespace_lines || $next_head !~ /^[ \t]*\n\z/s;
9493        $ill_white_cnt++;
9494      }
9495    } else {
9496      $! = 0; $next_head = $msg->getline;
9497      if (defined $next_head) {
9498        $pos += length($next_head);
9499      } else {
9500        $eof = 1; $next_head = "\n";
9501        $! == 0  or                # returning EBADF at EOF is a perl bug
9502          $! == EBADF ? do_log(0,"Error reading mail header section: $!")
9503                      : die "Error reading mail header section: $!";
9504      }
9505    }
9506    if ($next_head =~ /^[ \t]/) {
9507      $curr_head .= $next_head;  # folded
9508    } else {  # new header field
9509      if (!defined($curr_head)) {
9510        # no previous complete header field (we are at the first hdr field)
9511      } elsif ($curr_head !~ /^([!-9;-\176]+)[ \t]*:(.*)\z/s) {  # parse
9512        # invalid header field, but we'll write it anyway
9513      } else {  # count, edit, or delete
9514        # obsolete RFC 822 syntax allowed whitespace before colon
9515        my($field_name, $field_body) = ($1, $2);
9516        my $field_name_lc = lc $field_name;
9517        $received_cnt++  if $field_name_lc eq 'received';
9518        if (exists($self->{edit}{$field_name_lc})) {
9519          chomp($field_body);
9520          ### $field_body =~ s/\n(?=[ \t])//gs;  # unfold
9521          my $edit = $self->{edit}{$field_name_lc};  # listref of edits
9522          for my $e (@$edit) {  # possibly multiple (iterative) edits
9523            my($new_fbody,$verbatim);
9524            ($new_fbody,$verbatim) =
9525              &$e($field_name,$field_body)  if defined $e;
9526            if (!defined($new_fbody)) {
9527              ll(5) && do_log(5, 'deleted: %s:%s', $field_name, $field_body);
9528              $curr_head = undef; last;
9529            }
9530            $curr_head = $verbatim ? ($field_name . ':' . $new_fbody)
9531                                   : hdr($field_name, $new_fbody, 0, undef,
9532                                         $msginfo->smtputf8);
9533            chomp($curr_head); $curr_head .= "\n";
9534            $curr_head =~ /^([!-9;-\176]+)[ \t]*:(.*)\z/s;
9535            $field_body = $2; chomp($field_body);  # carry to next iteration
9536          }
9537        }
9538      }
9539      if (defined $curr_head) {
9540        if ($fix_bare_cr) { # sanitize header sect. by removing CR characters
9541          $curr_head =~ tr/\r//d  and $ill_bare_cr++;
9542        }
9543        if ($fix_whitespace_lines) {  # unfold illegal all-whitespace lines
9544          $curr_head =~ s/\n(?=[ \t]*\n)//g  and $ill_white_cnt++;
9545        }
9546        if ($fix_long_header_lines) {  # truncate long header lines to 998 ch
9547          $curr_head =~ s{^(.{995}).{4,}$}{$1...}gm  and $ill_long_cnt++;
9548        }
9549        # use buffering to reduce number of calls to datasend()
9550        if (length($str) > 16384) {
9551          $out_fh->print($str) or die "sending mail header: $!";
9552          $str = '';
9553        }
9554        $str .= $curr_head;
9555      }
9556      last  if $next_head eq "\n";   # header/body separator
9557      last  if substr($next_head,0,2) eq '--';  # mime sep. (missing h/b sep.)
9558      $curr_head = $next_head;
9559    }
9560  }
9561  do_log(0, "INFO: unfolded %d illegal all-whitespace ".
9562            "continuation lines", $ill_white_cnt)  if $ill_white_cnt;
9563  do_log(0, "INFO: truncated %d header line(s) longer than 998 characters",
9564            $ill_long_cnt)  if $ill_long_cnt;
9565  do_log(0, "INFO: removed bare CR from %d header line(s)",
9566            $ill_bare_cr)  if $ill_bare_cr;
9567  $str .= $_  for @{$self->{append}};
9568  $str .= "\n";  # end of header section - a separator line
9569  $out_fh->print($str) or die "sending mail header final: $!";
9570  section_time('write-header');
9571  ($received_cnt, $pos);
9572}
95731;
9574
9575#
9576package Amavis::Out;
9577use strict;
9578use re 'taint';
9579
9580BEGIN {
9581  require Exporter;
9582  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
9583  $VERSION = '2.412';
9584  @ISA = qw(Exporter);
9585  @EXPORT = qw(&mail_dispatch);
9586  import Amavis::Conf qw(:platform :confvars c cr ca);
9587  import Amavis::Util qw(ll do_log);
9588}
9589
9590sub mail_dispatch($$$;$) {
9591  my($msginfo, $initial_submission, $dsn_per_recip_capable, $filter) = @_;
9592
9593  my $tmp_hdr_edits;
9594  my $saved_hdr_edits = $msginfo->header_edits;
9595  if (!c('enable_dkim_signing')) {
9596    # no signing
9597  } elsif ($initial_submission && $initial_submission eq 'Quar') {
9598    # do not attempt to sign messages on their way to a quarantine
9599  } else {
9600    # generate and add DKIM signatures
9601    my(@signatures) = Amavis::DKIM::dkim_make_signatures($msginfo,0);
9602    if (@signatures) {
9603      $msginfo->dkim_signatures_new(\@signatures);
9604      if (!defined($tmp_hdr_edits)) {
9605        $tmp_hdr_edits = Amavis::Out::EditHeader->new;
9606        $tmp_hdr_edits->inherit_header_edits($saved_hdr_edits);
9607      }
9608      for my $signature (@signatures) {
9609        my $s = $signature->as_string;
9610        local($1); $s =~ s{\015\012}{\n}gs; $s =~ s{\n+\z}{}gs;
9611        $s =~ s/^((?:DKIM|DomainKey)-Signature)://si;
9612        $tmp_hdr_edits->prepend_header($1, $s, 2);
9613      }
9614      if (c('enable_dkim_verification') &&
9615          grep($_->recip_is_local, @{$msginfo->per_recip_data})) {
9616        # it is too late to split a message now, add the A-R header field
9617        # if at least one recipient is local
9618        my $allowed_hdrs = cr('allowed_added_header_fields');
9619        if ($allowed_hdrs && $allowed_hdrs->{lc('Authentication-Results')}) {
9620          for my $h (Amavis::DKIM::generate_authentication_results(
9621                                                $msginfo, 0, \@signatures)) {
9622            $tmp_hdr_edits->prepend_header('Authentication-Results', $h, 1);
9623          }
9624        }
9625      }
9626    }
9627    $msginfo->header_edits($tmp_hdr_edits)  if defined $tmp_hdr_edits;
9628  }
9629
9630  my $any_deliveries = 0;
9631  my $per_recip_data = $msginfo->per_recip_data;
9632  my $num_recips_notdone =
9633    scalar(grep(!$_->recip_done && (!$filter || &$filter($_)),
9634                @$per_recip_data));
9635  while ($num_recips_notdone > 0) {
9636    # a delivery method may be a scalar of a form protocol:socket_specs, or
9637    # a listref of such elements; if a list is provided, it is expected that
9638    # each entry will be using the same protocol name, otherwise behaviour
9639    # is unspecified - so just obtain the protocol name from the first entry
9640    #
9641    my(%protocols, $any_tempfail);
9642    for my $r (@$per_recip_data) {
9643      if (!$dsn_per_recip_capable) {
9644        my $recip_smtp_response = $r->recip_smtp_response;  # any 4xx code ?
9645        if (defined($recip_smtp_response) && $recip_smtp_response =~ /^4/) {
9646          $any_tempfail = $recip_smtp_response . ' (' . $r->recip_addr . ')';
9647        }
9648      }
9649      if (!$r->recip_done && (!$filter || &$filter($r))) {
9650        my $proto_sockname = $r->delivery_method;
9651        defined $proto_sockname
9652          or die "mail_dispatch: undefined delivery_method";
9653        !ref $proto_sockname || ref $proto_sockname eq 'ARRAY'
9654          or die "mail_dispatch: not a scalar or array ref: $proto_sockname";
9655        for (ref $proto_sockname ? @$proto_sockname : $proto_sockname) {
9656          local($1);
9657          if (/^([a-z][a-z0-9.+-]*):/si) { $protocols{lc($1)} = 1 }
9658          else { die "mail_dispatch: no recognized protocol name: $_" }
9659        }
9660      }
9661    }
9662    my(@unknown) =
9663      grep(!/^(?:smtp|lmtp|pipe|bsmtp|sql|local)\z/i, keys %protocols);
9664    !@unknown  or die "mail_dispatch: unknown protocol: ".join(', ',@unknown);
9665
9666    if (!$dsn_per_recip_capable && defined $any_tempfail) {
9667      do_log(0, "temporary failures, giving up further deliveries: %s",
9668                $any_tempfail);
9669      my $smtp_resp =
9670        "451 4.5.0 Giving up due to previous temporary failures, id=" .
9671        $msginfo->log_id;
9672      # flag the remaining undelivered recipients as temporary failures
9673      for my $r (@$per_recip_data) {
9674        next if $r->recip_done;
9675        $r->recip_smtp_response($smtp_resp); $r->recip_done(1);
9676      }
9677      last;
9678    }
9679
9680    # do one protocol per iteration only, so that we can bail out
9681    # as soon as some 4xx temporary failure is detected, avoiding
9682    # further deliveries which would lead to duplicate deliveries
9683    #
9684    if ($protocols{'smtp'} || $protocols{'lmtp'}) {
9685      Amavis::Out::SMTP::mail_via_smtp(@_);
9686      $any_deliveries = 1;  # approximation, will do for the time being
9687    } elsif ($protocols{'local'}) {
9688      Amavis::Out::Local::mail_to_local_mailbox(@_);
9689      $any_deliveries = 1;  # approximation, will do for the time being
9690    } elsif ($protocols{'pipe'}) {
9691      Amavis::Out::Pipe::mail_via_pipe(@_);
9692      $any_deliveries = 1;  # approximation, will do for the time being
9693    } elsif ($protocols{'bsmtp'}) {
9694      Amavis::Out::BSMTP::mail_via_bsmtp(@_);
9695      $any_deliveries = 1;  # approximation, will do for the time being
9696    } elsif ($protocols{'sql'}) {
9697      $Amavis::extra_code_sql_quar && $Amavis::sql_storage
9698        or die "SQL quarantine code not enabled (1)";
9699      Amavis::Out::SQL::Quarantine::mail_via_sql(
9700                                        $Amavis::sql_dataset_conn_storage, @_);
9701      $any_deliveries = 1;  # approximation, will do for the time being
9702    }
9703
9704    # are we done yet?
9705    my $num_recips_notdone_after =
9706      scalar(grep(!$_->recip_done && (!$filter || &$filter($_)),
9707                  @$per_recip_data));
9708    if ($num_recips_notdone_after >= $num_recips_notdone) {
9709      do_log(-2, "TROUBLE: Number of recipients (%d) not reduced, ".
9710                 "abandoning effort, proto: %s",
9711                 $num_recips_notdone_after, join(', ', keys %protocols) );
9712      last;
9713    }
9714    if ($num_recips_notdone_after > 0) {
9715      do_log(3, "Sent to %s recipients, %s still to go",
9716                $num_recips_notdone - $num_recips_notdone_after,
9717                $num_recips_notdone_after);
9718    }
9719    $num_recips_notdone = $num_recips_notdone_after;
9720  }
9721  # restore header edits if modified
9722  $msginfo->header_edits($saved_hdr_edits)  if defined $tmp_hdr_edits;
9723
9724  $any_deliveries;  # (estimate) were any successful deliveries actually done?
9725}
9726
97271;
9728
9729#
9730package Amavis::UnmangleSender;
9731use strict;
9732use re 'taint';
9733
9734BEGIN {
9735  require Exporter;
9736  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
9737  $VERSION = '2.412';
9738  @ISA = qw(Exporter);
9739  @EXPORT_OK = qw(&first_received_from &oldest_public_ip_addr_from_received);
9740  import Amavis::Conf qw(:platform c cr ca);
9741  import Amavis::Util qw(ll do_log unique_list);
9742  import Amavis::rfc2821_2822_Tools qw(
9743                   split_address parse_received fish_out_ip_from_received);
9744  import Amavis::Lookup qw(lookup lookup2);
9745  import Amavis::Lookup::IP qw(normalize_ip_addr);
9746}
9747use subs @EXPORT_OK;
9748
9749# Obtain and parse the first entry (oldest) in the 'Received:' header field
9750# path trace - to be used as the value of a macro %t in customized messages
9751#
9752sub first_received_from($) {
9753  my $msginfo = $_[0];
9754  my $first_received;
9755  my $fields_ref =
9756    parse_received($msginfo->get_header_field_body('received'));  # last
9757  if (exists $fields_ref->{'from'}) {
9758    $first_received = join(' ', unique_list(grep(defined($_),
9759                                  @$fields_ref{qw(from from-tcp from-com)})));
9760    do_log(5, "first_received_from: %s", $first_received);
9761  }
9762  $first_received;
9763}
9764
9765
9766# Try to extract sender's IP address from the Received trace.
9767# Search bottom-up, use the first public IP address from the trace.
9768#
9769sub oldest_public_ip_addr_from_received($) {
9770  my($msginfo) = @_;
9771  my $received_from_ip;
9772  my $ip_trace_ref = $msginfo->ip_addr_trace_public;  # top-down trace
9773  $received_from_ip = $ip_trace_ref->[-1]  if $ip_trace_ref;  # last is oldest
9774  do_log(5, "oldest_public_ip_addr_from_received: %s", $received_from_ip)
9775    if defined $received_from_ip;
9776  $received_from_ip;
9777}
9778
97791;
9780
9781#
9782package Amavis::Unpackers::NewFilename;
9783use strict;
9784use re 'taint';
9785
9786BEGIN {
9787  require Exporter;
9788  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
9789  $VERSION = '2.412';
9790  @ISA = qw(Exporter);
9791  @EXPORT_OK = qw(&consumed_bytes);
9792  import Amavis::Conf qw(c cr ca
9793                         $MIN_EXPANSION_QUOTA $MIN_EXPANSION_FACTOR
9794                         $MAX_EXPANSION_QUOTA $MAX_EXPANSION_FACTOR);
9795  import Amavis::Util qw(ll do_log min max minmax);
9796}
9797
9798use vars qw($avail_quota);  # available bytes quota for unpacked mail
9799use vars qw($rem_quota);    # remaining bytes quota for unpacked mail
9800
9801sub new($;$$) {  # create a file name generator object
9802  my($class, $maxfiles,$mail_size) = @_;
9803  # calculate and initialize quota
9804  $avail_quota = $rem_quota =  # quota in bytes
9805    max($MIN_EXPANSION_QUOTA, $mail_size * $MIN_EXPANSION_FACTOR,
9806        min($MAX_EXPANSION_QUOTA, $mail_size * $MAX_EXPANSION_FACTOR));
9807  ll(4) && do_log(4,'Original mail size: %d; quota set to: %d bytes '.
9808                    '(fmin=%s, fmax=%s, qmin=%s, qmax=%s)',
9809                    $mail_size, $avail_quota,
9810                    map(defined $_ ? "$_" : 'UNDEF',
9811                        $MIN_EXPANSION_FACTOR, $MAX_EXPANSION_FACTOR,
9812                        $MIN_EXPANSION_QUOTA, $MAX_EXPANSION_QUOTA));
9813  # create object
9814  bless {
9815    num_of_issued_names => 0,  first_issued_ind => 1,  last_issued_ind => 0,
9816    maxfiles => $maxfiles,  # undef disables limit
9817    objlist => [],
9818  }, $class;
9819}
9820
9821sub parts_list_reset($) {  # clear a list of recently issued names
9822  my $self = $_[0];
9823  $self->{num_of_issued_names} = 0;
9824  $self->{first_issued_ind} = $self->{last_issued_ind} + 1;
9825  $self->{objlist} = [];
9826}
9827
9828sub parts_list($) {  # returns a ref to a list of recently issued names
9829  my $self = $_[0];
9830  $self->{objlist};
9831}
9832
9833sub parts_list_add($$) {  # add a parts object to the list of parts
9834  my($self, $part) = @_;
9835  push(@{$self->{objlist}}, $part);
9836}
9837
9838sub generate_new_num($$) {  # make-up a new number for a file and return it
9839  my($self, $ignore_limit) = @_;
9840  if (!$ignore_limit && defined($self->{maxfiles}) &&
9841      $self->{num_of_issued_names} >= $self->{maxfiles}) {
9842    # do not change the text in die without adjusting decompose_part()
9843    die "Maximum number of files ($self->{maxfiles}) exceeded";
9844  }
9845  $self->{num_of_issued_names}++; $self->{last_issued_ind}++;
9846  $self->{last_issued_ind};
9847}
9848
9849sub consumed_bytes($$;$$) {
9850  my($bytes, $bywhom, $tentatively, $exquota) = @_;
9851  if (ll(4)) {
9852    my $perc = !$avail_quota ? '' : sprintf(", (%.0f%%)",
9853                 100 * ($avail_quota - ($rem_quota - $bytes)) / $avail_quota);
9854    do_log(4,"Charging %d bytes to remaining quota %d (out of %d%s) - by %s",
9855             $bytes, $rem_quota, $avail_quota, $perc, $bywhom);
9856  }
9857  if ($bytes > $rem_quota && $rem_quota >= 0) {
9858    # Do not modify the following signal text, it gets matched elsewhere!
9859    my $msg = "Exceeded storage quota $avail_quota bytes by $bywhom; ".
9860              "last chunk $bytes bytes";
9861    do_log(-1, "%s", $msg);
9862    die "$msg\n"  if !$exquota;   # die, unless allowed to exceed quota
9863  }
9864  $rem_quota -= $bytes  unless $tentatively;
9865  $rem_quota;  # return remaining quota
9866}
9867
98681;
9869
9870#
9871package Amavis::Unpackers::Part;
9872use strict;
9873use re 'taint';
9874
9875BEGIN {
9876  require Exporter;
9877  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
9878  $VERSION = '2.412';
9879  @ISA = qw(Exporter);
9880  import Amavis::Util qw(ll do_log);
9881}
9882
9883use vars qw($file_generator_object);
9884sub init($) { $file_generator_object = $_[0] }
9885
9886sub new($;$$$) {  # create a part descriptor object
9887  my($class, $dir_name,$parent,$ignore_limit) = @_;
9888  my $self = bless {}, $class;
9889  if (!defined($dir_name) && !defined($parent)) {
9890    # just make an empty object, presumably used as a new root
9891  } else {
9892    $self->number($file_generator_object->generate_new_num($ignore_limit));
9893    $self->dir_name($dir_name)  if defined $dir_name;
9894    if (defined $parent) {
9895      $self->parent($parent);
9896      my $ch_ref = $parent->children;
9897      push(@$ch_ref,$self); $parent->children($ch_ref);
9898    }
9899    $file_generator_object->parts_list_add($self);  # save it
9900    ll(4) && do_log(4, "Issued a new %s: %s",
9901            defined $dir_name ? "file name" : "pseudo part", $self->base_name);
9902  }
9903  $self;
9904}
9905
9906sub number
9907  { @_<2 ? shift->{number}   : ($_[0]->{number} = $_[1]) };
9908sub dir_name
9909  { @_<2 ? shift->{dir_name} : ($_[0]->{dir_name} = $_[1]) };
9910sub parent
9911  { @_<2 ? shift->{parent}   : ($_[0]->{parent} = $_[1]) };
9912sub children
9913  { @_<2 ? shift->{children}||[] : ($_[0]->{children} = $_[1]) };
9914sub mime_placement    # part location within a MIME tree, e.g. "1/1/3"
9915  { @_<2 ? shift->{place}    : ($_[0]->{place} = $_[1]) };
9916sub type_short        # string or a ref to a list of strings, case sensitive
9917  { @_<2 ? shift->{ty_short} : ($_[0]->{ty_short} = $_[1]) };
9918sub type_long
9919  { @_<2 ? shift->{ty_long}  : ($_[0]->{ty_long} = $_[1]) };
9920sub type_declared
9921  { @_<2 ? shift->{ty_decl}  : ($_[0]->{ty_decl} = $_[1]) };
9922sub name_declared     # string or a ref to a list of strings
9923  { @_<2 ? shift->{nm_decl}  : ($_[0]->{nm_decl} = $_[1]) };
9924sub report_type       # a string, e.g. 'delivery-status', RFC 6522
9925  { @_<2 ? shift->{rep_typ}  : ($_[0]->{rep_typ} = $_[1]) };
9926sub size              # size in bytes
9927  { @_<2 ? shift->{size}     : ($_[0]->{size} = $_[1]) };
9928sub digest            # digest of a mime part contents (typically SHA1, hex)
9929  { @_<2 ? shift->{digest}   : ($_[0]->{digest} = $_[1]) };
9930sub exists
9931  { @_<2 ? shift->{exists}   : ($_[0]->{exists} = $_[1]) };
9932sub attributes        # a string of characters representing attributes
9933  { @_<2 ? shift->{attr}     : ($_[0]->{attr} = $_[1]) };
9934
9935sub attributes_add {  # U=undecodable, C=crypted, D=directory,S=special,L=link
9936  my $self = shift; my $a = $self->{attr}; $a = '' if !defined $a;
9937  for my $arg (@_) { $a .= $arg  if $arg ne '' && index($a,$arg) < 0 }
9938  $self->{attr} = $a;
9939};
9940
9941sub base_name { my $self = $_[0]; sprintf("p%03d",$self->number) }
9942
9943sub full_name {
9944  my $self = $_[0]; my $d = $self->dir_name;
9945  !defined($d) ? undef : $d.'/'.$self->base_name;
9946}
9947
9948# returns a ref to a list of part ancestors, starting with the root object,
9949# and including the part object itself
9950#
9951sub path {
9952  my $self = $_[0];
9953  my(@path);
9954  for (my $p=$self; defined($p); $p=$p->parent) { unshift(@path,$p) }
9955  \@path;
9956};
9957
99581;
9959
9960#
9961package Amavis::Unpackers::OurFiler;
9962use strict;
9963use re 'taint';
9964
9965BEGIN {
9966  require Exporter;
9967  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
9968  $VERSION = '2.412';
9969  @ISA = qw(Exporter MIME::Parser::Filer);  # subclass of MIME::Parser::Filer
9970}
9971# This package will be used by mime_decode().
9972#
9973# We don't want no heavy MIME::Parser machinery for file name extension
9974# guessing, decoding charsets in filenames (and listening to complaints
9975# about it), checking for evil filenames, checking for filename contention, ...
9976# (which cannot be turned off completely by ignore_filename(1) !!!)
9977# Just enforce our file name! And while at it, collect generated filenames.
9978#
9979sub new($$$) {
9980  my($class, $dir, $parent_obj) = @_;
9981  $dir =~ s{/+\z}{};  # chop off trailing slashes from directory name
9982  bless {parent => $parent_obj, directory => $dir}, $class;
9983}
9984
9985# provide a generated file name
9986#
9987sub output_path($@) {
9988  my($self, $head) = @_;
9989  my $newpart_obj =
9990    Amavis::Unpackers::Part->new($self->{directory}, $self->{parent}, 1);
9991  get_amavisd_part($head, $newpart_obj);  # store object into head
9992  $newpart_obj->full_name;
9993}
9994
9995sub get_amavisd_part($;$) {
9996  my $head = shift;
9997  !@_ ? $head->{amavisd_parts_obj} : ($head->{amavisd_parts_obj} = shift);
9998}
9999
100001;
10001
10002#
10003package Amavis::Unpackers::Validity;
10004use strict;
10005use re 'taint';
10006
10007BEGIN {
10008  require Exporter;
10009  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
10010  $VERSION = '2.412';
10011  @ISA = qw(Exporter);
10012  @EXPORT_OK = qw(&check_header_validity &check_for_banned_names);
10013  import Amavis::Util qw(ll do_log min max minmax untaint untaint_inplace
10014                         is_valid_utf_8 truncate_utf_8);
10015  import Amavis::Conf qw(:platform %banned_rules c cr ca);
10016  import Amavis::Lookup qw(lookup lookup2);
10017}
10018use subs @EXPORT_OK;
10019
10020sub check_header_validity($) {
10021  my $msginfo = $_[0];
10022  my(%field_head_counts, @bad);
10023  my $minor_badh_category = 0;
10024  my $allowed_tests = cr('allowed_header_tests');
10025  my($t_syntax, $t_empty, $t_long, $t_control, $t_8bit, $t_utf8,
10026     $t_missing, $t_multiple) =
10027    !$allowed_tests ? () : @$allowed_tests{qw(syntax empty long control
10028                                              8bit utf8 missing multiple)};
10029  # minor category:  2: 8-bit char, 3: NUL/CR control, 4: empty line, 5: long,
10030  #                  6: syntax, 7: missing, 8: multiple
10031  local($1,$2,$3);
10032  for my $curr_head (@{$msginfo->orig_header}) {#array of hdr fields, not lines
10033    my($field_name,$msg1,$msg2,$pre,$mid,$post);
10034    # obsolete RFC 822 syntax allowed whitespace before colon
10035    $field_name = $1  if $curr_head =~ /^([!-9;-\x7E\x80-\xFF]+)[ \t]*:/s;
10036    $field_head_counts{lc($field_name)}++  if defined $field_name;
10037    if (!defined($field_name) || substr($field_name,0,2) eq '--') {
10038      if ($t_syntax) {
10039        $msg1 = "Invalid header field syntax"; $msg2 = $curr_head;
10040        $minor_badh_category = max(6, $minor_badh_category);
10041      }
10042    } elsif ($t_syntax && $field_name =~ /([^\x00-\x7F])/gs) {
10043      $mid = $1; $msg1 = "Invalid header field name, contains non-ASCII char";
10044      $minor_badh_category = max(6, $minor_badh_category);
10045    } elsif ($t_empty && $curr_head =~ /^([ \t]+)(?=\n|\z)/gms) {
10046      $mid = $1;
10047      $msg1 ="Improper folded header field made up entirely of whitespace";
10048      # note: using //g and pos to avoid deep recursion in regexp
10049      $minor_badh_category = max(4, $minor_badh_category);
10050    } elsif ($t_long && $curr_head =~ /^([^\n]{999,})(?=\n|\z)/gms) {
10051      $msg1 = "Header line longer than 998 characters"; $msg2 = $1;
10052      substr($msg2, 50) = '[...]'  if length($msg2) > 55;
10053      $minor_badh_category = max(5, $minor_badh_category);
10054    } elsif ($t_control && $curr_head =~ /([\000\015])/gs) {
10055      $mid = $1; $msg1 = "Improper use of control character";
10056      $minor_badh_category = max(3, $minor_badh_category);
10057    } elsif ($t_8bit && $curr_head =~ /([^\x00-\x7F])/gs) {  # non-ASCII
10058      $mid = $1;
10059      if (!is_valid_utf_8($curr_head)) {
10060        $msg1 = 'Non-encoded non-ASCII data (and not UTF-8)';
10061      } elsif ($curr_head =~ /^([\x00-\x08\x0B-\x1F\x7F])/xgs) { # but TAB,NL
10062        $mid = $1; $msg1 = 'UTF-8 string contains C0 Controls';
10063      } elsif ($curr_head =~
10064          /( (?: \xC2 | \xE0 \x82 | \xF0 \x80 \x82 ) [\x80-\x9F] )/xgs) {
10065        # RFC 5198 prohibits "C1 Controls" (U+0080..U+009F) for Net-Unicode
10066        $mid = $1; $msg1 = 'UTF-8 string contains C1 Controls';
10067      } elsif ($msginfo->smtputf8) {
10068        # UTF-8 header bodies (but not field names) are valid with SMTPUTF8
10069      } elsif ($t_utf8) {
10070        $msg1 = 'Non-encoded UTF-8 string in non-EAI mail';
10071        if ($curr_head =~ /( [\xC0-\xDF][\x80-\xBF] |
10072                             [\xE0-\xEF][\x80-\xBF]{2} |
10073                             [\xF0-\xF4][\x80-\xBF]{3} )/xgs ) {
10074          $mid = $1;  # capture the entire first non-ASCII UTF-8 character
10075        }
10076      }
10077      $minor_badh_category = max(2, $minor_badh_category)  if defined $msg1;
10078    }
10079    if (defined $msg1) {
10080      $mid = ''  if !defined $mid;
10081      if (!defined $msg2) {
10082        $pre = substr($curr_head, 0, pos($curr_head)-length($mid))
10083          if !defined $pre;
10084        $post = substr($curr_head,pos($curr_head))  if !defined $post;
10085        chomp($post);
10086        $mid  = truncate_utf_8($mid, 15).'[...]'  if length($mid)  > 20;
10087        $post = truncate_utf_8($post,15).'[...]'  if length($post) > 20;
10088        if (length($pre)-length($field_name)-2 > 50-length($post)) {
10089          $pre = $field_name . ': ...'
10090                 . substr($pre, length($pre) - (45-length($post)));
10091        }
10092        $msg2 = $pre . $mid . $post;
10093      }
10094      if ($mid ne '' && length($mid) <= 4) {
10095        $msg1 .= " (char ";
10096        $msg1 .= join(' ', map(sprintf('%02X',ord($_)), split(//,$mid)));
10097        $msg1 .= " hex)";
10098      }
10099      push(@bad, "$msg1: $msg2");
10100      last  if @bad >= 100;  # some sanity limit
10101    }
10102  }
10103  # RFC 5322 (ex RFC 2822), RFC 2045, RFC 2183
10104  for (qw(Date From Sender Reply-To To Cc Bcc Subject Message-ID References
10105          In-Reply-To MIME-Version Content-Type Content-Transfer-Encoding
10106          Content-ID Content-Description Content-Disposition Auto-Submitted)) {
10107    my $n = $field_head_counts{lc($_)};
10108    if (!$n && $t_missing && /^(?:Date|From)\z/i) {
10109      push(@bad, "Missing required header field: \"$_\"");
10110      $minor_badh_category = max(7, $minor_badh_category);
10111    } elsif ($n > 1 && $t_multiple) {
10112      if ($n == 2) {
10113        push(@bad, "Duplicate header field: \"$_\"");
10114      } else {
10115        push(@bad, sprintf('Header field occurs more than once: "%s" '.
10116                           'occurs %d times', $_, $n));
10117      }
10118      $minor_badh_category = max(8, $minor_badh_category);
10119    }
10120  }
10121  for (@bad) {  # sanitize C0 controls and non-ASCII
10122    s{ ( [^\x20-\x7E] | \\ (?= x \{ ) ) }
10123     { sprintf('\\x{%02X}', ord($1)) }xgse  if tr/\x00-\x7F//c;
10124  }
10125  if (!@bad) {
10126    do_log(5,"check_header: %d, OK", $minor_badh_category);
10127  } elsif (ll(2)) {
10128    do_log(2,"check_header: %d, %s", $minor_badh_category, $_)  for @bad;
10129  }
10130  (\@bad, $minor_badh_category);
10131}
10132
10133sub check_for_banned_names($) {
10134  my $msginfo = $_[0];
10135  do_log(3, "Checking for banned types and filenames");
10136  my $bfnmr = ca('banned_filename_maps');  # two-level map: recip, partname
10137  my(@recip_tables);  # a list of records describing banned tables for recips
10138  my $any_table_in_recip_tables = 0;  my $any_not_bypassed = 0;
10139  for my $r (@{$msginfo->per_recip_data}) {
10140    my $recip = $r->recip_addr;
10141    my(@tables,@tables_m);  # list of banned lookup tables for this recipient
10142    if (!$r->bypass_banned_checks) {  # not bypassed
10143      $any_not_bypassed = 1;
10144      my($t_ref,$m_ref) = lookup2(1,$recip,$bfnmr);
10145      if (defined $t_ref) {
10146        for my $ti (0..$#$t_ref) { # collect all relevant tables for each recip
10147          my $t = $t_ref->[$ti];
10148          # an entry may be a ref to a list of lookup tables, or a comma- or
10149          # whitespace-separated list of table names (suitable for SQL),
10150          # which are mapped to actual lookup tables through %banned_rules
10151          if (!defined($t)) {
10152            # ignore
10153          } elsif (ref($t) eq 'ARRAY') {  # a list of actual lookup tables
10154            push(@tables, @$t);
10155            push(@tables_m, ($m_ref->[$ti]) x @$t);
10156          } else {  # a list of rules _names_, to be mapped via %banned_rules
10157            my(@names);
10158            my(@rawnames) = grep(!/^[, ]*\z/,
10159                                 ($t =~ /\G (?: " (?: \\. | [^"\\] ){0,999} "
10160                                              | [^, ] )+ | [, ]+/xgs));
10161            # in principle quoted strings could be used
10162            # to construct lookup tables on-the-fly (not implemented)
10163            for my $n (@rawnames) {  # collect only valid names
10164              if (!exists($banned_rules{$n})) {
10165                do_log(2,"INFO: unknown banned table name %s, recip=%s",
10166                         $n,$recip);
10167              } elsif (!defined($banned_rules{$n})) {  # ignore undef
10168              } else { push(@names,$n) }
10169            }
10170            ll(3) && do_log(3,"collect banned table[%d]: %s, tables: %s",
10171              $ti,$recip, join(', ',map($_.'=>'.$banned_rules{$_}, @names)));
10172            if (@names) {  # any known and valid table names?
10173              push(@tables, map($banned_rules{$_}, @names));
10174              push(@tables_m, ($m_ref->[$ti]) x @names);
10175            }
10176          }
10177        }
10178      }
10179    }
10180    push(@recip_tables, { r => $r, recip => $recip,
10181                          tables => \@tables, tables_m => \@tables_m } );
10182    $any_table_in_recip_tables=1  if @tables;
10183  }
10184  my $bnpre = cr('banned_namepath_re');
10185  $bnpre = $$bnpre  if ref($bnpre) eq 'REF';  # allow one level of indirection
10186  if (!$any_not_bypassed) {
10187    do_log(3,"skipping banned check: all recipients bypass banned checks");
10188  } elsif (!$any_table_in_recip_tables && !ref($bnpre)) {
10189    do_log(3,"skipping banned check: no applicable lookup tables");
10190  } else {
10191    do_log(4,"starting banned checks - traversing message structure tree");
10192    my $parts_root = $msginfo->parts_root;
10193    my $part;
10194    for (my(@unvisited)=($parts_root);
10195         @unvisited and $part=shift(@unvisited);
10196         push(@unvisited,@{$part->children}))
10197    { # traverse decomposed parts tree breadth-first
10198      my(@path) = @{$part->path};
10199      next  if @path <= 1;
10200      shift(@path);  # ignore place-holder root node
10201      next  if @{$part->children};  # ignore non-leaf nodes
10202      my(@descr_trad);  # a part path: list of predecessors of a message part
10203      my(@descr);  # same, but in form suitable for check on banned_namepath_re
10204      for my $p (@path) {
10205        my(@k,$n);
10206        $n = $p->base_name;
10207        if ($n ne '') { $n=~s/[\t\n]/ /g; push(@k,"P=$n") }
10208        $n = $p->mime_placement;
10209        if ($n ne '') { $n=~s/[\t\n]/ /g; push(@k,"L=$n") }
10210        $n = $p->type_declared;
10211        $n = [$n]  if !ref($n);
10212        for (@$n) {if ($_ ne ''){my $m=$_; $m=~s/[\t\n]/ /g; push(@k,"M=$m")}}
10213        $n = $p->type_short;
10214        $n = [$n]  if !ref($n);
10215        for (@$n) {if (defined($_) && $_ ne '')
10216                     {my $m=$_; $m=~s/[\t\n]/ /g; push(@k,"T=$m")} }
10217        $n = $p->name_declared;
10218        $n = [$n]  if !ref($n);
10219        for (@$n) {if (defined($_) && $_ ne '')
10220                     {my $m=$_; $m=~s/[\t\n]/ /g; push(@k,"N=$m")} }
10221        $n = $p->attributes;
10222        if (defined $n && $n ne '') { push(@k,"A=$_") for split(/ */,$n) }
10223        push(@descr, join("\t",@k));
10224        push(@descr_trad, [map { local($1,$2);
10225             /^([a-zA-Z0-9])=(.*)\z/s; my($key_what,$key_val) = ($1,$2);
10226             $key_what eq 'M' || $key_what eq 'N' ? $key_val
10227           : $key_what eq 'T' ? ('.'.$key_val)  # prepend a dot (compatibility)
10228           : $key_what eq 'A' && $key_val eq 'U' ? 'UNDECIPHERABLE' : ()} @k]);
10229      }
10230      # we have obtained a description of a part as a list of its predecessors
10231      # in a message structure including the part itself at the end of the list
10232      my $key_val_str = join(' | ',@descr);  $key_val_str =~ s/\t/,/g;
10233      my $key_val_trad_str = join(' | ', map(join(',',@$_), @descr_trad));
10234      # simplified result to be presented in an SMTP response and DSN
10235      my $simple_part_name = join(',', @{$descr_trad[-1]});  # just leaf node
10236      # evaluate current mail component path against each recipients' tables
10237      ll(4) && do_log(4, "check_for_banned (%s) %s",
10238                      join(',', map($_->base_name, @path)), $key_val_trad_str);
10239      for my $e (@recip_tables) {
10240        @$e{qw(found result matchk part_descr_attr part_descr_trad part_name)}
10241          = (0, undef, undef, undef, undef, undef);
10242      }
10243      my($result, $matchingkey, $t_ref_old);
10244      for my $e (@recip_tables) {  # for each recipient and his tables
10245        my($found,$recip,$t_ref) = @$e{qw(found recip tables)};
10246        if ($t_ref && @$t_ref) {
10247          my $same_as_prev = $t_ref_old && @$t_ref_old==@$t_ref &&
10248                !grep($t_ref_old->[$_] ne $t_ref->[$_], (0..$#$t_ref)) ? 1 : 0;
10249          if ($same_as_prev) {
10250            do_log(4,
10251             "skip banned check for %s, same tables as previous, result => %s",
10252              $recip,$result);
10253          } else {
10254            do_log(5,"doing banned check for %s on %s",
10255                     $recip,$key_val_trad_str);
10256            ($result,$matchingkey) =
10257              lookup2(0, [map(@$_,@descr_trad)],  # check all attribs in one go
10258                      [map(ref($_) eq 'ARRAY' ? @$_ : $_, @$t_ref)],
10259                      Label=>"check_bann:$recip");
10260            $t_ref_old = $t_ref;
10261          }
10262          if (defined $result) {
10263            @$e{qw(found result matchk
10264                   part_descr_attr part_descr_trad part_name)} =
10265              (1, $result, $matchingkey,
10266               $key_val_str, $key_val_trad_str, $simple_part_name);
10267          }
10268        }
10269      }
10270      if (ref $bnpre && grep(!$_->{result}, @recip_tables)) {  # any non-true?
10271        # try new style: banned_namepath_re; it is global, not per-recipient
10272        my $descr_str = join("\n",@descr);
10273        if ($] < 5.012003) {
10274          # avoid a [perl #62048] bug in lookup_re():
10275          #   Unwarranted "Malformed UTF-8 character" on tainted variable
10276          untaint_inplace($descr_str);
10277        }
10278        my($result,$matchingkey) = lookup2(0, $descr_str, [$bnpre],
10279                                           Label=>'banned_namepath_re');
10280        if (defined $result) {
10281          for my $e (@recip_tables) {
10282            if (!$e->{found}) {
10283              @$e{qw(found result matchk
10284                     part_descr_attr part_descr_trad part_name)} =
10285                (1, $result, $matchingkey,
10286                 $key_val_str, $key_val_trad_str, $simple_part_name);
10287            }
10288          }
10289        }
10290      }
10291      my(%esc) = (r => "\r", n => "\n", f => "\f", b => "\b",
10292                  e => "\e", a => "\a", t => "\t");  # for pretty-printing
10293      my $ll = grep($_->{result}, @recip_tables) ? 1 : 3;  # log level
10294      for my $e (@recip_tables) {  # log and store results
10295        my($r, $recip, $result, $matchingkey,
10296           $part_descr_attr, $part_descr_trad, $part_name) =
10297          @$e{qw(r recip result matchk
10298                 part_descr_attr part_descr_trad part_name)};
10299        if (ll($ll)) {  # only bother with logging when needed
10300          local($1);
10301          my $mk = defined $matchingkey ? $matchingkey : '';  # pretty-print
10302          $mk =~ s{ \\(.) }{ exists($esc{$1}) ? $esc{$1} : '\\'.$1 }xgse;
10303          do_log($result?1:3, 'p.path%s %s: "%s"%s',
10304                           !$result?'':" BANNED:$result", $recip, $key_val_str,
10305                           !defined $result ? '' : ", matching_key=\"$mk\"");
10306        }
10307        my $a;
10308        if ($result) {  # the part being tested is banned for this recipient
10309          $a = $r->banned_parts    || [];
10310          push(@$a,$part_descr_trad);  $r->banned_parts($a);
10311          $a = $r->banned_parts_as_attr || [];
10312          push(@$a,$part_descr_attr);  $r->banned_parts_as_attr($a);
10313          $a = $r->banning_rule_rhs || [];
10314          push(@$a,$result);      $r->banning_rule_rhs($a);
10315          $a = $r->banning_rule_key || [];
10316          $matchingkey = "$matchingkey";  # make a plain string out of a qr
10317          push(@$a,$matchingkey); $r->banning_rule_key($a);
10318          my(@comments) = $matchingkey =~ / \( \? \# \s* (.*?) \s* \) /xgs;
10319          $a = $r->banning_rule_comment || [];
10320          push(@$a, @comments ? join(' ',@comments) : $matchingkey);
10321          $r->banning_rule_comment($a);
10322          if (!defined($r->banning_reason_short)) {  # just the first
10323            my $s = $part_name;
10324            $s =~ s/[ \t]{6,}/ ... /g;  # compact whitespace
10325            $s = join(' ',@comments) . ':' . $s  if @comments;
10326            $r->banning_reason_short($s);
10327          }
10328        }
10329      }
10330    # last  if !grep(!$_->{result}, @recip_tables);  # stop if all recips true
10331    } # endfor: message tree traversal
10332  } # endif: doing parts checking
10333}
10334
103351;
10336
10337#
10338package Amavis::Unpackers::MIME;
10339use strict;
10340use re 'taint';
10341
10342BEGIN {
10343  require Exporter;
10344  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
10345  $VERSION = '2.412';
10346  @ISA = qw(Exporter);
10347  @EXPORT_OK = qw(&mime_decode);
10348  import Amavis::Conf qw(:platform c cr ca $TEMPBASE $MAXFILES);
10349  import Amavis::Timing qw(section_time);
10350  import Amavis::Util qw(snmp_count untaint ll do_log
10351                         safe_decode safe_decode_latin1
10352                         safe_encode safe_encode_utf8_inplace);
10353  import Amavis::Unpackers::NewFilename qw(consumed_bytes);
10354}
10355use subs @EXPORT_OK;
10356
10357use Errno qw(ENOENT EACCES);
10358use IO::File qw(O_RDONLY O_WRONLY O_CREAT O_EXCL);
10359use MIME::Parser;
10360use MIME::Words;
10361use Digest::MD5;
10362use Digest::SHA;
10363# use Scalar::Util qw(tainted);
10364
10365# save MIME preamble and epilogue (if nontrivial) as extra (pseudo)parts
10366#
10367sub mime_decode_pre_epi($$$$$) {
10368  my($pe_name, $pe_lines, $tempdir, $parent_obj, $placement) = @_;
10369  if (defined $pe_lines && @$pe_lines) {
10370    do_log(5, "mime_decode_%s: %d lines", $pe_name, scalar(@$pe_lines));
10371    if (@$pe_lines > 5 || "@$pe_lines" !~ m{^[A-Za-z0-9/\@:;,. \t\n_-]*\z}s) {
10372      my $newpart_obj =
10373        Amavis::Unpackers::Part->new("$tempdir/parts",$parent_obj,1);
10374      $newpart_obj->mime_placement($placement);
10375      $newpart_obj->name_declared($pe_name);
10376      my $newpart = $newpart_obj->full_name;
10377      my $outpart = IO::File->new;
10378      # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
10379      $outpart->open($newpart, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)
10380        or die "Can't create $pe_name file $newpart: $!";
10381      binmode($outpart,':bytes') or die "Can't cancel :utf8 mode: $!";
10382      my $len;
10383      for (@$pe_lines) {
10384        $outpart->print($_) or die "Can't write $pe_name to $newpart: $!";
10385        $len += length($_);
10386      }
10387      $outpart->close or die "Error closing $pe_name $newpart: $!";
10388      $newpart_obj->size($len);
10389      consumed_bytes($len, "mime_decode_$pe_name", 0, 1);
10390    }
10391  }
10392}
10393
10394# traverse MIME::Entity object depth-first,
10395# extracting preambles and epilogues as extra (pseudo)parts, and
10396# filling-in additional information into Amavis::Unpackers::Part objects
10397#
10398sub mime_traverse($$$$$);  # prototype
10399sub mime_traverse($$$$$) {
10400  my($entity, $tempdir, $parent_obj, $depth, $placement) = @_;
10401  mime_decode_pre_epi('preamble', $entity->preamble,
10402                      $tempdir, $parent_obj, $placement);
10403  my($mt, $et) = ($entity->mime_type, $entity->effective_type);
10404  my $part; my $head = $entity->head; my $body = $entity->bodyhandle;
10405  if (!defined($body)) {  # a MIME container only contains parts, no bodypart
10406    # create pseudo-part objects for MIME containers (e.g. multipart/* )
10407    $part = Amavis::Unpackers::Part->new(undef,$parent_obj,1);
10408#   $part->type_short('no-file');
10409    do_log(2, "%s %s Content-Type: %s", $part->base_name, $placement, $mt);
10410
10411  } else {  # does have a body part (i.e. not a MIME container)
10412    # base64 encoding represents line-endings in a canonical CRLF form, so it
10413    # must be converted to a local representation for text parts when decoding;
10414    # RFC 2045 explicitly prohibits encoding CR and LF of a canonical CRLF pair
10415    # in quoted-printable encoding of textual parts, but some mail generating
10416    # software ignores this requirement, so we have to normalize line endings
10417    # (turn CRLF to \n) for both the base64 and the quoted-printable encodings
10418    my $encoding = $head->mime_encoding;
10419    my $normalize_line_endings =
10420      $mt =~ m{^(?:text|message)(?:/|\z)}i &&
10421      ($encoding eq 'base64' || $encoding eq 'quoted-printable');
10422
10423    my $digest_ctx;  # body-part digester context object, or undef
10424    # choose a message digest: MD5: 128 bits, SHA family: 160..512 bits
10425    # Use SHA1 for SpamAssassin bayes compatibility!
10426    my $digest_algorithm = c('mail_part_digest_algorithm');
10427    if (defined $digest_algorithm) {
10428      $digest_ctx = uc $digest_algorithm eq 'MD5' ? Digest::MD5->new
10429                      : Digest::SHA->new($digest_algorithm);
10430    }
10431    my $size;
10432    my $fn = $body->path;
10433    if (!defined $fn) {
10434      # body part resides in memory only
10435      if (!$digest_ctx) {
10436        $size = length($body->as_string);
10437      } else {
10438        my $buff = $body->as_string;
10439        $size = length $buff;
10440        $buff =~ s{\015(?=\012|\z)}{}gs  if $normalize_line_endings;
10441        $digest_ctx->add($buff);
10442      }
10443    } else {
10444      # body part resides on a file
10445      my $msg; my $errn = lstat($fn) ? 0 : 0+$!;
10446      if ($errn == ENOENT) { $msg = "does not exist" }
10447      elsif ($errn) { $msg = "is inaccessible: $!" }
10448      elsif (!-r _) { $msg = "is not readable" }
10449      elsif (!-f _) { $msg = "is not a regular file" }
10450      else {
10451        $size = -s _;
10452        if ($size == 0) {
10453          do_log(4,"mime_traverse: file %s is empty", $fn);
10454        } elsif ($digest_ctx) {
10455          my $fh = IO::File->new;
10456          $fh->open($fn,O_RDONLY)  # does a sysopen
10457            or die "Can't open file $fn for reading: $!";
10458          $fh->binmode or die "Can't set file $fn to binmode: $!";
10459          my($nbytes,$buff);
10460          while ($nbytes=sysread($fh,$buff,32768)) {
10461            $buff =~ s{\015(?=\012|\z)}{}gs  if $normalize_line_endings;
10462            $digest_ctx->add($buff);
10463          }
10464          defined $nbytes or die "Error reading file $fn: $!";
10465        }
10466      }
10467      do_log(-1,"WARN: mime_traverse: file %s %s", $fn,$msg)  if defined $msg;
10468    }
10469    consumed_bytes($size, 'mime_decode', 0, 1);
10470    # retrieve Amavis::Unpackers::Part object (if any), stashed into head obj
10471    $part = Amavis::Unpackers::OurFiler::get_amavisd_part($head);
10472    if (defined $part) {
10473      $part->size($size);
10474      if (defined($size) && $size==0) {
10475        $part->type_short('empty'); $part->type_long('empty');
10476      }
10477      my $digest;
10478      if ($digest_ctx) {
10479        $digest = $digest_ctx->hexdigest;
10480        # store as a hex digest, followed by Content-Type
10481        $part->digest($digest . ':' . lc($mt||''));
10482      }
10483      if (ll(2)) {  # pretty logging
10484        my $filename = $head->recommended_filename;
10485        $encoding = 'QP'  if $encoding eq 'quoted-printable';
10486        do_log(2, "%s %s Content-Type: %s, %s, size: %d%s%s",
10487                  $part->base_name, $placement, $mt, $encoding, $size,
10488                  defined $digest ? ", $digest_algorithm digest: $digest" : '',
10489                  defined $filename ? ", name: $filename" : '');
10490      }
10491      my $old_parent_obj = $part->parent;
10492      if ($parent_obj ne $old_parent_obj) {  # reparent if necessary
10493        ll(5) && do_log(5,"reparenting %s from %s to %s", $part->base_name,
10494                          $old_parent_obj->base_name, $parent_obj->base_name);
10495        my $ch_ref = $old_parent_obj->children;
10496        $old_parent_obj->children([grep($_ ne $part, @$ch_ref)]);
10497        $ch_ref = $parent_obj->children;
10498        push(@$ch_ref,$part); $parent_obj->children($ch_ref);
10499        $part->parent($parent_obj);
10500      }
10501    }
10502  }
10503  if (defined $part) {
10504    $part->mime_placement($placement);
10505    $part->type_declared($mt eq $et ? $mt : [$mt, $et]);
10506    $part->attributes_add('U','C')  if $mt =~ m{/.*encrypted}si ||
10507                                       $et =~ m{/.*encrypted}si;
10508    my %rn_seen;
10509    my @rn;  # recommended file names, both raw and RFC 2047 / RFC 2231 decoded
10510    for my $attr_name ('content-disposition.filename', 'content-type.name') {
10511      my $val_raw = $head->mime_attr($attr_name);
10512      next  if !defined $val_raw || $val_raw eq '';
10513      my $val_dec = '';  # decoded, represented as native Perl characters
10514      eval {
10515        my(@chunks) = MIME::Words::decode_mimewords($val_raw);
10516        for my $pair (@chunks) {
10517          my($data,$encoding) = @$pair;
10518          if (!defined $encoding || $encoding eq '') {
10519            $val_dec .= safe_decode_latin1($data);  # assumes ISO-8859-1
10520          } else {
10521            $encoding =~ s/\*[^*]*\z//s;  # strip RFC 2231 language suffix
10522            $val_dec .= safe_decode($encoding,$data);
10523          }
10524        }
10525        1;
10526      } or do {
10527        do_log(3, "mime_traverse: decoding MIME words failed: %s", $@);
10528      };
10529      if ($val_dec ne '' && !$rn_seen{$val_dec}) {
10530        push(@rn,$val_dec); $rn_seen{$val_dec} = 1;
10531      }
10532      if (!$rn_seen{$val_raw}) {
10533        push(@rn,$val_raw); $rn_seen{$val_raw} = 1;
10534      }
10535    }
10536    $part->name_declared(@rn==1 ? $rn[0] : \@rn)  if @rn;
10537    my $val = $head->mime_attr('content-type.report-type');
10538    safe_encode_utf8_inplace($val);
10539    $part->report_type($val)  if defined $val && $val ne '';
10540  }
10541  mime_decode_pre_epi('epilogue', $entity->epilogue,
10542                      $tempdir, $parent_obj, $placement);
10543  my $item_num = 0;
10544  for my $e ($entity->parts) {  # recursive descent
10545    $item_num++;
10546    mime_traverse($e, $tempdir, $part, $depth+1, "$placement/$item_num");
10547  }
10548}
10549
10550# Break up mime parts, return a MIME::Entity object
10551#
10552sub mime_decode($$$) {
10553  my($msg, $tempdir, $parent_obj) = @_;
10554  # $msg may be an open file handle, or a file name, or a string ref
10555
10556  my $parser = MIME::Parser->new;
10557  # File::Temp->new defaults to /tmp or a current directory, ignoring TMPDIR
10558  $parser->tmp_dir($TEMPBASE)  if $parser->UNIVERSAL::can('tmp_dir');
10559  $parser->filer(
10560    Amavis::Unpackers::OurFiler->new("$tempdir/parts", $parent_obj) );
10561  $parser->ignore_errors(1);  # also is the default
10562  # if bounce killer is enabled, extract_nested_messages must be off,
10563  # otherwise we lose headers of attached message/rfc822 or message/global
10564  $parser->extract_nested_messages(0);
10565# $parser->extract_nested_messages("NEST");  # parse embedded message/rfc822
10566    # "NEST" complains with "part did not end with expected boundary" when
10567    # the outer message is message/partial and the inner message is chopped
10568  $parser->extract_uuencode(1);              # to enable or not to enable ???
10569  $parser->max_parts($MAXFILES)  if defined $MAXFILES && $MAXFILES > 0 &&
10570                                    $parser->UNIVERSAL::can('max_parts');
10571  snmp_count('OpsDecByMimeParser');
10572  my $entity;
10573  { local($1,$2,$3,$4,$5,$6);  # avoid Perl 5.8.* bug, $1 can get tainted !
10574    if (!defined $msg) {
10575      $entity = $parser->parse_data('');
10576    } elsif (!ref $msg) {  # assume $msg is a file name
10577      do_log(4, "Extracting mime components from file %s", $msg);
10578      $entity = $parser->parse_open("$tempdir/parts/$msg");
10579    } elsif (ref $msg eq 'SCALAR') {
10580      do_log(4, "Extracting mime components from a string");
10581      # parse_data() should be avoided with IO::File 1.09 or older:
10582      # it uses a mode '>:' to force a three-argument open(), but a mode
10583      # with a colon is only recognized starting with IO::File 1.10,
10584      # which comes with perl 5.8.1
10585      IO::File->VERSION(1.10);  # required minimal version
10586      $entity = $parser->parse_data($msg);  # takes a ref to a string
10587    } elsif (ref $msg) {  # assume an open file handle
10588      do_log(4, "Extracting mime components from a file");
10589      $msg->seek(0,0) or die "Can't rewind mail file: $!";
10590      $entity = $parser->parse($msg);
10591    }
10592  }
10593  my $mime_err;
10594  my(@mime_errors) = $parser->results->errors;  # a list!
10595  if (@mime_errors) {
10596  # $mime_err = $mime_errors[0];  # only show the first error
10597    $mime_err = join('; ',@mime_errors);  # show all errors
10598  }
10599  if (defined $mime_err) {
10600    $mime_err=~s/\s+\z//; $mime_err=~s/[ \t\r]*\n+/; /g; $mime_err=~s/\s+/ /g;
10601    substr($mime_err,250) = '[...]'  if length($mime_err) > 250;
10602    do_log(1, "WARN: MIME::Parser %s", $mime_err)  if $mime_err ne '';
10603  } elsif (!defined($entity)) {
10604    $mime_err = "Unable to parse, perhaps message contains too many parts";
10605    do_log(1, "WARN: MIME::Parser %s", $mime_err);
10606    $entity = '';
10607  }
10608  mime_traverse($entity, $tempdir, $parent_obj, 0, '1')  if $entity;
10609  section_time('mime_decode');
10610  ($entity, $mime_err);
10611}
10612
106131;
10614
10615#
10616package Amavis::MIME::Body::OnOpenFh;
10617
10618# A body class that keeps data on an open file handle, read-only,
10619# while allowing to prepend a couple of lines when reading from it.
10620# $skip_bytes bytes at the beginning of a given open file are ignored.
10621
10622use strict;
10623use re 'taint';
10624
10625BEGIN {
10626  require Exporter;
10627  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
10628  $VERSION = '2.412';
10629  @ISA = qw(Exporter MIME::Body);  # subclass of MIME::Body
10630  import Amavis::Util qw(ll do_log);
10631}
10632
10633sub init {
10634  my($self, $fh,$prefix_lines,$skip_bytes) = @_;
10635  $self->{MB_Am_fh} = $fh;
10636  $self->{MB_Am_prefix} = defined $prefix_lines ? join('',@$prefix_lines) : '';
10637  $self->{MB_Am_prefix_l} = length($self->{MB_Am_prefix});
10638  $self->{MB_Am_skip_bytes} = !defined $skip_bytes ? 0 : $skip_bytes;
10639  $self->is_encoded(1);
10640  $self;
10641}
10642
10643sub open {
10644  my($self,$mode) = @_;
10645  $self->close;  # ignoring status
10646  $mode eq 'r' or die "Only offers read-only access, mode: $mode";
10647  my $fh = $self->{MB_Am_fh}; my $skip = $self->{MB_Am_skip_bytes};
10648  $fh->seek($skip,0) or die "Can't rewind mail file: $!";
10649  $self->{MB_Am_pos} = 0;
10650  bless { parent => $self };   #** One-argument "bless" warning
10651}
10652
10653sub close { 1 }
10654
10655sub read {  # SCALAR,LENGTH,OFFSET
10656  my $self = shift; my $len = $_[1]; my $offset = $_[2];
10657  my $parent = $self->{parent}; my $pos = $parent->{MB_Am_pos};
10658  my $str1 = ''; my $str2 = ''; my $nbytes = 0;
10659  if ($len > 0 && $pos < $parent->{MB_Am_prefix_l}) {
10660    $str1 = substr($parent->{MB_Am_prefix}, $pos, $len);
10661    $nbytes += length($str1); $len -= $nbytes;
10662  }
10663  my $msg;
10664  if ($len > 0) {
10665    my $nb = $parent->{MB_Am_fh}->read($str2,$len);
10666    if (!defined $nb) {
10667      $msg = "Error reading: $!";
10668    } elsif ($nb < 1) {
10669      # read returns 0 at eof
10670    } else {
10671      $nbytes += $nb; $len -= $nb;
10672    }
10673  }
10674  if (defined $msg) {
10675    undef $nbytes;  # $! already set by a failed read
10676  } else {
10677    ($offset ? substr($_[0],$offset) : $_[0]) = $str1.$str2;
10678    $pos += $nbytes; $parent->{MB_Am_pos} = $pos;
10679  }
10680  $nbytes;   # eof: 0;  error: undef
10681}
10682
106831;
10684
10685#
10686package Amavis::Notify;
10687use strict;
10688use re 'taint';
10689
10690BEGIN {
10691  require Exporter;
10692  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
10693  $VERSION = '2.412';
10694  @ISA = qw(Exporter);
10695  @EXPORT_OK = qw(&delivery_status_notification &delivery_short_report
10696                  &build_mime_entity &defanged_mime_entity
10697                  &msg_from_quarantine &expand_variables);
10698  import Amavis::Util qw(ll do_log sanitize_str min max minmax
10699                  untaint untaint_inplace
10700                  idn_to_ascii idn_to_utf8 mail_addr_idn_to_ascii
10701                  is_valid_utf_8 safe_decode_utf8
10702                  safe_encode safe_encode_utf8 safe_encode_utf8_inplace
10703                  orcpt_encode orcpt_decode xtext_decode safe_decode_mime
10704                  make_password ccat_split ccat_maj generate_mail_id);
10705  import Amavis::Timing qw(section_time);
10706  import Amavis::Conf qw(:platform :confvars c cr ca);
10707  import Amavis::ProcControl qw(exit_status_str proc_status_ok
10708                                run_command collect_results);
10709  import Amavis::Out::EditHeader qw(hdr);
10710  import Amavis::Lookup qw(lookup lookup2);
10711  import Amavis::Expand qw(expand);
10712  import Amavis::rfc2821_2822_Tools;
10713}
10714use subs @EXPORT_OK;
10715
10716use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
10717use MIME::Entity;
10718use Time::HiRes ();
10719
10720# replace substring ${myhostname} with a value of a corresponding variable
10721sub expand_variables($) {
10722  my $str = $_[0]; local($1,$2);
10723  my $myhost = idn_to_utf8(c('myhostname'));
10724  $str =~ s{ \$ (?: \{ ([^\}]+) \} |
10725                    ([a-zA-Z](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?\b) ) }
10726           { { 'myhostname'       => $myhost,
10727               'myhostname_utf8'  => $myhost,
10728               'myhostname_ascii' => idn_to_ascii($myhost),
10729             }->{lc($1.$2)}
10730           }xgse;
10731  $str;
10732}
10733
10734# wrap a mail message into a ZIP archive
10735#
10736sub wrap_message_into_archive($$) {
10737  my($msginfo,$prefix_lines_ref) = @_;
10738
10739  # a file with a copy of a mail msg as retrieved from a quarantine:
10740  my $attachment_email_name = c('attachment_email_name');  # 'msg-%m.eml'
10741  # an archive file (will contain a retrieved message) to be attached:
10742  my $attachment_outer_name = c('attachment_outer_name');  # 'msg-%m.zip'
10743
10744  my($email_fh, $arch_size);
10745  my $mail_id = $msginfo->mail_id;
10746  if (!defined $mail_id || $mail_id eq '') {
10747    $mail_id = '';
10748  } else {
10749    $mail_id =~ /^[A-Za-z0-9_-]*\z/  or die "unsafe mail_id: $mail_id";
10750    untaint_inplace($mail_id);
10751  }
10752  for ($attachment_email_name, $attachment_outer_name) {
10753    local $1;
10754    s{%(.)}{  $1 eq 'b' ? $msginfo->body_digest
10755            : $1 eq 'P' ? $msginfo->partition_tag
10756            : $1 eq 'm' ? $mail_id
10757            : $1 eq 'n' ? $msginfo->log_id
10758            : $1 eq 'i' ? iso8601_timestamp($msginfo->rx_time,1)  #,'-')
10759            : $1 eq '%' ? '%' : '%'.$1 }gse;
10760    $_ = $msginfo->mail_tempdir . '/' . $_;
10761  }
10762  my $eval_stat;
10763  eval {
10764    # copy a retrieved message to a file
10765    $email_fh = IO::File->new;
10766    $email_fh->open($attachment_email_name, O_CREAT|O_EXCL|O_RDWR, 0640)
10767      or die "Can't create file $attachment_email_name: $!";
10768    binmode($email_fh,':bytes') or die "Can't cancel :utf8 mode: $!";
10769    for (@$prefix_lines_ref) {
10770      $email_fh->print($_)
10771        or die "Error writing to $attachment_email_name: $!";
10772    }
10773    my $msg = $msginfo->mail_text;
10774    my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
10775    $msg = $msg_str_ref  if ref $msg_str_ref;
10776    # copy quarantined mail starting at skip_bytes to $attachment_email_name
10777    my $file_position = $msginfo->skip_bytes;
10778    if (!defined $msg) {
10779      # empty mail
10780    } elsif (ref $msg eq 'SCALAR') {
10781      # do it in chunks, saves memory, cache friendly
10782      while ($file_position < length($$msg)) {
10783        $email_fh->print(substr($$msg,$file_position,16384))
10784          or die "Error writing to $attachment_email_name: $!";
10785        $file_position += 16384;  # may overshoot, no problem
10786      }
10787    } elsif ($msg->isa('MIME::Entity')) {
10788      die "wrapping a MIME::Entity object is not implemented";
10789    } else {
10790      $msg->seek($file_position,0) or die "Can't rewind mail file: $!";
10791      my($nbytes,$buff);
10792      while (($nbytes = $msg->read($buff,16384)) > 0) {
10793        $email_fh->print($buff)
10794          or die "Error writing to $attachment_email_name: $!";
10795      }
10796      defined $nbytes or die "Error reading mail file: $!";
10797      undef $buff;  # release storage
10798    }
10799    $email_fh->close or die "Can't close file $attachment_email_name: $!";
10800    undef $email_fh;
10801
10802    # create a password-protected archive containing the just prepared file;
10803    # no need to shell-protect arguments, as this does not invoke a shell
10804    my $password = $msginfo->attachment_password;
10805    my(@command) = ( qw(zip -q -j -l),
10806                     $password eq '' ? () : ('-P', $password),
10807                     $attachment_outer_name, $attachment_email_name );
10808    # supplying a password on a command line is lame as it shows in ps(1),
10809    # but an option -e would require a pseudo terminal, which is really
10810    # an overweight cannon unnecessary here: the password is used as a
10811    # scrambler only, protecting against accidental opening of a file,
10812    # so there is no security issue here
10813    $password = 'X' x length($password);  # can't hurt to wipe out
10814    my($proc_fh,$pid) = run_command(undef,undef,@command);
10815    my($r,$status) = collect_results($proc_fh,$pid,'zip',16384,[0]);
10816    undef $proc_fh; undef $pid;
10817    do_log(2,'archiver said: %s',$$r)  if ref $r && $$r ne '';
10818    $status == 0 or die "Error creating an archive: $status, $$r";
10819    my $errn = lstat($attachment_outer_name) ? 0 : 0+$!;
10820    if ($errn) { die "Archive $attachment_outer_name is inaccessible: $!" }
10821    else { $arch_size = 0 + (-s _) }
10822    1;
10823  } or do {
10824    $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
10825  };
10826  if ($eval_stat ne '' || !$arch_size) {  # handle failure
10827    my $msg = $eval_stat ne '' ? $eval_stat
10828                               : sprintf("archive size %d", $arch_size);
10829    do_log(-1,'Preparing an archive from a quarantined message failed: %s',
10830              $msg);
10831    if (defined $email_fh && $email_fh->fileno) {
10832      $email_fh->close
10833        or do_log(-1,"Can't close %s: %s", $attachment_email_name, $!);
10834    }
10835    undef $email_fh;
10836    if (-e $attachment_email_name) {
10837      unlink($attachment_email_name)
10838        or do_log(-1,"Can't remove %s: %s", $attachment_email_name, $!);
10839    }
10840    if (-e $attachment_outer_name) {
10841      unlink($attachment_outer_name)
10842        or do_log(-1,"Can't remove %s: %s", $attachment_outer_name, $!);
10843    }
10844    die "Preparing an archive from a quarantined message failed: $msg\n";
10845  }
10846  $attachment_outer_name;
10847}
10848
10849# Create a MIME::Entity object. If $mail_as_string_ref points to a string
10850# (multiline mail header with a plain text body) it is added as the first
10851# MIME part. Optionally attach a message header section from original mail,
10852# or attach a complete original message.
10853#
10854sub build_mime_entity($$$$$$$) {
10855  my($mail_as_string_ref, $msginfo, $mime_type, $msg_format, $flat,
10856     $attach_orig_headers, $attach_orig_message) = @_;
10857
10858  $msg_format = ''  if !defined $msg_format;
10859  if (!defined $mime_type || $mime_type !~ m{^ multipart (?: / | \z)}xsi) {
10860    my $multipart_cnt = 0;
10861    $multipart_cnt++  if $mail_as_string_ref;
10862    $multipart_cnt++  if defined $msginfo &&
10863                        ($attach_orig_headers || $attach_orig_message);
10864    $mime_type = 'multipart/mixed'  if $multipart_cnt > 1;
10865  }
10866  my($entity,$m_hdr,$m_body);
10867  if (!$mail_as_string_ref) {
10868    # no plain text part
10869  } elsif ($$mail_as_string_ref eq '') {
10870    $m_hdr = $m_body = '';
10871  } elsif (substr($$mail_as_string_ref, 0,1) eq "\n") { # empty header section?
10872    $m_hdr = ''; $m_body = substr($$mail_as_string_ref,1);
10873  } else {
10874    # calling index and substr is much faster than an equiv. split into $1,$2
10875    # by a regular expression: /^( (?!\n) .*? (?:\n|\z))? (?: \n (.*) )? \z/xs
10876    my $ind = index($$mail_as_string_ref,"\n\n");  # find header/body separator
10877    if ($ind < 0) {  # no body
10878      $m_hdr = $$mail_as_string_ref; $m_body = '';
10879    } else {  # normal mail, nonempty header section and nonempty body
10880      $m_hdr  = substr($$mail_as_string_ref, 0, $ind+1);
10881      $m_body = substr($$mail_as_string_ref, $ind+2);
10882    }
10883  }
10884  safe_encode_utf8_inplace($m_hdr);
10885  $m_body = safe_encode(c('bdy_encoding'), $m_body)  if defined $m_body;
10886  # make sure _our_ source line number is reported in case of failure
10887  my $multipart_cnt = 0;
10888  $mime_type = 'multipart/mixed'  if !defined $mime_type;
10889  eval {
10890    # RFC 6522: 7bit should always be adequate for multipart/report encoding
10891    $entity = MIME::Entity->build(
10892      Type => $mime_type, Encoding => '8bit',
10893      'X-Mailer' => undef);
10894    1;
10895  } or do {
10896    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
10897    die $eval_stat;
10898  };
10899  if (defined $m_hdr) {  # insert header fields into MIME::Head entity;
10900    # Mail::Header::modify allows all-or-nothing control over automatic header
10901    # fields folding by Mail::Header, which is too bad - we would prefer
10902    # to have full control on folding of header fields that are explicitly
10903    # inserted here, and let Mail::Header handle the rest. Sorry, can't be
10904    # done, so let's just disable folding by Mail::Header (which does a poor
10905    # job when presented with few break opportunities), and wrap our header
10906    # fields ourselves, hoping the remaining automatically generated header
10907    # fields won't be too long.
10908    local($1,$2);
10909    my $head = $entity->head;  $head->modify(0);
10910    $m_hdr =~ s/\r?\n(?=[ \t])//gs;  # unfold header fields in a template
10911    for my $hdr_line (split(/\r?\n/, $m_hdr)) {
10912      if ($hdr_line =~ /^([^:]*?)[ \t]*:[ \t]*(.*)\z/s) {
10913        my($fhead,$fbody) = ($1,$2);
10914        $fbody = safe_decode_mime($fbody);  # to logical characters
10915        # encode, wrap, ...
10916        my $str = hdr($fhead, $fbody, 0, ' ', $msginfo->smtputf8);
10917        # re-split the result
10918        ($fhead,$fbody) = ($1,$2)  if $str =~ /^([^:]*):[ \t]*(.*)\z/s;
10919        chomp($fbody);
10920        do_log(5, "build_mime_entity %s: %s", $fhead,$fbody);
10921        eval {  # make sure _our_ source line number is reported on failure
10922          $head->replace($fhead,$fbody);  1;
10923        } or do {
10924          $@ = "errno=$!"  if $@ eq '';  chomp $@;
10925          die $@  if $@ =~ /^timed out\b/;  # resignal timeout
10926          die sprintf("%s header field '%s: %s'",
10927                      ($@ eq '' ? "invalid" : "$@, "), $fhead,$fbody);
10928        };
10929      }
10930    }
10931  }
10932  my(@prefix_lines);
10933  if (defined $m_body) {
10934    if ($flat && $attach_orig_message) {
10935      my($pos,$j);  # split $m_body into lines, retaining each \n
10936      for ($pos=0; ($j=index($m_body,"\n",$pos)) >= 0; $pos = $j+1) {
10937        push(@prefix_lines, substr($m_body,$pos,$j-$pos+1));
10938      }
10939      push(@prefix_lines, substr($m_body,$pos))  if $pos < length($m_body);
10940    } else {
10941      my $cnt_8bit = $m_body =~ tr/\x00-\x7F//c;
10942      eval {  # make sure _our_ source line number is reported on failure
10943        $entity->attach(
10944          Type => 'text/plain', Data => $m_body,
10945          Charset  => !$cnt_8bit ? 'us-ascii' : c('bdy_encoding'),
10946          Encoding => !$cnt_8bit ? '7bit'
10947                    : $cnt_8bit < 0.2 * length($m_body) ? 'quoted-printable'
10948                                                        : 'base64',
10949        );
10950        $multipart_cnt++; 1;
10951      } or do {
10952        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
10953        die $eval_stat;
10954      };
10955    }
10956  }
10957  # prepend a Return-Path to make available the envelope sender address
10958  push(@prefix_lines, "\n")  if @prefix_lines;  # separates text from a message
10959  push(@prefix_lines, sprintf("Return-Path: %s\n", $msginfo->sender_smtp));
10960
10961  if (defined $msginfo && $attach_orig_headers && !$attach_orig_message) {
10962    # attach a header section only
10963    my $hdr_8bit =
10964      $msginfo->header_8bit || grep(tr/\x00-\x7F//c, @prefix_lines);
10965    my $hdr_utf8 = 1;
10966    if ($hdr_8bit) {
10967      for (@prefix_lines, @{$msginfo->orig_header}) {
10968        if (tr/\x00-\x7F//c && !is_valid_utf_8($_)) { $hdr_utf8 = 0; last }
10969      }
10970    }
10971
10972    # RFC 6522 Encoding considerations for text/rfc822-headers:
10973    # 7-bit is sufficient for normal mail headers, however, if the
10974    # headers are broken or extended and require encoding to make them
10975    # legal 7-bit content, they MAY be encoded with quoted-printable
10976    # as defined in [MIME].
10977
10978    # RFC 6532 section 3.5: allows newly defined MIME types to permit
10979    # content-transfer-encoding, and it allows content-transfer-encoding
10980    # for message/global.
10981
10982    # RFC 6533: Note that [RFC6532] relaxed a restriction from MIME [RFC2046]
10983    # regarding the use of Content-Transfer-Encoding in new "message"
10984    # subtypes. This specification (RFC 6533) explicitly allows the use
10985    # of Content-Transfer-Encoding in message/global-headers and
10986    # message/global-delivery-status.
10987
10988    my $headers_mime_type =
10989      $flat ? 'text/plain' :
10990      $hdr_8bit && $hdr_utf8 ? 'message/global-headers'  # RFC 6533
10991                             : 'text/rfc822-headers';    # RFC 6522
10992
10993    # [rt.cpan.org #98737] MIME::Tools 5.505 prohibits quoted-printable
10994    # for message/global-headers. Fixed by a later release.
10995    # my $headers_mime_encoding =
10996    #   !$hdr_8bit ? '7bit' :
10997    #   $headers_mime_type =~ m{^text/}i || MIME::Entity->VERSION > 5.505
10998    #     ? 'quoted-printable' : '8bit';
10999
11000    my $headers_mime_encoding = $hdr_8bit ? '8bit' : '7bit';
11001
11002    ll(4) && do_log(4,"build_mime_entity: attaching original ".
11003                      "header section, MIME type: %s, encoding: %s",
11004                      $headers_mime_type, $headers_mime_encoding);
11005
11006    # RFC 6533 section 6.3. Interoperability considerations:
11007    # It is important that message/global-headers media type is not
11008    # converted to a charset other than UTF-8.  As a result, implementations
11009    # MUST NOT include a charset parameter with this media type.
11010
11011    eval {  # make sure _our_ source line number is reported on failure
11012      $entity->attach(
11013        Data => [@prefix_lines, @{$msginfo->orig_header}],
11014        Type     => $headers_mime_type,
11015        Encoding => $headers_mime_encoding,
11016        Filename => $headers_mime_type eq 'message/global-headers' ?
11017                      'header.u8hdr' : 'header.hdr',
11018        Disposition => 'inline',
11019        Description => 'Message header section',
11020      );
11021      $multipart_cnt++; 1;
11022    } or do {
11023      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
11024      die $eval_stat;
11025    };
11026
11027  } elsif (defined $msginfo && $attach_orig_message) {
11028    # attach a complete message
11029    my $password;
11030    if ($msg_format eq 'attach') {   # not 'arf' and not 'dsn'
11031      $password = $msginfo->attachment_password;  # already have it?
11032      if (!defined $password) {  # make one, and store it for later
11033        $password = make_password(c('attachment_password'), $msginfo);
11034        $msginfo->attachment_password($password);
11035      }
11036    }
11037    if ($msg_format eq 'attach' &&   # not 'arf' and not 'dsn'
11038        defined $password && $password ne '') {
11039      # attach as a ZIP archive
11040      $password = 'X' x length($password);  # can't hurt to wipe out
11041      do_log(4, "build_mime_entity: attaching entire original message as zip");
11042      my $archive_fn = wrap_message_into_archive($msginfo,\@prefix_lines);
11043      local($1); $archive_fn =~ m{([^/]*)\z}; my $att_filename = $1;
11044      eval {  # make sure _our_ source line number is reported on failure
11045        my $att = $entity->attach(  # RFC 2046
11046          Type => 'application/zip', Filename => $att_filename,
11047          Path => $archive_fn, Encoding => 'base64',
11048          Disposition => 'attachment', Description => 'Original message',
11049        );
11050        $multipart_cnt++; 1;
11051      } or do {
11052        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
11053        die $eval_stat;
11054      };
11055
11056    } else {
11057      # attach as a normal message
11058      do_log(4, "build_mime_entity: attaching entire original message, plain");
11059      my $orig_mail_as_body;
11060      my $msg = $msginfo->mail_text;
11061      my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
11062      $msg = $msg_str_ref  if ref $msg_str_ref;
11063      if (!defined $msg) {
11064        # empty mail
11065      } elsif (ref $msg eq 'SCALAR') {
11066        # will be handled by ->attach
11067      } elsif ($msg->isa('MIME::Entity')) {
11068        die "attaching a MIME::Entity object is not implemented";
11069      } else {
11070        $orig_mail_as_body =
11071          Amavis::MIME::Body::OnOpenFh->new($msginfo->mail_text,
11072                                         \@prefix_lines, $msginfo->skip_bytes);
11073        $orig_mail_as_body or die "Can't create Amavis::MIME::Body object: $!";
11074      }
11075
11076      # RFC 6532 section 3.7: Internationalized messages in message/global
11077      # format MUST only be transmitted as authorized by [RFC6531]
11078      # or within a non-SMTP environment that supports these messages.
11079      my $message_mime_type =
11080        $flat ? 'text/plain' :
11081        $msginfo->smtputf8 && $msginfo->header_8bit
11082          ? 'message/global'  # RFC 6532
11083          : 'message/rfc822';
11084
11085      # [rt.cpan.org #98737] MIME::Tools 5.505 prohibits quoted-printable
11086      # for message/global. Fixed by a later release.
11087      my $message_mime_encoding =
11088        !$msginfo->header_8bit && !$msginfo->body_8bit ? '7bit' :
11089        $message_mime_type =~ m{^text/}i || MIME::Entity->VERSION > 5.505
11090          ? 'quoted-printable' : '8bit';
11091
11092      eval {  # make sure _our_ source line number is reported on failure
11093        my $att = $entity->attach(  # RFC 2046, RFC 6532
11094          Type => $message_mime_type,
11095          Encoding => $message_mime_encoding,
11096          Data => defined $orig_mail_as_body ? []
11097                : !$msginfo->skip_bytes ? $msg
11098                : substr($$msg, $msginfo->skip_bytes),
11099        # Path => $msginfo->mail_text_fn,
11100          $flat ? () : (Disposition => 'attachment', Filename => 'message',
11101                        Description => 'Original message'),
11102          # RFC 6532: File extension ".u8msg" is suggested for message/global
11103        );
11104        # direct access to tempfile handle
11105        $att->bodyhandle($orig_mail_as_body)  if defined $orig_mail_as_body;
11106        $multipart_cnt++; 1;
11107      } or do {
11108        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
11109        die $eval_stat;
11110      };
11111    }
11112  }
11113  $entity->make_singlepart  if $multipart_cnt < 2;
11114  $entity;  # return the constructed MIME::Entity
11115}
11116
11117# If $msg_format is 'dsn' generate a delivery status notification according
11118# to RFC 6522 (ex RFC 3462, RFC 1892), RFC 3464 (ex RFC 1894) and RFC 3461
11119# (ex RFC 1891).
11120# If $msg_format is 'arf' generate an abuse report according to RFC 5965
11121# - "An Extensible Format for Email Feedback Reports". If $msg_format is
11122# 'attach', generate a report message and attach the original message.
11123# If $msg_format is 'plain', generate a simple (flat) mail with the only
11124# MIME part being the original message (abuse@yahoo.com can't currently
11125# handle attachments in reports). Returns a message object, or undef if
11126# DSN is requested but not needed.
11127#   $request_type:  dsn, release, requeue, report
11128#   $msg_format:    dsn, arf, attach, plain, resend
11129#   $feedback_type: abuse, dkim, fraud, miscategorized, not-spam,
11130#                   opt-out, virus, other
11131#
11132sub delivery_status_notification($$$;$$$$) {  # ..._or_report
11133  my($msginfo,$dsn_per_recip_capable,$builtins_ref,
11134     $notif_recips,$request_type,$feedback_type,$msg_format) = @_;
11135  my $notification; my $suppressed = 0;
11136  my $is_smtputf8 = $msginfo->smtputf8;  # UTF-8 allowed
11137  if (!defined($msg_format)) {
11138    $msg_format = $request_type eq 'dsn'    ? 'dsn'
11139                : $request_type eq 'report' ? c('report_format')
11140                                            : c('release_format');
11141  }
11142  my($is_arf,$is_dsn,$is_attach,$is_plain) = (0) x 4;
11143  if    ($msg_format eq 'dsn')    { $is_dsn = 1 }
11144  elsif ($msg_format eq 'arf')    { $is_arf = 1 }
11145  elsif ($msg_format eq 'attach') { $is_attach = 1 }
11146  else                            { $is_plain = 1 }  # 'plain'
11147  my $dsn_time = $msginfo->rx_time;  # time of dsn creation - same as message
11148    # use a reception time for consistency and to be resilient to clock jumps
11149  $dsn_time = Time::HiRes::time  if !$dsn_time;  # now, if missing
11150  my $rfc2822_dsn_time = rfc2822_timestamp($dsn_time);
11151  my $sender = $msginfo->sender;
11152  my $dsn_passed_on = $msginfo->dsn_passed_on;  # NOTIFY=SUCCESS passed to MTA
11153  my $per_recip_data = $msginfo->per_recip_data;
11154  my $all_rejected = 0;
11155  if (@$per_recip_data) {
11156    $all_rejected = 1;
11157    for my $r (@$per_recip_data) {
11158      if ($r->recip_destiny != D_REJECT || $r->recip_smtp_response !~ /^5/)
11159        { $all_rejected = 0; last }
11160    }
11161  }
11162  my($min_spam_level, $max_spam_level) =
11163    minmax(map($_->spam_level, @{$msginfo->per_recip_data}));
11164  $min_spam_level = 0  if !defined $min_spam_level;
11165  $max_spam_level = 0  if !defined $max_spam_level;
11166
11167  my $is_credible = $msginfo->sender_credible || '';
11168  my $os_fingerprint = $msginfo->client_os_fingerprint;
11169  my($cutoff_byrecip_maps, $cutoff_bysender_maps);
11170  my($dsn_cutoff_level_bysender, $dsn_cutoff_level);
11171  if ($is_dsn && $sender ne '') {
11172    # for null sender it doesn't matter, as DSN will not be sent regardless
11173    if ($is_credible) {
11174      do_log(3, "DSN: sender is credible (%s), SA: %.3f, <%s>",
11175                $is_credible, $max_spam_level, $sender);
11176      $cutoff_byrecip_maps  = ca('spam_crediblefrom_dsn_cutoff_level_maps');
11177      $cutoff_bysender_maps =
11178                     ca('spam_crediblefrom_dsn_cutoff_level_bysender_maps');
11179    } else {
11180      do_log(5, "DSN: sender NOT credible, SA: %.3f, <%s>",
11181                $max_spam_level, $sender);
11182      $cutoff_byrecip_maps  = ca('spam_dsn_cutoff_level_maps');
11183      $cutoff_bysender_maps = ca('spam_dsn_cutoff_level_bysender_maps');
11184    }
11185    $dsn_cutoff_level_bysender = lookup2(0,$sender,$cutoff_bysender_maps);
11186  }
11187
11188  my $txt_recip = '';  # per-recipient part of dsn text according to RFC 3464
11189  my($any_succ,$any_fail,$any_delayed) = (0,0,0); local($1);
11190  for my $r (!$is_dsn ? () : @$per_recip_data) {  # prepare per-recip fields
11191    my $recip = $r->recip_addr;
11192    my $smtp_resp = $r->recip_smtp_response;
11193    my $recip_done = $r->recip_done; # 2=relayed to MTA, 1=faked deliv/quarant
11194    my $ccat_name = $r->setting_by_contents_category(\%ccat_display_names);
11195    $ccat_name = "NonBlocking:$ccat_name"  if !defined($r->blocking_ccat);
11196    my $spam_level = $r->spam_level;
11197    if (!$recip_done) {
11198      my $fwd_m = $r->delivery_method;
11199      if (!defined $fwd_m) {
11200        do_log(-2,"TROUBLE: recipient not done, undefined delivery_method: ".
11201                  "<%s> %s", $recip,$smtp_resp);
11202      } elsif ($fwd_m eq '') {  # e.g. milter
11203        # as far as we are concerned all is ok, delivery will be performed
11204        # by a helper program or MTA
11205        $smtp_resp = "250 2.5.0 Ok, continue delivery";
11206      } else {
11207        do_log(-2,"TROUBLE: recipient not done: <%s> %s", $recip,$smtp_resp);
11208      }
11209    }
11210    my $smtp_resp_class = $smtp_resp =~ /^(\d)/  ? $1 : '0';
11211    my $smtp_resp_code  = $smtp_resp =~ /^(\d+)/ ? $1 : '0';
11212    my $dsn_notify = $r->dsn_notify;
11213    my($notify_on_failure,$notify_on_success,$notify_on_delay,$notify_never) =
11214      (0,0,0,0);
11215    if (!defined($dsn_notify)) {
11216      $notify_on_failure = $notify_on_delay = 1;
11217    } else {
11218      for (@$dsn_notify) {  # validity of the list has already been checked
11219        if    ($_ eq 'FAILURE') { $notify_on_failure = 1 }
11220        elsif ($_ eq 'SUCCESS') { $notify_on_success = 1 }
11221        elsif ($_ eq 'DELAY')   { $notify_on_delay   = 1 }
11222        elsif ($_ eq 'NEVER')   { $notify_never = 1 }
11223      }
11224    }
11225    if ($notify_never || $sender eq '') {
11226      $notify_on_failure = $notify_on_success = $notify_on_delay = 0;
11227    }
11228    my $dest = $r->recip_destiny;
11229    my $remote_or_local = $recip_done==2 ? 'from MTA' :
11230                          $recip_done==1 ? '.' :  # this agent
11231                          'status-to-be-passed-back';
11232    # warn_sender is an old relic and does not fit well into DSN concepts;
11233    # we'll sneak it in, pretending to cause a DELAY notification
11234    my $warn_sender =
11235      $notify_on_delay && $smtp_resp_class eq '2' && $recip_done==2 &&
11236      $r->setting_by_contents_category(cr('warnsender_by_ccat'));
11237    ll(5) && do_log(5,
11238              "dsn: %s %s %s <%s> -> <%s>: on_succ=%d, on_dly=%d, ".
11239              "on_fail=%d, never=%d, warn_sender=%s, DSN_passed_on=%s, ".
11240              "destiny=%s, mta_resp: \"%s\"",
11241              $remote_or_local, $smtp_resp_code, $ccat_name, $sender, $recip,
11242              $notify_on_success, $notify_on_delay, $notify_on_failure,
11243              $notify_never, $warn_sender, $dsn_passed_on, $dest, $smtp_resp);
11244    # clearly log common cases to facilitate troubleshooting;
11245
11246    # first look for some standard reasons for not sending a DSN
11247    if ($smtp_resp_class eq '4') {
11248      do_log(4, "DSN: TMPFAIL %s %s %s, not to be reported: <%s> -> <%s>",
11249                $remote_or_local,$smtp_resp_code,$ccat_name,$sender,$recip);
11250    } elsif ($smtp_resp_class eq '5' && $dest==D_REJECT &&
11251             ($dsn_per_recip_capable || $all_rejected)) {
11252      do_log(4, "DSN: FAIL %s %s %s, status propagated back: <%s> -> <%s>",
11253                $remote_or_local,$smtp_resp_code,$ccat_name,$sender,$recip);
11254    } elsif ($smtp_resp_class eq '5' && !$notify_on_failure) {
11255      $suppressed = 1;
11256      do_log($recip_done==2 ? 0 : 4, # log level 0 for remotes, RFC 3461 5.2.2d
11257                "DSN: FAIL %s %s %s, %s requested to be IGNORED: <%s> -> <%s>",
11258                $remote_or_local,$smtp_resp_code,$ccat_name,
11259                $notify_never?'explicitly':'implicitly', $sender, $recip);
11260    } elsif ($smtp_resp_class eq '2' && !$notify_on_success && !$warn_sender) {
11261      my $fmt = $dest==D_DISCARD
11262                  ? "SUCC (discarded) %s %s %s, destiny=DISCARD"
11263                  : "SUCC %s %s %s, no DSN requested";
11264      do_log(5, "DSN: $fmt: <%s> -> <%s>",
11265             $remote_or_local,$smtp_resp_code,$ccat_name,$sender,$recip);
11266    } elsif ($smtp_resp_class eq '2' && $notify_on_success && $dsn_passed_on &&
11267             !$warn_sender) {
11268      do_log(5, "DSN: SUCC %s %s %s, DSN parameters PASSED-ON: <%s> -> <%s>",
11269                $remote_or_local,$smtp_resp_code,$ccat_name,$sender,$recip);
11270    } elsif ($notify_never || $sender eq '') {  # test sender just in case
11271      $suppressed = 1;
11272      do_log(5, "DSN: NEVER %s %s, <%s> -> %s",
11273                $smtp_resp_code,$ccat_name,$sender,$recip);
11274
11275    # next, look for some good _excuses_ for not sending a DSN
11276
11277    } elsif ($dest==D_DISCARD) {  # requested by final_*_destiny
11278      $suppressed = 1;
11279      do_log(4, "DSN: FILTER %s %s %s, destiny=DISCARD: <%s> -> <%s>",
11280                $remote_or_local,$smtp_resp_code,$ccat_name,$sender,$recip);
11281    } elsif (defined $r->dsn_suppress_reason) {
11282      $suppressed = 1;
11283      do_log(3, "DSN: FILTER %s %s, suppress reason: %s, <%s> -> <%s>",
11284                $smtp_resp_code, $ccat_name, $r->dsn_suppress_reason,
11285                $sender,$recip);
11286    } elsif (defined $dsn_cutoff_level_bysender &&
11287             $spam_level >= $dsn_cutoff_level_bysender) {
11288      $suppressed = 1;
11289      do_log(3, "DSN: FILTER %s %s, spam level %.3f exceeds cutoff %s%s, ".
11290                "<%s> -> <%s>", $smtp_resp_code, $ccat_name,
11291                $spam_level, $dsn_cutoff_level_bysender,
11292                !$is_credible ? '' : ", (credible: $is_credible)",
11293                $sender, $recip);
11294    } elsif (defined($cutoff_byrecip_maps) &&
11295             ( $dsn_cutoff_level=lookup2(0,$recip,$cutoff_byrecip_maps),
11296               defined($dsn_cutoff_level) &&
11297               ( $spam_level >= $dsn_cutoff_level ||
11298                 ( $r->recip_blacklisted_sender &&
11299                  !$r->recip_whitelisted_sender) )
11300              ) ) {
11301      $suppressed = 1;
11302      do_log(3, "DSN: FILTER %s %s, spam level %.3f exceeds ".
11303                "by-recipient cutoff %s%s, <%s> -> <%s>",
11304                $smtp_resp_code, $ccat_name,
11305                $spam_level, $dsn_cutoff_level,
11306                !$is_credible ? '' : ", (credible: $is_credible)",
11307                $sender, $recip);
11308    } elsif ($msginfo->is_bulk && ccat_maj($r->contents_category) > CC_CLEAN) {
11309      $suppressed = 1;
11310      do_log(3, "DSN: FILTER %s %s, suppressed, bulk mail (%s), <%s> -> <%s>",
11311                $smtp_resp_code,$ccat_name,$msginfo->is_bulk,$sender,$recip);
11312    } elsif ($os_fingerprint =~ /^Windows\b/ &&   # hard-coded limits!
11313             !$msginfo->dkim_envsender_sig   &&   # a hack
11314             $spam_level >=
11315               ($os_fingerprint=~/^Windows XP(?![^(]*\b2000 SP)/ ? 5 : 8)) {
11316      $os_fingerprint =~ /^(\S+\s+\S+)/;
11317      do_log(3, "DSN: FILTER %s %s, suppressed for mail from %s ".
11318                "at %s, score=%s, <%s> -> <%s>", $smtp_resp_code, $ccat_name,
11319                $1, $msginfo->client_addr, $spam_level, $sender,$recip);
11320    } else {
11321      # RFC 3461, section 5.2.8: "A single DSN may describe attempts to deliver
11322      # a message to multiple recipients of that message. If a DSN is issued
11323      # for some recipients in an SMTP transaction and not for others according
11324      # to the rules above, the DSN SHOULD NOT contain information for
11325      # recipients for whom DSNs would not otherwise have been issued."
11326      $txt_recip .= "\n";  # empty line between groups of per-recipient fields
11327
11328      my $dsn_orcpt = $r->dsn_orcpt;
11329      if (defined $dsn_orcpt) {
11330        # RFC 6533: systems generating a message/global-delivery-status
11331        # body part SHOULD use the utf-8-address form of the UTF-8 address
11332        # type for all addresses containing characters outside the ASCII
11333        # repertoire. These systems SHOULD upconvert the utf-8-addr-xtext
11334        # or the utf-8-addr-unitext form of a UTF-8 address type in the
11335        # ORCPT parameter to the utf-8-address form of a UTF-8 address type
11336        # in the "Original-Recipient:" field.
11337        my($addr_type, $addr) = orcpt_encode($dsn_orcpt, $is_smtputf8);
11338        $txt_recip .= "Original-Recipient: $addr_type;$addr\n";  # as octets
11339      }
11340      my $remote_mta = $r->recip_remote_mta;
11341      my $final_recip_encoded;
11342      { # normalize recipient address (like UTF-8 decoding)
11343        my($addr_type, $addr) = orcpt_decode(';'.quote_rfc2821_local($recip));
11344        ($addr_type, $addr) = orcpt_encode($addr_type.';'.$addr, $is_smtputf8);
11345        $final_recip_encoded = $addr_type.';'.$addr;
11346      }
11347      if (defined $dsn_orcpt || $remote_mta eq '' ||
11348          $r->recip_final_addr eq $recip) {
11349        $txt_recip .= "Final-Recipient: $final_recip_encoded\n";
11350      } else {
11351        $txt_recip .= "X-NextToLast-Final-Recipient: $final_recip_encoded\n";
11352        # normalize final recipient address (e.g. UTF-8 decoding)
11353        my($addr_type, $addr) =
11354          orcpt_decode(';'.quote_rfc2821_local($r->recip_final_addr));
11355        ($addr_type, $addr) = orcpt_encode($addr_type.';'.$addr, $is_smtputf8);
11356        $txt_recip .= "Final-Recipient: $addr_type;$addr\n";
11357      }
11358      my($smtp_resp_code, $smtp_resp_enhcode, $smtp_resp_msg);
11359      local($1,$2,$3);
11360      if ($smtp_resp =~ /^ (\d{3}) [ \t-] [ \t]* ([245] \. \d{1,3} \. \d{1,3})?
11361                           \s* (.*) \z/xs) {
11362        ($smtp_resp_code, $smtp_resp_enhcode, $smtp_resp_msg) = ($1,$2,$3);
11363      } else {
11364        $smtp_resp_msg = $smtp_resp;
11365      }
11366      if ($smtp_resp_enhcode eq '' && $smtp_resp_class =~ /^([245])\z/) {
11367        $smtp_resp_enhcode = "$1.0.0";
11368      }
11369      my $action;  # failed / relayed / delivered / expanded
11370      if ($recip_done == 2) {  # truly forwarded to MTA
11371        $action = $smtp_resp_class eq '5' ? 'failed'     # remote reject
11372                : $smtp_resp_class ne '2' ? undef        # shouldn't happen
11373                : !$dsn_passed_on ? 'relayed'   # relayed to non-conforming MTA
11374                : $warn_sender ? 'delayed'  # disguised as a DELAY notification
11375                : undef;  # shouldn't happen
11376      } elsif ($recip_done == 1) {
11377        # a faked delivery to bit bucket or to a quarantine
11378        $action = $smtp_resp_class eq '5' ? 'failed'     # local reject
11379                : $smtp_resp_class eq '2' ? 'delivered'  # discard / bit bucket
11380                : undef;  # shouldn't happen
11381      } elsif (!defined($recip_done) || $recip_done == 0) {
11382        $action = $smtp_resp_class eq '2' ? 'relayed'  #????
11383                : undef;  # shouldn't happen
11384      }
11385      defined $action  or die "Assert failed: $smtp_resp, $smtp_resp_class, ".
11386                              "$recip_done, $dsn_passed_on";
11387      if ($action eq 'failed') { $any_fail=1 }
11388      elsif ($action eq 'delayed') { $any_delayed=1 } else { $any_succ=1 }
11389      $txt_recip .= "Action: $action\n";
11390      $txt_recip .= "Status: $smtp_resp_enhcode\n";
11391      my $rem_smtp_resp = $r->recip_remote_mta_smtp_response;
11392      if ($warn_sender && $action eq 'delayed') {
11393        $smtp_resp = '250 2.6.0 Bad message, but will be delivered anyway';
11394      } elsif ($remote_mta ne '' && $rem_smtp_resp ne '') {
11395        $txt_recip .= "Remote-MTA: dns; $remote_mta\n";
11396        $smtp_resp = $rem_smtp_resp;
11397      } elsif ($smtp_resp !~ /\n/ && length($smtp_resp) > 78-23) { # wrap magic
11398        # take liberty to wrap our own SMTP responses
11399        $smtp_resp = wrap_string("x" x (23-11) . $smtp_resp, 78-11,'','',0);
11400        # length(" 554 5.0.0 ") = 11; length("Diagnostic-Code: smtp; ") = 23
11401        # insert and then remove prefix to maintain consistent wrapped size
11402        $smtp_resp =~ s/^x{12}//;
11403        # wrap response code according to RFC 3461 section 9.2
11404        $smtp_resp = join("\n", @{wrap_smtp_resp($smtp_resp)});
11405      }
11406      $smtp_resp =~ s/\n(?![ \t])/\n /gs;
11407      $txt_recip .= "Diagnostic-Code: smtp; $smtp_resp\n";
11408      # RFC 6533 adds optional field Localized-Diagnostic
11409      $txt_recip .= "Last-Attempt-Date: $rfc2822_dsn_time\n";
11410      my $final_log_id = $msginfo->log_id;
11411      $final_log_id .= '/' . $msginfo->mail_id  if defined $msginfo->mail_id;
11412      $txt_recip .= sprintf("Final-Log-ID: %s\n", $final_log_id);
11413      do_log(2, "DSN: NOTIFICATION: Action:%s, %s %s %s, spam level %.3f, ".
11414                "<%s> -> <%s>",  $action,
11415                $recip_done==2 && $action ne 'delayed' ? 'RELAYED' : 'LOCAL',
11416                $smtp_resp_code, $ccat_name, $spam_level, $sender, $recip);
11417    }
11418  }  # endfor per_recip_data
11419
11420  # prepare a per-message part of a report
11421  my $txt_msg = '';
11422  my $myhost = c('myhostname');  # my FQDN (DNS) name, UTF-8 octets
11423  $myhost = $is_smtputf8 ? idn_to_utf8($myhost) : idn_to_ascii($myhost);
11424  my $dsn_envid = $msginfo->dsn_envid;  # ENVID is encoded as xtext: RFC 3461
11425
11426  if ($is_dsn) {  # DSN - per-msg part of dsn text according to RFC 3464
11427    my $conn = $msginfo->conn_obj;
11428    my $from_mta = $conn->smtp_helo;
11429    my $client_ip = $conn->client_ip;
11430    $txt_msg .= "Reporting-MTA: dns; $myhost\n";
11431    $txt_msg .= "Received-From-MTA: dns; $from_mta ([$client_ip])\n"
11432      if $from_mta ne '';
11433    $txt_msg .= "Arrival-Date: ". rfc2822_timestamp($msginfo->rx_time) ."\n";
11434    my $dsn_envid = $msginfo->dsn_envid;  # ENVID is encoded as xtext: RFC 3461
11435    if (defined $dsn_envid) {
11436      $dsn_envid = sanitize_str(xtext_decode($dsn_envid));
11437      $txt_msg .= "Original-Envelope-Id: $dsn_envid\n";
11438    }
11439
11440  } elsif ($is_arf) {  # abuse report format - RFC 5965
11441    # abuse, dkim, fraud, miscategorized, not-spam, opt-out, virus, other
11442    $txt_msg .= "Version: 1\n";                     # required
11443    $txt_msg .= "Feedback-Type: $feedback_type\n";  # required
11444    # User-Agent must comply with RFC 2616, section 14.43
11445    my $ua_version = "$myproduct_name/$myversion_id ($myversion_date)";
11446    $txt_msg .= "User-Agent: $ua_version\n";        # required
11447    $txt_msg .= "Reporting-MTA: dns; $myhost\n";
11448    # optional fields:
11449
11450    # RFC 6692: Report generators that include an Arrival-Date report field
11451    # MAY choose to express the value of that date in Universal Coordinated
11452    # Time (UTC) to enable simpler correlation with local records at sites
11453    # that are following the provisions of RFC 6302.
11454    $txt_msg .= 'Arrival-Date: ';
11455    $txt_msg .= rfc2822_utc_timestamp($msginfo->rx_time) . "\n";
11456  # $txt_msg .= rfc2822_timestamp($msginfo->rx_time) . "\n";
11457
11458    my $cl_ip_addr = $msginfo->client_addr;
11459    if (defined $cl_ip_addr) {
11460      $cl_ip_addr = 'IPv6:'.$cl_ip_addr  if $cl_ip_addr =~ /:[0-9a-f]*:/i &&
11461                                            $cl_ip_addr !~ /^IPv6:/i;
11462      $txt_msg .= "Source-IP: $cl_ip_addr\n";
11463    }
11464    # RFC 6692 (was: draft-kucherawy-marf-source-ports):
11465    my $cl_ip_port = $msginfo->client_port;
11466    $txt_msg .= "Source-Port: $cl_ip_port\n" if defined $cl_ip_port;
11467    my $dsn_envid = $msginfo->dsn_envid;  # ENVID is encoded as xtext: RFC 3461
11468    if (defined $dsn_envid) {
11469      $dsn_envid = sanitize_str(xtext_decode($dsn_envid));
11470      $txt_msg .= "Original-Envelope-Id: $dsn_envid\n";
11471    }
11472    $txt_msg .= "Original-Mail-From: " . $msginfo->sender_smtp . "\n";
11473    for my $r (@$per_recip_data) {
11474      $txt_msg .= "Original-Rcpt-To: " . $r->recip_addr_smtp . "\n";
11475    }
11476    my $sigs_ref = $msginfo->dkim_signatures_valid;
11477    if ($sigs_ref) {
11478      for my $sig (@$sigs_ref) {
11479        my $type = $sig->isa('Mail::DKIM::DkSignature') ? 'DK' : 'DKIM';
11480        $txt_msg .= sprintf("Reported-Domain: %s (valid %s signature by)\n",
11481                            $sig->domain, $type);
11482      }
11483    }
11484    if (c('enable_dkim_verification')) {
11485      for (Amavis::DKIM::generate_authentication_results($msginfo,0)) {
11486        my $h = $_;  $h =~ tr/\n//d;  # remove potential folding points
11487        $txt_msg .= "Authentication-Results: $h\n";
11488      }
11489    }
11490    $txt_msg .= "Incidents: 1\n";
11491    # Reported-URI
11492  }
11493
11494  my($txt_8bit, $txt_utf8);
11495  my($delivery_status_mime_type, $delivery_status_mime_subtype);
11496  if ($is_dsn || $is_arf) {
11497    $txt_8bit = ($txt_msg=~tr/\x00-\x7F//c) + ($txt_recip=~tr/\x00-\x7F//c);
11498    $txt_utf8 = !$txt_8bit ||
11499                (is_valid_utf_8($txt_msg) && is_valid_utf_8($txt_recip));
11500    $delivery_status_mime_subtype =
11501        $is_arf ? 'feedback-report'
11502      : $txt_utf8 && ($is_smtputf8 || $txt_8bit) ? 'global-delivery-status'
11503                                                 : 'delivery-status';
11504    $delivery_status_mime_type = 'message/' . $delivery_status_mime_subtype;
11505  }
11506
11507  if ( $is_arf || $is_plain || $is_attach ||
11508      ($is_dsn && ($any_succ || $any_fail || $any_delayed)) ) {
11509    my(@hdr_to) = $notif_recips ? qquote_rfc2821_local(@$notif_recips)
11510                                : map($_->recip_addr_smtp, @$per_recip_data);
11511    $_ = mail_addr_idn_to_ascii($_)  for @hdr_to;
11512    my $hdr_from = $msginfo->setting_by_contents_category(
11513                              $is_dsn ? cr('hdrfrom_notify_sender_by_ccat') :
11514            $request_type eq 'report' ? cr('hdrfrom_notify_report_by_ccat') :
11515                                        cr('hdrfrom_notify_release_by_ccat') );
11516    # make sure it's in octets
11517    $hdr_from = expand_variables(safe_encode_utf8($hdr_from));
11518    # use the provided template text
11519    my(%mybuiltins) = %$builtins_ref;  # make a local copy
11520    # not really needed, these header fields are overridden later
11521    $mybuiltins{'f'} = safe_decode_utf8($hdr_from);
11522    $mybuiltins{'T'} = \@hdr_to;
11523    $mybuiltins{'d'} = $rfc2822_dsn_time;
11524    $mybuiltins{'report_format'} = $msg_format;
11525    $mybuiltins{'feedback_type'} = $feedback_type;
11526
11527    # RFC 3461 section 6.2: "If a DSN contains no notifications of
11528    # delivery failure, the MTA SHOULD return only the header section."
11529    my $dsn_ret = $msginfo->dsn_ret;
11530    my $attach_full_msg =
11531      !$is_dsn ? 1 : (defined $dsn_ret && $dsn_ret eq 'FULL' && $any_fail);
11532    if ($attach_full_msg && $is_dsn) {
11533      # apologize in the log, we should have supplied the full message, yet
11534      # RFC 3461 section 6.2 gives us an excuse: "However, if the length of the
11535      # message is greater than some implementation-specified length, the MTA
11536      # MAY return only the headers even if the RET parameter specified FULL."
11537      do_log(1, "DSN RET=%s requested, but we'll only attach a header section",
11538                $dsn_ret);
11539      $attach_full_msg = 0;  # override, just attach a header section
11540    }
11541    my $template_ref = $msginfo->setting_by_contents_category(
11542                                $is_dsn ? cr('notify_sender_templ_by_ccat') :
11543              $request_type eq 'report' ? cr('notify_report_templ_by_ccat') :
11544                                          cr('notify_release_templ_by_ccat') );
11545    my $report_str_ref = expand($template_ref, \%mybuiltins);
11546
11547    # 'multipart/report' MIME type is defined in RFC 6522. The report-type
11548    # parameter identifies the type of report. The parameter is the MIME
11549    # subtype of the second body part of the multipart/report.
11550    my $report_entity = build_mime_entity($report_str_ref, $msginfo,
11551       !$is_dsn && !$is_arf ? 'multipart/mixed'
11552         : "multipart/report; report-type=$delivery_status_mime_subtype",
11553       $msg_format, $is_plain, 1, $attach_full_msg);
11554
11555    my $head = $report_entity->head;
11556    # RFC 3464: The From field of the message header section of the DSN SHOULD
11557    # contain the address of a human who is responsible for maintaining the
11558    # mail system at the Reporting MTA site (e.g. Postmaster), so that a reply
11559    # to the DSN will reach that person.
11560    # Override header fields from the template:
11561    eval { $head->replace('From', $hdr_from); 1 }
11562      or do { chomp $@; die $@ };
11563    eval { $head->replace('To', join(', ',@hdr_to)); 1 }
11564      or do { chomp $@; die $@ };
11565    eval { $head->replace('Date', $rfc2822_dsn_time); 1 }
11566      or do { chomp $@; die $@ };
11567
11568    if ($is_dsn || $is_arf) {  # attach a delivery-status or a feedback-report
11569      ll(4) && do_log(4,"dsn: creating mime part %s, %s",
11570                        $delivery_status_mime_type,
11571                        !$txt_8bit ? 'us-ascii' : $txt_utf8 ? 'valid UTF-8'
11572                          : '8bit but *not* UTF-8');
11573      eval {  # make sure our source line number is reported in case of failure
11574        # RFC 6533: Note that [RFC6532] relaxed a restriction from MIME
11575        # [RFC2046] regarding the use of Content-Transfer-Encoding in new
11576        # "message" subtypes.  This specification explicitly allows the
11577        # use of Content-Transfer-Encoding in message/global-headers and
11578        # message/global-delivery-status.
11579        # RFC 5965: Encoding considerations for message/feedback-report:
11580        # "7bit" encoding is sufficient and MUST be used to maintain
11581        # readability when viewed by non-MIME mail readers.
11582        $report_entity->add_part(
11583          MIME::Entity->build(
11584            Top => 0,
11585            Type => $delivery_status_mime_type,
11586            Data => $txt_msg . $txt_recip,
11587            $delivery_status_mime_subtype ne 'global-delivery-status' ? ()
11588              : (Charset => 'UTF-8'),
11589            Encoding    => $txt_8bit ? '8bit' : '7bit',
11590            Disposition => 'inline',
11591            Filename    => $is_arf ? 'arf_status'
11592                         : $delivery_status_mime_subtype eq
11593                             'global-delivery-status' ? 'dsn_status.u8dsn'
11594                                                      : 'dsn_status.dsn',
11595            Description => $is_arf      ? "\u$feedback_type report"
11596                         : $any_fail    ? 'Delivery error report'
11597                         : $any_delayed ? 'Delivery delay report'
11598                         :                'Delivery report',
11599          ), 1);  # insert as a second mime part (at offset 1)
11600        1;
11601      } or do {
11602        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
11603        die $eval_stat;
11604      };
11605    }
11606    my $mailfrom =
11607      $is_dsn ? ''  # DSN envelope sender must be empty
11608              : mail_addr_idn_to_ascii(
11609                  unquote_rfc2821_local( (parse_address_list($hdr_from))[0] ));
11610    $notification = Amavis::In::Message->new;
11611    $notification->rx_time($dsn_time);
11612    $notification->log_id($msginfo->log_id);
11613    $notification->partition_tag($msginfo->partition_tag);
11614    $notification->parent_mail_id($msginfo->mail_id);
11615    $notification->mail_id(scalar generate_mail_id());
11616    $notification->conn_obj($msginfo->conn_obj);
11617    $notification->originating(
11618      ($request_type eq 'dsn' || $request_type eq 'report') ? 1 : 0);
11619    $notification->mail_text($report_entity);
11620    $notification->body_type($txt_8bit ? '8BITMIME' : '7BIT');
11621    $notification->add_contents_category(CC_CLEAN,0);
11622    my(@recips) = $notif_recips ? @$notif_recips
11623                                : map($_->recip_addr, @$per_recip_data);
11624    if ($request_type eq 'dsn' || $request_type eq 'report') {
11625      my $bcc = $msginfo->setting_by_contents_category(cr('dsn_bcc_by_ccat'));
11626      push(@recips, $bcc)  if defined $bcc && $bcc ne '';
11627    }
11628    if (grep( / [^\x00-\x7F] .*? \@ [^@]* \z/sx && is_valid_utf_8($_),
11629              ($mailfrom, @recips) )) {
11630      # localpart is non-ASCII UTF-8, we must use SMTPUTF8
11631      do_log(2, 'DSN notification requires SMTPUTF8');
11632      $notification->smtputf8(1);
11633    } else {
11634      $_ = mail_addr_idn_to_ascii($_)  for ($mailfrom, @recips);
11635    }
11636    $notification->sender($mailfrom);
11637    $notification->sender_smtp(qquote_rfc2821_local($mailfrom));
11638    $notification->auth_submitter('<>');
11639    $notification->auth_user(c('amavis_auth_user'));
11640    $notification->auth_pass(c('amavis_auth_pass'));
11641    $notification->recips(\@recips, 1);
11642    if (defined $hdr_from) {
11643      my(@rfc2822_from) =
11644        map(unquote_rfc2821_local($_), parse_address_list($hdr_from));
11645      $notification->rfc2822_from($rfc2822_from[0]);
11646    }
11647    my $notif_m = c('notify_method');
11648    $_->delivery_method($notif_m)  for @{$notification->per_recip_data};
11649  }
11650  do_log(5, 'delivery_status_notification: notif %d bytes, suppressed: %s',
11651            length($notification), $suppressed ? 'yes' : 'no');
11652  # $suppressed is true if DNS would be needed, but either the sender requested
11653  #   that DSN is not to be sent, or it is believed the bounce would not reach
11654  #   the correct sender (faked sender with viruses or spam);
11655  # $notification is undef if DSN is not needed
11656  ($notification, $suppressed);
11657}
11658
11659# Return a triple of arrayrefs of quoted recipient addresses (the first lists
11660# recipients with successful delivery status, the second lists all the rest),
11661# plus a list of short per-recipient delivery reports for failed deliveries,
11662# that can be used in the first MIME part (the free text format) of delivery
11663# status notifications.
11664#
11665sub delivery_short_report($) {
11666  my $msginfo = $_[0];
11667  my(@succ_recips, @failed_recips, @failed_recips_full);
11668  for my $r (@{$msginfo->per_recip_data}) {
11669    my $remote_mta  = $r->recip_remote_mta;
11670    my $smtp_resp   = $r->recip_smtp_response;
11671    my $qrecip_addr = scalar(qquote_rfc2821_local($r->recip_addr));
11672    if ($r->recip_destiny == D_PASS && ($smtp_resp=~/^2/ || !$r->recip_done)) {
11673      push(@succ_recips, $qrecip_addr);
11674    } else {
11675      push(@failed_recips, $qrecip_addr);
11676      push(@failed_recips_full, sprintf("%s:%s\n   %s", $qrecip_addr,
11677        (!defined($remote_mta)||$remote_mta eq '' ?'' :" [$remote_mta] said:"),
11678        $smtp_resp));
11679    }
11680  }
11681  (\@succ_recips, \@failed_recips, \@failed_recips_full);
11682}
11683
11684# Build a new MIME::Entity object based on the original mail, but hopefully
11685# safer to mail readers: conventional mail header fields are retained,
11686# original mail becomes an attachment of type 'message/rfc822' or
11687# 'message/global'. Text in $first_part becomes the first MIME part
11688# of type 'text/plain', $first_part may be a scalar string or a ref
11689# to a list of lines
11690#
11691sub defanged_mime_entity($$) {
11692  my($msginfo,$first_part) = @_;
11693  my $new_entity;
11694  $_ = safe_encode(c('bdy_encoding'), $_)
11695    for (ref $first_part ? @$first_part : $first_part);
11696  eval {  # make sure _our_ source line number is reported in case of failure
11697    $new_entity = MIME::Entity->build(
11698                    Type => 'multipart/mixed', 'X-Mailer' => undef);
11699    1;
11700  } or do {
11701    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
11702    die $eval_stat;
11703  };
11704  # reinserting some of the original header fields to a new header, sanitized
11705  my $hdr_edits = $msginfo->header_edits;
11706  if (!$hdr_edits) {
11707    $hdr_edits = Amavis::Out::EditHeader->new;
11708    $msginfo->header_edits($hdr_edits);
11709  }
11710  my(%desired_field);
11711  for (qw(Received From Sender To Cc Reply-To Date Message-ID
11712          Resent-From Resent-Sender Resent-To Resent-Cc
11713          Resent-Date Resent-Message-ID In-Reply-To References Subject
11714          Comments Keywords Organization Organisation User-Agent X-Mailer
11715          DKIM-Signature DomainKey-Signature))
11716    { $desired_field{lc($_)} = 1 };
11717  local($1,$2);
11718  for my $curr_head (@{$msginfo->orig_header}) {  # array of header fields
11719    # obsolete RFC 822 syntax allowed whitespace before colon
11720    my($field_name, $field_body) =
11721      $curr_head =~ /^([!-9;-\176]+)[ \t]*:(.*)\z/s
11722        ? ($1, $2) : (undef, $curr_head);
11723    if ($desired_field{lc($field_name)}) {  # only desired header fields
11724      # protect NUL, CR, and characters with codes above \377
11725      $field_body =~ s{ ( [^\001-\014\016-\377] ) }
11726                      { sprintf(ord($1)>255 ? '\\x{%04x}' : '\\x{%02x}',
11727                                ord($1)) }xgse;
11728      # protect NL in illegal all-whitespace continuation lines
11729      $field_body =~ s{\n([ \t]*)(?=\n)}{\\012$1}gs;
11730      $field_body =~ s{^(.{995}).{4,}$}{$1...}gm;  # truncate lines to 998
11731      chomp($field_body);    # note that field body is already folded
11732      if (lc($field_name) eq 'subject') {
11733        # needs to be inserted directly into new header section so that it
11734        # can be subjected to header edits, like inserting ***UNCHECKED***
11735        eval { $new_entity->head->add($field_name,$field_body); 1 }
11736          or do {chomp $@; die $@};
11737      } else {
11738        $hdr_edits->append_header($field_name,$field_body,2);
11739      }
11740    }
11741  }
11742
11743  eval {
11744    my $cnt_8bit = $first_part =~ tr/\x00-\x7F//c;
11745    $new_entity->attach(
11746      Type => 'text/plain', Data => $first_part,
11747      Charset => c('bdy_encoding'),
11748      Encoding => !$cnt_8bit ? '7bit'
11749                : $cnt_8bit > 0.2 * length($first_part) ? 'base64'
11750                : 'quoted-printable',
11751    );
11752    1;
11753  } or do {
11754    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
11755    die $eval_stat;
11756  };
11757  # prepend a Return-Path to make available the envelope sender address
11758  my $rp = sprintf("Return-Path: %s\n", $msginfo->sender_smtp);
11759  my $orig_mail_as_body;
11760  my $msg = $msginfo->mail_text;
11761  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
11762  $msg = $msg_str_ref  if ref $msg_str_ref;
11763  if (!defined $msg) {
11764    # empty mail
11765  } elsif (ref $msg eq 'SCALAR') {
11766    # will be handled by ->attach
11767  } elsif ($msg->isa('MIME::Entity')) {
11768    die "attaching a MIME::Entity object is not implemented";
11769  } else {
11770    $orig_mail_as_body =
11771      Amavis::MIME::Body::OnOpenFh->new($msginfo->mail_text,
11772                                        [$rp], $msginfo->skip_bytes);
11773    $orig_mail_as_body or die "Can't create Amavis::MIME::Body object: $!";
11774  }
11775  eval {
11776    my $att = $new_entity->attach(  # RFC 2046
11777      Type => ($msginfo->smtputf8 && $msginfo->header_8bit ? 'message/global'
11778                 : 'message/rfc822') . '; x-spam-type=original',
11779      Encoding => $msginfo->header_8bit || $msginfo->body_8bit ? '8bit':'7bit',
11780      Data => defined $orig_mail_as_body ? []
11781            : !$msginfo->skip_bytes ? $msg
11782            : substr($$msg, $msginfo->skip_bytes),
11783    # Path => $msginfo->mail_text_fn,
11784      Description => 'Original message',
11785      Filename => 'message', Disposition => 'attachment',
11786    );
11787    # direct access to tempfile handle
11788    $att->bodyhandle($orig_mail_as_body)  if defined $orig_mail_as_body;
11789    1;
11790  } or do {
11791    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
11792    die $eval_stat;
11793  };
11794  $new_entity;
11795}
11796
11797# Fill-in a message object with information based on a quarantined mail.
11798# Expects $msginfo->mail_text to be a file handle (not a Mime::Entity object),
11799# leaves it positioned at the beginning of a mail body (not to be relied upon).
11800# If given a BSMTP file, expects that it contains a single message only.
11801#
11802sub msg_from_quarantine($$$) {
11803  my($msginfo,$request_type,$feedback_type) = @_;
11804  my $fh = $msginfo->mail_text;
11805  my $sender_override = $msginfo->sender;
11806  my $recips_data_override = $msginfo->per_recip_data;
11807  my $quarantine_id = $msginfo->parent_mail_id;
11808  $quarantine_id = ''  if !defined $quarantine_id;
11809  my $reporting = $request_type eq 'report';
11810  my $release_m;
11811  if ($request_type eq 'requeue') {
11812    $release_m = c('requeue_method');
11813    $release_m ne '' or die "requeue_method is unspecified";
11814  } else {  # 'release' or 'report'
11815    $release_m = c('release_method');
11816    $release_m = c('notify_method') if !defined $release_m || $release_m eq '';
11817    $release_m ne '' or die "release_method and notify_method are unspecified";
11818  }
11819  $msginfo->originating(1);  # (also enables DKIM signing)
11820  $msginfo->add_contents_category(CC_CLEAN,0);
11821  $msginfo->auth_submitter('<>');
11822  $msginfo->auth_user(c('amavis_auth_user'));
11823  $msginfo->auth_pass(c('amavis_auth_pass'));
11824  $fh->seek($msginfo->skip_bytes, 0) or die "Can't rewind mail file: $!";
11825  my $bsmtp = 0;  # message stored in an RFC 2442 format?
11826  my($qid,$sender,@recips_all,@recips_blocked);
11827  my $have_recips_blocked = 0; my $curr_head;
11828  my $ln; my $eof = 0; my $position = 0;
11829  my $offset_bytes = 0;  # file position just past the prefixed header fields
11830  # extract envelope information from the quarantine file
11831  do_log(4, "msg_from_quarantine: releasing %s", $quarantine_id);
11832  for (;;) {
11833    if ($eof) { $ln = "\n" }
11834    else {
11835      $! = 0; $ln = $fh->getline;
11836      if (!defined($ln)) {
11837        $eof = 1; $ln = "\n";  # fake a missing header/body separator line
11838        $! == 0  or die "Error reading file ".$msginfo->mail_text_fn.": $!";
11839      }
11840    }
11841    if ($ln =~ /^[ \t]/) { $curr_head .= $ln }
11842    else {
11843      my $next_head = $ln; local($1,$2);
11844      local($_) = $curr_head;  chomp;  s/\n(?=[ \t])//gs;  # unfold
11845      if (!defined($curr_head)) {  # first time
11846      } elsif (/^(?:EHLO|HELO)(?: |$)/i) { $bsmtp = 1;
11847      } elsif (/^MAIL FROM:[ \t]*(<.*>)/i) {
11848        $bsmtp = 1; $sender = $1; $sender = unquote_rfc2821_local($sender);
11849      } elsif ( $bsmtp && /^RCPT TO:[ \t]*(<.*>)/i) {
11850        push(@recips_all, unquote_rfc2821_local($1));
11851      } elsif ( $bsmtp && /^(?:DATA|NOOP)$/i) {
11852      } elsif ( $bsmtp && /^RSET$/i) {
11853        $sender = undef; @recips_all = (); @recips_blocked = (); $qid = undef;
11854      } elsif ( $bsmtp && /^QUIT$/i) { last;
11855      } elsif (!$bsmtp && /^Delivered-To:/si) {
11856      } elsif (!$bsmtp && /^(Return-Path|X-Envelope-From):[ \t]*(.*)$/si) {
11857        if (!defined $sender) {
11858          my(@addr_list) = parse_address_list($2);
11859          @addr_list >= 1  or die "Address missing in $1";
11860          @addr_list <= 1  or die "More than one address in $1";
11861          $sender =
11862            mail_addr_idn_to_ascii(unquote_rfc2821_local($addr_list[0]));
11863        }
11864      } elsif (!$bsmtp && /^X-Envelope-To:[ \t]*(.*)$/si) {
11865        my(@addr_list) = parse_address_list($1);
11866        push(@recips_all,
11867             map(mail_addr_idn_to_ascii(unquote_rfc2821_local($_)),
11868                 @addr_list));
11869      } elsif (!$bsmtp && /^X-Envelope-To-Blocked:[ \t]*(.*)$/si) {
11870        my(@addr_list) = parse_address_list($1);
11871        push(@recips_blocked,
11872             map(mail_addr_idn_to_ascii(unquote_rfc2821_local($_)),
11873                 @addr_list));
11874        $have_recips_blocked = 1;
11875      } elsif (/^X-Quarantine-ID:[ \t]*(.*)$/si) {
11876        $qid = $1;   $qid = $1 if $qid =~ /^<(.*)>\z/s;
11877      } elsif (!$reporting && /^X-Amavis-(?:Hold|Alert|Modified|PenPals|
11878                                            PolicyBank|OS-Fingerprint):/xsi) {
11879        # skip
11880      } elsif (!$reporting && /^(?:X-Spam|X-CRM114)-.+:/si) {
11881        # skip header fields inserted by us
11882      } else {
11883        last;  # end of known header fields, to be marked as 'skip_bytes'
11884      }
11885      last  if $next_head eq "\n";  # end-of-header-section reached
11886      $offset_bytes = $position;    # move past last processed header field
11887      $curr_head = $next_head;
11888    }
11889    $position += length($ln);
11890  }
11891  @recips_blocked = @recips_all  if !$have_recips_blocked; # pre-2.6.0 compatib
11892  my(@except);
11893  if (@recips_blocked < @recips_all) {
11894    for my $rec (@recips_all)
11895      { push(@except,$rec)  if !grep($rec eq $_, @recips_blocked) }
11896  }
11897  my $sender_smtp = qquote_rfc2821_local($sender);
11898  do_log(0,"Quarantined message %s (%s): %s %s -> %s%s",
11899           $request_type, $feedback_type, $quarantine_id, $sender_smtp,
11900           join(',', qquote_rfc2821_local(@recips_blocked)),
11901           !@except ? '' : (", (excluded: ".
11902                            join(',', qquote_rfc2821_local(@except)) . " )" ));
11903  my(@m);
11904  if (!defined($qid)) { push(@m, 'missing X-Quarantine-ID') }
11905  elsif ($qid ne $quarantine_id) {
11906    push(@m, sprintf("stored quar. ID '%s' does not match requested ID '%s'",
11907                     $qid,$quarantine_id));
11908  }
11909  push(@m, 'missing '.($bsmtp?'MAIL FROM':'X-Envelope-From or Return-Path'))
11910    if !defined $sender;
11911  push(@m, 'missing '.($bsmtp?'RCPT TO'  :'X-Envelope-To'))  if !@recips_all;
11912  do_log(0, "Quarantine %s %s: %s",
11913            $request_type, $quarantine_id, join("; ",@m))  if @m;
11914  if ($qid ne $quarantine_id)
11915    { die "Stored quarantine ID '$qid' does not match ".
11916          "requested ID '$quarantine_id'" }
11917  if ($bsmtp)
11918    { die "Releasing messages in BSMTP format not yet supported ".
11919           "(dot de-stuffing not implemented)" }
11920  $msginfo->sender($sender); $msginfo->sender_smtp($sender_smtp);
11921  $msginfo->recips(\@recips_all);
11922  $_->delivery_method($release_m)  for @{$msginfo->per_recip_data};
11923  # mark a file location past prefixed header fields where orig message starts
11924  $msginfo->skip_bytes($offset_bytes);
11925
11926  my $msg_format = $request_type eq 'dsn'    ? 'dsn'
11927                 : $request_type eq 'report' ? c('report_format')
11928                                             : c('release_format');
11929  my $hdr_edits = Amavis::Out::EditHeader->new;
11930  $msginfo->header_edits($hdr_edits);
11931  if ($msg_format eq 'resend') {
11932    if (!defined($recips_data_override)) {
11933      $msginfo->recips(\@recips_blocked);  # override 'all' by 'blocked' recips
11934    } else {  # recipients specified in the request override stored info
11935      ll(5) && do_log(5, 'overriding recips %s by %s',
11936                  join(',', qquote_rfc2821_local(@recips_blocked)),
11937                  join(',', map($_->recip_addr_smtp, @$recips_data_override)));
11938      $msginfo->per_recip_data($recips_data_override);
11939    }
11940    $_->delivery_method($release_m)  for @{$msginfo->per_recip_data};
11941  } else {
11942    # collect more information from a quarantined message, making it available
11943    # to a report generator and to macros during template expansion
11944    Amavis::get_body_digest($msginfo, c('mail_digest_algorithm'));
11945    Amavis::collect_some_info($msginfo);
11946    if (defined($recips_data_override) && ll(5)) {
11947      do_log(5, 'overriding recips %s by %s',
11948                join(',', qquote_rfc2821_local(@recips_blocked)),
11949                join(',', map($_->recip_addr_smtp, @$recips_data_override)));
11950    }
11951    my($notification,$suppressed) = delivery_status_notification(
11952      $msginfo, 0, \%Amavis::builtins,
11953      !defined($recips_data_override) ? \@recips_blocked
11954        : [ map($_->recip_addr, @$recips_data_override) ],
11955      $request_type, $feedback_type, undef);
11956    # pushes original quarantined message into an attachment of a notification
11957    $msginfo = $notification;
11958  }
11959  if (defined $sender_override) {
11960    # sender specified in the request, overrides stored info
11961    do_log(5, "overriding sender %s by %s", $sender, $sender_override);
11962    $msginfo->sender($sender_override);
11963    $msginfo->sender_smtp(qquote_rfc2821_local($sender_override));
11964  }
11965  if ($msg_format eq 'resend') { # keep quarantined message at a top MIME level
11966    # Resent-* header fields must precede corresponding Received header field
11967    # "Resent-From:" and "Resent-Date:" are required fields!
11968    my $hdrfrom_recip = $msginfo->setting_by_contents_category(
11969                                           cr('hdrfrom_notify_recip_by_ccat'));
11970    # make sure it's in octets
11971    $hdrfrom_recip = expand_variables(safe_encode_utf8($hdrfrom_recip));
11972    if ($msginfo->requested_by eq '') {
11973      $hdr_edits->add_header('Resent-From', $hdrfrom_recip);
11974    } else {
11975      $hdr_edits->add_header('Resent-From',
11976                             qquote_rfc2821_local($msginfo->requested_by));
11977      $hdr_edits->add_header('Resent-Sender',
11978                             $hdrfrom_recip)  if $hdrfrom_recip ne '';
11979    }
11980    my $prd = $msginfo->per_recip_data;
11981    $hdr_edits->add_header('Resent-To',
11982                           $prd && @$prd==1 ? $prd->[0]->recip_addr_smtp
11983                                            : 'undisclosed-recipients:;');
11984    $hdr_edits->add_header('Resent-Date', # time of the release
11985                  rfc2822_timestamp($msginfo->rx_time));
11986    my $myhost = c('myhostname');  # my FQDN (DNS) name, UTF-8 octets
11987    $myhost = $msginfo->smtputf8 ? idn_to_utf8($myhost) :idn_to_ascii($myhost);
11988    $hdr_edits->add_header('Resent-Message-ID',
11989               sprintf('<%s-%s@%s>',
11990                       $msginfo->parent_mail_id||'', $msginfo->mail_id||'',
11991                       $myhost) );
11992  }
11993  $hdr_edits->add_header('Received', make_received_header_field($msginfo,1),1);
11994  my $bcc = $msginfo->setting_by_contents_category(cr('always_bcc_by_ccat'));
11995  if (defined $bcc && $bcc ne '' && $request_type ne 'report') {
11996    my $recip_obj = Amavis::In::Message::PerRecip->new;
11997    $recip_obj->recip_addr_modified($bcc);
11998
11999    # leave recip_addr and recip_addr_smtp undefined to hide it from the log?
12000    $recip_obj->recip_addr($bcc);
12001    $recip_obj->recip_addr_smtp(qquote_rfc2821_local($bcc));  #****
12002
12003    $recip_obj->recip_is_local(
12004      lookup2(0, $bcc, ca('local_domains_maps')) ? 1 : 0);
12005    $recip_obj->recip_destiny(D_PASS);
12006    $recip_obj->dsn_notify(['NEVER']);
12007    $recip_obj->delivery_method(c('notify_method'));
12008    $recip_obj->add_contents_category(CC_CLEAN,0);
12009    $msginfo->per_recip_data([@{$msginfo->per_recip_data}, $recip_obj]);
12010    do_log(2,"adding recipient - always_bcc: %s, delivery method %s",
12011             $bcc, $recip_obj->delivery_method);
12012  }
12013  $msginfo;
12014}
12015
120161;
12017
12018#
12019package Amavis::Custom;
12020# MAIL PROCESSING SEQUENCE:
12021# child process initialization
12022# loop for each mail:
12023#  - receive mail, parse and make available some basic information
12024#  * custom hook: new() - may inspect info, may load policy banks
12025#  - mail checking and collecting results
12026#  * custom hook: checks() - may inspect or modify checking results
12027#  - deciding mail fate (lookup on *_lovers, thresholds, ...)
12028#  - quarantining
12029#  - sending notifications (to admin and recips)
12030#  * custom hook: before_send() - may send other notif, quarantine, modify mail
12031#  - forwarding (unless blocked)
12032#  * custom hook: after_send() - may suppress DSN, may send reports, quarantine
12033#  - sending delivery status notification (if needed)
12034#  - issue main log entry, manage statistics (timing, counters, nanny)
12035#  * custom hook: mail_done() - may inspect results
12036# endloop after $max_requests or earlier
12037
12038use strict;
12039use re 'taint';
12040
12041sub new         { my($class,$conn,$msginfo) = @_; undef }
12042sub checks      { my($self,$conn,$msginfo)  = @_; undef }
12043sub before_send { my($self,$conn,$msginfo)  = @_; undef }
12044sub after_send  { my($self,$conn,$msginfo)  = @_; undef }
12045sub mail_done   { my($self,$conn,$msginfo)  = @_; undef }
12046
120471;
12048
12049#
12050package Amavis;
12051require 5.005;     # need qr operator and \z in regexp
12052require 5.008;     # need basic Unicode support
12053require 5.008001;  # need utf8::is_utf8()
12054use strict;
12055use re 'taint';
12056
12057BEGIN {
12058  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
12059  $VERSION = '2.412';
12060  import Amavis::Conf qw(:platform :sa :confvars c cr ca);
12061  import Amavis::Util qw(untaint untaint_inplace
12062                         min max minmax unique_list unique_ref
12063                         ll do_log do_log_safe update_current_log_level
12064                         dump_captured_log log_capture_enabled am_id
12065                         sanitize_str debug_oneshot proto_decode
12066                         truncate_utf_8 is_valid_utf_8 safe_decode_mime
12067                         safe_encode safe_encode_utf8 safe_encode_utf8_inplace
12068                         safe_decode safe_decode_utf8 safe_decode_latin1
12069                         clear_idn_cache idn_to_utf8 idn_to_ascii
12070                         mail_addr_idn_to_ascii mail_addr_decode
12071                         orcpt_encode orcpt_decode
12072                         format_time_interval add_entropy stir_random
12073                         generate_mail_id make_password
12074                         prolong_timer get_deadline waiting_for_client
12075                         switch_to_my_time switch_to_client_time
12076                         snmp_counters_init snmp_count dynamic_destination
12077                         ccat_split ccat_maj cmp_ccat cmp_ccat_maj
12078                         setting_by_given_contents_category_all
12079                         setting_by_given_contents_category);
12080  import Amavis::ProcControl qw(exit_status_str proc_status_ok
12081                         cloexec run_command collect_results);
12082  import Amavis::Log qw(open_log close_log collect_log_stats);
12083  import Amavis::Timing qw(section_time get_time_so_far
12084                         get_rusage rusage_report);
12085  import Amavis::rfc2821_2822_Tools;
12086  import Amavis::Lookup qw(lookup lookup2);
12087  import Amavis::Lookup::IP qw(lookup_ip_acl normalize_ip_addr);
12088  import Amavis::Out;
12089  import Amavis::Out::EditHeader;
12090  import Amavis::UnmangleSender qw(oldest_public_ip_addr_from_received
12091                         first_received_from);
12092  import Amavis::Unpackers::Validity qw(
12093                         check_header_validity check_for_banned_names);
12094  import Amavis::Unpackers::MIME qw(mime_decode);
12095  import Amavis::Expand qw(expand tokenize);
12096  import Amavis::Notify qw(delivery_status_notification delivery_short_report
12097                  build_mime_entity defanged_mime_entity expand_variables);
12098  import Amavis::In::Connection;
12099  import Amavis::In::Message;
12100}
12101
12102use Errno qw(ENOENT EACCES EAGAIN ESRCH EBADF EINVAL);
12103use POSIX qw(locale_h);
12104use Fcntl qw(:flock F_GETFL F_SETFL FD_CLOEXEC);
12105use IO::Handle;
12106use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
12107use IO::Socket::UNIX;
12108use Time::HiRes ();
12109# body digest, either MD5 or SHA-1 (or perhaps SHA-256)
12110use Digest::MD5;
12111use Digest::SHA;
12112use Net::Server 0.87;  # need Net::Server::PreForkSimple::done
12113use MIME::Base64;
12114
12115use vars qw(
12116  $extra_code_zmq $extra_code_db $extra_code_redis
12117  $extra_code_sql_base $extra_code_sql_log $extra_code_sql_quar
12118  $extra_code_sql_lookup $extra_code_ldap
12119  $extra_code_in_ampdp $extra_code_in_smtp $extra_code_in_courier
12120  $extra_code_out_smtp $extra_code_out_pipe
12121  $extra_code_out_bsmtp $extra_code_out_local $extra_code_p0f
12122  $extra_code_antivirus $extra_code_antispam
12123  $extra_code_antispam_extprog $extra_code_antispam_rspamc
12124  $extra_code_antispam_spamc $extra_code_antispam_sa
12125  $extra_code_unpackers $extra_code_dkim $extra_code_tools);
12126
12127use vars qw(%modules_basic %got_signals);
12128use vars qw($user_id_sql $user_policy_id_sql $wb_listed_sql);
12129use vars qw($implicit_maps_inserted $maps_have_been_labeled);
12130use vars qw($db_env $snmp_db $zmq_obj @zmq_sockets);
12131use vars qw(%builtins);    # macros in customizable notification messages
12132use vars qw($last_task_completed_at);
12133use vars qw($child_invocation_count $child_task_count);
12134use vars qw($child_init_hook_was_called);
12135# $child_invocation_count  # counts child re-use from 1 to max_requests
12136# $child_task_count  # counts check_mail_begin_task (and check_mail) calls;
12137                     # this often runs in sync with $child_invocation_count,
12138                     # but with SMTP or LMTP input there may be more than one
12139                     # message passed during a single SMTP session
12140use vars qw(@config_files);  # configuration files provided by -c or defaulted
12141use vars qw($MSGINFO $report_ref);
12142use vars qw($av_output @virusname @detecting_scanners @av_scanners_results
12143            $banned_filename_any $banned_filename_all @bad_headers);
12144
12145# Amavis::In::AMPDP, Amavis::In::SMTP and In::Courier objects
12146use vars qw($ampdp_in_obj $smtp_in_obj $courier_in_obj);
12147
12148use vars qw($sql_dataset_conn_lookups); # Amavis::Out::SQL::Connection object
12149use vars qw($sql_dataset_conn_storage); # Amavis::Out::SQL::Connection object
12150use vars qw($sql_storage);              # Amavis::Out::SQL::Log object
12151use vars qw($sql_lookups $sql_wblist);  # Amavis::Lookup::SQL objects
12152use vars qw($ldap_connection);          # Amavis::LDAP::Connection object
12153use vars qw($ldap_lookups);             # Amavis::Lookup::LDAP object
12154use vars qw($redis_storage);            # Amavis::Redis object: penpals & repu
12155use vars qw($dns_resolver);             # a reusable Net::DNS::Resolver object
12156use vars qw($warm_restart);       # 1: warm (reload),  0: cold start (restart)
12157use vars qw(@public_networks_maps);
12158
12159sub new {
12160  my $class = shift;
12161  # make Amavis a subclass of Net::Server::whatever
12162  @ISA = !$daemonize && $max_servers==1 ? 'Net::Server' # facilitates debugging
12163                 : defined $min_servers ? 'Net::Server::PreFork'
12164                                        : 'Net::Server::PreForkSimple';
12165# $class->SUPER::new(@_);  # available since Net::Server 0.91
12166  bless { server => $_[0] }, $class;  # works with all versions
12167}
12168
12169sub macro_rusage {
12170  my($msginfo,$recip_index,$name,$arg) = @_;
12171  my($rusage_self, $rusage_children) = get_rusage();
12172  !$rusage_self || !$rusage_children || !defined($rusage_self->{$arg}) ? ''
12173    : $rusage_self->{$arg} + $rusage_children->{$arg};
12174}
12175
12176# implements macros: T, and SA lookalikes: TESTS, TESTSSCORES
12177#
12178sub macro_tests {
12179  my($msginfo,$recip_index,$name,$sep) = @_;
12180  my(@s);  my $per_recip_data = $msginfo->per_recip_data;
12181  if (defined $recip_index) {  # return info on one particular recipient
12182    my $r;
12183    $r = $per_recip_data->[$recip_index]  if $recip_index >= 0;
12184    if (defined $r) {
12185      my $spam_tests = $r->spam_tests;
12186      @s = split(/,/, join(',',map($$_,@$spam_tests)))  if $spam_tests;
12187    }
12188  } else {
12189    my(%all_spam_tests);
12190    for my $r (@$per_recip_data) {
12191      my $spam_tests = $r->spam_tests;
12192      if ($spam_tests) {
12193        $all_spam_tests{$_} = 1 for split(/,/,join(',',map($$_,@$spam_tests)));
12194      }
12195    }
12196    @s = sort keys %all_spam_tests;
12197  }
12198  if (@s > 50) { $#s = 50-1; push(@s,"...") }   # sanity limit
12199  @s = map { my($tn,$ts) = split(/=/,$_,2); $tn } @s  if $name eq 'TESTS';
12200  if ($name eq 'T' || !defined($sep)) { \@s } else { join($sep,@s) }
12201};
12202
12203# implements macros: c, and SA lookalikes: SCORE(pad), STARS(*)
12204#
12205sub macro_score {
12206  my($msginfo,$recip_index,$name,$arg) = @_;
12207  my $per_recip_data = $msginfo->per_recip_data;
12208  my($result, $sl_min, $sl_max, $w); $w = '';
12209  if ($name eq 'SCORE' && defined($arg) && $arg=~/^(0+| +)\z/) {
12210    $w = length($arg)+4; $w = $arg=~/^0/ ? "0$w" : "$w";  # SA style padding
12211  }
12212  my $fmt = "%$w.3f"; my $fmts = "%+$w.3f";  # padding, sign
12213  if (defined $recip_index) {  # return info on one particular recipient
12214    my $r;
12215    $r = $per_recip_data->[$recip_index]  if $recip_index >= 0;
12216    $sl_min = $sl_max = $r->spam_level  if defined $r;
12217  } else {
12218    ($sl_min,$sl_max) = minmax(map($_->spam_level, @$per_recip_data));
12219  }
12220  if ($name eq 'STARS') {
12221    my $slc = $arg ne '' ? $arg : c('sa_spam_level_char');
12222    $result = !defined $slc || $slc eq '' || !defined $sl_min || $sl_min<1 ? ''
12223              : $slc x min(50, int $sl_min);
12224  } elsif (!defined $sl_min) {
12225    $result = '-';
12226# } elsif ($name eq 'SCORE' || abs($sl_min-$sl_max) < 0.1) {
12227  } elsif (abs($sl_min-$sl_max) < 0.1) {
12228    # users expect a single value, or not worth reporting a small difference
12229    $result = sprintf($fmt,$sl_min);  $result =~ s/\.?0*\z//;  # trim fraction
12230  } else {  # format SA score as min..max
12231    $sl_min = sprintf($fmt,$sl_min);  $sl_min =~ s/\.?0*\z//;
12232    $sl_max = sprintf($fmt,$sl_max);  $sl_max =~ s/\.?0*\z//;
12233    $result = $sl_min . '..' . $sl_max;
12234  }
12235  $result;
12236};
12237
12238# implements macro 'header_field', providing a requested header field
12239# from a message; attempts decoding UTF-8 to logical characters
12240# unless a macro name is 'header_field_octets'; non-decodable UTF-8
12241# is left unchanged as octets
12242#
12243sub macro_header_field {
12244  my($msginfo,$name,$header_field_name,$limit,$hf_index) = @_;
12245  undef $hf_index  if $hf_index !~ /^[+-]?\d+\z/;  # defaults to last
12246  my $s = $msginfo->get_header_field_body($header_field_name, $hf_index);
12247  return undef  if !defined($s);
12248  # unfold, trim, protect any leftover CR and LF
12249  chomp($s); $s=~s/\n(?=[ \t])//gs; $s=~s/^[ \t]+//; $s=~s/[ \t\n]+\z//;
12250  if ($header_field_name =~
12251      /^(?:Message-ID|Resent-Message-ID|In-Reply-To|References)\z/i) {
12252    $s = join(' ',parse_message_id($s))  if $s ne '';  # strip CFWS
12253  }
12254  if ($name ne 'header_field_octets' &&
12255      $s =~ tr/\x00-\x7F//c && is_valid_utf_8($s)) {
12256    eval { $s = safe_decode_utf8($s, 1|8); 1 }
12257  }
12258  if (defined($limit) && $limit !~ /^\s+\z/ &&
12259      $limit > 5 && length($s) > $limit) {
12260    substr($s,$limit-5) = '';  $s .= '[...]';
12261  }
12262  $s =~ s{ ( [\r\n] ) }{ sprintf('\\x{%02X}',ord($1)) }xgse;
12263  $s;
12264};
12265
12266sub dkim_test {
12267  my($name,$which) = @_;
12268  my $w = lc $which;
12269  my $sigs_ref = $MSGINFO->dkim_signatures_valid;
12270  $sigs_ref = []  if !$sigs_ref;
12271  $w eq 'any' || $w eq '' ? (!@$sigs_ref ? undef : scalar(@$sigs_ref))
12272: $w eq 'author'    ? $MSGINFO->dkim_author_sig
12273: $w eq 'sender'    ? $MSGINFO->dkim_sender_sig
12274: $w eq 'thirdparty'? $MSGINFO->dkim_thirdparty_sig
12275: $w eq 'envsender' ? $MSGINFO->dkim_envsender_sig
12276: $w eq 'identity'  ? join(',', map($_->identity, @$sigs_ref))
12277: $w eq 'selector'  ? join(',', map($_->selector, @$sigs_ref))
12278: $w eq 'domain'    ? join(',', map($_->domain,   @$sigs_ref))
12279: $w eq 'sig_sd'    ? join(',', unique_list(map($_->selector.':'.$_->domain,
12280                                                @$sigs_ref)))
12281: $w eq 'newsig_sd' ? join(',', unique_list(map($_->selector.':'.$_->domain,
12282                                        @{$MSGINFO->dkim_signatures_new||[]})))
12283: dkim_acceptable_signing_domain($MSGINFO,$which);
12284}
12285
12286sub dkim_acceptable_signing_domain($@) {
12287  my($msginfo,@acceptable_sdid) = @_;
12288  my $matches = 0;
12289  my $sigs_ref = $msginfo->dkim_signatures_valid;
12290  if ($sigs_ref && @$sigs_ref) {
12291    for my $sig (@$sigs_ref) {
12292      my $sdid_ace = idn_to_ascii($sig->domain);
12293      for (@acceptable_sdid) {
12294        my $ad = !defined $_ ? '' : $_;
12295        local($1);
12296        $ad = $1  if $ad =~ /\@([^\@]*)\z/;  # compatibility with pre-2.6.5
12297        if ($ad eq '') {  # checking for author domain signature
12298          $matches = 1  if $msginfo->dkim_author_sig;
12299        } elsif ($ad =~ /^\.(.*)\z/s) {  # domain itself or its subdomain
12300          my $d = idn_to_ascii($1);
12301          if ($sdid_ace eq $d || $sdid_ace =~ /\.\Q$d\E\z/s) {
12302            $matches = 1; last;
12303          }
12304        } else {
12305          if ($sdid_ace eq idn_to_ascii($ad)) { $matches = 1; last }
12306        }
12307      }
12308      last if $matches;
12309    }
12310  }
12311  $matches;
12312};
12313
12314# initialize the %builtins, which is an associative array of built-in macros
12315# to be used in notification message expansion and log templates
12316#
12317sub init_builtin_macros() {
12318  # A key (macro name) used to be a single character, but can now be a longer
12319  # string, typically a name containing letters, numbers and '_' or '-'.
12320  # Upper case letters may (as a mnemonic) suggest the value is an array,
12321  # lower case may suggest the value is a scalar string - but this is only
12322  # a convention and not enforced. All-uppercase multicharacter names are
12323  # intended as SpamAssassin-lookalike macros, although there is nothing
12324  # special about them and can be called like other macros.
12325  #
12326  # A value may be a reference to a subroutine which will be called later at
12327  # a time of macro expansion. This way we can provide a method for obtaining
12328  # information which is not yet available at the time of initialization, such
12329  # as AV scanner results, or provide a lazy evaluation for more expensive
12330  # calculations. Subroutine will be called in scalar context, its first
12331  # argument is a macro name (a string), remaining arguments (strings, if any)
12332  # are arguments of a macro call as specified in the call. The subroutine may
12333  # return a scalar string (or undef), or an array reference.
12334  #
12335  # for SpamAssassin-lookalike macros semantics see Mail::SpamAssassin::Conf
12336  %builtins = (
12337    '.' => undef,
12338    p => sub {c('policy_bank_path')},
12339
12340    # mail reception timestamp (e.g. start of an SMTP transaction):
12341    DATE => sub {rfc2822_timestamp($MSGINFO->rx_time)},
12342    d    => sub {rfc2822_timestamp($MSGINFO->rx_time)},  # RFC 5322 local time
12343    U => sub {iso8601_utc_timestamp($MSGINFO->rx_time)}, # iso8601 UTC
12344    u => sub {sprintf("%010d",int($MSGINFO->rx_time))},# s since Unix epoch,UTC
12345    # equivalent, but with more descriptive macro names:
12346    date_unix_utc      => sub {sprintf("%010d",int($MSGINFO->rx_time))},
12347    date_iso8601_utc   => sub {iso8601_utc_timestamp($MSGINFO->rx_time)},
12348    date_iso8601_local => sub {iso8601_timestamp($MSGINFO->rx_time)},
12349    date_rfc2822_local => sub {rfc2822_timestamp($MSGINFO->rx_time)},
12350    week_iso8601       => sub {iso8601_week($MSGINFO->rx_time)},
12351    weekday            => sub {iso8601_weekday($MSGINFO->rx_time)},
12352    y => sub {sprintf("%.0f", 1000*get_time_so_far())},  # elapsed time in ms
12353    h => sub { $MSGINFO->smtputf8
12354                 ? safe_decode_utf8(idn_to_utf8(c('myhostname')))
12355                 : idn_to_ascii(c('myhostname')) },
12356    HOSTNAME => sub {safe_decode_utf8(idn_to_utf8(c('myhostname')))},
12357    l => sub {$MSGINFO->originating ? 1 : undef}, # our client (mynets/roaming)
12358    s => sub {$MSGINFO->sender_smtp}, # orig. unmodified env. sender addr in <>
12359    S => sub {$MSGINFO->sender_smtp}, # kept for compatibility, avoid!
12360    o => sub { # best attempt at determining true sender (origin) of the virus,
12361               sanitize_str($MSGINFO->sender_source) },   # normally same as %s
12362    R => sub {$MSGINFO->recips},    # original message recipients list
12363    D => sub {my($y,$n,$f)=delivery_short_report($MSGINFO); $y}, #succ. delivrd
12364    O => sub {my($y,$n,$f)=delivery_short_report($MSGINFO); $n}, #failed recips
12365    N => sub {my($y,$n,$f)=delivery_short_report($MSGINFO); $f}, #short dsn
12366    actions_performed => sub {join(',',@{$MSGINFO->actions_performed||[]})},
12367    Q => sub {$MSGINFO->queue_id},  # MTA queue ID of the message if known
12368    m => sub {my $m_id = $MSGINFO->get_header_field_body('message-id');
12369              defined $m_id ? (parse_message_id($m_id))[0] : undef },
12370    r => sub {my $m_id = $MSGINFO->get_header_field_body('resent-message-id');
12371              defined $m_id ? (parse_message_id($m_id))[0] : undef },
12372    j => sub {macro_header_field($MSGINFO,'header','Subject')},
12373    log_domains => sub {
12374      my %domains;
12375    # $domains{'ORIG'} = 1  if $MSGINFO->originating;
12376      for my $r (@{$MSGINFO->per_recip_data}) {
12377        if (!$r->recip_is_local) {
12378          $domains{'EXT'} = 1;
12379        } else {
12380          my($localpart,$domain) = split_address($r->recip_addr);
12381          $domain =~ s/^\@//;  $domains{lc($domain)} = 1;
12382        }
12383      }
12384      join(',', sort {$a cmp $b} keys %domains);
12385    },
12386    rfc2822_sender => sub {my $s = $MSGINFO->rfc2822_sender;
12387                           !defined($s) ? undef : qquote_rfc2821_local($s) },
12388    rfc2822_from   => sub {my $f = $MSGINFO->rfc2822_from;
12389                           !defined($f) ? undef :
12390                             qquote_rfc2821_local(ref $f ? @$f : $f)},
12391    rfc2822_resent_sender => sub {my $rs = $MSGINFO->rfc2822_resent_sender;
12392                           !defined($rs) ? undef :
12393                             qquote_rfc2821_local(grep(defined $_, @$rs))},
12394    rfc2822_resent_from => sub {my $rf = $MSGINFO->rfc2822_resent_from;
12395                           !defined($rf) ? undef :
12396                             qquote_rfc2821_local(grep(defined $_, @$rf))},
12397    header_field_octets => sub {macro_header_field($MSGINFO,@_)}, # as octets
12398    header_field => sub {macro_header_field($MSGINFO,@_)}, # as characters
12399    HEADER       => sub {macro_header_field($MSGINFO,@_)},
12400    useragent =>  # argument: 'name' or 'body', or empty to return entire field
12401      sub { my($macro_name,$which_part) = @_;  my($head,$body);
12402            $body = macro_header_field($MSGINFO,'header', $head='User-Agent');
12403            $body = macro_header_field($MSGINFO,'header', $head='X-Mailer')
12404              if !defined $body;
12405            !defined($body) ? undef
12406            : lc($which_part) eq 'name' ? $head
12407            : lc($which_part) eq 'body' ? $body : "$head: $body";
12408          },
12409    ccat =>
12410      sub {  # somewhat expensive! #**
12411        my($name,$attr,$which) = @_;
12412        $attr = lc $attr;    # name | major | minor | <empty>
12413                             # | is_blocking | is_nonblocking
12414                             # | is_blocked_by_nonmain
12415        $which = lc $which;  # main | blocking | auto
12416        my $result = '';  my $blocking_ccat = $MSGINFO->blocking_ccat;
12417        if ($attr eq 'is_blocking') {
12418          $result =  defined($blocking_ccat) ? 1 : '';
12419        } elsif ($attr eq 'is_nonblocking') {
12420          $result = !defined($blocking_ccat) ? 1 : '';
12421        } elsif ($attr eq 'is_blocked_by_nonmain') {
12422          if (defined($blocking_ccat)) {
12423            my $aref = $MSGINFO->contents_category;
12424            $result = 1  if ref($aref) && @$aref > 0
12425                            && $blocking_ccat ne $aref->[0];
12426          }
12427        } elsif ($attr eq 'name') {
12428          $result =
12429            $which eq 'main' ?
12430              $MSGINFO->setting_by_main_contents_category(\%ccat_display_names)
12431          : $which eq 'blocking' ?
12432              $MSGINFO->setting_by_blocking_contents_category(
12433                                                         \%ccat_display_names)
12434          :   $MSGINFO->setting_by_contents_category(    \%ccat_display_names);
12435        } else {  # attr = major, minor, or anything else returns a pair
12436          my($maj,$min) = ccat_split(
12437                            ($which eq 'blocking' ||
12438                             $which ne 'main' && defined $blocking_ccat)
12439                             ? $blocking_ccat : $MSGINFO->contents_category);
12440          $result = $attr eq 'major' ? $maj
12441             : $attr eq 'minor' ? sprintf('%d',$min)
12442             : sprintf('(%d,%d)',$maj,$min);
12443        }
12444        $result;
12445      },
12446    ccat_maj =>   # deprecated, use [:ccat|major]
12447      sub { my $blocking_ccat = $MSGINFO->blocking_ccat;
12448            (ccat_split(defined $blocking_ccat ? $blocking_ccat
12449                                            : $MSGINFO->contents_category))[0];
12450          },
12451    ccat_min =>   # deprecated, use [:ccat|minor]
12452      sub { my $blocking_ccat = $MSGINFO->blocking_ccat;
12453            (ccat_split(defined $blocking_ccat ? $blocking_ccat
12454                                            : $MSGINFO->contents_category))[1];
12455          },
12456    ccat_name =>  # deprecated, use [:ccat|name]
12457      sub { $MSGINFO->setting_by_contents_category(\%ccat_display_names) },
12458    dsn_notify => sub {
12459      return 'NEVER'  if $MSGINFO->sender eq '';
12460      my(%merged);
12461      for my $r (@{$MSGINFO->per_recip_data}) {
12462        my $dn = $r->dsn_notify;
12463        for ($dn ? @$dn : ('FAILURE')) { $merged{uc($_)} = 1 }
12464      }
12465      uc(join(',', sort keys %merged));
12466    },
12467    attachment_password => sub {
12468      my $password = $MSGINFO->attachment_password;  # already have it?
12469      if (!defined $password) {  # make one, and store it for later
12470        $password = make_password(c('attachment_password'), $MSGINFO);
12471        $MSGINFO->attachment_password($password);
12472      }
12473      $password;
12474    },
12475    b => sub {$MSGINFO->body_digest},  # original message body digest, hex enc
12476    body_digest => sub {  # original message body digest, raw bytes (binary!)
12477      my $bd = $MSGINFO->body_digest;  # hex digits, high nybble first
12478      !defined $bd ? '' : pack('H*',$bd);
12479    },
12480    n => sub {$MSGINFO->log_id},   # amavis internal task id (in log and nanny)
12481    i => sub {$MSGINFO->mail_id},  # long-term unique mail id on this system
12482    secret_id => sub {$MSGINFO->secret_id}, # mail_id's counterpart, base64url
12483    mail_id => sub {$MSGINFO->mail_id}, # synonym for %i, base64url (RFC 4648)
12484    parent_mail_id => sub {$MSGINFO->parent_mail_id},
12485    log_id => sub {$MSGINFO->log_id},   # synonym for %n
12486    MAILID => sub {$MSGINFO->mail_id},  # synonym for %i (no equivalent in SA)
12487    LOGID  => sub {$MSGINFO->log_id},   # synonym for %n (no equivalent in SA)
12488    P => sub {$MSGINFO->partition_tag}, # SQL partition tag
12489    partition_tag => sub {$MSGINFO->partition_tag},  # synonym for %P
12490    q => sub { my $q = $MSGINFO->quarantined_to;
12491               $q && [map { my $m=$_; $m=~s{^\Q$QUARANTINEDIR\E/}{}; $m } @$q];
12492             },  # list of quarantine mailboxes
12493    v => sub { !defined $av_output ? undef     # anti-virus scanner output
12494                 : [split(/[ \t]*\r?\n/, $av_output)]},
12495    V => sub { my $vn = $MSGINFO->virusnames;  # unique virus names
12496               $vn && unique_ref($vn) },
12497    W => sub { my($name,@args) = @_;  # detecting scanners & their virus names
12498               # with no args: return a list of av scanners detecting a virus
12499               return \@detecting_scanners  if !@args;
12500               # otherwise provide a per-scanner report of virus names found
12501               join('; ', map { my($av, $status, @virus_names) = @$_;
12502                                my $scanner_name = $av && $av->[0];
12503                                for ($scanner_name) {  # aliasing to $_
12504                                  if (!/^[^:" \t]+\z/)
12505                                    { tr/"/'/;  $_ = '"'.$_.'"' }
12506                                }
12507                                $scanner_name . ':' .
12508                                  (!$status ? '-'
12509                                            : '['.join(',',@virus_names).']');
12510                              } @av_scanners_results);
12511             },
12512    F => sub { my $b;
12513               # first banned part name with a comment from a rule regexp
12514               for my $r (@{$MSGINFO->per_recip_data}) {
12515                 $b = $r->banning_reason_short;
12516                 last  if defined $b;
12517               }
12518               $b },
12519    banning_rule_key => sub {
12520               # regexp of a matching banning rules yielding a true rhs result
12521               unique_ref(map { my $v = $_->banning_rule_key;
12522                                !defined($v) ? () : @$v }
12523                              @{$MSGINFO->per_recip_data});
12524             },
12525    banning_rule_comment => sub {
12526               # just a comment (or a whole regexp if it contains no comments)
12527               # from matching banning regexp rules yielding a true rhs result
12528               unique_ref(map { my $v = $_->banning_rule_comment;
12529                                !defined($v) ? () : @$v }
12530                              @{$MSGINFO->per_recip_data});
12531             },
12532    banning_rule_rhs => sub {
12533               # right-hand-side of those matching banning rules yielding true
12534               # (a r.h.s. of a rule can be a string, is treated as a boolean,
12535               # but often it is just an implicit 0 or 1)
12536               unique_ref(map { my $v = $_->banning_rule_rhs;
12537                                !defined($v) ? () : @$v }
12538                              @{$MSGINFO->per_recip_data});
12539             },
12540    banned_parts => sub {          # list of banned parts with their full paths
12541               my $b = unique_ref(map(@{$_->banned_parts},
12542                 grep(defined($_->banned_parts),@{$MSGINFO->per_recip_data})));
12543               my $b_chopped = @$b > 2;  @$b = (@$b[0,1],'...') if $b_chopped;
12544               s/[ \t]{6,}/ ... /g  for @$b;
12545               $b },
12546    banned_parts_as_attr => sub {  # list of banned parts with their full paths
12547               my $b = unique_ref(map(@{$_->banned_parts_as_attr},
12548                 grep(defined($_->banned_parts_as_attr),
12549                      @{$MSGINFO->per_recip_data})));
12550               my $b_chopped = @$b > 2;  @$b = (@$b[0,1],'...') if $b_chopped;
12551               s/[ \t]{6,}/ ... /g  for @$b;
12552               $b },
12553    X => sub {\@bad_headers},
12554    H => sub {[map(split(/\n/,$_), @{$MSGINFO->orig_header})]}, # arry of lines
12555    A       => sub {[split(/\r?\n/, $MSGINFO->spam_summary)]}, # SA report text
12556    SUMMARY => sub {$MSGINFO->spam_summary},
12557    REPORT  => sub {sanitize_str($MSGINFO->spam_report,1)}, #contains any octet
12558    TESTSSCORES => sub {macro_tests($MSGINFO,undef,@_)}, # tests with scores
12559    TESTS       => sub {macro_tests($MSGINFO,undef,@_)}, # tests without scores
12560    z => sub {$MSGINFO->msg_size}, #mail size as defined by RFC 1870, or approx
12561    ip_trace_all => sub {  # all IP addresses in the Received trace, top-down
12562               my $trace = $MSGINFO->trace; return if !$trace;
12563               [ map(defined $_ ? sanitize_str($_) : 'x',
12564                     map($_->{ip}, @$trace)) ];
12565             },
12566    ip_trace_public => sub {  # all public IP addresses in the Received trace
12567               my $ip_trace = $MSGINFO->ip_addr_trace_public;
12568               return if !$ip_trace;
12569               [ map(defined $_ ? sanitize_str($_) : 'x',  @$ip_trace) ];
12570             },
12571    ip_proto_trace_all => sub {  # from a Received trace
12572               # protocol type from the WITH clause and an IP address
12573               my $trace_ref = $MSGINFO->trace; return if !$trace_ref;
12574               my(@trace) = @$trace_ref;
12575               shift(@trace);  # chop off the last hop (MTA -> amavisd)
12576               [ map(sanitize_str( (!$_->{with} ? '' : $_->{with}.'://') .
12577                                   (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip}
12578                                     : '['.$_->{ip}.']:'.$_->{port})),@trace)];
12579             },
12580    ip_proto_trace_public => sub {  # from a Received trace
12581               # protocol type from the WITH clause and an IP address
12582               my $trace_ref = $MSGINFO->trace; return if !$trace_ref;
12583               my(@trace) = @$trace_ref;
12584               shift(@trace);  # chop off the last hop (MTA -> amavisd)
12585               [ map(sanitize_str( (!$_->{with} ? '' : $_->{with}.'://') .
12586                                   (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip}
12587                                     : '['.$_->{ip}.']:'.$_->{port}) ),
12588                     grep($_->{public}, @trace)) ];
12589             },
12590    protocol =>  # "WITH protocol type" as seen by amavisd (the last hop)
12591      sub { my $c = $MSGINFO->conn_obj; !$c ? '' : $c->appl_proto },
12592    t => sub { # first (oldest) entry in the Received trace
12593               sanitize_str(first_received_from($MSGINFO)) },
12594    e => sub { # first (oldest) valid public IP in the Received trace,
12595               # same as the last entry in ip_trace_public
12596               sanitize_str(oldest_public_ip_addr_from_received($MSGINFO)) },
12597    a => sub { $MSGINFO->client_addr }, # original SMTP session client IP addr
12598    client_addr => sub { $MSGINFO->client_addr },  # synonym with 'a'
12599    client_port => sub { $MSGINFO->client_port },
12600    client_addr_port => sub { # original SMTP session client IP addr & port no.
12601      my($a,$p) = ($MSGINFO->client_addr, $MSGINFO->client_port);
12602      !defined $a || $a eq '' ? undef : ('[' . $a . ']' . ($p ? ":$p" : ''));
12603    },
12604    g => sub { # original SMTP session client DNS name
12605               sanitize_str($MSGINFO->client_name) },
12606    client_helo => sub { # original SMTP session EHLO/HELO name
12607                         sanitize_str($MSGINFO->client_helo) },
12608    client_protocol => sub { $MSGINFO->client_proto }, # XFORWARD PROTO, AM.PDP
12609    remote_mta    => sub { unique_ref(map($_->recip_remote_mta,
12610                                          @{$MSGINFO->per_recip_data})) },
12611    smtp_response => sub { unique_ref(map($_->recip_smtp_response,
12612                                          @{$MSGINFO->per_recip_data})) },
12613    remote_mta_smtp_response =>
12614                     sub { unique_ref(map($_->recip_remote_mta_smtp_response,
12615                                          @{$MSGINFO->per_recip_data})) },
12616    REMOTEHOSTADDR =>  # where the request came from
12617            sub { my $c = $MSGINFO->conn_obj; !$c ? '' : $c->client_ip },
12618    REMOTEHOSTNAME =>
12619            sub { my $c = $MSGINFO->conn_obj;
12620                  my $ip = !$c ? '' : $c->client_ip;
12621                  $ip ne '' ? "[$ip]" : 'localhost' },
12622    AUTOLEARN       => sub {$MSGINFO->supplementary_info('AUTOLEARN')},
12623    ADDEDHEADERHAM  => sub {$MSGINFO->supplementary_info('ADDEDHEADERHAM')},
12624    ADDEDHEADERSPAM => sub {$MSGINFO->supplementary_info('ADDEDHEADERSPAM')},
12625    supplementary_info =>  # additional information from SA and other scanners
12626            sub { my($name,$key,$fmt)=@_;
12627                  my $info = $MSGINFO->supplementary_info($key);
12628                  $info eq '' ? '' : $fmt eq '' ? $info : sprintf($fmt,$info);
12629                },
12630    rusage => sub { macro_rusage($MSGINFO,undef,@_) }, # resource usage
12631    REQD => sub { my $tag2_level;
12632                  for (@{$MSGINFO->per_recip_data}) {  # get minimal tag2_level
12633                    my $tag2_l = lookup2(0, $_->recip_addr,
12634                                         ca('spam_tag2_level_maps'));
12635                    $tag2_level = $tag2_l  if defined($tag2_l) &&
12636                              (!defined($tag2_level) || $tag2_l < $tag2_level);
12637                  }
12638                  !defined($tag2_level) ? '-' : 0+sprintf("%.3f",$tag2_level);
12639                },
12640    '1'=> sub { # above tag level and not bypassed for any recipient?
12641                grep($_->is_in_contents_category(CC_CLEAN,1),
12642                     @{$MSGINFO->per_recip_data}) ? 'Y' : '0' },
12643    '2'=> sub { # above tag2 level and not bypassed for any recipient?
12644                grep($_->is_in_contents_category(CC_SPAMMY),
12645                     @{$MSGINFO->per_recip_data}) ? 'Y' : '0' },
12646    YESNO => sub { my($arg_spam, $arg_ham) = @_;  # like %2, but gives: Yes/No
12647                   grep($_->is_in_contents_category(CC_SPAMMY),
12648                        @{$MSGINFO->per_recip_data})
12649                     ? (defined $arg_spam ? $arg_spam : 'Yes')
12650                     : (defined $arg_ham  ? $arg_ham  : 'No') },
12651    YESNOCAPS =>
12652             sub { my($arg_spam, $arg_ham) = @_;  # like %2, but gives: YES/NO
12653                   grep($_->is_in_contents_category(CC_SPAMMY),
12654                        @{$MSGINFO->per_recip_data})
12655                     ? (defined $arg_spam ? $arg_spam : 'YES')
12656                     : (defined $arg_ham  ? $arg_ham  : 'NO') },
12657    'k'=> sub { # above kill level and not bypassed for any recipient?
12658                grep($_->is_in_contents_category(CC_SPAM),
12659                     @{$MSGINFO->per_recip_data}) ? 'Y' : '0' },
12660    score_boost => 0,  # legacy
12661    c      => sub {macro_score($MSGINFO,undef,@_)},  # info on all recipients
12662    SCORE  => sub {macro_score($MSGINFO,undef,@_)},  # info on all recipients
12663    STARS  => sub {macro_score($MSGINFO,undef,@_)},  # info on all recipients
12664    dkim   => \&dkim_test,
12665    tls_in => sub {$MSGINFO->tls_cipher}, # currently only shows ciphers in use
12666    report_format => undef,  # notification message format, supplied elsewhere
12667    feedback_type => undef,  # (ARF) feedback type or empty, supplied elsewhere
12668    wrap   => sub {my($name,$width,$prefix,$indent,$str) = @_;
12669                   wrap_string($str,$width,$prefix,$indent)},
12670    lc     => sub {my $name=shift; lc(join('',@_))},  # to lowercase
12671    uc     => sub {my $name=shift; uc(join('',@_))},  # to uppercase
12672    substr => sub {my($name,$s,$ofs,$len) = @_;
12673                   defined $len ? substr($s,$ofs,$len) : substr($s,$ofs)},
12674    index  => sub {my($name,$s,$substr,$pos) = @_;
12675                   index($s, $substr, defined $pos ? $pos : 0)},
12676    len    => sub {my($name,$s) = @_; length($s)},
12677    incr   => sub {my($name,$v,@rest) = @_;
12678                   if (!@rest) { $v++ } else { $v += $_ for @rest };  "$v"},
12679    decr   => sub {my($name,$v,@rest) = @_;
12680                   if (!@rest) { $v-- } else { $v -= $_ for @rest };  "$v"},
12681    min    => sub {my($name,@args) = @_; min(map(/^\s*\z/?undef:$_, @args))},
12682    max    => sub {my($name,@args) = @_; max(map(/^\s*\z/?undef:$_, @args))},
12683    sprintf=> sub {my($name,$fmt,@args) = @_; sprintf($fmt,@args)},
12684    join   => sub {my($name,$sep,@args) = @_; join($sep,@args)},
12685    limit  => sub {my($name,$lim,$s) = @_; $lim < 6 || length($s) <= $lim ? $s
12686                                              : substr($s,0,$lim-5).'[...]' },
12687    dquote => sub {my $nm=shift;
12688                   join('', map { my $s=$_; $s=~s{"}{""}g; '"'.$s.'"' } @_)},
12689    uquote => sub {my $nm=shift;
12690                   join('', map { my $s=$_; $s=~s{[ \t]+}{_}g; $s     } @_)},
12691    rot13  => sub {my($name,$s) = @_;  # obfuscation (Caesar cipher)
12692                   $s=~tr/a-zA-Z/n-za-mN-ZA-M/; $s },
12693    hexenc    => sub {my $nm=shift; join('',  map(unpack('H*',$_), @_))},
12694    b64encode => sub {my $nm=shift; join(' ', map(encode_base64($_,''),@_))},
12695    b64enc    => sub {my $nm=shift;  # preferred over b64encode
12696                      join('', map { my $s=encode_base64($_,'');
12697                                     $s=~s/=+\z//; $s } @_)},
12698    b64urlenc => sub {my $nm=shift;
12699                      join('', map { my $s=encode_base64($_,'');
12700                                     $s=~s/=+\z//; $s=~tr{+/}{-_}; $s } @_)},
12701    mail_addr_decode => sub {my($nm,$addr) = @_; mail_addr_decode($addr,0)},
12702    mail_addr_decode_octets =>
12703                        sub {my($nm,$addr) = @_; mail_addr_decode($addr,1)},
12704    mime_decode => sub {
12705      # convert RFC 2047 encoded-words or UTF-8 octets to logical characters,
12706      # truncate to $max_len characters if limit is provded
12707      my($nm,$str,$max_len,$both_if_diff) = @_;
12708      return '' if  !defined $str || $str eq '';
12709      my $chars = safe_decode_mime($str);  # octets to logical characters
12710      if (!defined $max_len || $max_len <= 0) {  # no size limit
12711        return $chars  if !$both_if_diff;
12712        $chars .= ' (raw: ' . $str . ')'  if $chars ne $str;
12713      } else {  # truncate characters string at $max_len
12714        substr($chars,$max_len) = '' if length($chars) > $max_len;
12715        return $chars  if !$both_if_diff;
12716        # only compare the visible part
12717        my $octets = safe_encode_utf8($chars);
12718        substr($str,length($octets)) = '' if length($str) > length($octets);
12719        $chars .= ' (raw: ' . $str . ')'  if $str ne $chars;
12720      }
12721      $chars;
12722    },
12723    mime2utf8 => sub {
12724      # convert RFC 2047 encoded-words or UTF-8 to UTF-8 octets,
12725      # truncate to $max_len characters if limit is provded
12726      my($nm,$str,$max_len,$both_if_diff) = @_;
12727      return '' if !defined $str || $str eq '';
12728      my $chars  = safe_decode_mime($str);    # to logical characters
12729      my $octets = safe_encode_utf8($chars);  # to bytes, UTF-8 encoded
12730      $octets = truncate_utf_8($octets,$max_len);
12731      return $octets  if !$both_if_diff;
12732      # only compare the visible part
12733      if (defined $max_len && $max_len > 0 && length($str) > $max_len) {
12734        substr($str,$max_len) = '';
12735      }
12736      $str = $octets . ' (raw: ' . $str . ')'  if $octets ne $str;
12737      $str;
12738    },
12739    report_json => sub {
12740      return if !$report_ref;  # ugly globals
12741      structured_report_update_time($report_ref);
12742      return Amavis::JSON::encode($report_ref);  # as a string of characters
12743    },
12744    report_json => sub {
12745      return if !$report_ref;  # ugly globals
12746      structured_report_update_time($report_ref);
12747      my $macro_name = shift;
12748      if (!@_) {  # all fields, no filtering
12749        return Amavis::JSON::encode($report_ref);  # as a string of characters
12750      } else {  # filtering by field names
12751        my @keys = @_ == 1 ? split(' ',$_[0]) : @_;   # whitespace-separated?
12752        my(@negated_keys) = map(/^!(.*)\z/s ? $1 : (), @keys);
12753        my %filtered;
12754        if (@negated_keys) {  # take all but negated fields
12755          %filtered = %$report_ref;
12756          delete @filtered{@negated_keys};
12757        } else {  # take only listed fields
12758          %filtered =
12759            map(exists $report_ref->{$_} ? ($_,$report_ref->{$_}) : (), @keys);
12760        }
12761        return Amavis::JSON::encode(\%filtered);  # as a string of characters
12762      }
12763    },
12764    # macros f, T, C, B will be defined for each notification as appropriate
12765    # (representing From:, To:, Cc:, and Bcc: respectively)
12766    # remaining free letters: wxEGIJKLMYZ
12767  );
12768}
12769
12770# initialize %local_delivery_aliases
12771#
12772sub init_local_delivery_aliases() {
12773  # The %local_delivery_aliases maps local virtual 'localpart' to a mailbox
12774  # (e.g. to a quarantine filename or a directory). Used by method 'local:',
12775  # i.e. in mail_to_local_mailbox(), for direct local quarantining.
12776  # The hash value may be a ref to a pair of fixed strings, or a subroutine ref
12777  # (which must return a pair of strings (a list, not a list ref)) which makes
12778  # possible lazy evaluation when some part of the pair is not known before
12779  # the final delivery time. The first string in a pair must be either:
12780  #   - empty or undef, which will disable saving the message,
12781  #   - a filename, indicating a Unix-style mailbox,
12782  #   - a directory name, indicating a maildir-style mailbox,
12783  #     in which case the second string may provide a suggested file name.
12784  #
12785  %Amavis::Conf::local_delivery_aliases = (
12786    'virus-quarantine'      => sub { ($QUARANTINEDIR, undef) },
12787    'banned-quarantine'     => sub { ($QUARANTINEDIR, undef) },
12788    'unchecked-quarantine'  => sub { ($QUARANTINEDIR, undef) },
12789    'spam-quarantine'       => sub { ($QUARANTINEDIR, undef) },
12790    'bad-header-quarantine' => sub { ($QUARANTINEDIR, undef) },
12791    'clean-quarantine'      => sub { ($QUARANTINEDIR, undef) },
12792    'other-quarantine'      => sub { ($QUARANTINEDIR, undef) },
12793    'archive-quarantine'    => sub { ($QUARANTINEDIR, undef) },
12794
12795    # some more examples:
12796    'archive-files'     => sub { ("$QUARANTINEDIR",              undef) },
12797    'archive-mbox'      => sub { ("$QUARANTINEDIR/archive.mbox", undef) },
12798    'recip-quarantine'  => sub { ("$QUARANTINEDIR/recip-archive.mbox",undef) },
12799    'sender-quarantine' =>
12800      sub { my $s = $MSGINFO->sender;
12801            substr($s,100) = '...'  if length($s) > 100+3;
12802            $s =~ tr/a-zA-Z0-9@._+-/=/c; $s =~ s/\@/_at_/g;
12803            untaint_inplace($s) if $s =~ /^(?:[a-zA-Z0-9%=._+-]+)\z/; # untaint
12804            ($QUARANTINEDIR, "sender-$s-%m.gz");   # suggested file name
12805          },
12806#   'recip-quarantine2' => sub {
12807#      my(@fnames);
12808#      my $myfield =
12809#         Amavis::Lookup::SQLfield->new($sql_lookups,'some_field_name','S');
12810#       for my $r (@{$MSGINFO->recips}) {
12811#         my $field_value = lookup(0,$r,$myfield);
12812#         my $fname = $field_value;  # or perhaps: my $fname = $r;
12813#         local($1); $fname =~ s/[^a-zA-Z0-9._\@]/=/g; $fname =~ s/\@/%/g;
12814#         untaint_inplace($fname)  if $fname =~ /^([a-zA-Z0-9._=%]+)\z/;
12815#         $fname =~ s/%/%%/g;  # protect %
12816#         do_log(3, "Recipient: %s, field: %s, fname: %s",
12817#                   $r, $field_value, $fname);
12818#         push(@fnames, $fname);
12819#       }
12820#       # ???what file name to choose if there is more than one recipient???
12821#       ( $QUARANTINEDIR, "sender-$fnames[0]-%i-%n.gz" ); # suggested file name
12822#     },
12823  );
12824}
12825
12826# tokenize templates (input to macro expansion), after dropping privileges
12827#
12828sub init_tokenize_templates() {
12829  my(@templ_names) = qw(log_templ log_recip_templ
12830     notify_sender_templ notify_virus_recips_templ
12831     notify_virus_sender_templ notify_virus_admin_templ
12832     notify_spam_sender_templ notify_spam_admin_templ
12833     notify_release_templ notify_report_templ notify_autoresp_templ);
12834  for my $bank_name (keys %policy_bank) {
12835    for my $n (@templ_names) { # tokenize templates to speed up macro expansion
12836      my $s = $policy_bank{$bank_name}{$n};
12837      $s = $$s  if ref($s) eq 'SCALAR';
12838      if (defined $s) {
12839        # encode log templates to UTF-8, leave the rest as character strings
12840        safe_encode_utf8_inplace($s) if $n eq 'log_templ' || 'log_recip_templ';
12841        $policy_bank{$bank_name}{$n} = tokenize(\$s);
12842      }
12843    }
12844  }
12845}
12846
12847# pre-parse IP lookup tables to speed up lookups, after dropping privileges
12848#
12849sub init_preparse_ip_lookups() {
12850  for my $bank_name (keys %policy_bank) {
12851
12852    my $r = $policy_bank{$bank_name}{'inet_acl'};
12853    if (ref($r) eq 'ARRAY') {  # should be a ref to an IP lookup table
12854      $policy_bank{$bank_name}{'inet_acl'} = Amavis::Lookup::IP->new(@$r);
12855    }
12856    $r = $policy_bank{$bank_name}{'ip_repu_ignore_maps'};  # listref of tables
12857    if (ref($r) eq 'ARRAY') {  # should be an array, test just to make sure
12858      for my $table (@$r) {  # replace plain lists with pre-parsed objects
12859        $table = Amavis::Lookup::IP->new(@$table)  if ref($table) eq 'ARRAY';
12860      }
12861    }
12862    $r = $policy_bank{$bank_name}{'client_ipaddr_policy'};  # listref of pairs
12863    if (ref($r) eq 'ARRAY') {  # should be an array, test just to make sure
12864      my $odd = 1;
12865      for my $table (@$r) {  # replace plain lists with pre-parsed objects
12866        $table = Amavis::Lookup::IP->new(@$table)
12867          if $odd && ref($table) eq 'ARRAY';
12868        $odd = !$odd;
12869      }
12870    }
12871  }
12872}
12873
12874# initialize some remaining global variables in a master process;
12875# invoked after chroot and after privileges have been dropped, before forking
12876#
12877sub after_chroot_init() {
12878  $child_invocation_count = $child_task_count = 0;
12879  %modules_basic = %INC;  # helps to track missing modules in chroot
12880  do_log(5,"after_chroot_init: EUID: %s (%s);  EGID: %s (%s)", $>,$<, $),$( );
12881  my(@msg);
12882  my $euid = $>;  # effective UID
12883  $> = 0;         # try to become root
12884  POSIX::setuid(0)  if $> != 0;  # and try some more
12885  if ($euid == 0) {
12886    @msg = ('Running as EUID 0 (root), ABORTING!',
12887            'Please start as non-root, e.g. by su(1) or using option -u user,',
12888            'or configure the $daemon_user setting.');
12889  } elsif ($> == 0) {   # succeeded? panic!
12890    @msg = ("It is possible to change EUID from $euid to root, ABORTING!",
12891            'Please start as non-root, e.g. by su(1) or using option -u user,',
12892            'or configure the $daemon_user setting.');
12893  } elsif ($daemon_chroot_dir eq '') {
12894    # A quick check on vulnerability/protection of a config file
12895    # (non-exhaustive: doesn't test for symlink tricks and higher directories).
12896    # The config file has already been executed by now, so it may be
12897    # too late to feel sorry now, but better late then never.
12898    my(@actual_c_f) = Amavis::Conf::get_config_files_read();
12899    do_log(2,"config files read: %s", join(", ",@actual_c_f));
12900    for my $config_file (@actual_c_f) {
12901      local($1);  # IO::Handle::_open_mode_string can taint $1 if mode is '+<'
12902      my $fh = IO::File->new;
12903      my $errn = stat($config_file) ? 0 : 0+$!;
12904      if ($errn) {
12905        # not accessible, don't bother to test further
12906      } elsif ($i_know_what_i_am_doing{no_conf_file_writable_check}) {
12907        # skip checking
12908      } elsif ($fh->open($config_file,O_RDWR)) {
12909        push(@msg, "Config file \"$config_file\" is writable, ".
12910                   "UID $<, EUID $>, EGID $)" );
12911        $fh->close;  # close, ignoring status
12912      } elsif (rename($config_file, $config_file.'.moved')) {
12913        my $m = 'appears writable (unconfirmed)';
12914        my $errn_cf_orig = stat($config_file)          ? 0 : 0+$!;
12915        my $errn_cf_movd = stat($config_file.'.moved') ? 0 : 0+$!;
12916        if ($errn_cf_orig==ENOENT && $errn_cf_movd!=ENOENT) {
12917          # try to rename back, ignoring status
12918          rename($config_file.'.moved', $config_file);
12919          $m = 'is writable (confirmed)';
12920        }
12921        push(@msg, "Directory of a config file \"$config_file\" $m, ".
12922                   "UID $<, EUID $>, EGID $)" );
12923      }
12924      last  if @msg;
12925    }
12926  }
12927  if (@msg) {
12928    do_log(-3,"FATAL: %s",$_)  for @msg;
12929    print STDERR (map("$_\n", @msg));
12930    die "SECURITY PROBLEM, ABORTING";
12931    exit 1;  # just in case
12932  }
12933  init_tokenize_templates();
12934  init_preparse_ip_lookups();
12935
12936  # report versions of some (more interesting) modules
12937  for my $m ('Amavis::Conf',
12938          sort map { my $s = $_; $s =~ s/\.pm\z//; $s =~ s{/}{::}g; $s }
12939               grep(/\.pm\z/, keys %INC)) {
12940    next  if !grep($_ eq $m, qw(Amavis::Conf
12941      Archive::Tar Archive::Zip Compress::Zlib Compress::Raw::Zlib
12942      Convert::TNEF Convert::UUlib File::LibMagic
12943      MIME::Entity MIME::Parser MIME::Tools Mail::Header Mail::Internet
12944      Digest::MD5 Digest::SHA Digest::SHA1 Crypt::OpenSSL::RSA
12945      Authen::SASL Authen::SASL::XS Authen::SASL::Cyrus Authen::SASL::Perl
12946      Encode Scalar::Util Time::HiRes File::Temp Unix::Syslog Unix::Getrusage
12947      Socket Socket6 IO::Socket::INET6 IO::Socket::IP IO::Socket::SSL
12948      Net::Server NetAddr::IP Net::DNS Net::LibIDN Net::SSLeay Net::Patricia
12949      Net::LDAP Mail::SpamAssassin Mail::DKIM::Verifier Mail::DKIM::Signer
12950      Mail::ClamAV Mail::SPF Mail::SPF::Query URI Razor2::Client::Version
12951      DBI DBD::mysql DBD::Pg DBD::SQLite BerkeleyDB DB_File
12952      ZMQ ZMQ::LibZMQ2 ZMQ::LibZMQ3 ZeroMQ SAVI Anomy::Sanitizer));
12953    do_log(1, "Module %-19s %s", $m, eval{$m->VERSION} || '?');
12954  }
12955  do_log(1,"Amavis::ZMQ code    %s loaded", $extra_code_zmq        ?'':" NOT");
12956  do_log(1,"Amavis::DB code     %s loaded", $extra_code_db         ?'':" NOT");
12957  do_log(1,"SQL base code       %s loaded", $extra_code_sql_base   ?'':" NOT");
12958  do_log(1,"SQL::Log code       %s loaded", $extra_code_sql_log    ?'':" NOT");
12959  do_log(1,"SQL::Quarantine     %s loaded", $extra_code_sql_quar   ?'':" NOT");
12960  do_log(1,"Lookup::SQL code    %s loaded", $extra_code_sql_lookup ?'':" NOT");
12961  do_log(1,"Lookup::LDAP code   %s loaded", $extra_code_ldap       ?'':" NOT");
12962  do_log(1,"AM.PDP-in proto code%s loaded", $extra_code_in_ampdp   ?'':" NOT");
12963  do_log(1,"SMTP-in proto code  %s loaded", $extra_code_in_smtp    ?'':" NOT");
12964  do_log(1,"Courier proto code  %s loaded", $extra_code_in_courier ?'':" NOT");
12965  do_log(1,"SMTP-out proto code %s loaded", $extra_code_out_smtp   ?'':" NOT");
12966  do_log(1,"Pipe-out proto code %s loaded", $extra_code_out_pipe   ?'':" NOT");
12967  do_log(1,"BSMTP-out proto code%s loaded", $extra_code_out_bsmtp  ?'':" NOT");
12968  do_log(1,"Local-out proto code%s loaded", $extra_code_out_local  ?'':" NOT");
12969  do_log(1,"OS_Fingerprint code %s loaded", $extra_code_p0f        ?'':" NOT");
12970  do_log(1,"ANTI-VIRUS code     %s loaded", $extra_code_antivirus  ?'':" NOT");
12971  do_log(1,"ANTI-SPAM code      %s loaded", $extra_code_antispam   ?'':" NOT");
12972  do_log(1,"ANTI-SPAM-EXT code  %s loaded",
12973                                      $extra_code_antispam_extprog ?'':" NOT");
12974  do_log(1,"ANTI-SPAM-C code    %s loaded",
12975                                      $extra_code_antispam_spamc   ?'':" NOT");
12976  do_log(1,"ANTI-SPAM-Rspamd code%s loaded",
12977                                      $extra_code_antispam_rspamc  ?'':" NOT");
12978  do_log(1,"ANTI-SPAM-SA code   %s loaded", $extra_code_antispam_sa?'':" NOT");
12979  do_log(1,"Unpackers code      %s loaded", $extra_code_unpackers  ?'':" NOT");
12980  do_log(1,"DKIM code           %s loaded", $extra_code_dkim       ?'':" NOT");
12981  do_log(1,"Tools code          %s loaded", $extra_code_tools      ?'':" NOT");
12982
12983  # store policy names into 'policy_bank_name' fields, if not explicitly set
12984  for my $name (keys %policy_bank) {
12985    if (ref($policy_bank{$name}) eq 'HASH' &&
12986        !exists($policy_bank{$name}{'policy_bank_name'})) {
12987      $policy_bank{$name}{'policy_bank_name'} = $name;
12988      $policy_bank{$name}{'policy_bank_path'} = $name;
12989    }
12990  }
12991};
12992
12993# overlay the current policy bank by settings from the
12994# $policy_bank{$policy_bank_name}, or load the default policy bank (empty name)
12995#
12996sub load_policy_bank($;$) {
12997  my($policy_bank_name, $msginfo) = @_;
12998  if (!defined $policy_bank_name) {
12999    # silently ignore
13000  } elsif (!exists $policy_bank{$policy_bank_name}) {
13001    do_log(5,'policy bank "%s" does not exist, ignored', $policy_bank_name);
13002  } elsif ($policy_bank_name eq '') {  # special case
13003    %current_policy_bank = %{$policy_bank{$policy_bank_name}};  # copy base
13004    update_current_log_level();
13005    do_log(4,'loaded base policy bank');
13006  } elsif ($policy_bank_name eq c('policy_bank_name')) {
13007    do_log(5,'policy bank "%s" just loaded, ignored', $policy_bank_name);
13008  } else {
13009    # compatibility: policy bank MYNETS implicitly pre-sets 'originating' flag
13010    $current_policy_bank{'originating'} = 1  if $policy_bank_name eq 'MYNETS';
13011    my $cpbp = c('policy_bank_path');  # currently loaded bank
13012    my $new_bank_ref = $policy_bank{$policy_bank_name};
13013    my $do_log5 = ll(5);
13014    for my $k (keys %$new_bank_ref) {
13015      if ($k eq 'ACTION') {
13016        if (ref $new_bank_ref->{$k} eq 'CODE') {
13017          do_log(5,'invoking user ACTION on loading a policy bank %s',
13018                   $policy_bank_name);
13019          eval {
13020            # $msginfo may be undef when a policy bank load takes place early
13021            &{$new_bank_ref->{$k}}($msginfo,$policy_bank_name); 1;
13022          } or do {
13023            my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
13024            do_log(-1,'failed ACTION on loading a policy bank %s: %s',
13025                      $policy_bank_name, $eval_stat);
13026          };
13027        }
13028      } elsif (!exists $current_policy_bank{$k}) {
13029        do_log(-1,'loading policy bank "%s": unknown field "%s"',
13030                  $policy_bank_name,$k);
13031      } elsif (ref($new_bank_ref->{$k}) ne 'HASH' ||
13032          ref($current_policy_bank{$k}) ne 'HASH') {
13033        $current_policy_bank{$k} = $new_bank_ref->{$k};
13034      # do_log(5,'loading policy bank %s, curr{%s} replaced by %s',
13035      #           $policy_bank_name, $k, $current_policy_bank{$k}) if $do_log5;
13036      } else {  # new hash to be merged into or replacing an existing hash
13037        if ($new_bank_ref->{$k}{REPLACE}) {  # replace the entire hash
13038          $current_policy_bank{$k} = { %{$new_bank_ref->{$k}} };  # copy of new
13039          do_log(5,'loading policy bank %s, curr{%s} hash replaced',
13040                    $policy_bank_name, $k)  if $do_log5;
13041        } else { # merge field-by-field, old fields missing in new are retained
13042          $current_policy_bank{$k} = { %{$current_policy_bank{$k}} };  # copy
13043          while (my($key,$val) = each %{$new_bank_ref->{$k}}) {
13044            do_log(5,'loading policy bank %s, curr{%s}{%s} = %s, %s',
13045                     $policy_bank_name, $k, $key, $val,
13046                     !exists($current_policy_bank{$k}{$key}) ? 'new'
13047                                   : 'replaces '.$current_policy_bank{$k}{$key}
13048                  )  if $do_log5;
13049            $current_policy_bank{$k}{$key} = $val;
13050          }
13051        }
13052        delete $current_policy_bank{$k}{REPLACE};
13053      }
13054    }
13055    $current_policy_bank{'policy_bank_path'} =
13056      ($cpbp eq '' ? '' : $cpbp.'/') . $policy_bank_name;
13057    ll(3) && do_log(3,'loaded policy bank "%s"%s', $policy_bank_name,
13058                      $cpbp eq '' ? '' : " over \"$cpbp\"");
13059    # update global settings which may have changed
13060    update_current_log_level();
13061    $msginfo->originating(c('originating')) if $msginfo;
13062  }
13063}
13064
13065# systemd notifier
13066#
13067sub sd_notify($@) {
13068# my($unset_environment, @messages) = @_;
13069  my $unset_environment = shift;
13070  my $result;  # undef=failure, 0=nothing to do, 1=success
13071  my $socket_name = $ENV{NOTIFY_SOCKET};
13072  if (!@_) {  # no messages
13073    $result = 0;
13074  } elsif (!defined $socket_name || $socket_name eq '') {
13075    $result = 0;
13076    ll(2) && do_log(2, "sd_notify (no socket): %s", join("\n",@_));
13077  } elsif ($socket_name !~ m{^[/@].}s) {
13078    # must be an absolute path or an abstract socket
13079    do_log(0, "sd_notify: NOTIFY_SOCKET env.var '%s' must be ".
13080              "an absolute path or an abstract socket", $socket_name);
13081    $! = EINVAL;
13082  } else {
13083    ll(1) && do_log(1, "sd_notify (%s): %s", $socket_name, join("\n",@_));
13084    $socket_name =~ s{^\@}{\x{00}}s;  # abstract socket (Linux specific)
13085    eval {
13086      my $sock = IO::Socket::UNIX->new(Type => SOCK_DGRAM);
13087      $sock or die "Can't create a socket object of type AF_LOCAL: $!";
13088      # should also send credentials, e.g. using IO::Handle::Record module
13089      #  FreeBSD: struct cmsgcred; send a SCM_CREDS message
13090      #  OpenBSD: struct sockpeercred; SO_PASSCRED
13091      #  Linux: struct ucred; send a SCM_CREDENTIALS msg; SO_PEERCRED; unix(7)
13092      $sock->connect( pack_sockaddr_un(untaint($socket_name)) )
13093        or die "Can't connect to NOTIFY_SOCKET $socket_name: $!";
13094      defined $sock->send(join("\n",@_), MSG_NOSIGNAL)
13095        or die "Error sending to NOTIFY_SOCKET $socket_name: $!";
13096      $sock->close or die "Error closing NOTIFY_SOCKET: $!";
13097      $result = 1;
13098    } or do {
13099      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
13100      do_log(-1, "sd_notify: %s", $eval_stat);
13101    };
13102  }
13103  undef $ENV{NOTIFY_SOCKET}  if $unset_environment;
13104  $result;
13105}
13106
13107sub sd_notifyf($$;@) {
13108  my($unset_environment, $message, @args) = @_;
13109  sd_notify($unset_environment, @args ? sprintf($message,@args) : $message);
13110}
13111
13112### Net::Server hook
13113### Occurs in the parent (master) process after (possibly) opening a log file,
13114### creating pid file, reopening STDIN/STDOUT to /dev/null and daemonizing;
13115### but before binding to sockets
13116#
13117sub post_configure_hook {
13118  if ($warm_restart) {
13119    sd_notify(0, "STATUS=Preparing to re-bind sockets.");
13120  } elsif (!$daemonize) {
13121    sd_notify(0, "STATUS=Preparing to bind sockets.");
13122  } else {
13123    sd_notify(0, "MAINPID=$$","STATUS=Daemonized, preparing to bind sockets.");
13124  }
13125# umask(0007);  # affects protection of Unix sockets created by Net::Server
13126}
13127
13128sub set_sockets_access() {
13129  if (defined $unix_socket_mode && $unix_socket_mode ne '') {
13130    for my $s (@listen_sockets) {
13131      local($1);
13132      if ($s =~ m{^(/.+)\|unix\z}si) {
13133        my $path = $1;
13134        chmod($unix_socket_mode,$path)
13135          or do_log(-1, "Error setting mode 0%03o on a socket %s: %s",
13136                        $unix_socket_mode, $path, $!);
13137      }
13138    }
13139  }
13140}
13141
13142### Net::Server hook
13143### Occurs in the parent (master) process after binding to sockets,
13144### but before chrooting and dropping privileges
13145#
13146sub post_bind_hook {
13147  umask(0027);  # restore our preferred umask
13148  set_sockets_access()  if defined $warm_restart && !$warm_restart;
13149  sd_notify(0, "STATUS=Sockets bound, checking user and group.");
13150}
13151
13152### Net::Server hook
13153### This hook occurs in the parent (master) process after chroot,
13154### after change of user, and change of group has occurred.
13155### It allows for preparation before forking and looping begins.
13156#
13157sub pre_loop_hook {
13158  my $self = $_[0];
13159  local $SIG{CHLD} = 'DEFAULT';
13160# do_log(5, "entered pre_loop_hook");
13161  eval {
13162    sd_notify(0, "STATUS=The rest of pre-fork init, finding helper programs.");
13163    after_chroot_init();  # the rest of the top-level initialization
13164
13165    # this needs to be done after chroot, otherwise paths will be wrong
13166    find_external_programs([split(/:/,$path,-1)]);  # path, decoders, scanners
13167    # do some sanity checking
13168    my $name = $TEMPBASE;
13169    $name = "$daemon_chroot_dir $name"  if $daemon_chroot_dir ne '';
13170    my $errn = stat($TEMPBASE) ? 0 : 0+$!;
13171    if    ($errn==ENOENT) { die "No TEMPBASE directory: $name" }
13172    elsif ($errn)         { die "TEMPBASE directory inaccessible, $!: $name" }
13173    elsif (!-d _)         { die "TEMPBASE is not a directory: $name" }
13174    elsif (!-w _)         { die "TEMPBASE directory is not writable: $name" }
13175    if ($enable_db && $extra_code_db) {
13176      my $name = $db_home;
13177      $name = "$daemon_chroot_dir $name"  if $daemon_chroot_dir ne '';
13178      $errn = stat($db_home) ? 0 : 0+$!;
13179      if ($errn == ENOENT) {
13180        die "Please create an empty directory $name to hold a database".
13181            " (config variable \$db_home)\n" }
13182      elsif ($errn) { die "db_home $name inaccessible: $!" }
13183      elsif (!-d _) { die "db_home $name is not a directory" }
13184      elsif (!-w _) { die "db_home $name directory is not writable" }
13185      Amavis::DB::init(1, !$warm_restart);
13186    }
13187    if (!defined($sql_quarantine_chunksize_max)) {
13188      die "Variable \$sql_quarantine_chunksize_max is undefined\n";
13189    } elsif ($sql_quarantine_chunksize_max < 1024) {
13190      die "Setting of \$sql_quarantine_chunksize_max is too small: ".
13191          "$sql_quarantine_chunksize_max bytes, it would be inefficient\n";
13192    } elsif ($sql_quarantine_chunksize_max > 1024*1024) {
13193      do_log(-1, "Setting of %s is quite large: %d KiB, it unnecessarily ".
13194                 "wastes memory", '$sql_quarantine_chunksize_max',
13195                 $sql_quarantine_chunksize_max/1024);
13196    }
13197    if ($QUARANTINEDIR ne '') {
13198      my $name = $QUARANTINEDIR;
13199      $name = "$daemon_chroot_dir $name"  if $daemon_chroot_dir ne '';
13200      $errn = stat($QUARANTINEDIR) ? 0 : 0+$!;
13201      if    ($errn == ENOENT) { }  # ok
13202      elsif ($errn)        { die "QUARANTINEDIR $name inaccessible: $!" }
13203    # elsif (-d _ && !-w _){ die "QUARANTINEDIR directory $name not writable"}
13204    }
13205    $spamcontrol_obj->init_pre_fork  if $spamcontrol_obj;
13206    my(@modules_extra) = grep(!exists $modules_basic{$_}, keys %INC);
13207    if (@modules_extra) {
13208      do_log(1, "extra modules loaded after daemonizing/chrooting: %s",
13209        join(", ", sort @modules_extra));
13210      %modules_basic = %INC;
13211    }
13212    if (!grep { my $v = $policy_bank{$_}{'enable_dkim_verification'};
13213                defined(!ref $v ? $v : $$v) } keys %policy_bank)
13214    { do_log(0,'DKIM signature verification disabled, corresponding features '.
13215        'not available. If not intentional, consider enabling it by setting: '.
13216        '$enable_dkim_verification to 1, or explicitly disable it by setting '.
13217        'it to 0 to mute this warning.');
13218    }
13219    # systemd, Type=notify
13220    sd_notify(0, "READY=1", "STATUS=Initialization done.");
13221    1;
13222  } or do {
13223    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
13224    my $msg = "TROUBLE in pre_loop_hook: $eval_stat";
13225    do_log(-2,"%s",$msg);
13226    sd_notify(0, "STOPPING=1", "STATUS=$msg");
13227    die("Suicide (" . am_id() . ") " . $msg . "\n");
13228  };
13229  1;
13230}
13231
13232# (!)_DIE: Unable to create sub named "" at /usr/local/sbin/amavisd line 9947.
13233# The line 9947 was in sub write_to_log_hook: local $SIG{CHLD} = 'DEFAULT';
13234# perl #60360: local $SIG{FOO} = sub {...}; sets signal handler to SIG_DFL
13235# # http://www.perlmonks.org/?node_id=721692
13236# # non-atomic, clears to SIG_DFL, then sets: local $SIG{ALRM} = sub {...};
13237# use Sub::ScopeFinalizer qw( scope_finalizer );
13238# my $sentry = local_sassign $SIG{ALRM}, \&alarm_handler;
13239# sub local_sassign {
13240#   my $r = \($_[0]);
13241#   my $sentry = scope_finalizer { $$r = $_[0] } { args => [ $$r ] };
13242#   $$r = $_[1]; return $sentry;
13243# }
13244# or use:
13245#   use POSIX qw(:signal_h) ;
13246#   my $sigset   = POSIX::SigSet->new ;
13247#   my $blockset = POSIX::SigSet->new( SIGALRM ) ;
13248#   sigprocmask(SIG_BLOCK, $blockset, $sigset );
13249#   local $SIG{ALRM} = sub .... ;
13250#   sigprocmask(SIG_SETMASK, $sigset );
13251
13252### log routine Net::Server hook
13253### (Sys::Syslog MUST NOT be specified as a value of 'log_file'!)
13254#
13255# Redirect Net::Server logging to use Amavis' do_log().
13256# The main reason is that Net::Server uses Sys::Syslog
13257# (and has two bugs in doing it, at least the Net-Server-0.82),
13258# and Amavis users are accustomed to Unix::Syslog.
13259#
13260sub write_to_log_hook {
13261  my($self,$level,$msg) = @_;
13262  my $prop = $self->{server};
13263  local $SIG{CHLD} = 'DEFAULT';
13264  $level = 0 if $level < 0;  $level = 4 if $level > 4;
13265# my $ll = (-2,-1,0,1,3)[$level];  # 0=err, 1=warn, 2=notice, 3=info, 4=debug
13266  my $ll = (-1, 0,1,3,4)[$level];  # 0=err, 1=warn, 2=notice, 3=info, 4=debug
13267  chomp($msg);  # just call Amavis' traditional logging
13268  ll($ll) && do_log($ll, "Net::Server: %s", $msg);
13269  1;
13270}
13271
13272### user customizable Net::Server hook (Net::Server 0.88 or later),
13273### This hook occurs in the master process at the top of run_n_children
13274### which is called each time the server goes to start more child processes.
13275#
13276sub run_n_children_hook {
13277# do_log(5, "entered run_n_children_hook");
13278  sd_notify(0, "STATUS=Starting child process(es), ready for work.");
13279  Amavis::AV::sophos_savi_reload()
13280    if $extra_code_antivirus && Amavis::AV::sophos_savi_stale();
13281  add_entropy(Time::HiRes::gettimeofday);
13282}
13283
13284### compatibility with patched Net::Server by SAVI patch (Net::Server <= 0.87)
13285#
13286sub parent_fork_hook { my $self = $_[0]; $self->run_n_children_hook }
13287
13288### user customizable Net::Server hook,
13289### run by every child process during its startup
13290#
13291sub child_init_hook {
13292  my $self = $_[0];
13293  local $SIG{CHLD} = 'DEFAULT';
13294  $child_init_hook_was_called = 1;
13295  do_log(5, "entered child_init_hook");
13296  $my_pid = $$;  $0 = c('myprogram_name') . ' (virgin child)';
13297# DB::enable_profile(sprintf("/tmp/nytprof-amavis-%s-%d.out",
13298#                            $my_pid, int rand 1000000)) if $profiling;
13299  stir_random();
13300  log_capture_enabled(1)  if $enable_log_capture;
13301  # reset log counters inherited from a master process
13302  collect_log_stats();
13303# my(@signames) = qw(HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV
13304#                    SYS PIPE ALRM TERM URG TSTP CONT TTIN TTOU IO
13305#                    XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2);
13306# my $h = sub { my $s = $_[0]; $got_signals{$s}++;
13307#               local($SIG{$s})='IGNORE'; kill($my_pid,$s) };
13308# @SIG{@signames} = ($h) x @signames;
13309  my $inherited_entropy;
13310  eval {
13311#   if (defined $daemon_user && $daemon_user ne '' && ($> == 0 || $< == 0)) {
13312#     # last resort, in case Net::Server didn't do it
13313#     do_log(2, "child_init_hook: dropping privileges, user=%s, group=%s",
13314#                $daemon_user,$daemon_group);
13315#     drop_priv($daemon_user,$daemon_group);
13316#   }
13317    undef $db_env; undef $snmp_db;  # just in case
13318    Amavis::Timing::init(); snmp_counters_init();
13319    close_log(); open_log();  # reopen syslog or log file to get per-process fd
13320    if ($enable_zmq && $extra_code_zmq && @zmq_sockets) {
13321      do_log(5, "child_init_hook: zmq socket: %s", join(', ',@zmq_sockets));
13322      $zmq_obj = Amavis::ZMQ->new(@zmq_sockets);
13323      if ($zmq_obj) {
13324        sleep 1;  # a crude way to avoid a "slow joiner" syndrome  #***
13325        $zmq_obj->register_proc(0,1,'');
13326      }
13327    }
13328    if ($extra_code_db) {
13329      # Berkeley DB handles should not be shared across process forks,
13330      # each forked child should acquire its own Berkeley DB handles
13331      $db_env = Amavis::DB->new;  # get access to a bdb environment
13332      $snmp_db = Amavis::DB::SNMP->new($db_env);
13333      $snmp_db->register_proc(0,1,'')  if $snmp_db;  # alive and idle
13334      my $var_ref = $snmp_db->read_snmp_variables('entropy');
13335      $inherited_entropy = $var_ref->[0]  if $var_ref && @$var_ref;
13336    }
13337#   if ($extra_code_db) {  # is it worth reporting the timing? (probably not)
13338#     section_time('bdb-open');
13339#     do_log(2, "%s", Amavis::Timing::report());  # report elapsed times
13340#   }
13341
13342    # Prepare permanent SQL dataset connection objects, does not connect yet!
13343    # $sql_dataset_conn_lookups and $sql_dataset_conn_storage may be the
13344    # same dataset (one connection used), or they may be separate objects,
13345    # which will make separate connections to (same or distinct) datasets,
13346    # possibly using different SQL engine types or servers
13347    if ($extra_code_sql_lookup && @lookup_sql_dsn) {
13348      $sql_dataset_conn_lookups =
13349        Amavis::Out::SQL::Connection->new(@lookup_sql_dsn);
13350    }
13351    if ($extra_code_sql_log && @storage_sql_dsn) {
13352      if (!$sql_dataset_conn_lookups || @storage_sql_dsn != @lookup_sql_dsn
13353          || grep($storage_sql_dsn[$_] ne $lookup_sql_dsn[$_],
13354                  (0..$#storage_sql_dsn)) )
13355      { # DSN differs or no SQL lookups, storage needs its own connection
13356        $sql_dataset_conn_storage =
13357          Amavis::Out::SQL::Connection->new(@storage_sql_dsn);
13358        if ($sql_dataset_conn_lookups) {
13359          do_log(2,"storage and lookups will use separate connections to SQL");
13360        } else {
13361          do_log(5,"only storage connections to SQL, no lookups");
13362        }
13363      } else {  # same dataset, use the same database connection object
13364        $sql_dataset_conn_storage = $sql_dataset_conn_lookups;
13365        do_log(2,"storage and lookups will use the same connection to SQL");
13366      }
13367    }
13368    # create storage/lookup objs to hold DBI handles and 'prepared' statements
13369    $sql_storage = Amavis::Out::SQL::Log->new($sql_dataset_conn_storage)
13370                                                  if $sql_dataset_conn_storage;
13371    $sql_lookups = Amavis::Lookup::SQL->new($sql_dataset_conn_lookups,
13372                                   'sel_policy')  if $sql_dataset_conn_lookups;
13373    $sql_wblist = Amavis::Lookup::SQL->new($sql_dataset_conn_lookups,
13374                                   'sel_wblist')  if $sql_dataset_conn_lookups;
13375
13376    if ($extra_code_redis && @storage_redis_dsn) {
13377      $redis_storage = Amavis::Redis->new(@storage_redis_dsn);
13378    }
13379    $spamcontrol_obj->init_child  if $spamcontrol_obj;
13380  # Amavis::Util::dump_subs();
13381    1;
13382  } or do {
13383    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
13384    do_log(-2, "TROUBLE in child_init_hook: %s", $eval_stat);
13385    die "Suicide in child_init_hook: $eval_stat\n";
13386  };
13387  add_entropy($inherited_entropy, Time::HiRes::gettimeofday, rand());
13388  Amavis::Timing::go_idle('vir');
13389# DB::disable_profile() if $profiling;
13390}
13391
13392### user customizable Net::Server hook
13393#
13394sub post_accept_hook {
13395  my $self = $_[0];
13396  local $SIG{CHLD} = 'DEFAULT';
13397# do_log(5, "entered post_accept_hook");
13398  DB::enable_profile(sprintf("/tmp/nytprof-amavis-%s-%d.out",
13399                             $my_pid, int rand 1000000)) if $profiling;
13400  if (!$child_init_hook_was_called) {
13401    # this can happen with base Net::Server (not PreFork nor PreForkSiple)
13402    do_log(5, "post_accept_hook: invoking child_init_hook which was skipped");
13403    $self->child_init_hook;
13404  }
13405  $child_invocation_count++;
13406  $0 = sprintf("%s (ch%d-accept)",
13407               c('myprogram_name'), $child_invocation_count);
13408  Amavis::Util::am_id(undef);
13409  Amavis::Timing::go_busy('hi ');
13410  # establish initial time right after 'accept'
13411  Amavis::Timing::init(); snmp_counters_init();
13412  $zmq_obj->register_proc(1,1,'A')  if $zmq_obj;  # enter 'accept' state
13413  $snmp_db->register_proc(1,1,'A')  if $snmp_db;
13414  if ($child_invocation_count % 13 == 0)  # every now and then
13415    { clear_idn_cache(); clear_query_keys_cache() }
13416  load_policy_bank('');    # start with a builtin baseline policy bank
13417}
13418
13419# load policy banks according to my socket (destination),
13420# then check for allowed access from the peer (client/source)
13421#
13422sub access_is_allowed($;$$$$) {
13423  my($unix_socket_path, $src_addr, $src_port, $dst_addr, $dst_port) = @_;
13424  my(@bank_names);
13425  if (defined $unix_socket_path) {
13426    push(@bank_names, $interface_policy{"SOCK"});
13427    push(@bank_names, $interface_policy{$unix_socket_path});
13428  } elsif (defined $dst_addr && defined $dst_port) {
13429    $dst_addr = '['.lc($dst_addr).']' if $dst_addr =~ /:[0-9a-f]*:/i;  # IPv6?
13430    push(@bank_names, $interface_policy{$dst_port});
13431    push(@bank_names, $interface_policy{"$dst_addr:$dst_port"});
13432  }
13433  load_policy_bank($_) for @bank_names;
13434  # note that the new policy bank may have replaced the inet_acl access table
13435  if (defined $unix_socket_path) {
13436    # always permit access - unix sockets are immune to this check
13437  } elsif (defined $src_addr) {
13438    my($permit,$fullkey,$err) = lookup_ip_acl($src_addr,
13439                       Amavis::Lookup::Label->new('inet_acl'), ca('inet_acl'));
13440    if ($err) {
13441      do_log(-1, "DENIED ACCESS due to INVALID PEER IP ADDRESS %s: %s",
13442                 $src_addr, $err);
13443      return 0;
13444    } elsif (!$permit) {
13445      do_log(-1, "DENIED ACCESS from IP %s, policy bank '%s'%s",
13446                 $src_addr, c('policy_bank_path'),
13447                 !defined $fullkey ? '' : ", blocked by rule $fullkey");
13448      return 0;
13449    }
13450  }
13451  1;
13452}
13453
13454### user customizable Net::Server hook, load a by-interface policy bank;
13455### if this hook returns 1 the request is processed
13456### if this hook returns 0 the request is denied
13457#
13458sub allow_deny_hook {
13459  my $self = $_[0];
13460  local($1,$2,$3,$4);  # Perl bug: $1 and $2 come tainted from Net::Server !
13461  local $SIG{CHLD} = 'DEFAULT';
13462# do_log(5, "entered allow_deny_hook");
13463  my $prop = $self->{server};
13464  my $sock = $prop->{client};
13465  my $is_ux = $sock && $sock->UNIVERSAL::can('NS_proto') &&
13466              $sock->NS_proto eq 'UNIX';
13467  if ($is_ux) {
13468    my $unix_socket_path = $sock->hostpath;
13469    $unix_socket_path = 'UNKNOWN'  if !defined $unix_socket_path;
13470    return access_is_allowed($unix_socket_path);
13471  } else {
13472    return access_is_allowed(undef,
13473                             $prop->{peeraddr}, $prop->{peerport},
13474                             $prop->{sockaddr}, $prop->{sockport});
13475  }
13476}
13477
13478### The heart of the program
13479### user customizable Net::Server hook
13480#
13481sub process_request {
13482  my $self = $_[0];
13483  local $SIG{CHLD} = 'DEFAULT';
13484# do_log(5, "entered process_request");
13485  local($1,$2,$3,$4);  # Perl bug: $1 and $2 come tainted from Net::Server !
13486  my $prop = $self->{server}; my $sock = $prop->{client};
13487  ll(3) && do_log(3, "process_request: fileno sock=%s, STDIN=%s, STDOUT=%s",
13488                     fileno($sock), fileno(STDIN), fileno(STDOUT));
13489  # Net::Server 0.91 dups a socket to STDIN and STDOUT, which we do not want;
13490  #   it also forgets to close STDIN & STDOUT afterwards, so session remains
13491  #   open (smtp QUIT does not work), fixed in 0.92;
13492  # Net::Server 0.92 introduced option no_client_stdout, but it
13493  #   breaks Net::Server::get_client_info by setting it, so we can't use it;
13494  # On NetBSD closing fh STDIN (on fd0) somehow leaves fd0 still assigned to
13495  #   a socket (Net::Server 0.91) and cannot be closed even by a POSIX::close
13496  # Let's just leave STDIN and STDOUT as they are, which works for versions
13497  # of Net::Server 0.90 and older, is wasteful with 0.91 and 0.92, and is
13498  # fine with 0.93.
13499  if (ref($sock) !~ /^(?:IO::Socket::SSL|Net::Server::Proto::SSL)\z/) {
13500    # binmode not implemented in IO::Socket::SSL and returns false
13501    binmode($sock) or die "Can't set socket $sock to binmode: $!";
13502  }
13503  local $SIG{ALRM} = sub { die "timed out\n" };  # do not modify the sig text!
13504  my $eval_stat;
13505  eval {
13506#   if ($] < 5.006)  # Perl older than 5.6.0 did not set FD_CLOEXEC on sockets
13507#     { cloexec($_,1,$_)  for @{$prop->{sock}} }
13508    switch_to_my_time('new request');  # timer init
13509    if ($extra_code_ldap && !$ldap_lookups) {
13510      # make LDAP lookup object
13511      $ldap_connection = Amavis::LDAP::Connection->new($default_ldap);
13512      $ldap_lookups = Amavis::Lookup::LDAP->new($default_ldap,$ldap_connection)
13513        if $ldap_connection;
13514    }
13515    if ($ldap_lookups &&
13516        $lookup_maps_imply_sql_and_ldap && !$implicit_maps_inserted) {
13517      # make LDAP field lookup objects with incorporated field names
13518      # fieldtype: B=boolean, N=numeric, S=string, L=list
13519      #            B-, N-, S-, L-  returns undef if field does not exist
13520      #            B0: boolean, nonexistent field treated as false,
13521      #            B1: boolean, nonexistent field treated as true
13522      my $lf = sub{Amavis::Lookup::LDAPattr->new($ldap_lookups,@_)};
13523
13524      unshift(@Amavis::Conf::local_domains_maps,       $lf->('amavisLocal',              'B1'));
13525
13526      unshift(@Amavis::Conf::virus_lovers_maps,        $lf->('amavisVirusLover',         'B-'));
13527      unshift(@Amavis::Conf::spam_lovers_maps,         $lf->('amavisSpamLover',          'B-'));
13528      unshift(@Amavis::Conf::unchecked_lovers_maps,    $lf->('amavisUncheckedLover',     'B-'));
13529      unshift(@Amavis::Conf::banned_files_lovers_maps, $lf->('amavisBannedFilesLover',   'B-'));
13530      unshift(@Amavis::Conf::bad_header_lovers_maps,   $lf->('amavisBadHeaderLover',     'B-'));
13531
13532      unshift(@Amavis::Conf::bypass_virus_checks_maps, $lf->('amavisBypassVirusChecks',  'B-'));
13533      unshift(@Amavis::Conf::bypass_spam_checks_maps,  $lf->('amavisBypassSpamChecks',   'B-'));
13534      unshift(@Amavis::Conf::bypass_banned_checks_maps,$lf->('amavisBypassBannedChecks', 'B-'));
13535      unshift(@Amavis::Conf::bypass_header_checks_maps,$lf->('amavisBypassHeaderChecks', 'B-'));
13536
13537      unshift(@Amavis::Conf::spam_tag_level_maps,      $lf->('amavisSpamTagLevel',       'N-'));
13538      unshift(@Amavis::Conf::spam_tag2_level_maps,     $lf->('amavisSpamTag2Level',      'N-'));
13539      unshift(@Amavis::Conf::spam_tag3_level_maps,     $lf->('amavisSpamTag3Level',      'N-'));
13540
13541      unshift(@Amavis::Conf::spam_kill_level_maps,     $lf->('amavisSpamKillLevel',      'N-'));
13542      unshift(@Amavis::Conf::spam_dsn_cutoff_level_maps,$lf->('amavisSpamDsnCutoffLevel','N-'));
13543      unshift(@Amavis::Conf::spam_quarantine_cutoff_level_maps,$lf->('amavisSpamQuarantineCutoffLevel','N-'));
13544
13545      unshift(@Amavis::Conf::spam_subject_tag_maps,    $lf->('amavisSpamSubjectTag',     'S-'));
13546      unshift(@Amavis::Conf::spam_subject_tag2_maps,   $lf->('amavisSpamSubjectTag2',    'S-'));
13547      unshift(@Amavis::Conf::spam_subject_tag3_maps,   $lf->('amavisSpamSubjectTag3',    'S-'));
13548
13549      unshift(@Amavis::Conf::virus_quarantine_to_maps, $lf->('amavisVirusQuarantineTo',  'S-'));
13550      unshift(@Amavis::Conf::spam_quarantine_to_maps,  $lf->('amavisSpamQuarantineTo',   'S-'));
13551      unshift(@Amavis::Conf::banned_quarantine_to_maps, $lf->('amavisBannedQuarantineTo','S-'));
13552      unshift(@Amavis::Conf::unchecked_quarantine_to_maps, $lf->('amavisUncheckedQuarantineTo','S-'));
13553      unshift(@Amavis::Conf::bad_header_quarantine_to_maps, $lf->('amavisBadHeaderQuarantineTo', 'S-'));
13554      unshift(@Amavis::Conf::clean_quarantine_to_maps, $lf->('amavisCleanQuarantineTo',  'S-'));
13555      unshift(@Amavis::Conf::archive_quarantine_to_maps, $lf->('amavisArchiveQuarantineTo', 'S-'));
13556      unshift(@Amavis::Conf::message_size_limit_maps,  $lf->('amavisMessageSizeLimit',   'N-'));
13557
13558      unshift(@Amavis::Conf::addr_extension_virus_maps, $lf->('amavisAddrExtensionVirus', 'S-'));
13559      unshift(@Amavis::Conf::addr_extension_spam_maps,  $lf->('amavisAddrExtensionSpam',  'S-'));
13560      unshift(@Amavis::Conf::addr_extension_banned_maps, $lf->('amavisAddrExtensionBanned','S-'));
13561      unshift(@Amavis::Conf::addr_extension_bad_header_maps, $lf->('amavisAddrExtensionBadHeader','S-'));
13562
13563      unshift(@Amavis::Conf::warnvirusrecip_maps,      $lf->('amavisWarnVirusRecip',     'B-'));
13564      unshift(@Amavis::Conf::warnbannedrecip_maps,     $lf->('amavisWarnBannedRecip',    'B-'));
13565      unshift(@Amavis::Conf::warnbadhrecip_maps,       $lf->('amavisWarnBadHeaderRecip', 'B-'));
13566
13567      unshift(@Amavis::Conf::newvirus_admin_maps,      $lf->('amavisNewVirusAdmin',      'S-'));
13568      unshift(@Amavis::Conf::virus_admin_maps,         $lf->('amavisVirusAdmin',         'S-'));
13569      unshift(@Amavis::Conf::spam_admin_maps,          $lf->('amavisSpamAdmin',          'S-'));
13570      unshift(@Amavis::Conf::banned_admin_maps,        $lf->('amavisBannedAdmin',        'S-'));
13571      unshift(@Amavis::Conf::bad_header_admin_maps,    $lf->('amavisBadHeaderAdmin',     'S-'));
13572
13573      unshift(@Amavis::Conf::banned_filename_maps,     $lf->('amavisBannedRuleNames',    'S-'));
13574      unshift(@Amavis::Conf::disclaimer_options_bysender_maps,
13575                                                       $lf->('amavisDisclaimerOptions',  'S-'));
13576      unshift(@Amavis::Conf::forward_method_maps,      $lf->('amavisForwardMethod',      'S-'));
13577      unshift(@Amavis::Conf::sa_userconf_maps,         $lf->('amavisSaUserConf',         'S-'));
13578      unshift(@Amavis::Conf::sa_username_maps,         $lf->('amavisSaUserName',         'S-'));
13579
13580      section_time('ldap-prepare');
13581    }
13582    if ($sql_lookups &&
13583        $lookup_maps_imply_sql_and_ldap && !$implicit_maps_inserted) {
13584      # make SQL field lookup objects with incorporated field names
13585      # fieldtype: B=boolean, N=numeric, S=string,
13586      #            B-, N-, S-   returns undef if field does not exist
13587      #            B0: boolean, nonexistent field treated as false,
13588      #            B1: boolean, nonexistent field treated as true
13589      my $nf = sub{Amavis::Lookup::SQLfield->new($sql_lookups,@_)}; # shorthand
13590      $user_id_sql =        $nf->('id',        'S-');
13591      $user_policy_id_sql = $nf->('policy_id', 'S-');
13592      unshift(@Amavis::Conf::local_domains_maps,        $nf->('local',                'B1'));
13593
13594      unshift(@Amavis::Conf::virus_lovers_maps,         $nf->('virus_lover',          'B-'));
13595      unshift(@Amavis::Conf::spam_lovers_maps,          $nf->('spam_lover',           'B-'));
13596      unshift(@Amavis::Conf::unchecked_lovers_maps,     $nf->('unchecked_lover',      'B-'));
13597      unshift(@Amavis::Conf::banned_files_lovers_maps,  $nf->('banned_files_lover',   'B-'));
13598      unshift(@Amavis::Conf::bad_header_lovers_maps,    $nf->('bad_header_lover',     'B-'));
13599
13600      unshift(@Amavis::Conf::bypass_virus_checks_maps,  $nf->('bypass_virus_checks',  'B-'));
13601      unshift(@Amavis::Conf::bypass_spam_checks_maps,   $nf->('bypass_spam_checks',   'B-'));
13602      unshift(@Amavis::Conf::bypass_banned_checks_maps, $nf->('bypass_banned_checks', 'B-'));
13603      unshift(@Amavis::Conf::bypass_header_checks_maps, $nf->('bypass_header_checks', 'B-'));
13604
13605      unshift(@Amavis::Conf::spam_tag_level_maps,       $nf->('spam_tag_level',       'N-'));
13606      unshift(@Amavis::Conf::spam_tag2_level_maps,      $nf->('spam_tag2_level',      'N-'));
13607      unshift(@Amavis::Conf::spam_tag3_level_maps,      $nf->('spam_tag3_level',      'N-'));
13608
13609      unshift(@Amavis::Conf::spam_kill_level_maps,      $nf->('spam_kill_level',      'N-'));
13610      unshift(@Amavis::Conf::spam_dsn_cutoff_level_maps,$nf->('spam_dsn_cutoff_level','N-'));
13611      unshift(@Amavis::Conf::spam_quarantine_cutoff_level_maps,$nf->('spam_quarantine_cutoff_level','N-'));
13612
13613      unshift(@Amavis::Conf::spam_subject_tag_maps,     $nf->('spam_subject_tag',     'S-'));
13614      unshift(@Amavis::Conf::spam_subject_tag2_maps,    $nf->('spam_subject_tag2',    'S-'));
13615      unshift(@Amavis::Conf::spam_subject_tag3_maps,    $nf->('spam_subject_tag3',    'S-'));
13616
13617      unshift(@Amavis::Conf::virus_quarantine_to_maps,  $nf->('virus_quarantine_to',  'S-'));
13618      unshift(@Amavis::Conf::spam_quarantine_to_maps,   $nf->('spam_quarantine_to',   'S-'));
13619      unshift(@Amavis::Conf::banned_quarantine_to_maps, $nf->('banned_quarantine_to', 'S-'));
13620      unshift(@Amavis::Conf::unchecked_quarantine_to_maps, $nf->('unchecked_quarantine_to', 'S-'));
13621      unshift(@Amavis::Conf::bad_header_quarantine_to_maps, $nf->('bad_header_quarantine_to','S-'));
13622      unshift(@Amavis::Conf::clean_quarantine_to_maps,  $nf->('clean_quarantine_to',  'S-'));
13623      unshift(@Amavis::Conf::archive_quarantine_to_maps,$nf->('archive_quarantine_to','S-'));
13624      unshift(@Amavis::Conf::message_size_limit_maps,   $nf->('message_size_limit',   'N-'));
13625
13626      unshift(@Amavis::Conf::addr_extension_virus_maps, $nf->('addr_extension_virus', 'S-'));
13627      unshift(@Amavis::Conf::addr_extension_spam_maps,  $nf->('addr_extension_spam',  'S-'));
13628      unshift(@Amavis::Conf::addr_extension_banned_maps,$nf->('addr_extension_banned','S-'));
13629      unshift(@Amavis::Conf::addr_extension_bad_header_maps,$nf->('addr_extension_bad_header','S-'));
13630
13631      unshift(@Amavis::Conf::warnvirusrecip_maps,   $nf->('warnvirusrecip',   'B-'));
13632      unshift(@Amavis::Conf::warnbannedrecip_maps,  $nf->('warnbannedrecip',  'B-'));
13633      unshift(@Amavis::Conf::warnbadhrecip_maps,    $nf->('warnbadhrecip',    'B-'));
13634
13635      unshift(@Amavis::Conf::newvirus_admin_maps,   $nf->('newvirus_admin',   'S-'));
13636      unshift(@Amavis::Conf::virus_admin_maps,      $nf->('virus_admin',      'S-'));
13637      unshift(@Amavis::Conf::spam_admin_maps,       $nf->('spam_admin',       'S-'));
13638      unshift(@Amavis::Conf::banned_admin_maps,     $nf->('banned_admin',     'S-'));
13639      unshift(@Amavis::Conf::bad_header_admin_maps, $nf->('bad_header_admin', 'S-'));
13640
13641      unshift(@Amavis::Conf::banned_filename_maps,  $nf->('banned_rulenames', 'S-'));
13642      unshift(@Amavis::Conf::disclaimer_options_bysender_maps,
13643                                                    $nf->('disclaimer_options', 'S-'));
13644      unshift(@Amavis::Conf::forward_method_maps,   $nf->('forward_method',   'S-'));
13645      unshift(@Amavis::Conf::sa_userconf_maps,      $nf->('sa_userconf',      'S-'));
13646      unshift(@Amavis::Conf::sa_username_maps,      $nf->('sa_username',      'S-'));
13647
13648      section_time('sql-prepare');
13649    }
13650
13651    $implicit_maps_inserted = 1;
13652    if (!$maps_have_been_labeled)
13653      { Amavis::Conf::label_default_maps(); $maps_have_been_labeled = 1 }
13654
13655    my $ns_proto = $sock->NS_proto;  # Net::Server::Proto submodules
13656    my $conn = Amavis::In::Connection->new;  # keeps info about connection
13657    $conn->socket_proto($ns_proto);
13658    my $suggested_protocol = c('protocol');  # suggested by the policy bank
13659    $suggested_protocol = ''  if !defined $suggested_protocol;
13660    do_log(5,"process_request: suggested_protocol=\"%s\" on a %s socket",
13661             $suggested_protocol, $ns_proto);
13662    $zmq_obj->register_proc(2,0,'b')  if $zmq_obj;  # begin protocol
13663  # $snmp_db->register_proc(2,0,'b')  if $snmp_db;
13664    if ($ns_proto eq 'UNIX') {
13665      my $path = $sock->hostpath;
13666      $conn->socket_path($path);
13667      # how to test:  $ socat stdio unix-connect:/var/amavis/amavisd.sock,crnl
13668    } else {  # TCP, UDP, UNIXDGRAM, SSLEAY, SSL (Net::Server::Proto modules)
13669      my $sock_addr = $prop->{sockaddr};
13670      my $peer_addr = $prop->{peeraddr};
13671      if ($sock_addr eq $peer_addr) {  # common, small optimization
13672        $peer_addr = $sock_addr = normalize_ip_addr($sock_addr);
13673      } else {
13674        $sock_addr = normalize_ip_addr($sock_addr);
13675        $peer_addr = normalize_ip_addr($peer_addr);
13676      }
13677      # untaint IP addresses and port numbers, just in case
13678      $conn->socket_port(untaint($prop->{sockport}));
13679      $conn->client_port(untaint($prop->{peerport}));
13680      $conn->socket_ip(untaint($sock_addr));
13681      $conn->client_ip(untaint($peer_addr));
13682    }
13683    if ($suggested_protocol eq 'SMTP' || $suggested_protocol eq 'LMTP' ||
13684        ($suggested_protocol eq '' && $ns_proto =~ /^(?:TCP|SSLEAY|SSL)\z/)) {
13685      if (!$extra_code_in_smtp) {
13686        die "incoming TCP connection, but dynamic SMTP/LMTP code not loaded";
13687      }
13688      $smtp_in_obj = Amavis::In::SMTP->new  if !$smtp_in_obj;
13689      $smtp_in_obj->process_smtp_request(
13690              $sock, ($suggested_protocol eq 'LMTP'?1:0), $conn, \&check_mail);
13691    } elsif ($suggested_protocol eq 'AM.PDP') {
13692      # amavis policy delegation protocol (e.g. new milter or amavisd-release)
13693      $ampdp_in_obj = Amavis::In::AMPDP->new  if !$ampdp_in_obj;
13694      $ampdp_in_obj->process_policy_request($sock, $conn, \&check_mail, 0);
13695    } elsif ($suggested_protocol eq 'COURIER') {
13696      die "unavailable support for protocol: $suggested_protocol";
13697    } elsif ($suggested_protocol eq 'QMQPqq') {
13698      die "unavailable support for protocol: $suggested_protocol";
13699    } elsif ($suggested_protocol eq 'TCP-LOOKUP') { #postfix maps, experimental
13700      process_tcp_lookup_request($sock, $conn);
13701      do_log(2, "%s", Amavis::Timing::report());  # report elapsed times
13702#   } elsif ($suggested_protocol eq 'AM.CL') {
13703#     # defaults to old amavis helper program protocol
13704#     $ampdp_in_obj = Amavis::In::AMPDP->new  if !$ampdp_in_obj;
13705#     $ampdp_in_obj->process_policy_request($sock, $conn, \&check_mail, 1);
13706    } elsif ($suggested_protocol eq '') {
13707      die "protocol not specified, $ns_proto";
13708    } else {
13709      die "unsupported protocol: $suggested_protocol, $ns_proto";
13710    }
13711    Amavis::Out::SMTP::Session::rundown_stale_sessions(0)
13712      if $extra_code_out_smtp;
13713    1;
13714  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
13715  alarm(0);  # stop the timer
13716  if (defined $eval_stat) {
13717    chomp $eval_stat; my $timed_out = $eval_stat =~ /^timed out\b/;
13718    if ($timed_out) {
13719      my $msg = "Requesting process rundown, task exceeded allowed time";
13720      $msg .= " during waiting for input from client"  if waiting_for_client();
13721      do_log(-1, $msg);
13722    } else {
13723      do_log(-2, "TROUBLE in process_request: %s", $eval_stat);
13724      $smtp_in_obj->preserve_evidence(1)  if $smtp_in_obj;
13725      do_log(-1, "Requesting process rundown after fatal error");
13726    }
13727    undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj;
13728    $self->done(1);
13729  } elsif (defined $max_requests && $max_requests > 0 &&
13730           $child_task_count >= $max_requests) {
13731    # in case of multiple-transaction protocols (e.g. SMTP, LMTP)
13732    # we do not like to keep running indefinitely at the mercy of MTA
13733    do_log(2, "Requesting process rundown after %d tasks (and %s sessions)",
13734              $child_task_count, $child_invocation_count);
13735    undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj;
13736    $self->done(1);
13737  } elsif ($extra_code_antivirus && Amavis::AV::sophos_savi_stale() ) {
13738    do_log(0, "Requesting process rundown due to stale Sophos virus data");
13739    undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj;
13740    $self->done(1);
13741  }
13742  my(@modules_extra) = grep(!exists $modules_basic{$_}, keys %INC);
13743# do_log(2, "modules loaded: %s", join(", ", sort keys %modules_basic));
13744  if (@modules_extra) {
13745    do_log(1, "extra modules loaded: %s", join(", ", sort @modules_extra));
13746    %modules_basic = %INC;
13747  }
13748  ll(5) && do_log(5, 'exiting process_request');
13749}
13750
13751sub child_goes_idle($) {
13752  my $where = $_[0];
13753  do_log(5, 'child_goes_idle (%s)', $where);
13754  my(@disconnected_what);
13755# $extra_code_out_smtp && eval {
13756#   Amavis::Out::SMTP::Session::rundown_stale_sessions(1) &&
13757#     push(@disconnected_what,'SMTP');
13758# };
13759  $sql_dataset_conn_storage && eval {
13760    $sql_dataset_conn_storage->disconnect_from_sql &&
13761      push(@disconnected_what,'SQL-storage');
13762  };
13763  $sql_dataset_conn_lookups && eval {
13764    # $sql_dataset_conn_lookups possibly the same as $sql_dataset_conn_storage,
13765    # attempting to disconnect twice does no harm
13766    $sql_dataset_conn_lookups->disconnect_from_sql &&
13767      push(@disconnected_what,'SQL-lookup');
13768  };
13769  $ldap_connection && eval {
13770    $ldap_connection->disconnect_from_ldap &&
13771      push(@disconnected_what,'LDAP');
13772  };
13773  do_log(5, 'child_goes_idle: disconnected %s (%s)',
13774            !@disconnected_what ? 'none' : join(', ',@disconnected_what),
13775            $where);
13776}
13777
13778### After processing of a request, but before client connection has been closed
13779### user customizable Net::Server hook
13780#
13781sub post_process_request_hook {
13782  my $self = $_[0];
13783  my $prop = $self->{server}; my $sock = $prop->{client};
13784  local $SIG{CHLD} = 'DEFAULT';
13785# do_log(5, "entered post_process_request_hook");
13786  alarm(0);  # stop the timer
13787  child_goes_idle('post_process_request')  if !$database_sessions_persistent;
13788  debug_oneshot(0);
13789  $0 = sprintf("%s (ch%d-avail)",
13790               c('myprogram_name'), $child_invocation_count);
13791  $zmq_obj->register_proc(1,0,'')  if $zmq_obj;  # alive and idle again
13792  $snmp_db->register_proc(1,0,'')  if $snmp_db;
13793  Amavis::Timing::go_idle('bye');
13794  if (ll(3)) {
13795    my $load_report = Amavis::Timing::report_load();
13796    do_log(3,$load_report)  if defined $load_report;
13797  }
13798  dump_captured_log(1, c('enable_log_capture_dump'));
13799  # workaround: Net::Server 0.91 forgets to disconnect session
13800  if (Net::Server->VERSION == 0.91) { close STDIN; close STDOUT }
13801# DB::disable_profile() if $profiling;
13802  DB::finish_profile() if $profiling;
13803}
13804
13805### Child is about to be terminated
13806### user customizable Net::Server hook
13807#
13808sub child_finish_hook {
13809  my $self = $_[0];
13810  local $SIG{CHLD} = 'DEFAULT';
13811# do_log_safe(5, "entered child_finish_hook");
13812# for my $m (sort map { s/\.pm\z//; s[/][::]g; $_ } grep(/\.pm\z/, keys %INC)){
13813#   do_log(0, "Module %-19s %s", $m, $m->VERSION || '?')
13814#     if grep($m=~/^$_/, qw(Mail::ClamAV Mail::SpamAssassin Razor2 Net::DNS));
13815# }
13816  child_goes_idle('child finishing');
13817  $spamcontrol_obj->rundown_child  if $spamcontrol_obj;
13818  $0 = sprintf("%s (ch%d-finish)",
13819               c('myprogram_name'), $child_invocation_count);
13820  do_log_safe(5,"child_finish_hook: invoking DESTROY methods");
13821  undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj;
13822  undef $sql_storage; undef $sql_wblist; undef $sql_lookups;
13823  undef $sql_dataset_conn_lookups; undef $sql_dataset_conn_storage;
13824  undef $ldap_lookups; undef $ldap_connection; undef $redis_storage;
13825  # unregister our process
13826  if ($zmq_obj) {
13827    eval { $zmq_obj->register_proc(0,0,undef); 1; }
13828      or do_log_safe(-1, "child_finish_hook: ZMQ unregistering failed: %s",$@);
13829  }
13830  if ($snmp_db) {
13831    eval { $snmp_db->register_proc(0,0,undef); 1; }
13832      or do_log_safe(-1, "child_finish_hook: DB unregistering failed: %s",$@);
13833  }
13834  undef $snmp_db; undef $db_env; undef $zmq_obj;
13835  log_capture_enabled(0);
13836}
13837
13838### user customizable Net::Server hook,
13839### hook occurs in the main process before the server begins shutting down
13840#
13841sub pre_server_close_hook {
13842  sd_notify(0, "STOPPING=1",
13843               "STATUS=Server rundown, notifying child processes.");
13844}
13845
13846### user customizable Net::Server hook,
13847### hook occurs in the main process after child proceses have been shut down
13848#
13849sub post_child_cleanup_hook {
13850  sd_notify(0, "STATUS=Child processes have been stopped.");
13851}
13852
13853### user customizable Net::Server hook,
13854### hook occurs in the main process if a server has received a HUP signal.
13855### It occurs just before restarting the server via exec.
13856#
13857sub restart_close_hook {
13858  sd_notify(0, "RELOADING=1",
13859               "STATUS=Reloading server, about to re-exec the program.");
13860}
13861
13862### user customizable Net::Server hook,
13863### hook occurs in the main process if a server has been restarted via the HUP
13864### signal and re-exec'd.  It occurs just before reopening to the filenos of
13865### the sockets that were already opened.
13866#
13867sub restart_open_hook {
13868  sd_notify(0, "STATUS=Warm restart, re-binding sockets.");
13869}
13870
13871sub END {                # runs before exiting the module
13872  local($@,$!);
13873# do_log_safe(5,"at the END handler: invoking DESTROY methods");
13874  undef $smtp_in_obj; undef $ampdp_in_obj; undef $courier_in_obj;
13875  undef $sql_storage; undef $sql_wblist; undef $sql_lookups;
13876  undef $sql_dataset_conn_lookups; undef $sql_dataset_conn_storage;
13877  undef $ldap_lookups; undef $ldap_connection; undef $redis_storage;
13878  # unregister our process
13879  if ($zmq_obj) {
13880    eval { $zmq_obj->register_proc(0,0,undef); 1; }
13881      or do_log_safe(-1, "Amavis::END: ZMQ unregistering failed: %s", $@);
13882  }
13883  if ($snmp_db) {
13884    eval { $snmp_db->register_proc(0,0,undef); 1; }
13885      or do_log_safe(-1, "Amavis::END: DB unregistering failed: %s", $@);
13886  }
13887  undef $snmp_db; undef $db_env; undef $zmq_obj;
13888  log_capture_enabled(0);
13889}
13890
13891# implements Postfix TCP lookup server, see tcp_table(5) man page; experimental
13892#
13893sub process_tcp_lookup_request($$) {
13894  my($sock, $conn) = @_;
13895  local($/) = "\012";  # set line terminator to LF (regardless of platform)
13896  my $req_cnt; my $ln;
13897  for ($! = 0; defined($ln=$sock->getline); $! = 0) {
13898    $req_cnt++; my $level = 0; local($1);
13899    my($resp_code, $resp_msg) = (400, 'INTERNAL ERROR');
13900    if ($ln =~ /^get (.*?)\015?\012\z/si) {
13901      my $key = proto_decode($1);
13902      my $sl = lookup2(0,$key, ca('spam_lovers_maps'));
13903      $resp_code = 200; $level = 2;
13904      $resp_msg = $sl ? "OK Recipient <$key> IS spam lover"
13905                      : "DUNNO Recipient <$key> is NOT spam lover";
13906    } elsif ($ln =~ /^put ([^ ]*) (.*?)\015?\012\z/si) {
13907      $resp_code = 500; $resp_msg = 'request not implemented: ' . $ln;
13908    } else {
13909      $resp_code = 500; $resp_msg = 'illegal request: ' . $ln;
13910    }
13911    do_log($level, "tcp_lookup(%s): %s %s", $req_cnt,$resp_code,$resp_msg);
13912    $sock->printf("%03d %s\012", $resp_code, tcp_lookup_encode($resp_msg))
13913      or die "Can't write to tcp_lookup socket: $!";
13914  }
13915  defined $ln || $! == 0 or die "Error reading from socket: $!";
13916  do_log(0, "tcp_lookup: RUNDOWN after %d requests", $req_cnt);
13917}
13918
13919sub tcp_lookup_encode($) {
13920  my $str = $_[0]; local($1);
13921  $str =~ s/([^\041-\044\046-\176])/sprintf("%%%02x",ord($1))/gse;
13922  $str;
13923}
13924
13925sub check_mail_begin_task() {
13926  # The check_mail_begin_task (and check_mail) may be called several times
13927  # per child lifetime and/or per-SMTP session. The variable $child_task_count
13928  # is mainly used by AV-scanner interfaces, e.g. to initialize when invoked
13929  # for the first time during child process lifetime
13930  $child_task_count++;
13931  do_log(4, "check_mail_begin_task: task_count=%d", $child_task_count);
13932
13933  # comment out to retain SQL/LDAP cache entries for the whole child lifetime:
13934  $sql_wblist->clear_cache    if $sql_wblist;
13935  $sql_lookups->clear_cache   if $sql_lookups;
13936  $ldap_lookups->clear_cache  if $ldap_lookups;
13937
13938  # reset certain global variables for each task
13939  undef $av_output; @detecting_scanners = (); @av_scanners_results = ();
13940  @virusname = (); @bad_headers = ();
13941  $banned_filename_any = $banned_filename_all = 0;
13942  undef $MSGINFO; undef $report_ref;
13943}
13944
13945# create a mail_id unique to a database and save preliminary info to SQL;
13946# if SQL is not enabled, just call a plain generate_mail_id() once
13947#
13948sub generate_unique_mail_id($) {
13949  my $msginfo = $_[0];
13950  my($mail_id,$secret_id);
13951  for (my $attempt = 5; ;) {  # sanity limit on retries
13952    ($mail_id,$secret_id) = generate_mail_id();
13953    $msginfo->secret_id($secret_id);
13954    $secret_id = 'X' x length($secret_id);  # can't hurt to wipe out
13955    $msginfo->mail_id($mail_id);  # assign a long-term unique id to the msg
13956
13957    my $is_unique = 1;
13958    # don't bother to save info on incoming messages - saves Redis storage
13959    # while still offering necessary data for a penpals function
13960    if ($redis_storage && $msginfo->originating) {
13961      # attempt to save a message placeholder to Redis, ensuring it is unique
13962      eval {
13963        $redis_storage->save_info_preliminary($msginfo) or ($is_unique=0);
13964        1;
13965      } or do {
13966        chomp $@;
13967        do_log(-1, 'storing preliminary info to redis failed: %s', $@);
13968      };
13969    }
13970    if ($is_unique && $sql_storage) {
13971      # attempt to save a message placeholder to SQL, ensuring it is unique
13972      $sql_storage->save_info_preliminary($msginfo) or ($is_unique=0);
13973    }
13974    last if $is_unique;
13975
13976    if (--$attempt <= 0) {
13977      do_log(-2,'too many retries on storing preliminary, info not saved');
13978      last;
13979    } else {
13980      snmp_count('GenMailIdRetries');
13981      do_log(2,'retrying storing preliminary, %d attempts remain', $attempt);
13982      sleep(int(1+rand(3)));
13983      add_entropy(Time::HiRes::gettimeofday, $attempt);
13984    }
13985  }
13986  $mail_id;
13987}
13988
13989sub extract_info_from_received_trace($) {
13990  my($msginfo) = @_;
13991  my(@trace);
13992  for (my $j=0;  ; $j++) {  # walk through Received header fields, top-down
13993    my $r = $msginfo->get_header_field_body('received',$j);
13994    last  if !defined $r;
13995    my $fields_ref = parse_received($r);
13996    my $ip = fish_out_ip_from_received($r,$fields_ref);  # possibly undef
13997    $ip = normalize_ip_addr($ip)  if defined $ip;
13998    push(@trace, { ip => $ip, %$fields_ref });
13999  }
14000  \@trace;
14001}
14002
14003# Collects some information derived from the envelope and the message,
14004# do some common lookups, storing the information into a $msginfo object
14005# to make commonly used information quickly and readily available to the
14006# rest of the program, e.g. avoiding a need for repeated lookups or parsing
14007# of the same attribute
14008#
14009sub collect_some_info($) {
14010  my $msginfo = $_[0];
14011
14012  my $partition_tag = c('partition_tag');
14013  $partition_tag = &$partition_tag($msginfo)  if ref $partition_tag eq 'CODE';
14014  $partition_tag = 0  if !defined $partition_tag;
14015  $msginfo->partition_tag($partition_tag);
14016
14017  my $sender = $msginfo->sender;
14018  $msginfo->sender_source($sender);
14019
14020  # obtain RFC 5322 From and Sender from the mail header section, parsed/clean
14021  my $rfc2822_sender     = $msginfo->get_header_field_body('sender');
14022  my $rfc2822_from_field = $msginfo->get_header_field_body('from');
14023  my(@rfc2822_from);  # RFC 5322 (ex RFC 2822) allows multiple author's addr
14024  local($1);
14025  if (defined $rfc2822_sender) {
14026    my(@sender_parsed) = map(unquote_rfc2821_local($_),
14027                             parse_address_list($rfc2822_sender));
14028    $rfc2822_sender = !@sender_parsed ? '' : $sender_parsed[0]; # none or one
14029    $msginfo->rfc2822_sender($rfc2822_sender);
14030  }
14031  if (defined $rfc2822_from_field) {
14032    @rfc2822_from = map(unquote_rfc2821_local($_),
14033                        parse_address_list($rfc2822_from_field));
14034    # rfc2822_from is a ref to a list when there are multiple author addresses!
14035    $msginfo->rfc2822_from(!@rfc2822_from    ? undef :
14036                           @rfc2822_from < 2 ?  $rfc2822_from[0]
14037                                             : \@rfc2822_from);
14038  }
14039  my $rfc2822_to = $msginfo->get_header_field_body('to');
14040  if (defined $rfc2822_to) {
14041    my(@to_parsed) = map(unquote_rfc2821_local($_),
14042                         parse_address_list($rfc2822_to));
14043    $msginfo->rfc2822_to(@to_parsed<2 ? $to_parsed[0] : \@to_parsed);
14044  }
14045  my $rfc2822_cc = $msginfo->get_header_field_body('cc');
14046  if (defined $rfc2822_cc) {
14047    my(@cc_parsed) = map(unquote_rfc2821_local($_),
14048                         parse_address_list($rfc2822_cc));
14049    $msginfo->rfc2822_cc(@cc_parsed<2 ? $cc_parsed[0] : \@cc_parsed);
14050  }
14051  my(@rfc2822_resent_from, @rfc2822_resent_sender);
14052  if (defined $msginfo->get_header_field2('resent-from') ||
14053      defined $msginfo->get_header_field2('resent-sender')) {  # triage
14054    # Each Resent block should have exactly one Resent-From, and none or one
14055    # Resent-Sender address.  A HACK: undef in each list is used to separate
14056    # addresses obtained from different resent blocks, for the benefit of
14057    # those interested in traversing them block by block (e.g. when choosing
14058    # a DKIM signing key). The RFC 5322 section 3.6.6 says: All of the resent
14059    # fields corresponding to a particular resending of the message SHOULD be
14060    # grouped together.
14061    my(@r_from, @r_sender); local($1);
14062    for (my $j = 0;  ; $j++) {  # traverse header section by fields, top-down
14063      my($f_i,$f) = $msginfo->get_header_field2(undef,$j);
14064      if ( @r_from && (
14065             !defined($f) ||                # end of a header section
14066             $f !~ /^Resent-/si ||          # presumably end of a resent block
14067             $f =~ /^Resent-From\s*:/si ||  # another Resent-From encountered
14068             $f =~ /^Resent-Sender\s*:/si && @r_sender  # another Resent-Sender
14069           ) ) {  # end of a current resent block
14070        # a hack: undef in a list is used to separate addresses
14071        # from different resent blocks
14072        push(@rfc2822_resent_from,   undef, @r_from);   @r_from = ();
14073        push(@rfc2822_resent_sender, undef, @r_sender); @r_sender = ();
14074      }
14075      last  if !defined $f;
14076      if ($f =~ /^Resent-From\s*:(.*)\z/si) {
14077        push(@r_from, map(unquote_rfc2821_local($_), parse_address_list($1)));
14078      } elsif ($f =~ /^Resent-Sender\s*:(.*)\z/si) {
14079        # multiple Resent-Sender in a block are illegal, store them all anyway
14080        push(@r_sender,map(unquote_rfc2821_local($_), parse_address_list($1)));
14081      }
14082    }
14083    if (@r_from || @r_sender) {  # any leftovers not forming a resent block?
14084      push(@rfc2822_resent_from,   undef, @r_from);
14085      push(@rfc2822_resent_sender, undef, @r_sender);
14086    }
14087    shift(@rfc2822_resent_from)   if @rfc2822_resent_from;    # remove undef
14088    shift(@rfc2822_resent_sender) if @rfc2822_resent_sender;  # remove undef
14089    # rfc2822_resent_from and rfc2822_resent_sender are listrefs (or undef)
14090    $msginfo->rfc2822_resent_from(\@rfc2822_resent_from)
14091      if @rfc2822_resent_from;
14092    $msginfo->rfc2822_resent_sender(\@rfc2822_resent_sender)
14093      if @rfc2822_resent_sender;
14094  }
14095
14096  my $refs_in_reply_to = $msginfo->get_header_field_body('in-reply-to');
14097  my $refs_references  = $msginfo->get_header_field_body('references');
14098  my(@refs) = grep(defined $_, $refs_in_reply_to, $refs_references);
14099  @refs = parse_message_id(join(' ',@refs))  if @refs;
14100  do_log(4, 'references: %s', join(', ',@refs))  if @refs;
14101  $msginfo->references(\@refs);
14102
14103  my $mail_size = $msginfo->msg_size;  # use corrected ESMTP size if avail.
14104  if (!defined($mail_size) || $mail_size <= 0) {  # not yet known?
14105    $mail_size = $msginfo->orig_header_size + $msginfo->orig_body_size;
14106    $msginfo->msg_size($mail_size);    # store back
14107    do_log(4,"message size unknown, size set to %d", $mail_size);
14108  }
14109
14110  my $trace_ref = extract_info_from_received_trace($msginfo);
14111  my $cl_ip = $msginfo->client_addr;
14112  if (defined $cl_ip) {
14113    my $last_hop = $trace_ref->[0];
14114    my $last_hop_ip = $last_hop && $last_hop->{ip};
14115    if (!defined $last_hop_ip || lc($cl_ip) ne lc($last_hop_ip)) {  # milter?
14116      do_log(5,"prepending client's IP address to trace: %s", $cl_ip);
14117      unshift(@$trace_ref, {
14118        ip   => $msginfo->client_addr,
14119        port => $msginfo->client_port,
14120        with => $msginfo->client_proto,
14121      });
14122    } elsif ($last_hop->{ip} && !$last_hop->{port}) {
14123      # add a missing information, not available in a Received trace
14124      $last_hop->{port} = $msginfo->client_port;
14125    }
14126  }
14127  { # add the last hop (ours, currently underway) to the trace
14128    my $conn = $msginfo->conn_obj;  # the connection between MTA and amavisd
14129    my $recips = $msginfo->recips;
14130    my $myhelo = c('localhost_name');  # my EHLO/HELO/LHLO name, UTF-8 octets
14131    $myhelo = 'localhost'  if $myhelo eq '';
14132    $myhelo = $msginfo->smtputf8 ? idn_to_utf8($myhelo) : idn_to_ascii($myhelo);
14133    unshift(@$trace_ref, {
14134      ip   => $conn->client_ip,
14135      port => $conn->client_port,
14136      from => $conn->smtp_helo,
14137      by   => $myhelo,
14138      with => $conn->appl_proto,
14139      # id => $msginfo->mail_id,  # not yet known
14140      $recips && @$recips==1 ? (for => qquote_rfc2821_local(@$recips)) : (),
14141      # ";"  => rfc2822_timestamp($msginfo->rx_time),  # not needed
14142    });
14143  }
14144
14145  my(@ip_trace_public);
14146  for my $hop (@$trace_ref) {
14147    next if !$hop;
14148    my $ip = $hop->{ip};
14149    if ($ip) {
14150      my($public,$key,$err) = lookup_ip_acl($ip, @public_networks_maps);
14151      if ($public && !$err) { $hop->{public} = 1; push(@ip_trace_public,$ip) }
14152    }
14153    my $with = $hop->{with};
14154    $hop->{with} = $with  if defined $with && $with =~ tr/A-Za-z0-9.+-/_/c;
14155  }
14156  $msginfo->trace($trace_ref);
14157  $msginfo->ip_addr_trace_public(\@ip_trace_public);
14158# ll(5) && do_log(5, "trace: %s", Amavis::JSON::encode($trace_ref));
14159  ll(3) && do_log(3, "trace: %s",
14160    join(' < ', map( (!$_->{with} ? '' : $_->{with}.'://') .
14161                     (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip}
14162                        : '['.$_->{ip}.']:'.$_->{port}), @$trace_ref ) ));
14163  # check for mailing lists, bulk mail and auto-responses
14164  my $is_mlist;  # mail from a mailing list
14165  my $is_auto;   # bounce, auto-response, challenge-response, ...
14166  my $is_bulk;   # bulk mail or $is_mlist or $is_auto
14167  if (defined $msginfo->get_header_field2('list-id')) {  # RFC 2919
14168    $is_mlist = $msginfo->get_header_field_body('list-id');
14169  } elsif (defined $msginfo->get_header_field2('list-post')) {
14170    $is_mlist = $msginfo->get_header_field_body('list-post');
14171  } elsif (defined $msginfo->get_header_field2('list-unsubscribe')) {
14172    $is_mlist = $msginfo->get_header_field_body('list-unsubscribe');
14173  } elsif (defined $msginfo->get_header_field2('mailing-list')) {
14174    $is_mlist = $msginfo->get_header_field_body('mailing-list');  # non-std.
14175  } elsif ($sender =~ /^ (?: [^\@]+ -(?:request|bounces|owner|admin) |
14176                             owner- [^\@]+ ) (?: \@ | \z )/xsi) {
14177    $is_mlist = 'sender=' . $sender;
14178  } elsif ($rfc2822_from[0] =~ /^ (?: [^\@]+ -(?:request|bounces|owner) |
14179                             owner- [^\@]+ ) (?: \@ | \z )/xsi) {
14180    $is_mlist = 'From:' . $rfc2822_from[0];
14181  }
14182  if (defined $is_mlist) {  # sanitize a bit
14183    local($1);  $is_mlist = $1 if $is_mlist =~ / < (.*) > [^>]* \z/xs;
14184    $is_mlist =~ s/\s+/ /g; $is_mlist =~ s/^ //; $is_mlist =~ s/ \z//;
14185    $is_mlist =~ s/^mailto://i;
14186    $is_mlist = 'ml:' . $is_mlist;
14187  }
14188  if (defined $msginfo->get_header_field2('precedence')) {
14189    my $prec = $msginfo->get_header_field_body('precedence');
14190    $prec =~ s/^[ \t]+//; local($1);
14191    $is_mlist = $1  if !defined($is_mlist) && $prec =~ /^(list)/si;
14192    $is_auto  = $1  if $prec =~ /^(auto.?reply)\b/si;
14193    $is_bulk  = $1  if $prec =~ /^(bulk|junk)\b/si;
14194  }
14195  if (defined $is_auto) {
14196    # already set
14197  } elsif (defined $msginfo->get_header_field2('auto-submitted')) {
14198    my $auto = $msginfo->get_header_field_body('auto-submitted');
14199    $auto =~ s/ \( [^)]* \) //gx; $auto =~ s/^[ \t]+//; $auto =~ s/[ \t]+\z//;
14200    $is_auto = 'Auto-Submitted:' . $auto  if lc($auto) ne 'no';
14201  } elsif ($sender eq '') {
14202    $is_auto = 'sender=<>';
14203  } elsif ($sender =~
14204           /^ (?: mailer-daemon|double-bounce|mailer|autoreply )
14205              (?: \@ | \z )/xsi) {
14206    # 'postmaster' is also common, but a bit risky
14207    $is_auto = 'sender=' . $sender;
14208  } elsif ($rfc2822_from[0] =~  # just checks the first author, good enough
14209           /^ (?: mailer-daemon|double-bounce|mailer|autoreply )
14210              (?: \@ | \z )/xsi) {
14211    $is_auto = 'From:' . $rfc2822_from[0];
14212  }
14213  if (defined $is_mlist) {
14214    $is_bulk = $is_mlist;
14215  } elsif (defined $is_auto) {
14216    $is_bulk = $is_auto;
14217  } elsif (defined $is_bulk) {
14218    # already set
14219  } elsif ($rfc2822_from[0] =~  # just checks the first author, good enough
14220             /^ (?: [^\@]+ -relay | postmaster | uucp ) (?: \@ | \z )/xsi) {
14221    $is_bulk = 'From:' . $rfc2822_from[0];
14222  }
14223  $is_mlist = 1  if defined $is_mlist && !$is_mlist;  # make sure it is true
14224  $is_auto  = 1  if defined $is_auto  && !$is_auto;   # make sure it is true
14225  $is_bulk  = 1  if defined $is_bulk  && !$is_bulk;   # make sure it is true
14226  $msginfo->is_mlist($is_mlist)  if $is_mlist;
14227  $msginfo->is_auto($is_auto)    if $is_auto;
14228  $msginfo->is_bulk($is_bulk)    if $is_bulk;
14229
14230  # now that we have a parsed From, check if we have a valid
14231  # author domain signature and do other DKIM pre-processing
14232  if (c('enable_dkim_verification')) {
14233    Amavis::DKIM::collect_some_dkim_info($msginfo);
14234  }
14235  if ($sender ne '') {  # provide some initial default for sender_credible
14236    my(@cred) = ( $msginfo->originating        ? 'orig' : (),
14237                  $msginfo->dkim_envsender_sig ? 'dkim' : () );
14238    $msginfo->sender_credible(join(',',@cred))  if @cred;
14239  }
14240}
14241
14242# Checks the message stored on a file. File must already
14243# be open on file handle $msginfo->mail_text; it need not be positioned
14244# properly, check_mail must not close the file handle.
14245# Alternatively, the $msginfo->mail_text can be a ref to a string
14246# containing an entire message - suitable for short messages.
14247#
14248sub check_mail($$) {
14249  my($msginfo, $dsn_per_recip_capable) = @_;
14250
14251  my $which_section = 'check_init';
14252  my $t0_sect;
14253  my $elapsed = {}; $msginfo->time_elapsed($elapsed);
14254  $elapsed->{'TimeElapsedReceiving'} = Time::HiRes::time - $msginfo->rx_time;
14255  my $point_of_no_return = 0;  # past the point where mail or DSN was sent
14256  my $mail_id = $msginfo->mail_id;  # typically undef at this stage
14257  my $am_id = $msginfo->log_id;
14258  my $conn = $msginfo->conn_obj;
14259  if (!defined($am_id)) { $am_id = am_id(); $msginfo->log_id($am_id) }
14260  $zmq_obj->register_proc(1,0,'=',$am_id)  if $zmq_obj;  # check begins
14261  $snmp_db->register_proc(1,0,'=',$am_id)  if $snmp_db;
14262  my($smtp_resp, $exit_code, $preserve_evidence);
14263  my $custom_object;
14264  my $hold;      # set to some string causes the message to be placed on hold
14265                 # (frozen) by MTA (if configured to understand the inserted
14266                 # header field). This can be used in cases when we stumble
14267                 # across some permanent problem making us unable to decide
14268                 # if the message is to be really delivered.
14269  # is any mail component password protected or otherwise non-decodable?
14270  my $any_undecipherable = 0;
14271  my $mime_err;  # undef, or MIME parsing error string as given by MIME::Parser
14272  if (defined $last_task_completed_at) {
14273    my $dt = $msginfo->rx_time - $last_task_completed_at;
14274    do_log(3,"smtp connection cache, dt: %.1f, state: %d",
14275             $dt, $smtp_connection_cache_enable);
14276    if (!$smtp_connection_cache_on_demand) {}
14277    elsif (!$smtp_connection_cache_enable && $dt < 5) {
14278      do_log(3,"smtp connection cache, dt: %.1f -> enabling", $dt);
14279      $smtp_connection_cache_enable = 1;
14280    } elsif ($smtp_connection_cache_enable && $dt >= 15) {
14281      do_log(3,"smtp connection cache, dt: %.1f -> disabling", $dt);
14282      $smtp_connection_cache_enable = 0;
14283    }
14284  }
14285
14286  # ugly - save in a global to make it accessible to %builtins
14287  $MSGINFO = $msginfo;
14288  eval {
14289    $msginfo->checks_performed({})  if !$msginfo->checks_performed;
14290    $msginfo->add_contents_category(CC_CLEAN,0);  # CC_CLEAN is always present
14291    $_->add_contents_category(CC_CLEAN,0)  for @{$msginfo->per_recip_data};
14292    $msginfo->header_edits(Amavis::Out::EditHeader->new);
14293    add_entropy(Time::HiRes::gettimeofday, $child_task_count, $am_id,
14294                $msginfo->queue_id, $msginfo->mail_text_fn, $msginfo->sender);
14295    section_time($which_section);
14296
14297    $which_section = 'check_init2';
14298    { my $cwd = $msginfo->mail_tempdir;
14299      if (!defined $cwd || $cwd eq '') { $cwd = $TEMPBASE }
14300      chdir($cwd) or die "Can't chdir to $cwd: $!";
14301    }
14302    # compute body digest, measure mail size, check for 8-bit data, get entropy
14303    get_body_digest($msginfo, c('mail_digest_algorithm'));
14304
14305    $which_section = 'collect_info';
14306    collect_some_info($msginfo);
14307
14308    if (!defined($msginfo->client_addr)) {  # fetch missing IP addr from header
14309      my $trace_ref = $msginfo->trace;  # 'Received' trace info, top-down
14310      for my $hop ($trace_ref ? @$trace_ref : ()) {
14311        my $ip = $hop && $hop->{ip};
14312        if (defined $ip && $ip ne '') {
14313          do_log(3,"client IP address unknown, fetched from Received: %s",$ip);
14314          $msginfo->client_addr($ip); last;
14315        }
14316      }
14317    }
14318    section_time($which_section);
14319
14320    $which_section = 'check_init4';
14321    my $mail_size = $msginfo->msg_size;  # use corrected ESMTP size
14322    my $file_generator_object =   # maxfiles 0 disables the $MAXFILES limit
14323     Amavis::Unpackers::NewFilename->new($MAXFILES?$MAXFILES:undef,$mail_size);
14324    Amavis::Unpackers::Part::init($file_generator_object); # fudge: keep in var
14325    my $parts_root = Amavis::Unpackers::Part->new;
14326    $msginfo->parts_root($parts_root);
14327  # section_time($which_section);
14328
14329    if (!defined $mail_id && ($sql_store_info_for_all_msgs || !$sql_storage)) {
14330      $which_section = 'reg_proc';
14331      $zmq_obj->register_proc(2,0,'G',$am_id)  if $zmq_obj;
14332      $snmp_db->register_proc(2,0,'G',$am_id)  if $snmp_db;
14333    # section_time($which_section);
14334      $which_section = 'gen_mail_id';
14335      # create a mail_id unique to a database and save preliminary info to SQL
14336      generate_unique_mail_id($msginfo);
14337      $mail_id = $msginfo->mail_id;
14338      section_time($which_section)  if $sql_storage;  # || $redis_storage
14339    }
14340
14341    $which_section = "custom-new";
14342    eval {
14343      my $old_orig = c('originating');
14344      # may load policy banks
14345      $custom_object = Amavis::Custom->new($conn,$msginfo);
14346      my $new_orig = c('originating');  # may have changed by a pol. bank load
14347      $msginfo->originating($new_orig)  if ($old_orig?1:0) != ($new_orig?1:0);
14348      update_current_log_level();  1;
14349    } or do {
14350      undef $custom_object;
14351      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
14352      do_log(-1,"custom new err: %s", $eval_stat);
14353    };
14354    if (ref $custom_object) {
14355      do_log(5,"Custom hooks enabled"); section_time($which_section);
14356    }
14357
14358    if ($redis_storage && c('enable_ip_repu')) {
14359      $which_section = 'redis_ip_repu';
14360      my($score, $worst_ip) =
14361        $redis_storage->query_and_update_ip_reputation($msginfo);
14362      if ($score && $score >= 0.5) {
14363        $msginfo->ip_repu_score($score);
14364        my $spam_test = sprintf('AM.IP_BAD_%s=%.1f', $worst_ip, $score);
14365        for my $r (@{$msginfo->per_recip_data}) {
14366          $r->spam_level( ($r->spam_level || 0) + $score);
14367          $r->spam_tests([])  if !$r->spam_tests;
14368          unshift(@{$r->spam_tests}, \$spam_test);
14369        }
14370      }
14371      section_time($which_section);
14372    }
14373
14374    my $cl_ip = $msginfo->client_addr;
14375    my($os_fingerprint_obj,$os_fingerprint);
14376    my $os_fingerprint_method = c('os_fingerprint_method');
14377    if (!defined($os_fingerprint_method) || $os_fingerprint_method eq '') {
14378      # no fingerprinting service configured
14379    } elsif ($cl_ip eq '' || $cl_ip eq '0.0.0.0' || $cl_ip eq '::') {
14380      # original client IP address not available, can't query p0f
14381    } else {  # launch a query
14382      $which_section = "os_fingerprint";
14383      my $dst = c('os_fingerprint_dst_ip_and_port');
14384      my($dst_ip,$dst_port); local($1,$2,$3);
14385      ($dst_ip,$dst_port) = ($1.$2, $3)  if defined($dst) &&
14386                      $dst =~ m{^(?: \[ ([^\]]*) \] | ([^:]*) ) : ([^:]*) }six;
14387      $os_fingerprint_obj = Amavis::OS_Fingerprint->new(
14388        untaint(dynamic_destination($os_fingerprint_method,$conn)),
14389        0.050, $cl_ip, $msginfo->client_port, $dst_ip, $dst_port,
14390        defined $mail_id ? $mail_id : sprintf("%08x",rand(0x7fffffff)) );
14391    }
14392
14393    my $sender = $msginfo->sender;
14394    my(@recips) = map($_->recip_addr, @{$msginfo->per_recip_data});
14395    my $rfc2822_sender = $msginfo->rfc2822_sender;
14396    my $fm = $msginfo->rfc2822_from;
14397    my(@rfc2822_from) = !defined($fm) ? () : ref $fm ? @$fm : $fm;
14398    $mail_size = $msginfo->msg_size;  # refresh after custom hook, just in case
14399    add_entropy("$cl_ip $mail_size $sender", \@recips);
14400    if (ll(1)) {
14401      my $pbn = c('policy_bank_path');
14402      ll(1) && do_log(1,"Checking: %s %s%s%s -> %s", $mail_id||'',
14403                 $pbn eq '' ? '' : "$pbn ",  $cl_ip eq '' ? '' : "[$cl_ip] ",
14404                 qquote_rfc2821_local($sender),
14405                 join(',', qquote_rfc2821_local(@recips)) );
14406    }
14407    if (ll(3)) {
14408      my $envsender = qquote_rfc2821_local($sender);
14409      my $hdrsender = qquote_rfc2821_local($rfc2822_sender),
14410      my $hdrfrom   = qquote_rfc2821_local(@rfc2822_from);
14411      do_log(3,"2822.From: %s%s%s",
14412               @rfc2822_from==1 ? $hdrfrom
14413                 : sprintf("%d:[%s]", scalar @rfc2822_from, $hdrfrom),
14414               !defined($rfc2822_sender) ? '' : ", 2822.Sender: $hdrsender",
14415               defined $rfc2822_sender && $envsender eq $hdrsender ? ''
14416               : $envsender eq $hdrfrom ? '' : ", 2821.Mail_From: $envsender");
14417    }
14418
14419    my $cnt_local = 0; my $cnt_remote = 0;
14420    for my $r (@{$msginfo->per_recip_data}) {
14421      my $recip = $r->recip_addr;
14422      my $is_local = lookup2(0,$recip, ca('local_domains_maps'));
14423      $is_local ? $cnt_local++ : $cnt_remote++;
14424      $r->recip_is_local($is_local ? 1 : 0);  # canonical boolean, untainted
14425      if (!defined($r->bypass_virus_checks)) {
14426        my $bypassed_v = lookup2(0,$recip, ca('bypass_virus_checks_maps'));
14427        $r->bypass_virus_checks($bypassed_v);
14428      }
14429      if (!defined($r->bypass_banned_checks)) {
14430        my $bypassed_b = lookup2(0,$recip, ca('bypass_banned_checks_maps'));
14431        $r->bypass_banned_checks($bypassed_b);
14432      }
14433      if (!defined($r->bypass_spam_checks)) {
14434        my $bypassed_s = lookup2(0,$recip, ca('bypass_spam_checks_maps'));
14435        $r->bypass_spam_checks($bypassed_s);
14436      }
14437      if (defined $user_id_sql) {
14438        my($user_id_ref,$mk_ref) =  # list of all id's that match
14439          lookup2(1, $recip, [$user_id_sql], Label=>"users.id");
14440        $r->user_id($user_id_ref)  if ref $user_id_ref;  # listref or undef
14441      }
14442      if (defined $user_policy_id_sql) {
14443        my $user_policy_id = lookup2(0, $recip, [$user_policy_id_sql],
14444                                     Label=>"users.policy_id");
14445        $r->user_policy_id($user_policy_id);  # just the first match
14446      }
14447    }
14448    # update message count and message size snmp counters
14449    # orig local
14450    #   0   0  InMsgsOpenRelay
14451    #   0   1  InMsgsInbound
14452    #   0   x  (non-originating: inbound or open relay)
14453    #   1   0  InMsgsOutbound
14454    #   1   1  InMsgsInternal
14455    #   1   x  InMsgsOriginating (outbound or internal)
14456    #   x   0  (departing: outbound or open relay)
14457    #   x   1  (local: inbound or internal)
14458    #   x   x  InMsgs
14459    snmp_count('InMsgs');
14460    snmp_count('InMsgsBounceNullRPath')  if $sender eq '';
14461    snmp_count( ['InMsgsRecips', $cnt_local+$cnt_remote]); # recipients count
14462    snmp_count( ['InMsgsSize', $mail_size, 'C64'] );
14463    if ($msginfo->originating) {
14464      snmp_count('InMsgsOriginating');
14465      snmp_count( ['InMsgsRecipsOriginating', $cnt_local+$cnt_remote]);
14466      snmp_count( ['InMsgsSizeOriginating', $mail_size, 'C64'] );
14467    }
14468    if ($cnt_local > 0) {
14469      my $d = $msginfo->originating ? 'Internal' : 'Inbound';
14470      snmp_count('InMsgs'.$d);
14471      snmp_count( ['InMsgsRecips'.$d,   $cnt_local]);
14472      snmp_count( ['InMsgsRecipsLocal', $cnt_local]);
14473      snmp_count( ['InMsgsSize'.$d, $mail_size, 'C64'] );
14474    }
14475    if ($cnt_remote > 0) {
14476      my $d = $msginfo->originating ? 'Outbound' : 'OpenRelay';
14477      snmp_count('InMsgs'.$d);
14478      snmp_count( ['InMsgsRecips'.$d, $cnt_remote]);
14479      snmp_count( ['InMsgsSize'.$d, $mail_size, 'C64'] );
14480      if (!$msginfo->originating) {
14481        do_log(1,'Open relay? Nonlocal recips but not originating: %s',
14482                 join(', ', map($_->recip_addr,
14483                   grep(!$_->recip_is_local, @{$msginfo->per_recip_data}))));
14484      }
14485    }
14486
14487    # mkdir can be a costly operation (must be atomic, flushes buffers).
14488    # If we can re-use directory 'parts' from the previous invocation it saves
14489    # us precious time. Together with matching rmdir this can amount to 10-15 %
14490    # of total elapsed time on some traditional file systems (no spam checking)
14491    $which_section = "creating_partsdir";
14492    { my $tempdir = $msginfo->mail_tempdir;
14493      my $errn = lstat("$tempdir/parts") ? 0 : 0+$!;
14494      if ($errn == ENOENT) {  # needs to be created
14495        mkdir("$tempdir/parts", 0750)
14496          or die "Can't create directory $tempdir/parts: $!";
14497        section_time('mkdir parts'); }
14498      elsif ($errn != 0) { die "$tempdir/parts is not accessible: $!" }
14499      elsif (!-d _)      { die "$tempdir/parts is not a directory" }
14500      else {}  # fine, directory already exists and is accessible
14501    }
14502
14503    # FIRST: what kind of e-mail did we get? call content scanners
14504
14505    my($virus_presence_checked,$spam_presence_checked);
14506    my $virus_dejavu = 0;
14507
14508    my($will_do_virus_scanning, $all_bypass_virus_checks);
14509    if ($extra_code_antivirus) {
14510      $all_bypass_virus_checks =
14511         !grep(!$_->bypass_virus_checks, @{$msginfo->per_recip_data});
14512      $will_do_virus_scanning =
14513         !$virus_presence_checked && !$all_bypass_virus_checks;
14514    }
14515    my $will_do_banned_checking =  # banned name checking will be needed?
14516       @{ca('banned_filename_maps')} || cr('banned_namepath_re');
14517
14518    my($bounce_header_fields_ref,$bounce_msgid,$bounce_type);
14519
14520    if (c('bypass_decode_parts')) {
14521      do_log(5, 'decoding bypassed');
14522    } elsif (!$will_do_virus_scanning && !$will_do_banned_checking &&
14523             c('bounce_killer_score') <= 0) {
14524      do_log(5, 'decoding not needed');
14525    } else {
14526      # decoding parts can take a lot of time
14527      $which_section = "mime_decode-1";
14528      $zmq_obj->register_proc(2,0,'D',$am_id)  if $zmq_obj;  # decoding
14529      $snmp_db->register_proc(2,0,'D',$am_id)  if $snmp_db;
14530      $t0_sect = Time::HiRes::time;
14531      $mime_err = ensure_mime_entity($msginfo)
14532        if !defined($msginfo->mime_entity);
14533      prolong_timer($which_section);
14534
14535      if (c('bounce_killer_score') > 0) {
14536        $which_section = "dsn_parse";
14537        # analyze a bounce after MIME decoding but before further archive
14538        # decoding (which often replaces original MIME parts by decoded files)
14539        eval {  # just in case
14540          ($bounce_header_fields_ref,$bounce_type) =
14541            inspect_a_bounce_message($msginfo);
14542          1;
14543        } or do {
14544          my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
14545          do_log(-1, "inspect_a_bounce_message failed: %s", $eval_stat);
14546        };
14547        if ($bounce_header_fields_ref &&
14548            exists $bounce_header_fields_ref->{'message-id'}) {
14549          $bounce_msgid = $bounce_header_fields_ref->{'message-id'};
14550          if (defined $bounce_msgid && $bounce_msgid ne '') {
14551            my $refs = $msginfo->references;
14552            if (!$refs) { $refs = []; $msginfo->references($refs) }
14553            push(@$refs, $bounce_msgid);
14554          }
14555        }
14556        prolong_timer($which_section);
14557      }
14558
14559      $which_section = "parts_decode_ext";
14560      snmp_count('OpsDec');
14561      my($any_encrypted,$over_levels);
14562      ($hold, $any_undecipherable, $any_encrypted, $over_levels) =
14563        Amavis::Unpackers::decompose_mail($msginfo->mail_tempdir,
14564                                          $file_generator_object);
14565      $any_undecipherable ||= ($any_encrypted || $over_levels);
14566      if ($any_undecipherable) {
14567        $msginfo->add_contents_category(CC_UNCHECKED,0);
14568        $msginfo->add_contents_category(CC_UNCHECKED,1) if $any_encrypted;
14569        $msginfo->add_contents_category(CC_UNCHECKED,2) if $over_levels;
14570        for my $r (@{$msginfo->per_recip_data}) {
14571          next if $r->bypass_virus_checks;
14572          $r->add_contents_category(CC_UNCHECKED,0);
14573          $r->add_contents_category(CC_UNCHECKED,1) if $any_encrypted;
14574          $r->add_contents_category(CC_UNCHECKED,2) if $over_levels;
14575        }
14576      }
14577      $elapsed->{'TimeElapsedDecoding'} = Time::HiRes::time - $t0_sect;
14578    }
14579
14580    my $bphcm = ca('bypass_header_checks_maps');
14581    if (grep(!lookup2(0,$_->recip_addr,$bphcm), @{$msginfo->per_recip_data})) {
14582      $which_section = "check_header";
14583      my $allowed_tests = cr('allowed_header_tests');
14584      my($badh_ref,$minor_badh_cc);
14585      if ($allowed_tests && %$allowed_tests) {  # any test enabled?
14586        ($badh_ref,$minor_badh_cc) = check_header_validity($msginfo);
14587        $msginfo->checks_performed->{H} = 1;
14588        if (@$badh_ref) {
14589          push(@bad_headers, @$badh_ref);
14590          $msginfo->add_contents_category(CC_BADH,$minor_badh_cc);
14591        }
14592      }
14593      my $allowed_mime_test = $allowed_tests && $allowed_tests->{'mime'};
14594      # check for bad headers and for bad MIME subheaders / bad MIME structure
14595      if ($allowed_mime_test && defined $mime_err && $mime_err ne '') {
14596        push(@bad_headers, "MIME error: ".$mime_err);
14597        $msginfo->add_contents_category(CC_BADH,1);
14598      }
14599      for my $r (@{$msginfo->per_recip_data}) {
14600        my $bypassed = lookup2(0,$r->recip_addr,$bphcm);
14601        if (!$bypassed && @$badh_ref) {
14602          $r->add_contents_category(CC_BADH,$minor_badh_cc);
14603        }
14604        if (!$bypassed && $allowed_mime_test &&
14605            defined $mime_err && $mime_err ne '') {
14606          $r->add_contents_category(CC_BADH,1);  # CC_BADH min: 1=broken mime
14607        }
14608      }
14609      section_time($which_section);
14610    }
14611
14612    if ($will_do_banned_checking) {      # check for banned file contents
14613      $which_section = "check-banned";
14614      check_for_banned_names($msginfo);  # saves results in $msginfo
14615      $msginfo->checks_performed->{B} = 1;
14616      $banned_filename_any = 0; $banned_filename_all = 1;
14617      for my $r (@{$msginfo->per_recip_data}) {
14618        next  if $r->bypass_banned_checks;
14619        my $a = $r->banned_parts;
14620        if (!defined $a || !@$a) {
14621          $banned_filename_all = 0;
14622        } else {
14623          my $rhs = $r->banning_rule_rhs;
14624          if (defined $rhs) {
14625            for my $j (0..$#{$a}) {
14626              $r->dsn_suppress_reason(sprintf("BANNED:%s suggested by rule",
14627                                     $rhs->[$j]))  if $rhs->[$j] =~ /^DISCARD/;
14628            }
14629          }
14630          $banned_filename_any = 1;
14631          $r->add_contents_category(CC_BANNED,0);
14632        }
14633      }
14634      $msginfo->add_contents_category(CC_BANNED,0)  if $banned_filename_any;
14635      ll(4) && do_log(4,"banned check: any=%d, all=%s (%d)",
14636                        $banned_filename_any, $banned_filename_all?'Y':'N',
14637                        scalar(@{$msginfo->per_recip_data}));
14638    }
14639
14640    my $virus_checking_failed = 0;
14641    if (!$extra_code_antivirus) {
14642      do_log(5, "no anti-virus code loaded, skipping virus_scan");
14643    } elsif ($all_bypass_virus_checks) {
14644      do_log(5, "bypassing of virus checks requested");
14645    } elsif (defined $hold && $hold ne '') { # protect virus scanner from bombs
14646      do_log(0, "NOTICE: Virus scanning skipped: %s", $hold);
14647      $will_do_virus_scanning = 0;
14648    } else {
14649      if (!$will_do_virus_scanning)
14650        { do_log(-1, "NOTICE: will_do_virus_scanning is false???") }
14651      $mime_err = ensure_mime_entity($msginfo)
14652        if !defined($msginfo->mime_entity) && !c('bypass_decode_parts');
14653      # special case to make available a complete mail file for inspection
14654      if ((defined $mime_err && $mime_err ne '') ||
14655          !defined($msginfo->mime_entity) ||
14656          lookup2(0, 'MAIL', \@keep_decoded_original_maps) ||
14657          $any_undecipherable && lookup2(0,'MAIL-UNDECIPHERABLE',
14658                                         \@keep_decoded_original_maps)) {
14659        if (!defined($msginfo->mail_text_fn)) {
14660          do_log(5,"can't present full original message to scanners, no file");
14661        } else {
14662          # keep the email.txt by making a hard link to it in ./parts/
14663          $which_section = "linking-to-MAIL";
14664          my $tempdir = $msginfo->mail_tempdir;
14665          my $newpart_obj =
14666            Amavis::Unpackers::Part->new("$tempdir/parts", $parts_root, 1);
14667          my $newpart = $newpart_obj->full_name;
14668          ll(3) && do_log(3,'presenting full original message to scanners '.
14669                            'as %s%s%s%s',
14670            $newpart,
14671            !$any_undecipherable ? '' : ", $any_undecipherable undecipherable",
14672            defined $msginfo->mime_entity ? '' : ', MIME not decoded',
14673            !defined $mime_err || $mime_err eq '' ? ''
14674                                                  : ", MIME error: $mime_err");
14675          link($msginfo->mail_text_fn, $newpart)
14676            or die sprintf("Can't create hard link %s to %s: %s",
14677                           $newpart, $msginfo->mail_text_fn, $!);
14678          $newpart_obj->type_short('MAIL');  # case sensitive
14679          if ($msginfo->smtputf8 && $msginfo->header_8bit) {
14680            # RFC 6532 section 3.7
14681            $newpart_obj->type_declared('message/global');
14682            $newpart_obj->name_declared('message.u8msg');
14683          } else {
14684            $newpart_obj->type_declared('message/rfc822');
14685            $newpart_obj->name_declared('message.msg');
14686          }
14687        }
14688      }
14689
14690      $which_section = "virus_scan";
14691      $zmq_obj->register_proc(2,0,'V',$am_id)  if $zmq_obj;  # virus scan
14692      $snmp_db->register_proc(2,0,'V',$am_id)  if $snmp_db;
14693      my $av_ret;  $t0_sect = Time::HiRes::time;
14694      $virus_checking_failed = 1;
14695      eval {
14696        my($vn, $ds, $avsr);
14697        ($av_ret, $av_output, $vn, $ds, $avsr) =
14698          Amavis::AV::virus_scan($msginfo, $child_task_count==1);
14699        @virusname = @$vn; @detecting_scanners = @$ds;  # copy
14700        @av_scanners_results = @$avsr;
14701        if (defined $av_ret) {
14702          $virus_presence_checked = 1; $virus_checking_failed = 0;
14703          $msginfo->checks_performed->{V} = 1;
14704        }
14705        1;
14706      } or do {
14707        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
14708        do_log(-2, "AV: %s", $eval_stat);
14709        $virus_checking_failed = $eval_stat;
14710        $virus_checking_failed = 1  if !$virus_checking_failed;
14711      };
14712      $elapsed->{'TimeElapsedVirusCheck'} = Time::HiRes::time - $t0_sect;
14713      snmp_count('OpsVirusCheck');
14714
14715      if ($virus_presence_checked && @virusname && $snmp_db) {
14716        $which_section = "read_snmp_variables";
14717        # true if none found with a counter value of zero or undef
14718        $virus_dejavu = 1  if !grep(!defined($_) || $_ == 0,
14719                                    @{$snmp_db->read_snmp_variables(
14720                                      map("virus.byname.$_", @virusname))});
14721        section_time($which_section);
14722      }
14723    }
14724
14725    if ($virus_checking_failed) {
14726      $msginfo->add_contents_category(CC_UNCHECKED,0);
14727      for my $r (@{$msginfo->per_recip_data}) {
14728        $r->add_contents_category(CC_UNCHECKED,0)  if !$r->bypass_virus_checks;
14729      }
14730      if (c('virus_scanners_failure_is_fatal')) {
14731        $hold = 'AV: ' . $virus_checking_failed;
14732        die "$hold\n";  # TEMPFAIL
14733      }
14734    }
14735
14736    $which_section = "post_virus_scan";
14737    if (@virusname) {
14738      my $virus_suppress_reason;
14739      my($ccat_maj,$ccat_min) = (CC_VIRUS,0);
14740      my $vtfsm = ca('viruses_that_fake_sender_maps');
14741      if (@$vtfsm) {
14742        for my $vn (@virusname) {
14743          my($result,$matchingkey) = lookup2(0,$vn,$vtfsm);
14744          if ($result) {  # is a virus known to fake a sender address
14745            do_log(3,"Virus %s matches %s, sender addr ignored",
14746                     $vn,$matchingkey);
14747            # try to get some info on sender source from his IP address
14748            my $first_rcvd_from_ip =
14749              oldest_public_ip_addr_from_received($msginfo);
14750            if (defined $first_rcvd_from_ip && $first_rcvd_from_ip ne '') {
14751              $msginfo->sender_source(sprintf('?@[%s]', $first_rcvd_from_ip));
14752            } else {
14753              $msginfo->sender_source(undef);
14754            }
14755            $virus_suppress_reason = 'INFECTED';
14756          # $ccat_min = 1;
14757            last;
14758          }
14759        }
14760      }
14761      $msginfo->add_contents_category($ccat_maj,$ccat_min);
14762      for my $r (@{$msginfo->per_recip_data}) {
14763        $r->add_contents_category(
14764                           $ccat_maj,$ccat_min)  if !$r->bypass_virus_checks;
14765        if (defined $virus_suppress_reason) {
14766          $r->dsn_suppress_reason($virus_suppress_reason .
14767                    (!defined $_ ? '' : ", $_"))  for $r->dsn_suppress_reason;
14768        }
14769      }
14770      $msginfo->virusnames([@virusname]);  # save a copy of virus names
14771
14772      my $vntpbm = ca('virus_name_to_policy_bank_maps');
14773      if (@$vntpbm) {
14774        my(@bank_names);
14775        for my $vn (@virusname) {
14776          my($result,$matchingkey) = lookup2(0,$vn,$vntpbm);
14777          next if !$result;
14778          if ($result eq '1') {
14779            # a handy usability trick to supply a hardwired policy bank
14780            # name when acl-style lookup table is used, which can only
14781            # return a boolean (undef, 0, or 1)
14782            $result = 'VIRUS';
14783          }
14784          # $result is a list of policy bank names as a comma-separated string
14785          local $1;
14786          my(@pbn) = map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $result));
14787          if (@pbn) {
14788            push(@bank_names, @pbn);
14789            ll(2) && do_log(2, "virus %s loads policy bank(s) %s, match: %s",
14790                               $vn, join(',',@pbn), $matchingkey);
14791          }
14792        }
14793        load_policy_bank($_) for @bank_names;
14794      }
14795    }
14796
14797    if (defined($os_fingerprint_obj)) {
14798      $which_section = "fingerprint_collect";
14799      $os_fingerprint = $os_fingerprint_obj->collect_response;
14800      if (defined $os_fingerprint && $os_fingerprint ne '') {
14801        $msginfo->checks_performed->{F} = 1;
14802        if ($msginfo->originating)
14803          { $os_fingerprint = 'MYNETWORKS' }  # blank-out our smtp clients info
14804        $msginfo->client_os_fingerprint($os_fingerprint);  # store info
14805      }
14806    }
14807
14808    my($bypass_spam_checks_by_bounce_killer);
14809    if (!$bounce_header_fields_ref) {
14810      # not a bounce
14811    } elsif ($msginfo->originating) {
14812      # will be rescued from bounce killing by the originating flag
14813    } elsif (defined($bounce_msgid) &&
14814             $bounce_msgid =~ /(\@[^\@>() \t][^\@>]*?)[ \t]*>?\z/ &&
14815             lookup2(0,$1, ca('local_domains_maps'))) {
14816      # will be rescued from bounce killing by a local domain
14817      # in referenced Message-ID
14818    } elsif (!defined($sql_storage) || !$sql_store_info_for_all_msgs ||
14819             c('penpals_bonus_score') <= 0 || c('penpals_halflife') <= 0) {
14820      # will be rescued from bounce killing by pen pals disabled
14821    } elsif (c('bounce_killer_score') > 20) {
14822      # is a bounce and is eligible to bounce killing, no need for spam scan
14823      $bypass_spam_checks_by_bounce_killer = 1;
14824    }
14825
14826    # consider doing spam scanning
14827    if (!$extra_code_antispam) {
14828      do_log(5, "no anti-spam code loaded, skipping spam_scan");
14829    } elsif ($bypass_spam_checks_by_bounce_killer) {
14830      do_log(5, "bypassing of spam checks by a bounce killer");
14831    } elsif (!grep(!$_->bypass_spam_checks, @{$msginfo->per_recip_data})) {
14832      do_log(5, "bypassing of spam checks requested for all recips");
14833    } else {
14834      # preliminary test - would a message be allowed to pass for any recipient
14835      # based on evidence collected so far (virus, banned)
14836      my $any_pass = 0; my $prelim_blocking_ccat;
14837      for my $r (@{$msginfo->per_recip_data}) {
14838        my $final_destiny = D_PASS;
14839        my $recip = $r->recip_addr;
14840        my(@fd_tuples) = $r->setting_by_main_contents_category_all(
14841                           cr('final_destiny_maps_by_ccat'),
14842                           cr('lovers_maps_by_ccat'));
14843        for my $tuple (@fd_tuples) {
14844          my($cc, $fd_map_ref, $lovers_map_ref) = @$tuple;
14845          my $fd = !ref $fd_map_ref ? $fd_map_ref  # compatibility
14846                                    : lookup2(0, $recip, $fd_map_ref,
14847                                              Label => 'Destiny1');
14848          if (!defined $fd || $fd == D_PASS) {
14849            $fd = D_PASS;  # keep D_PASS
14850          } elsif (defined($lovers_map_ref) &&
14851                   lookup2(0, $recip, $lovers_map_ref, Label => 'Lovers1')) {
14852            $fd = D_PASS;  # D_PASS for content lovers
14853          } elsif ($fd == D_BOUNCE && ($sender eq '' || $msginfo->is_bulk) &&
14854                   ccat_maj($cc) == CC_BADH) {
14855            # have mercy on bad header section from mailing lists and in DSN
14856            $fd = D_PASS;  # change D_BOUNCE to D_PASS for CC_BADH
14857          } else {  # $fd != D_PASS, blocked
14858            $prelim_blocking_ccat = $cc; $final_destiny = $fd;
14859            last;
14860          }
14861        }
14862        $any_pass = 1  if $final_destiny == D_PASS;
14863      }
14864      if (!$any_pass) {
14865        do_log(5, "bypassing of spam checks, message will be blocked anyway ".
14866                  "due to %s", $prelim_blocking_ccat);
14867      } else {
14868        $which_section = "spam-wb-list";
14869        my($any_wbl, $all_wbl) = Amavis::SpamControl::white_black_list(
14870                           $msginfo, $sql_wblist, $user_id_sql, $ldap_lookups);
14871        section_time($which_section);
14872        if ($all_wbl) {
14873          do_log(5, "sender white/blacklisted, skipping spam_scan");
14874        } elsif (!$spamcontrol_obj) {
14875          do_log(5, "spam scanning disabled, no spamcontrol_obj");
14876        } else {
14877          $which_section = "spam_scan";
14878          $zmq_obj->register_proc(2,0,'S',$am_id)  if $zmq_obj;
14879          $snmp_db->register_proc(2,0,'S',$am_id)  if $snmp_db;
14880          $t0_sect = Time::HiRes::time;
14881          # sets $msginfo->spam_level, spam_status,
14882          #      spam_report, spam_summary, supplementary_info
14883          $spamcontrol_obj->spam_scan($msginfo);
14884          eval {  # treat any failures there as non-fatal, just in case
14885            $spamcontrol_obj->auto_learn($msginfo); 1;
14886          } or do {
14887            my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
14888            do_log(-1, "Auto-learn failed: %s", $eval_stat);
14889          };
14890          $msginfo->checks_performed->{S} = 1;
14891          prolong_timer($which_section);
14892          $elapsed->{'TimeElapsedSpamCheck'} = Time::HiRes::time - $t0_sect;
14893          snmp_count('OpsSpamCheck');
14894          $spam_presence_checked = 1;
14895        }
14896      }
14897    }
14898
14899    if (ref $custom_object) {
14900      $which_section = "custom-checks";
14901      eval {
14902        $custom_object->checks($conn,$msginfo);
14903        update_current_log_level();  1;
14904      } or do {
14905        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
14906        do_log(-1,"custom checks error: %s", $eval_stat);
14907      };
14908      section_time($which_section);
14909    }
14910
14911    snmp_count("virus.byname.$_")  for @virusname;
14912
14913    my(@sa_tests,%sa_tests);
14914    { my $tests = $msginfo->supplementary_info('TESTS');
14915      if (defined($tests) && $tests ne 'none') {
14916        @sa_tests = $tests =~ /([^=,;]+)(?==)/g;
14917        %sa_tests = map(($_,1), @sa_tests);
14918      }
14919    }
14920
14921    # SECOND: now that we know what we got, decide what to do with it
14922    $which_section = 'after_scanning';
14923
14924    Amavis::DKIM::adjust_score_by_signer_reputation($msginfo)
14925      if $msginfo->dkim_signatures_valid;
14926
14927    my($min_spam_level, $max_spam_level) =
14928      minmax(map($_->spam_level, @{$msginfo->per_recip_data}));
14929    $min_spam_level = 0  if !defined $min_spam_level;
14930    $max_spam_level = 0  if !defined $max_spam_level;
14931
14932    $which_section = "penpals_check";
14933    my $pp_age;
14934
14935    if (!$redis_storage &&
14936        !(defined $sql_storage && $sql_store_info_for_all_msgs)) {
14937      # pen pals disabled - data on past mail transactions unavailable
14938    } elsif ($msginfo->is_in_contents_category(CC_VIRUS)) {
14939      # pen pals disabled, not needed for infected messages
14940    } else {
14941      my $pp_bonus = c('penpals_bonus_score');  # score points
14942      my $pp_halflife = c('penpals_halflife');  # seconds
14943      if ($pp_bonus <= 0 || $pp_halflife <= 0) {
14944        # penpals disabled
14945      } elsif (defined($penpals_threshold_low) && !defined($bounce_msgid) &&
14946               $max_spam_level < $penpals_threshold_low) {
14947        # low score for all recipients, no need for aid
14948        do_log(5,"penpals: low score, no need for penpals aid");
14949      } elsif (defined($penpals_threshold_high) && !defined($bounce_msgid) &&
14950               $min_spam_level - $pp_bonus > $penpals_threshold_high) {
14951        # spam, can't get below threshold_high even under best circumstances
14952        do_log(5,"penpals: high score, penpals won't help");
14953      } elsif ($sender ne '' && !$msginfo->originating &&
14954               lookup2(0, $sender, ca('local_domains_maps'))) {
14955        # no bonus to unauthent. senders from outside claiming a local domain
14956        do_log(5,"penpals: local sender from outside, ignored: %s", $sender);
14957      } else {
14958        $t0_sect = Time::HiRes::time;
14959        $zmq_obj->register_proc(2,0,'P',$am_id)  if $zmq_obj;  # penpals
14960        $snmp_db->register_proc(2,0,'P',$am_id)  if $snmp_db;
14961        my $refs = $msginfo->references;
14962        my $sid = $msginfo->sender_maddr_id;
14963        section_time("pre-penpals");
14964
14965        if ($redis_storage) {
14966          # does all recipient queries in one go
14967          my $ok = eval { $redis_storage->penpals_find($msginfo, $refs) };
14968          section_time("penpals-redis")  if $ok;
14969        }
14970
14971        for my $r (@{$msginfo->per_recip_data}) {
14972          next  if $r->recip_done;  # already dealt with
14973          my $recip = $r->recip_addr;
14974          if ($r->recip_is_local && lc($sender) ne lc($recip)) {
14975            # inbound or internal_to_internal, except self_to_self
14976
14977            my $pp_mail_id = $r->recip_penpals_related;
14978            my $pp_age = $r->recip_penpals_age;
14979            my $pp_subj;
14980            my $rid = $r->recip_maddr_id;
14981            if ($sql_storage && defined $sid && defined $rid) {
14982              # NOTE: swap $rid and $sid as args in a query here, as we are
14983              # now checking for a potential reply mail - whether the current
14984              # recipient has recently sent any mail to the sender of the
14985              # current mail:
14986              my($pp_age_sql, $pp_mail_id_sql, $pp_subj_sql) =
14987                $sql_storage->penpals_find($rid, $sid, $refs, $msginfo);
14988              if (defined $pp_age_sql) {
14989                if (!defined $pp_age || $pp_age_sql < $pp_age) {
14990                  $pp_age = $pp_age_sql; $pp_mail_id = $pp_mail_id_sql;
14991                  $r->recip_penpals_age($pp_age);
14992                  $r->recip_penpals_related($pp_mail_id);
14993                }
14994                $pp_subj = $pp_subj_sql;
14995              }
14996              section_time("penpals-sql");
14997            }
14998
14999            $msginfo->checks_performed->{P} = 1;
15000            if (defined $pp_age) {  # found info about previous correspondence
15001              my $weight = exp(-($pp_age/$pp_halflife) * log(2));
15002              # weight is a factor between 1 and 0, representing
15003              # exponential decay: weight(t) = 1 / 2^(t/halflife)
15004              # i.e. factors 1, 1/2, 1/4, 1/8... at age 0, hl, 2*hl, 3*hl...
15005              my $adj = - $weight * $pp_bonus;
15006              $r->recip_penpals_score($adj);
15007              $r->spam_level( ($r->spam_level || 0) + $adj);
15008              { my $spam_tests = 'AM.PENPAL=' . (0+sprintf("%.3f",$adj));
15009                if (!$r->spam_tests) {
15010                  $r->spam_tests([ \$spam_tests ]);
15011                } else {
15012                  unshift(@{$r->spam_tests}, \$spam_tests);
15013                }
15014              }
15015              if (ll(2)) {
15016                do_log(2,"penpals: adj.bonus %.3f, age %s (%d), ".
15017                       "SA score %.3f, <%s> replying to <%s>, ref mail_id: %s",
15018                       -$adj, format_time_interval($pp_age), $pp_age,
15019                       $r->spam_level, $sender, $recip, $pp_mail_id);
15020                if (defined $pp_subj) {
15021                  my $this_subj = $msginfo->get_header_field_body('subject');
15022                  $this_subj = $1  if $this_subj =~ /^\s*(.*?)\s*$/;
15023                  do_log(2,"penpals: prev Subject: %s", $pp_subj);
15024                  do_log(2,"penpals: this Subject: %s", $this_subj);
15025                }
15026              }
15027            }
15028          }
15029        }
15030      # section_time($which_section);
15031        $elapsed->{'TimeElapsedPenPals'} = Time::HiRes::time - $t0_sect;
15032      }
15033    }
15034
15035    $which_section = "bounce_killer";
15036    if ($bounce_header_fields_ref) {  # message looks like a DSN (= bounce)
15037      snmp_count('InMsgsBounce');
15038      my $bounce_rescued;
15039      if (defined $pp_age && $pp_age < 8*24*3600) {  # less than 8 days ago
15040        # found by pen pals by a Message-ID in attachment and recip. address;
15041        # is a bounce, refers to our previous outgoing message, treat it kindly
15042        snmp_count('InMsgsBounceRescuedByPenPals');
15043        $bounce_rescued = 'by penpals';
15044      } elsif ($msginfo->originating) {
15045        snmp_count('InMsgsBounceRescuedByOriginating');
15046        $bounce_rescued = 'by originating';
15047      } elsif (defined($bounce_msgid) &&
15048               $bounce_msgid =~ /(\@[^\@>() \t][^\@>]*?)[ \t]*>?\z/ &&
15049               lookup2(0,$1, ca('local_domains_maps'))) {
15050        # not in pen pals, but domain in Message-ID is a local domain;
15051        # it is only useful until spammers figure out the trick,
15052        # then it should be disabled
15053        snmp_count('InMsgsBounceRescuedByDomain');
15054        $bounce_rescued = 'by domain';
15055      } elsif (!defined($sql_storage) ||
15056               c('penpals_bonus_score') <= 0 || c('penpals_halflife') <= 0) {
15057        $bounce_rescued = 'by: pen pals disabled';
15058      }
15059      ll(2) && do_log(2, "bounce %s (%s), %s -> %s, %s",
15060                 defined $bounce_rescued ?'rescued '.$bounce_rescued :'killed',
15061                 $bounce_type, qquote_rfc2821_local($sender),
15062                 join(',', qquote_rfc2821_local(@recips)),
15063                 join(', ', map { $_ . ': ' . $bounce_header_fields_ref->{$_} }
15064                      sort( grep(/^(?:From|Return-Path|Message-ID|Date)\z/i,
15065                                 keys %$bounce_header_fields_ref) )) );
15066      if (!$bounce_rescued) {
15067        snmp_count('InMsgsBounceKilled');
15068        my $bounce_killer_score = c('bounce_killer_score');
15069        for my $r (@{$msginfo->per_recip_data}) {
15070          $r->spam_level( ($r->spam_level || 0) + $bounce_killer_score);
15071          my $spam_tests = 'AM.BOUNCE=' . $bounce_killer_score;
15072          if (!$r->spam_tests) {
15073            $r->spam_tests([ \$spam_tests ]);
15074          } else {
15075            unshift(@{$r->spam_tests}, \$spam_tests);
15076          }
15077        }
15078      }
15079
15080    # else: not a recognizable bounce
15081    } elsif ($msginfo->is_auto ||
15082             $sender          =~ /^postmaster(?:\@|\z)/si ||
15083             $rfc2822_from[0] =~ /^postmaster(?:\@|\z)/si ||
15084             $sa_tests{'ANY_BOUNCE_MESSAGE'} ) {
15085      # message could be some kind of a non-standard bounce or autoresponse,
15086      # but lacks recognizable structure and a header section from orig. mail
15087      ll(2) && do_log(2, "bounce unverifiable%s, %s -> %s",
15088                         !$msginfo->originating ? '' : ', originating',
15089                         qquote_rfc2821_local($sender),
15090                         join(',', qquote_rfc2821_local(@recips)));
15091      snmp_count('InMsgsBounce'); snmp_count('InMsgsBounceUnverifiable');
15092    }
15093
15094    $which_section = "decide_mail_destiny";
15095    $zmq_obj->register_proc(2,0,'r',$am_id)  if $zmq_obj;  # results...
15096    $snmp_db->register_proc(2,0,'r',$am_id)  if $snmp_db;
15097    my $considered_oversize_by_some_recips;
15098    my $mslm = ca('message_size_limit_maps');
15099    for my $r (@{$msginfo->per_recip_data}) {
15100      next  if $r->recip_done;  # already dealt with
15101      my $recip = $r->recip_addr;
15102      my $spam_level = $r->spam_level;
15103
15104      # consider adding CC_SPAM or CC_SPAMMY to the contents_category list;
15105      # spaminess is an individual matter, we must compare spam level
15106      # with each recipient setting, there is no single global criterion
15107      my($tag_level,$tag2_level,$tag3_level,$kill_level);
15108      my $bypassed = $r->bypass_spam_checks;
15109      if (!$bypassed) {
15110        $tag_level  = lookup2(0,$recip, ca('spam_tag_level_maps'));
15111        $tag2_level = lookup2(0,$recip, ca('spam_tag2_level_maps'));
15112        $tag3_level = lookup2(0,$recip, ca('spam_tag3_level_maps'));
15113        $kill_level = lookup2(0,$recip, ca('spam_kill_level_maps'));
15114      }
15115      my $blacklisted = $r->recip_blacklisted_sender;
15116      my $whitelisted = $r->recip_whitelisted_sender;
15117      my $do_tag = !$bypassed && (
15118                    $blacklisted || !defined $tag_level || $tag_level eq '' ||
15119                   ($spam_level + ($whitelisted?-10:0) >= $tag_level));
15120      my($do_tag2,$do_tag3,$do_kill) =
15121        map { !$bypassed && !$whitelisted &&
15122              ($blacklisted || (defined($_) && $spam_level >= $_) ) }
15123            ($tag2_level,$tag3_level,$kill_level);
15124      $do_tag2 = $do_tag2 || $do_tag3;  # tag3 implies tag2, just in case
15125
15126      if ($do_tag) {   # spaminess is at or above tag level
15127        $msginfo->add_contents_category(CC_CLEAN,1);
15128        $r->add_contents_category(CC_CLEAN,1)  if !$bypassed;
15129      }
15130      if ($do_tag2) {  # spaminess is at or above tag2 level
15131        $msginfo->add_contents_category(CC_SPAMMY);
15132        $r->add_contents_category(CC_SPAMMY)   if !$bypassed;
15133      }
15134      if ($do_tag3) {  # spaminess is at or above tag3 level
15135        $msginfo->add_contents_category(CC_SPAMMY,1);
15136        $r->add_contents_category(CC_SPAMMY,1) if !$bypassed;
15137      }
15138      if ($do_kill) {  # spaminess is at or above kill level
15139        $msginfo->add_contents_category(CC_SPAM,0);
15140        $r->add_contents_category(CC_SPAM,0)   if !$bypassed;
15141      }
15142      # consider adding CC_OVERSIZED to the contents_category list;
15143      if (@$mslm) {  # checking of mail size is needed?
15144        my $size_limit = lookup2(0,$r->recip_addr,$mslm);
15145        if ($enforce_smtpd_message_size_limit_64kb_min &&
15146            $size_limit && $size_limit < 65536)
15147          { $size_limit = 65536 }  # RFC 5321 requires at least 64k
15148        if ($size_limit && $mail_size > $size_limit) {
15149          do_log(1,"OVERSIZED from %s to %s: size %s B, limit %s B",
15150                   $msginfo->sender_smtp, $r->recip_addr_smtp,
15151                   $mail_size, $size_limit)
15152            if !$considered_oversize_by_some_recips;
15153          $considered_oversize_by_some_recips = 1;
15154          $r->add_contents_category(CC_OVERSIZED,0);
15155          $msginfo->add_contents_category(CC_OVERSIZED,0);
15156        }
15157      }
15158
15159      # determine true reason for blocking,considering lovers and final_destiny
15160      my $blocking_ccat; my $final_destiny = D_PASS; my $to_be_mangled;
15161      my(@fd_tuples) = $r->setting_by_main_contents_category_all(
15162                         cr('final_destiny_maps_by_ccat'),
15163                         cr('lovers_maps_by_ccat'),
15164                         cr('defang_maps_by_ccat') );
15165      for my $tuple (@fd_tuples) {
15166        my($cc, $fd_map_ref, $lovers_map_ref, $mangle_map_ref) = @$tuple;
15167        my $fd = !ref $fd_map_ref ? $fd_map_ref  # compatibility
15168                                  : lookup2(0, $recip, $fd_map_ref,
15169                                            Label => 'Destiny2');
15170        if (!defined $fd || $fd == D_PASS) {
15171          ll(5) && do_log(5, 'final_destiny (ccat=%s) is PASS, recip %s',
15172                             $cc, $recip);
15173          $fd = D_PASS;  # keep D_PASS
15174        } elsif (defined($lovers_map_ref) &&
15175                 lookup2(0, $recip, $lovers_map_ref, Label => 'Lovers2')) {
15176          ll(5) && do_log(5, 'contents lover (ccat=%s), '.
15177                             'changing final_destiny %d to PASS, recip %s',
15178                             $cc, $fd, $recip);
15179          $fd = D_PASS;  # change to D_PASS for content lovers
15180        } elsif ($fd == D_BOUNCE && ($sender eq '' || $msginfo->is_bulk) &&
15181                 ccat_maj($cc) == CC_BADH) {
15182          # have mercy on bad header section in mail from mailing lists and
15183          # in DSN: since a bounce for such mail will be suppressed, it is
15184          # probably better to just let a mail with a bad header section pass,
15185          # it is rather innocent
15186          my $is_bulk = $msginfo->is_bulk;
15187          do_log(1, 'allow bad header section from %s<%s> -> <%s>: %s, '.
15188                    'changing final_destiny %d to PASS',
15189            !$is_bulk ? '' : "($is_bulk) ",
15190            $sender, $recip, $bad_headers[0], $fd);
15191          $fd = D_PASS;  # change D_BOUNCE to D_PASS for CC_BADH
15192        } else {  # $fd != D_PASS, blocked
15193          $blocking_ccat = $cc; $final_destiny = $fd;
15194          my $cc_main = $r->contents_category;
15195          $cc_main = $cc_main->[0]  if $cc_main;
15196          if ($blocking_ccat eq $cc_main) {
15197            do_log(3, 'blocking contents category is (%s) for %s, '.
15198                      'final_destiny %d',
15199                      $blocking_ccat, $recip, $fd);
15200          } else {
15201            do_log(3, 'blocking ccat (%s) differs from ccat_maj=%s, %s, '.
15202                      'final_destiny %d',
15203                      $blocking_ccat, $cc_main, $recip, $fd);
15204          }
15205          last;  # first blocking wins, also skips turning on mangling
15206        }
15207        # topmost mangling reason wins
15208        if (!defined($to_be_mangled) && defined($mangle_map_ref)) {
15209          my $mangle_type =
15210            !ref($mangle_map_ref) ? $mangle_map_ref  # compatibility
15211                       : lookup2(0,$recip,$mangle_map_ref, Label=>'Mangling1');
15212          $to_be_mangled = $mangle_type  if $mangle_type ne '';
15213        }
15214      }
15215      $r->recip_destiny($final_destiny);
15216
15217      if (defined $blocking_ccat) {  # save a blocking contents category
15218        $r->blocking_ccat($blocking_ccat);
15219        # summarize per-recipient blocking_ccat to a message level
15220        my $msg_bl_ccat = $msginfo->blocking_ccat;
15221        if (!defined($msg_bl_ccat) || cmp_ccat($blocking_ccat,$msg_bl_ccat)>0)
15222          { $msginfo->blocking_ccat($blocking_ccat) }
15223      } else {  # defanging/mangling only has effect on passed mail
15224        # defang_all serves mostly for testing purposes and compatibility
15225        $to_be_mangled = 1  if !$to_be_mangled && c('defang_all');
15226        if ($to_be_mangled) {
15227          my $orig_to_be_mangled = $to_be_mangled;
15228          if ($to_be_mangled =~ /^(?:disclaimer|nulldisclaimer)\z/i) {
15229            # disclaimers can only go to mail originating from internal
15230            # networks - the 'allow_disclaimers' should (only) be enabled
15231            # by an appropriate policy bank, e.g. MYNETS and/or ORIGINATING
15232            if (!c('allow_disclaimers')) {
15233              $to_be_mangled = 0;  # not for remote or unauthorized clients
15234              do_log(5,"will not add disclaimer, allow_disclaimers is false");
15235            } else {
15236              my $rf = $msginfo->rfc2822_resent_from;
15237              my $rs = $msginfo->rfc2822_resent_sender;
15238              # disclaimers should only go to mail with 2822.From or
15239              # 2822.Sender or 2822.Resent-From or 2822.Resent-Sender
15240              # or 2821.mail_from address matching local domains
15241              if (!grep(defined($_) && $_ ne '' &&
15242                        lookup2(0,$_, ca('local_domains_maps')),
15243                      unique_list( (!$rf ? () : @$rf), (!$rs ? () : @$rs),
15244                                   @rfc2822_from, $rfc2822_sender, $sender))) {
15245                $to_be_mangled = 0;  # not for foreign 'Sender:' or 'From:'
15246                do_log(5,"will not add disclaimer, sender not local");
15247              } elsif (c('outbound_disclaimers_only') && $r->recip_is_local) {
15248                $to_be_mangled = 0;
15249                do_log(5, "will not add disclaimer, recipient is local");
15250              }
15251            }
15252          } else {  # defanging (not disclaiming)
15253            # defanging and other mail mangling/munging only applies to
15254            # incoming mail, i.e. for recipients matching local_domains_maps
15255            $to_be_mangled = 0  if !$r->recip_is_local;
15256          }
15257          # store a boolean or a mangling name (defang, disclaimer, ...)
15258          $r->mail_body_mangle($to_be_mangled)  if $to_be_mangled;
15259          ll(2) && do_log(2, "mangling %s: %s (was: %s), ".
15260            "discl_allowed=%d, <%s> -> <%s>", $to_be_mangled ? 'YES' : 'NO',
15261            $to_be_mangled, $orig_to_be_mangled, c('allow_disclaimers'),
15262            $sender, $recip);
15263        }
15264      }
15265
15266      # penpals_score is already accounted for in spam_level
15267      my $penpals_score = $r->recip_penpals_score;  # is zero or negative!
15268      if ($penpals_score && $penpals_score < 0) {
15269        # only for logging and statistics purposes
15270        my($do_tag2_nopp, $do_tag3_nopp, $do_kill_nopp) =
15271          map { !$whitelisted &&
15272                ($blacklisted ||
15273                 (defined($_) && $spam_level-$penpals_score >= $_) ) }
15274              ($tag2_level, $tag3_level, $kill_level);
15275        $do_tag2_nopp ||= $do_tag3_nopp;
15276        my $which = $do_kill_nopp && !$do_kill ? 'kill'
15277                  : $do_tag3_nopp && !$do_tag3 ? 'tag3'
15278                  : $do_tag2_nopp && !$do_tag2 ? 'tag2' : undef;
15279        if (defined $which) {
15280          snmp_count("PenPalsSavedFrom\u$which")  if $final_destiny==D_PASS;
15281          do_log(2, "penpals: PenPalsSavedFrom%s %.3f%.3f%s, <%s> -> <%s>",
15282                    "\u$which", $spam_level-$penpals_score, $penpals_score,
15283                    ($final_destiny==D_PASS ? '' : ', but mail still blocked'),
15284                    $sender, $recip);
15285        }
15286      }
15287
15288      if ($final_destiny == D_PASS) {
15289        # recipient wants this message, malicious or not
15290        do_log(5, "final_destiny PASS, recip %s", $recip);
15291      } else {  # recipient does not want this content
15292        do_log(5, "final_destiny %s, recip %s", $final_destiny, $recip);
15293        # supply RFC 3463 enhanced status codes, see also RFC 5248
15294        my $status = setting_by_given_contents_category(
15295          $blocking_ccat,
15296          { CC_VIRUS,       "554 5.7.0",
15297            CC_BANNED,      "554 5.7.0",
15298            CC_UNCHECKED,   "554 5.7.0",
15299            CC_SPAM,        "554 5.7.0",
15300            CC_SPAMMY,      "554 5.7.0",
15301            CC_BADH.",2",   "554 5.6.3",  # nonencoded 8-bit character
15302            CC_BADH,        "554 5.6.0",
15303            CC_OVERSIZED,   "552 5.3.4",
15304            CC_MTA,         "550 5.3.5",
15305            CC_CATCHALL,    "554 5.7.0",
15306          });
15307        my($statoverride,$softfailed); $softfailed = '';
15308        if ($status =~ /^[24]/) {  # just in case
15309          # keep unchanged
15310        } elsif ($final_destiny == D_TEMPFAIL) {
15311          $statoverride = '450';  # 5xx -> 450
15312        } elsif (c('soft_bounce')) {
15313          $statoverride = '450';  # 5xx -> 450
15314          $softfailed = ' (soft_bounce)';
15315          ll(5) && do_log(5, "soft_bounce: %s %s -> %s",
15316                            $final_destiny == D_DISCARD ? 'discard' : 'bounce',
15317                            $status, $statoverride);
15318        } elsif ($final_destiny == D_DISCARD) {
15319          $statoverride = '250';  # 5xx -> 250
15320        }
15321        if (defined $statoverride) {
15322          my $code = substr($statoverride,0,1); local($1,$2);
15323          $status =~ s{^\d(\d\d) \d(\.\d\.\d)}{$statoverride $code$2};
15324        }
15325        # get the custom smtp response reason text
15326        my $smtp_reason = setting_by_given_contents_category(
15327                            $blocking_ccat, cr('smtp_reason_by_ccat'));
15328        $smtp_reason = ''  if !defined $smtp_reason;
15329        if ($smtp_reason ne '') {
15330          my(%mybuiltins) = %builtins;  # make a local copy
15331          $smtp_reason = expand(\$smtp_reason, \%mybuiltins);
15332          $smtp_reason = !ref($smtp_reason) ? '' : $$smtp_reason;
15333          chomp($smtp_reason); $smtp_reason = sanitize_str($smtp_reason,1);
15334          # coarsely chop to a sane size, wrap_smtp_resp() will finely adjust
15335          substr($smtp_reason,450) = '...'  if length($smtp_reason) > 450+3;
15336        }
15337        my $response = sprintf("%s %s%s%s", $status,
15338          ($final_destiny == D_PASS     ? "Ok" :
15339           $final_destiny == D_DISCARD  ? "Ok, discarded" :
15340           $final_destiny == D_REJECT   ? "Reject" :
15341           $final_destiny == D_BOUNCE   ? "Bounce" :
15342           $final_destiny == D_TEMPFAIL ? "Temporary failure" :
15343                                          "Not ok ($final_destiny)" ),
15344          $softfailed,
15345          $smtp_reason eq '' ? '' : ', '.$smtp_reason);
15346        # the wrap_smtp_resp() will enforce the requirement in
15347        # RFC 5321 section 4.5.3.1.5 on a length of a reply line
15348        ll(4) && do_log(4, "blocking ccat=%s, SMTP response: %s",
15349                           $blocking_ccat,$response);
15350        $r->recip_smtp_response($response);
15351        $r->recip_done(1); # fake a delivery (confirm delivery to a bit bucket)
15352        # note that 5xx status rejects may later be converted to bounces
15353      }
15354    }
15355    section_time($which_section);
15356
15357    $which_section = "quar+notif";  $t0_sect = Time::HiRes::time;
15358    $zmq_obj->register_proc(2,0,'Q',$am_id)  if $zmq_obj;  # notify, quar
15359    $snmp_db->register_proc(2,0,'Q',$am_id)  if $snmp_db;
15360    do_notify_and_quarantine($msginfo, $virus_dejavu);
15361#   $which_section = "aux_quarantine";
15362#   do_quarantine($msginfo, undef, ['archive-files'], 'local:archive/%m');
15363#   do_quarantine($msginfo, undef, ['archive@localhost'], 'local:all-%m');
15364#   do_quarantine($msginfo, undef, ['sender-quarantine'], 'local:user-%m'
15365#                ) if lookup(0,$sender, ['user1@domain','user2@domain']);
15366#   section_time($which_section);
15367    $elapsed->{'TimeElapsedQuarantineAndNotify'} = Time::HiRes::time - $t0_sect;
15368
15369    if (defined $hold && $hold ne '')
15370      { do_log(-1, "NOTICE: HOLD reason: %s", $hold) }
15371
15372    # THIRD: now that we know what to do with it, do it! (deliver or bounce)
15373
15374    { # update Content*Msgs* counters
15375      my $ccat_name =
15376        $msginfo->setting_by_contents_category(\%ccat_display_names_major);
15377      my $counter_name = 'Content'.$ccat_name.'Msgs';
15378      snmp_count($counter_name);
15379      if ($msginfo->originating) {
15380        snmp_count($counter_name.'Originating');
15381      }
15382      if ($cnt_local > 0) {
15383        my $d = $msginfo->originating ? 'Internal' : 'Inbound';
15384        snmp_count($counter_name.$d);
15385      }
15386      if ($cnt_remote > 0) {
15387        my $d = $msginfo->originating ? 'Outbound' : 'OpenRelay';
15388        snmp_count($counter_name.$d);
15389      }
15390    }
15391
15392    # set $r->delivery_method according to forward_method_maps_by_ccat lookup
15393    # or defaults
15394    for my $r (@{$msginfo->per_recip_data}) {
15395      next  if defined($r->delivery_method);
15396      my $fwd_map = $r->setting_by_contents_category(
15397                                            cr('forward_method_maps_by_ccat'));
15398      my $fwd_m;
15399      $fwd_m = lookup2(0, $r->recip_addr, $fwd_map,
15400                       Label=>"forward_method")  if ref $fwd_map;
15401      $fwd_m = ''  if !defined $fwd_m;
15402      $r->delivery_method($fwd_m);
15403    }
15404    # a custom hook may change $r->delivery_method
15405    if (ref $custom_object) {
15406      $which_section = "custom-before_send";
15407      eval {
15408        $custom_object->before_send($conn,$msginfo);
15409        update_current_log_level();  1;
15410      } or do {
15411        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
15412        do_log(-1,"custom before_send error: %s", $eval_stat);
15413      };
15414      section_time($which_section);
15415    }
15416    if (ll(3)) {  # log delivery method by recipients
15417      my(%fwd_m_displ_log);
15418      for my $r (@{$msginfo->per_recip_data}) {
15419        my $fwd_m = $r->delivery_method;
15420        my $fwd_m_displ =
15421          !defined $fwd_m ? "undefined, mail will not be forwarded"
15422                   : map(ref eq 'ARRAY' ? '('.join(', ',@$_).')' : $_, $fwd_m);
15423        if (!$fwd_m_displ_log{$fwd_m_displ}) {
15424          $fwd_m_displ_log{$fwd_m_displ} = [ $r ];
15425        } else {
15426          push(@{$fwd_m_displ_log{$fwd_m_displ}}, $r);
15427        }
15428      }
15429      for my $log_msg (sort keys %fwd_m_displ_log) {
15430        do_log(3, "delivery method is %s, recips: %s", $log_msg,
15431          join(', ', map($_->recip_addr, @{$fwd_m_displ_log{$log_msg}})));
15432      }
15433    }
15434    my $bcc = $msginfo->setting_by_contents_category(cr('always_bcc_by_ccat'));
15435    if (defined $bcc && $bcc ne '') {
15436      my $recip_obj = Amavis::In::Message::PerRecip->new;
15437      $recip_obj->recip_addr_modified($bcc);
15438
15439      # leave recip_addr and recip_addr_smtp undefined to hide it from the log?
15440      $recip_obj->recip_addr($bcc);
15441      $recip_obj->recip_addr_smtp(qquote_rfc2821_local($bcc));  #****
15442
15443      $recip_obj->recip_is_local(
15444        lookup2(0, $bcc, ca('local_domains_maps')) ? 1 : 0);
15445      $recip_obj->recip_destiny(D_PASS);
15446      $recip_obj->dsn_notify(['NEVER']);
15447      $recip_obj->delivery_method(c('notify_method'));
15448      $recip_obj->contents_category($msginfo->contents_category);
15449    # $recip_obj->add_contents_category(CC_CLEAN,0);
15450      $msginfo->per_recip_data([@{$msginfo->per_recip_data}, $recip_obj]);
15451      do_log(2,"adding recipient - always_bcc: %s, delivery method %s",
15452               $bcc, $recip_obj->delivery_method);
15453    }
15454    my $hdr_edits = $msginfo->header_edits;
15455
15456    # to be delivered explicitly (not by an AM.PDP client)
15457    if (grep(!$_->recip_done && $_->delivery_method ne '',
15458             @{$msginfo->per_recip_data})) {  # forwarding is needed
15459      $which_section = "forwarding";  $t0_sect = Time::HiRes::time;
15460      $zmq_obj->register_proc(2,0,'F',$am_id)  if $zmq_obj;  # forwarding
15461      $snmp_db->register_proc(2,0,'F',$am_id)  if $snmp_db;
15462      $hdr_edits = add_forwarding_header_edits_common(
15463        $msginfo, $hdr_edits, $hold, $any_undecipherable,
15464        $virus_presence_checked, $spam_presence_checked);
15465      for (;;) {  # do the delivery, in batches if necessary
15466        my $r_hdr_edits = Amavis::Out::EditHeader->new;  # per-recip edits set
15467        $r_hdr_edits->inherit_header_edits($hdr_edits);
15468        my $done_all;
15469        my $recip_cl;  # ref to a list of recip objects needing same mail edits
15470
15471        # prepare header section edits, clusterize
15472        ($r_hdr_edits, $recip_cl, $done_all) =
15473          add_forwarding_header_edits_per_recip(
15474            $msginfo, $r_hdr_edits, $hold, $any_undecipherable,
15475            $virus_presence_checked, $spam_presence_checked, undef);
15476        last  if !@$recip_cl;
15477        $msginfo->header_edits($r_hdr_edits);  # store edits for this batch
15478
15479        # preserve information that may be changed by prepare_modified_mail()
15480        my($m_t,$m_tfn,$m_ofs) =
15481          ($msginfo->mail_text, $msginfo->mail_text_fn, $msginfo->skip_bytes);
15482        my(@m_dm) = map($_->delivery_method, @{$msginfo->per_recip_data});
15483        # mail body mangling/defanging/sanitizing
15484        my $body_modified =
15485          prepare_modified_mail($msginfo,$hold,$any_undecipherable,$recip_cl);
15486        # defanged_mime_entity have modified header edits, refetch just in case
15487        $r_hdr_edits = $msginfo->header_edits;
15488        if ($body_modified) {
15489          my $resend_m = c('resend_method');
15490          if (defined $resend_m && $resend_m ne '') {
15491            $_->delivery_method($resend_m)  for @{$msginfo->per_recip_data};
15492            do_log(3,"mail body mangling in effect, resend_m: %s", $resend_m);
15493          } else {
15494            do_log(3,"mail body mangling in effect");
15495          }
15496        }
15497        if (mail_dispatch($msginfo, 0, $dsn_per_recip_capable,
15498                          sub { my $r = $_[0]; grep($_ eq $r, @$recip_cl) })) {
15499          $point_of_no_return = 1;  # now past the point where mail was sent
15500        }
15501        # close and delete replacement file, if any
15502        my $tmp_fh = $msginfo->mail_text;  # replacement file, to be removed
15503        if ($tmp_fh && !$tmp_fh->isa('MIME::Entity') && $tmp_fh ne $m_t) {
15504          $tmp_fh->close or do_log(-1,"Can't close replacement: %s", $!);
15505          if (debug_oneshot()) {
15506            do_log(5, "defanging+debug, preserving %s",$msginfo->mail_text_fn);
15507          } else {
15508            unlink($msginfo->mail_text_fn)
15509              or do_log(-1,"Can't remove %s: %s", $msginfo->mail_text_fn, $!);
15510          }
15511        }
15512        # restore temporarily modified settings
15513        $msginfo->mail_text($m_t); $msginfo->mail_text_fn($m_tfn);
15514        $msginfo->skip_bytes($m_ofs);
15515        $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef);
15516        $_->delivery_method(shift @m_dm)  for @{$msginfo->per_recip_data};
15517        last  if $done_all;
15518      }
15519      # turn on CC_MTA in case of MTA trouble (e.g, rejected by MTA on fwding)
15520      for my $r (@{$msginfo->per_recip_data}) {
15521        my $smtp_resp = $r->recip_smtp_response;
15522        # skip successful deliveries and non- MTA-generated status codes
15523        next  if $smtp_resp =~ /^2/ || $r->recip_done != 2;
15524        my $min_ccat = $smtp_resp =~ /^5/ ? 2 : $smtp_resp =~ /^4/ ? 1 : 0;
15525        $r->add_contents_category(CC_MTA,$min_ccat);
15526        $msginfo->add_contents_category(CC_MTA,$min_ccat);
15527        my $blocking_ccat = sprintf("%d,%d", CC_MTA,$min_ccat);
15528        $r->blocking_ccat($blocking_ccat);
15529        $msginfo->blocking_ccat($blocking_ccat)
15530                                          if !defined($msginfo->blocking_ccat);
15531        my $fd_map_ref =
15532          $r->setting_by_contents_category(cr('final_destiny_maps_by_ccat'));
15533        my $final_destiny =
15534          !ref $fd_map_ref ? $fd_map_ref  # compatibility
15535                : lookup2(0, $r->recip_addr, $fd_map_ref, Label => 'Destiny3');
15536        $final_destiny = D_PASS  if !defined $final_destiny;
15537        if ($final_destiny == D_PASS) {
15538          # impossible to pass, change to tempfail or reject
15539          $final_destiny = $smtp_resp =~ /^5/ ? D_REJECT : D_TEMPFAIL;
15540        }
15541        $r->recip_destiny($final_destiny);
15542        local($1,$2);
15543        if ($smtp_resp !~ /^5/) {
15544          # keep unchanged
15545        } elsif ($final_destiny == D_DISCARD) {
15546          $smtp_resp =~ s{^\d(\d\d) \d(\.\d\.\d)}{250 2$2};  # 5xx -> 250
15547        } elsif (c('soft_bounce')) {
15548          do_log(5, "soft_bounce: (mta) %s -> 450", $smtp_resp);
15549          $smtp_resp =~ s{^\d(\d\d) \d(\.\d\.\d)}{450 4$2};  # 5xx -> 450
15550        }
15551        my $smtp_reason =  # get the custom smtp response reason text
15552          $r->setting_by_contents_category(cr('smtp_reason_by_ccat'));
15553        $smtp_reason = ''  if !defined $smtp_reason;
15554        if ($smtp_reason ne '') {
15555          my(%mybuiltins) = %builtins;  # make a local copy
15556          $smtp_reason = expand(\$smtp_reason, \%mybuiltins);
15557          $smtp_reason = !ref($smtp_reason) ? '' : $$smtp_reason;
15558          chomp($smtp_reason); $smtp_reason = sanitize_str($smtp_reason,1);
15559          # coarsely chop to a sane size, wrap_smtp_resp() will finely adjust
15560          substr($smtp_reason,450) = '...'  if length($smtp_reason) > 450+3;
15561        }
15562        $smtp_resp =~ /^(\d\d\d(?: \d\.\d\.\d)?)\s*(.*)\z/s;
15563        my $dis = $final_destiny == D_DISCARD ? ' Discarded' : '';
15564        # the wrap_smtp_resp() will enforce the requirement in
15565        # RFC 5321 section 4.5.3.1.5 on a length of a reply line
15566        $r->recip_smtp_response("$1$dis $smtp_reason, $2");
15567        $r->recip_done(1); # fake a delivery (confirm delivery to a bit bucket)
15568        # note that 5xx status rejects may later be converted to bounces
15569      }
15570      $msginfo->header_edits($hdr_edits); # restore original edits just in case
15571      $elapsed->{'TimeElapsedForwarding'} = Time::HiRes::time - $t0_sect;
15572    }
15573
15574    # AM.PDP or AM.CL (milter)
15575    if (grep(!$_->recip_done && $_->delivery_method eq '',
15576             @{$msginfo->per_recip_data})) {
15577      $which_section = "AM.PDP headers";
15578      $hdr_edits = add_forwarding_header_edits_common(
15579        $msginfo, $hdr_edits, $hold, $any_undecipherable,
15580        $virus_presence_checked, $spam_presence_checked);
15581      my $done_all;
15582      my $recip_cl;  # ref to a list of similar recip objects
15583      ($hdr_edits, $recip_cl, $done_all) =
15584        add_forwarding_header_edits_per_recip(
15585          $msginfo, $hdr_edits, $hold, $any_undecipherable,
15586          $virus_presence_checked, $spam_presence_checked, undef);
15587      if (c('enable_dkim_signing')) {  # add DKIM signatures
15588        my(@signatures) = Amavis::DKIM::dkim_make_signatures($msginfo,0);
15589        $msginfo->dkim_signatures_new(\@signatures)  if @signatures;
15590        for my $signature (@signatures) {
15591          my $s = $signature->as_string;
15592          local($1); $s =~ s{\015\012}{\n}gs; $s =~ s{\n+\z}{}gs;
15593          $s =~ s/^((?:DKIM|DomainKey)-Signature):[ \t]*//si;
15594          $hdr_edits->prepend_header($1, $s, 2);
15595        }
15596      }
15597      $msginfo->header_edits($hdr_edits);  # store edits (redundant)
15598      if (@$recip_cl && !$done_all) {
15599        do_log(-1, "AM.PDP: RECIPIENTS REQUIRE DIFFERENT HEADERS");
15600      };
15601    }
15602    prolong_timer($which_section);
15603
15604    if (ref $custom_object) {
15605      $which_section = "custom-after_send";
15606      eval {
15607        $custom_object->after_send($conn,$msginfo);
15608        update_current_log_level();  1;
15609      } or do {
15610        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
15611        do_log(-1,"custom after_send error: %s", $eval_stat);
15612      };
15613      section_time($which_section);
15614    }
15615
15616    $which_section = "delivery-notification";  $t0_sect = Time::HiRes::time;
15617    # generate a delivery status notification according to RFC 6522 & RFC 3464
15618    my($notification,$suppressed) = delivery_status_notification(
15619               $msginfo, $dsn_per_recip_capable, \%builtins,
15620               [$sender], 'dsn', undef, undef);
15621    my $ndn_needed;
15622    ($smtp_resp, $exit_code, $ndn_needed) =
15623      one_response_for_all($msginfo, $dsn_per_recip_capable,
15624                           $suppressed && !defined($notification) );
15625    do_log(4, "notif=%s, suppressed=%d, ndn_needed=%s, exit=%s, %s",
15626              defined $notification ? 'Y' : 'N',  $suppressed,
15627              $ndn_needed, $exit_code, $smtp_resp);
15628    section_time('prepare-dsn');
15629    if ($suppressed && !defined($notification)) {
15630      $msginfo->dsn_sent(2);  # would-be-bounced, but bounce was suppressed
15631    } elsif (defined $notification) {  # dsn needed, send delivery notification
15632      mail_dispatch($notification, 'Dsn', 0);
15633      my($n_smtp_resp, $n_exit_code, $n_dsn_needed) =
15634        one_response_for_all($notification, 0);  # check status
15635      if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) {  # dsn successful?
15636        $msginfo->dsn_sent(1);     # mark the message as bounced
15637        $point_of_no_return = 2;   # now past the point where DSN was sent
15638        build_and_save_structured_report($notification,'DSN');
15639      } elsif ($n_smtp_resp =~ /^4/) {
15640        die sprintf("temporarily unable to send DSN to <%s>: %s",
15641                    $msginfo->sender, $n_smtp_resp);
15642      } else {
15643        do_log(-1,"NOTICE: UNABLE TO SEND DSN to <%s>: %s",
15644                  $sender, $n_smtp_resp);
15645#       # if dsn cannot be sent, try to send it to postmaster
15646#       $notification->recips(['postmaster']);
15647#       # attempt double bounce
15648#       mail_dispatch($notification, 'Notif', 0);
15649      }
15650    # $notification->purge;
15651    }
15652    prolong_timer($which_section);
15653    $elapsed->{'TimeElapsedDSN'} = Time::HiRes::time - $t0_sect;
15654
15655    $which_section = "snmp-counters";  $t0_sect = Time::HiRes::time;
15656    { # increment appropriate InMsgsStatus* SNMP counters and do some sanity
15657      # checking along the way;  also sets $msginfo->actions_performed
15658      #
15659      my($err, %which_counts);
15660      my $orig = $msginfo->originating;
15661      my $dsn_sent = $msginfo->dsn_sent;  # 1=bounced, 2=suppressed
15662      for my $r (@{$msginfo->per_recip_data}) {
15663        my $which;
15664        my $done = $r->recip_done;   # 2=relayed to MTA, 1=faked deliv/quarant
15665        my $dest = $r->recip_destiny;
15666        my $resp_code = $smtp_resp;  # per-msg status (one_response_for_all)
15667        $resp_code = $r->recip_smtp_response  if $dsn_per_recip_capable;
15668        my $resp_class = substr($resp_code||'0', 0, 1);
15669        if (!$done) {
15670          $which = 'Accepted';
15671          my $fwd_m = $r->delivery_method;  # double-checking our sanity
15672          if (defined $fwd_m && $fwd_m ne '') {
15673            $err = "Recip not done, nonempty delivery method: $fwd_m";
15674          }
15675        } elsif ($resp_class !~ /^[245]\z/) {
15676          $err = "Bad response code: $resp_code";
15677        } elsif ($resp_class eq '4') {
15678          $which = 'TempFailed';
15679        } elsif ($resp_class eq '5' && $dest == D_REJECT) {
15680          $which = 'Rejected';
15681        } else {  # $resp_class eq '2' || $resp_class eq '5' && $dest!=D_REJECT
15682          # a 2xx SMTP response code is set both by internal Discard and
15683          # by a genuine successful delivery. To distinguish between the two
15684          # we need to check $r->recip_destiny
15685          if ($done == 2) {  # successful genuine forwarding
15686            $which = $r->recip_tagged ? 'RelayedTagged' : 'RelayedUntagged';
15687            $err = "Forwarded, but destiny not D_PASS? ($dest)"
15688              if $dest != D_PASS;
15689            $err = "Forwarded, but status not 2xx? ($resp_code)"
15690              if $resp_class ne '2';
15691          } elsif ($dest == D_DISCARD) {  # forwarded to a bit bucket
15692            $which = 'Discarded';
15693          } elsif ( $dest == D_BOUNCE ||
15694                   ($dest == D_REJECT && $resp_class eq '2') ) {
15695            if ($dsn_sent && $dsn_sent == 1) {
15696              $which = 'Bounced';  # genuine bounce (DSN) sent
15697            } elsif ($dsn_sent) {
15698              $which = 'NoBounce';  # bounce suppressed
15699            } else {  # sanity check
15700              $err = "To be bounced, but DSN was neither sent nor suppressed?";
15701            }
15702          } elsif ($dest == D_REJECT) {
15703            $which = 'Rejected';
15704            $err = "Rejected, but status not 5xx? ($resp_code)"
15705              if $resp_class ne '5';
15706          } else {  # sanity check
15707            $err = "Recip forwarding suppressed but not DISCARD?";
15708          }
15709        }
15710        $which = 'Unknown'  if !defined $which;
15711        $which_counts{$which}++;  # counts status without a direction
15712        $which_counts{'Relayed'}++  if $which eq 'RelayedTagged' ||
15713                                       $which eq 'RelayedUntagged';
15714        my $islocal = $r->recip_is_local;
15715        if ($orig) {
15716          if ($islocal) { $which_counts{$which.'Internal'}++ }
15717          else          { $which_counts{$which.'Outbound'}++ }
15718          $which_counts{$which.'Originating'}++;
15719        } else {
15720          if ($islocal) { $which_counts{$which.'Inbound'}++ }
15721          else          { $which_counts{$which.'OpenRelay'}++ }
15722        }
15723        do_log(0, "unexpected status/result, please verify: %s, %s",
15724                   $err, $r->recip_addr_smtp)  if defined $err;
15725      }
15726      my @which_list = sort keys %which_counts;
15727
15728      # prefer this status in the list first, before a 'Quarantined' entry;
15729      # ignore a plain status name without mail direction to reduce clutter;
15730      # ignore Originating, as it is always paired with Internal or Outbound
15731      $msginfo->actions_performed([])  if !$msginfo->actions_performed;
15732      unshift(@{$msginfo->actions_performed},
15733              map(/^RelayedUntagged(.*)/ ? "Relayed$1" : $_,  # short log name
15734              grep(/(?:Inbound|Internal|Outbound|OpenRelay)\z/, @which_list)));
15735
15736      snmp_count('InMsgsStatus'.$_)  for @which_list;
15737      ll(3) && do_log(3, 'status counters: InMsgsStatus{%s}',
15738                         join(',', @which_list));
15739    }
15740    prolong_timer($which_section);
15741
15742    # merge similar timing entries
15743    $elapsed->{'TimeElapsedSending'} = 0;
15744    $elapsed->{'TimeElapsedSending'} +=
15745      delete $elapsed->{$_}  for ('TimeElapsedQuarantineAndNotify',
15746                                  'TimeElapsedForwarding', 'TimeElapsedDSN');
15747
15748    $which_section = 'report';
15749    eval {  # protect the new code just in case
15750      # structured_report returns a string as perl characters (not octets)
15751      $report_ref = structured_report($msginfo); 1;
15752    } or do {
15753      chomp $@; do_log(-1,"structured_report failed: %s", $@);
15754    };
15755    section_time($which_section);
15756
15757    # generate customized log report at log level 0 - this is usually the
15758    # only log entry interesting to administrators during normal operation
15759    $which_section = 'main_log_entry';
15760    my(%mybuiltins) = %builtins;  # make a local copy
15761    { # do a per-message log entry
15762      # macro %T has overloaded semantics, ugly
15763      $mybuiltins{'T'} = $mybuiltins{'TESTSSCORES'};
15764      my($y,$n,$f) = delivery_short_report($msginfo);
15765      @mybuiltins{'D','O','N'} = ($y,$n,$f);
15766      if (ll(0)) {
15767        my $strr = expand(cr('log_templ'), \%mybuiltins);
15768        for my $logline (split(/[ \t]*\n/, $$strr)) {
15769          do_log(0, '%s', $logline)  if $logline ne '';
15770        }
15771      }
15772    }
15773    if (c('log_recip_templ') ne '') {  # do per-recipient log entries
15774      # redefine some macros with a by-recipient semantics
15775      my $j = 0;
15776      for my $r (@{$msginfo->per_recip_data}) {
15777        # recipient counter in macro %. may indicate to the template
15778        # that a per-recipient expansion semantics is expected
15779        $j++; $mybuiltins{'.'} = sprintf("%d",$j);
15780        my $recip = $r->recip_addr;
15781        my $qrecip_addr = scalar(qquote_rfc2821_local($recip));
15782        my $remote_mta  = $r->recip_remote_mta;
15783        my $smtp_resp   = $r->recip_smtp_response;
15784        $mybuiltins{'remote_mta'} = $remote_mta;
15785        $mybuiltins{'smtp_response'} = $smtp_resp;
15786        $mybuiltins{'remote_mta_smtp_response'} =
15787                                            $r->recip_remote_mta_smtp_response;
15788        $mybuiltins{'D'} = $mybuiltins{'O'} = $mybuiltins{'N'} = undef;
15789        if ($r->recip_destiny==D_PASS &&($smtp_resp=~/^2/ || !$r->recip_done)){
15790          $mybuiltins{'D'} = $qrecip_addr;
15791        } else {
15792          $mybuiltins{'O'} = $qrecip_addr;
15793          $mybuiltins{'N'} = sprintf("%s:%s\n   %s", $qrecip_addr,
15794                  ($remote_mta eq '' ?'' :" [$remote_mta] said:"), $smtp_resp);
15795        }
15796        my(@b);  @b = @{$r->banned_parts}  if defined $r->banned_parts;
15797        my $b_chopped = @b > 2;  @b = (@b[0,1],'...')  if $b_chopped;
15798        s/[ \t]{6,}/ ... /g  for @b;
15799        $mybuiltins{'banned_parts'} = \@b;         # list of banned parts
15800        $mybuiltins{'F'} = $r->banning_reason_short;  # just one name & comment
15801        $mybuiltins{'banning_rule_comment'} =
15802          !defined($r->banning_rule_comment) ? undef
15803                                        : unique_ref($r->banning_rule_comment);
15804        $mybuiltins{'banning_rule_rhs'} =
15805          !defined($r->banning_rule_rhs) ? undef
15806                                        : unique_ref($r->banning_rule_rhs);
15807        my $dn = $r->dsn_notify;
15808        $mybuiltins{'dsn_notify'} =
15809          uc(join(',', $sender eq '' ? 'NEVER' : !$dn ? 'FAILURE' : @$dn));
15810        my($tag_level,$tag2_level,$kill_level);
15811        if (!$r->bypass_spam_checks) {
15812          $tag_level  = lookup2(0,$recip, ca('spam_tag_level_maps'));
15813          $tag2_level = lookup2(0,$recip, ca('spam_tag2_level_maps'));
15814          $kill_level = lookup2(0,$recip, ca('spam_kill_level_maps'));
15815        }
15816        my $is_local = $r->recip_is_local;
15817        my $do_tag   = $r->is_in_contents_category(CC_CLEAN,1);
15818        my $do_tag2  = $r->is_in_contents_category(CC_SPAMMY);
15819        my $do_kill  = $r->is_in_contents_category(CC_SPAM);
15820        for ($do_tag,$do_tag2,$do_kill) { $_ = $_ ? 'Y' : '0' }  # normalize
15821        for ($is_local)                 { $_ = $_ ? 'L' : '0' }  # normalize
15822        for ($tag_level,$tag2_level,$kill_level) { $_ = 'x'  if !defined($_) }
15823        $mybuiltins{'R'} = $recip;
15824        $mybuiltins{'c'} = $mybuiltins{'SCORE'} = $mybuiltins{'STARS'} =
15825          sub { macro_score($msginfo, $j-1, @_) };  # info on one recipient
15826        $mybuiltins{'T'} = $mybuiltins{'TESTSSCORES'} = $mybuiltins{'TESTS'} =
15827          sub { macro_tests($msginfo, $j-1, @_)};   # info on one recipient
15828        $mybuiltins{'tag_level'} =         # replacement for deprecated %3
15829          !defined($tag_level)  ? '-' : 0+sprintf("%.3f",$tag_level);
15830        $mybuiltins{'tag2_level'} = $mybuiltins{'REQD'} =  # replacement for %4
15831          !defined($tag2_level) ? '-' : 0+sprintf("%.3f",$tag2_level);
15832        $mybuiltins{'kill_level'} =        # replacement for deprecated %5
15833          !defined($kill_level) ? '-' : 0+sprintf("%.3f",$kill_level);
15834        @mybuiltins{('0','1','2','k')} = ($is_local,$do_tag,$do_tag2,$do_kill);
15835        # macros %3, %4, %5 are deprecated, replaced by tag/tag2/kill_level
15836        @mybuiltins{('3','4','5')} = ($tag_level,$tag2_level,$kill_level);
15837
15838        $mybuiltins{'ccat'} =
15839          sub {
15840            my($name,$attr,$which) = @_;
15841            $attr = lc $attr;     # name | major | minor | <empty>
15842                                  # | is_blocking | is_nonblocking
15843                                  # | is_blocked_by_nonmain
15844            $which = lc $which;   # main | blocking | auto
15845            my $result = '';  my $blocking_ccat = $r->blocking_ccat;
15846            if ($attr eq 'is_blocking') {
15847              $result =  defined($blocking_ccat) ? 1 : '';
15848            } elsif ($attr eq 'is_nonblocking') {
15849              $result = !defined($blocking_ccat) ? 1 : '';
15850            } elsif ($attr eq 'is_blocked_by_nonmain') {
15851              if (defined($blocking_ccat)) {
15852                my $aref = $r->contents_category;
15853                $result = 1  if ref($aref) && @$aref > 0
15854                                && $blocking_ccat ne $aref->[0];
15855              }
15856            } elsif ($attr eq 'name') {
15857              $result =
15858                $which eq 'main' ?
15859                  $r->setting_by_main_contents_category(\%ccat_display_names)
15860              : $which eq 'blocking' ?
15861                  $r->setting_by_blocking_contents_category(
15862                                                        \%ccat_display_names)
15863              :   $r->setting_by_contents_category(     \%ccat_display_names);
15864            } else {  # attr = major, minor, or anything else returns a pair
15865              my($maj,$min) = ccat_split(
15866                                ($which eq 'blocking' ||
15867                                 $which ne 'main' && defined $blocking_ccat)
15868                                 ? $blocking_ccat : $r->contents_category);
15869              $result = $attr eq 'major' ? $maj
15870                 : $attr eq 'minor' ? sprintf("%d",$min)
15871                 : sprintf("(%d,%d)",$maj,$min);
15872            }
15873            $result;
15874          };
15875
15876        my $strr = expand(cr('log_recip_templ'), \%mybuiltins);
15877        for my $logline (split(/[ \t]*\n/, $$strr)) {
15878          do_log(0, "%s", $logline)  if $logline ne '';
15879        }
15880      }
15881    }
15882    section_time($which_section);
15883    prolong_timer($which_section);
15884
15885    if (defined $os_fingerprint && $os_fingerprint ne '') {
15886      $which_section = 'log_p0f';
15887      # log and collect statistics on contents type vs. OS
15888      my $spam_ham_thd = 2.0;   # reasonable threshold guesstimate
15889      local($1); my $os_short;  # extract operating system name when avail.
15890      $os_short = $1  if $os_fingerprint =~ /^([^,([]*)/;
15891      $os_short = $1  if $os_short =~ /^[ \t,-]*(.*?)[ \t,-]*\z/;
15892      my $snmp_counter_name;
15893      if ($os_short ne '') {
15894        $os_short = $1  if $os_short =~ /^(Windows [^ ]+|[^ ]+)/;  # drop vers.
15895        $os_short =~ s{[^0-9A-Za-z:./_+-]}{-}g; $os_short =~ s{\.}{,}g;
15896        $snmp_counter_name = $msginfo->setting_by_contents_category(
15897                  { CC_VIRUS,'virus', CC_BANNED,'banned',
15898                    CC_SPAM,'spam', CC_SPAMMY,'spammy', CC_CATCHALL,'clean' });
15899        if ($snmp_counter_name eq 'clean') {
15900          $snmp_counter_name = $max_spam_level <= $spam_ham_thd ?'ham' : undef;
15901        }
15902        if (defined $snmp_counter_name) {
15903          snmp_count("$snmp_counter_name.byOS.$os_short");
15904          if ($snmp_counter_name eq 'ham' &&
15905              $os_fingerprint =~ /^Windows XP(?![^(]*\b2000 SP)/) {
15906            do_log(3, 'Ham from Windows XP? Most weird! %s [%s] score=%.3f',
15907                      $mail_id||'', $cl_ip, $max_spam_level);
15908          }
15909        }
15910      }
15911      do_log(2, "OS_fingerprint: %s %s %s.%s - %s",
15912                $msginfo->client_addr, $max_spam_level,
15913                defined $snmp_counter_name ? $snmp_counter_name : 'x',
15914                $os_short, $os_fingerprint);
15915    }
15916
15917    if ($redis_storage && defined $msginfo->mail_id) {
15918      $which_section = 'redis-update';
15919      # save final information to Redis
15920      eval {
15921        $redis_storage->save_info_final($msginfo,$report_ref); 1;
15922      } or do {
15923        chomp $@; do_log(-1, 'save_info_final failed, Redis error: %s', $@);
15924      };
15925      section_time($which_section);
15926    }
15927
15928    if ($sql_storage && defined $msginfo->mail_id) {
15929      # save final information to SQL (if enabled)
15930      $which_section = 'sql-update';
15931      for (my $attempt=5; $attempt>0; ) {  # sanity limit on retries
15932        if ($sql_storage->save_info_final($msginfo,$report_ref)) {
15933          last;
15934        } elsif (--$attempt <= 0) {
15935          do_log(-2,"ERROR sql_storage: too many retries ".
15936                    "on storing final, info not saved");
15937        } else {
15938          do_log(2,"sql_storage: retrying on final, %d attempts remain",
15939                   $attempt);
15940          sleep(int(1+rand(3)));  # can't mix Time::HiRes::sleep with alarm
15941        }
15942      };
15943      section_time($which_section);
15944    }
15945
15946    if (ll(2)) {  # log SpamAssassin timing report if available
15947      my $sa_tim = $msginfo->supplementary_info('TIMING');
15948      if (defined $sa_tim && $sa_tim ne '') {
15949        my $sa_rusage = $msginfo->supplementary_info('RUSAGE-SA');
15950        if ($sa_rusage && @$sa_rusage) {
15951          local $1; my $sa_cpu_sum = 0; $sa_cpu_sum += $_ for @$sa_rusage;
15952          $sa_tim =~ s{^(total [0-9.]+ ms)}
15953                      {sprintf("[%s, cpu %.0f ms]", $1, $sa_cpu_sum*1000)}se;
15954        }
15955        do_log(2, "TIMING-SA %s", $sa_tim);
15956      }
15957    }
15958
15959    if ($snmp_db || $zmq_obj) {
15960      $which_section = 'update_snmp';
15961      my($log_lines, $log_entries_by_level_ref,
15962         $log_retries, $log_status_counts_ref) = collect_log_stats();
15963      snmp_count( ['LogLines', $log_lines, 'C64'] );
15964      my $log_entries_all_cnt = 0;
15965      for my $level_str (keys %$log_entries_by_level_ref) {
15966        my $level = 0+$level_str;
15967        my $cnt = $log_entries_by_level_ref->{$level_str};
15968        $log_entries_all_cnt += $cnt;
15969      # snmp_count( ['LogEntriesEmerg',   $cnt, 'C64'] );  # not in use
15970      # snmp_count( ['LogEntriesAlert',   $cnt, 'C64'] );  # not in use
15971        snmp_count( ['LogEntriesCrit',    $cnt, 'C64'] )  if $level <= -3;
15972        snmp_count( ['LogEntriesErr',     $cnt, 'C64'] )  if $level <= -2;
15973        snmp_count( ['LogEntriesWarning', $cnt, 'C64'] )  if $level <= -1;
15974        snmp_count( ['LogEntriesNotice',  $cnt, 'C64'] )  if $level <=  0;
15975        snmp_count( ['LogEntriesInfo',    $cnt, 'C64'] )  if $level <=  1;
15976        snmp_count( ['LogEntriesDebug',   $cnt, 'C64'] );
15977        if    ($level < 0) { $level_str = "0" }
15978        elsif ($level > 5) { $level_str = "5" }
15979        snmp_count( ['LogEntriesLevel'.$level_str, $cnt, 'C64'] );
15980      }
15981      snmp_count( ['LogEntries', $log_entries_all_cnt, 'C64'] );
15982      if ($log_retries > 0) {
15983        snmp_count( ['LogRetries', $log_retries, 'C64'] );
15984        do_log(3,"Syslog retries: %d x %s", $log_status_counts_ref->{$_}, $_)
15985          for (keys %$log_status_counts_ref);
15986      }
15987      snmp_count( ['entropy',0,'STR'] );
15988      $elapsed->{'TimeElapsedTotal'} = Time::HiRes::time - $msginfo->rx_time;
15989      # Will end up as SNMPv2-TC TimeInterval (INTEGER), units of 0.01 seconds,
15990      # but we keep it in milliseconds in the bdb database!
15991      # Note also the use of C32 instead of INT, we want cumulative time.
15992      snmp_count([$_, int(1000*$elapsed->{$_}+0.5), 'C32']) for keys %$elapsed;
15993      $snmp_db->update_snmp_variables  if $snmp_db;
15994      $zmq_obj->update_snmp_variables  if $zmq_obj;
15995      section_time($which_section);
15996    }
15997    if (ref $custom_object) {
15998      $which_section = "custom-mail_done";
15999      eval {
16000        $custom_object->mail_done($conn,$msginfo);
16001        update_current_log_level();  1;
16002      } or do {
16003        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
16004        do_log(-1,"custom mail_done error: %s", $eval_stat);
16005      };
16006      section_time($which_section);
16007    }
16008    $which_section = 'finishing';
16009    1;
16010  } or do {
16011    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
16012    $preserve_evidence = 1  if $allow_preserving_evidence;
16013    my $msg = "$which_section FAILED: $eval_stat";
16014    if ($point_of_no_return) {
16015      do_log(-2, "TROUBLE in check_mail, but must continue (%s): %s",
16016                 $point_of_no_return, $msg);
16017    } else {
16018      do_log(-2, "TROUBLE in check_mail: %s", $msg);
16019      undef $smtp_resp;  # to be provided below
16020    }
16021    if (!defined($smtp_resp)) {
16022      $smtp_resp = "451 4.5.0 Error in processing, id=$am_id, $msg";
16023      $exit_code = EX_TEMPFAIL;
16024      for my $r (@{$msginfo->per_recip_data}) {
16025        next if $r->recip_done;
16026        $r->recip_smtp_response($smtp_resp); $r->recip_done(1);
16027      }
16028    }
16029  };
16030
16031# if (defined $hold && $hold ne '') {
16032#   do_log(-1, "NOTICE: Evidence is to be preserved: %s", $hold);
16033#   $preserve_evidence = 1  if $allow_preserving_evidence;
16034# }
16035  if (!$preserve_evidence && debug_oneshot()) {
16036    do_log(0, "DEBUG_ONESHOT CAUSES EVIDENCE TO BE PRESERVED");
16037    $preserve_evidence = 1;  # regardless of $allow_preserving_evidence
16038  }
16039  if ($redis_storage &&
16040      $redis_logging_queue_size_limit && c('redis_logging_key') ) {
16041    if ($report_ref) {  # already have it
16042      # last-minute update of the "elapsed" field
16043      structured_report_update_time($report_ref);
16044    } else {  # prepare the log report
16045      eval {  # protect the new code just in case
16046        # structured_report returns a string as perl characters (not octets)
16047        $report_ref = structured_report($msginfo); 1;
16048      } or do {
16049        chomp $@; do_log(-1, 'structured_report failed: %s', $@);
16050      };
16051    }
16052    eval {
16053      $redis_storage->save_structured_report($report_ref,
16054        c('redis_logging_key'), $redis_logging_queue_size_limit); 1;
16055    } or do {
16056      chomp $@; do_log(-1, 'save_structured_report failed: %s', $@);
16057    };
16058  }
16059  $zmq_obj->register_proc(1,0,'.')  if $zmq_obj;  # content checking done
16060  $snmp_db->register_proc(1,0,'.')  if $snmp_db;
16061  do_log(-1, "signal: %s", join(', ',keys %got_signals))  if %got_signals;
16062  undef $MSGINFO;  # release global reference
16063  ($smtp_resp, $exit_code, $preserve_evidence);
16064} # end check_mail
16065
16066# ROT13 obfuscation (Caesar cipher)
16067#   (possibly useful as a weak privacy measure when analyzing logs)
16068#
16069sub rot13 {
16070  my $str = $_[0];
16071  $str =~ tr/a-zA-Z/n-za-mN-ZA-M/;
16072  $str;
16073}
16074
16075# Assemble a structured report, suitable for JSON serialization, useful
16076# in save_info_final(). Resulting string is in Perl logical characters
16077# (not necessarily with UTF8 flag set if all-ASCII).
16078#
16079sub structured_report($;$) {
16080  my($msginfo, $notification_type) = @_;
16081
16082  my(@recipients);      # per-recipient records
16083  my(@queued_as_list);  # list of unique MTA queue IDs of forwarded mail
16084  my(@smtp_status_code_list);  # list of unique SMTP responses
16085  my(@destiny_list);    # list of destiny names
16086  my(@mail_id_related); # list of related mail_id's according to penpals
16087  my(%spam_test_names);
16088  my $true = Amavis::JSON::boolean(1);
16089  local($1,$2);
16090
16091  my $sender_smtp = $msginfo->sender_smtp;
16092  $sender_smtp =~ s/^<(.*)>\z/$1/s;
16093  my(@rcpt_smtp) = map($_->recip_addr_smtp, @{$msginfo->per_recip_data});
16094  s/^<(.*)>\z/$1/s  for @rcpt_smtp;
16095
16096  my $h_sender = $msginfo->rfc2822_sender; # undef or scalar
16097  my $h_from   = $msginfo->rfc2822_from;   # undef, scalar or listref
16098  my $h_to     = $msginfo->rfc2822_to;     # undef, scalar or listref
16099  my $h_cc     = $msginfo->rfc2822_cc;     # undef, scalar or listref
16100  my(@arr_h_from, @arr_h_to, @arr_h_cc);
16101  @arr_h_from = ref $h_from ? @$h_from : $h_from  if defined $h_from;
16102  @arr_h_to   = ref $h_to   ? @$h_to   : $h_to    if defined $h_to;
16103  @arr_h_cc   = ref $h_cc   ? @$h_cc   : $h_cc    if defined $h_cc;
16104
16105  # Message-ID can contain an international domain name with A-labels
16106  my(@arr_m_id, @arr_refs);
16107  my $m_id = $msginfo->get_header_field_body('message-id');
16108  @arr_m_id = parse_message_id($m_id)  if defined $m_id && $m_id ne '';
16109  my $h_refs = $msginfo->references;
16110  @arr_refs = @$h_refs  if $h_refs;
16111  $_ = mail_addr_decode($_)  for (@arr_m_id, @arr_refs,
16112                                  $sender_smtp, @rcpt_smtp, $h_sender,
16113                                  @arr_h_from, @arr_h_to, @arr_h_cc);
16114  my $j = 0;
16115  for my $r (@{$msginfo->per_recip_data}) {
16116    my $recip_smtp = $rcpt_smtp[$j++];  # already processed for UTF-8
16117    my $orig_rcpt = $r->dsn_orcpt;  # RCPT command ORCPT option, RFC 3461
16118    if (defined $orig_rcpt) {
16119      my($addr_type, $addr) = orcpt_encode($orig_rcpt,1);  # to octets
16120      # is orcpt redundant?
16121      $orig_rcpt = defined $recip_smtp && $addr eq $recip_smtp ? undef
16122                     : safe_decode_utf8($addr);  # to characters
16123    }
16124    my $dest = $r->recip_destiny;
16125    my $resp = $r->recip_smtp_response;
16126    my $rem_smtp_resp = $r->recip_remote_mta_smtp_response;
16127    my($queued_as, $resp_code, $resp_code_enh);
16128    $queued_as = $1  if defined $rem_smtp_resp &&
16129                        $rem_smtp_resp =~ /\bqueued as ([0-9A-Za-z]+)$/;
16130    ($resp_code, $resp_code_enh) = ($1,$2)
16131      if $resp =~ /^(\d{3}) (?: [ \t]+ ([245] \. \d{1,3} \. \d{1,3}) \b)? /xs;
16132    my $d = $resp=~/^4/ ? 'TEMPFAIL'
16133         : ($dest==D_BOUNCE && $resp=~/^5/) ? 'BOUNCE'
16134         : ($dest!=D_BOUNCE && $resp=~/^5/) ? 'REJECT'
16135         : ($dest==D_DISCARD) ? 'DISCARD'
16136         : ($dest==D_PASS && ($resp=~/^2/ || !$r->recip_done))
16137             ? ($notification_type ? $notification_type : 'PASS') : '?';
16138    push(@destiny_list, $d);
16139    push(@smtp_status_code_list, $resp_code);
16140    push(@queued_as_list, $queued_as)  if defined $queued_as;
16141    my $rid = $r->recip_maddr_id;  # may be undefined
16142    my $o_rid = $r->recip_maddr_id_orig;  # may be undefined
16143    my $banning_reason_short = $r->banning_reason_short;
16144    my $spam_level = $r->spam_level;
16145    my $user_policy_id = $r->user_policy_id;
16146    my $ccat_blk_name =
16147      $r->setting_by_blocking_contents_category(\%ccat_display_names);
16148    my $ccat_main_name =
16149      $r->setting_by_main_contents_category(\%ccat_display_names);
16150    if (!defined $ccat_main_name ||
16151      # ($ccat_main_name =~ /^(?:Clean|CatchAll)\z/s) ||
16152        (defined $ccat_blk_name && $ccat_main_name eq $ccat_blk_name)) {
16153      # not worth reporting main ccat if the same as blocking ccat (or clean?)
16154      undef $ccat_main_name;
16155    }
16156    my $spam_tests = $r->spam_tests;  # arrayref of scalar refs
16157    if ($spam_tests) {
16158      for my $test_name_val (split(/,/,join(',',map($$_,@$spam_tests)))) {
16159        my($tname, $tscore) = split(/=/, $test_name_val, 2);
16160        $spam_test_names{$tname} = max($tscore, $spam_test_names{$tname});
16161      }
16162    }
16163    my $penpals_age = $r->recip_penpals_age; # penpals age in seconds, or undef
16164    my $penpals_related = $r->recip_penpals_related;
16165    push(@mail_id_related, $penpals_related) if defined $penpals_related;
16166
16167    my(%recip) = (
16168      rcpt_to => $recip_smtp,
16169      defined $orig_rcpt ? (rcpt_to_orig => $orig_rcpt) : (),
16170      defined $rid   ? (rid => $rid) : (),
16171      defined $o_rid ? (rid_orig => Amavis::JSON::numeric($o_rid)) : (),
16172      rcpt_is_local => Amavis::JSON::boolean($r->recip_is_local),
16173      defined $user_policy_id ? (sql_user_policy_id => $user_policy_id) : (),
16174      action => $d,  # i.e. destiny
16175      defined $resp          ? (smtp_response => $resp)  : (),
16176      defined $resp_code     ? (smtp_code => $resp_code) : (),
16177    # defined $resp_code_enh ? (smtp_code_enh => $resp_code_enh) : (),
16178      defined $queued_as     ? (queued_as => $queued_as) : (),
16179      !defined $spam_level ? ()
16180        : (spam_score => Amavis::JSON::numeric(sprintf("%.3f",$spam_level))),
16181      $r->recip_blacklisted_sender ? (blacklisted => $true) : (),
16182      $r->recip_whitelisted_sender ? (whitelisted => $true) : (),
16183      $r->bypass_virus_checks  ? (bypass_virus_checks  => $true) : (),
16184      $r->bypass_banned_checks ? (bypass_banned_checks => $true) : (),
16185      $r->bypass_spam_checks   ? (bypass_spam_checks   => $true) : (),
16186      defined $ccat_blk_name   ? (ccat_blocking => $ccat_blk_name) : (),
16187      defined $ccat_main_name  ? (ccat_main => $ccat_main_name) : (),
16188      $banning_reason_short ? (banning_reason => $banning_reason_short) : (),
16189      defined $penpals_related ? (mail_id_related => $penpals_related) : (),
16190      !defined $penpals_age ? ()
16191        : (penpals_age => Amavis::JSON::numeric(int($penpals_age))),
16192      # recip_tagged  # was tagged by address extension or Subject or X-Spam
16193    );
16194    push(@recipients, \%recip);
16195  }
16196
16197  my $q_type = $msginfo->quar_type;
16198  # only keep the first quarantine type used (e.g. ignore archival quar.)
16199  $q_type = $q_type->[0]  if ref $q_type;
16200
16201  my $q_to = $msginfo->quarantined_to;  # ref to a list of quar. locations
16202  if (!$q_to || !@$q_to) { undef $q_to }
16203  else {
16204    $q_to = $q_to->[0];  # keep only the first quarantine location
16205    $q_to =~ s{^\Q$QUARANTINEDIR\E/}{};  # strip directory name
16206  }
16207
16208  my($min_spam_level, $max_spam_level) =
16209    minmax(map($_->spam_level, @{$msginfo->per_recip_data}));
16210
16211  my(@test_names_spam_topdown) =
16212    sort { $spam_test_names{$b} <=> $spam_test_names{$a} }
16213    grep($spam_test_names{$_} > 0, keys %spam_test_names);
16214
16215  my(@test_names_ham_bottomup) =
16216    sort { $spam_test_names{$a} <=> $spam_test_names{$b} }
16217    grep($spam_test_names{$_} < 0, keys %spam_test_names);
16218
16219  my $useragent = $msginfo->get_header_field_body('user-agent');
16220  $useragent = $msginfo->get_header_field_body('x-mailer')  if !$useragent;
16221  $useragent =~ s/^\s*(.*?)\s*\z/$1/s  if $useragent;
16222  my $subj = $msginfo->get_header_field_body('subject');
16223  my $from = $msginfo->get_header_field_body('from');  # raw full field
16224  for ($subj,$from) {  # character set decoding, unfolding
16225    chomp; s/\n(?=[ \t])//gs; s/^[ \t]+//s; s/[ \t]+\z//s;  # unfold, trim
16226    $_ = safe_decode_mime($_);  # to logical characters
16227  }
16228
16229  my($conn, $src_ip, $dst_ip, $dst_port, $appl_proto);
16230  $conn = $msginfo->conn_obj;
16231  if ($conn) {  # MTA -> amavisd
16232    $src_ip = $conn->client_ip;      # immediate client IP addr, i.e. our MTA
16233    $dst_ip = $conn->socket_ip;      # IP address of our receiving socket
16234    $dst_port = $conn->socket_port;  # port number of our receiving socket
16235    $appl_proto = $conn->appl_proto; # protocol - the 'WITH' field
16236  }
16237  my $client_addr = $msginfo->client_addr;  # SMTP client -> MTA
16238  my $client_port = $msginfo->client_port;  # SMTP client -> MTA
16239  my $trace_ref = $msginfo->trace;  # "Received" trace entries (hashrefs)
16240  my $ip_trace_public = $msginfo->ip_addr_trace_public;  # "Received" IP trace
16241  my $checks_performed = $msginfo->checks_performed;
16242  $checks_performed = join(' ', grep($checks_performed->{$_},
16243                                     qw(V S H B F P D))) if $checks_performed;
16244  my $actions_performed = $msginfo->actions_performed;
16245  $actions_performed = join(' ', @$actions_performed) if $actions_performed;
16246  @destiny_list = unique_list(\@destiny_list);
16247  my $partition_tag = $msginfo->partition_tag;
16248  my $sid = $msginfo->sender_maddr_id;
16249  my $policy_bank_path = c('policy_bank_path');
16250  my $is_mlist = $msginfo->is_mlist;
16251  $is_mlist =~ s/^ml:(?=.)//s  if $is_mlist;  # strip ml: prefix
16252  my $os_fp = $msginfo->client_os_fingerprint;
16253  my $dsn_sent = $msginfo->dsn_sent;
16254  my $queue_id = $msginfo->queue_id;
16255  @queued_as_list = unique_list(\@queued_as_list);
16256  @smtp_status_code_list = unique_list(\@smtp_status_code_list);
16257
16258  my $dkim_author_sig = $msginfo->dkim_author_sig;
16259  my $dkim_sigs_new_ref = $msginfo->dkim_signatures_new;
16260  my $dkim_sigs_ref = $msginfo->dkim_signatures_valid;
16261  my(@dkim_sigs_valid, @dkim_sigs_new);  # domain names, IDN-decoded
16262  @dkim_sigs_valid = unique_list(map(idn_to_utf8($_->domain),
16263                                   @$dkim_sigs_ref)) if $dkim_sigs_ref;
16264  @dkim_sigs_new = unique_list(map(idn_to_utf8($_->domain),
16265                                   @$dkim_sigs_new_ref)) if $dkim_sigs_new_ref;
16266
16267  my $vn = $msginfo->virusnames;
16268  undef $vn  if $vn && !@$vn;
16269  my(%scanners_report);  # per-scanner report of virus names found
16270  if ($vn) {
16271    for (@av_scanners_results) {
16272      my($av, $status, @virus_names) = @$_;
16273      my $scanner = $av && $av->[0];
16274      if ($status && defined $scanner) {
16275        $scanner =~ tr/"/'/;  # sanitize scanner name for json
16276        $scanner =~ tr/\x00-\x1F\x7F\x80-\x9F\\/ /;
16277        $scanners_report{$scanner} = \@virus_names;
16278      }
16279    }
16280  }
16281
16282  my $rx_time = $msginfo->rx_time;
16283  my $mjd = $rx_time/86400 + 40587;  # Modified Julian Day, float
16284  my($iso8601_year, $iso8601_wn) = iso8601_year_and_week($rx_time);
16285
16286  my(%elapsed);
16287  if (!$notification_type) {
16288    my $elapsed_ref = $msginfo->time_elapsed;
16289    if ($elapsed_ref) {
16290      while (my($k,$v) = each(%$elapsed_ref)) {
16291        next if $k eq 'TimeElapsedPenPals';  # quick, don't bother
16292        $k =~ s/^TimeElapsed//;
16293        $elapsed{$k} = $v;  # cast to numeric later down
16294      }
16295    }
16296  }
16297
16298  my(%result) = (
16299    type => 'amavis',
16300    host => safe_decode_utf8(idn_to_utf8(c('myhostname'))),
16301    log_id => $msginfo->log_id,
16302  # secret_id => $msginfo->secret_id,
16303    mail_id => $msginfo->mail_id,
16304    !defined $msginfo->parent_mail_id ? () :
16305      (mail_id_parent => $msginfo->parent_mail_id),
16306    @mail_id_related ? (mail_id_related => \@mail_id_related) : (),
16307    defined $src_ip  ? (src_ip => $src_ip) : (),
16308    defined $dst_ip  ? (dst_ip => $dst_ip) : (),
16309    $dst_port ? (dst_port => Amavis::JSON::numeric($dst_port)) : (),
16310    defined $client_addr ? (client_ip => $client_addr) : (),
16311    $client_port ? (client_port => Amavis::JSON::numeric($client_port)) : (),
16312    defined $partition_tag ? (partition => $partition_tag) : (),
16313    defined $queue_id && $queue_id ne '' ? (queue_id => $queue_id) : (),
16314    defined $sid ? (sid => $sid) : (),
16315    defined $appl_proto ? (protocol => $appl_proto) : (),
16316
16317    # addresses from SMTP envelope:
16318    mail_from => $sender_smtp,
16319    rcpt_to  => \@rcpt_smtp,  # list of recipient addresses
16320    rcpt_num => Amavis::JSON::numeric(scalar @rcpt_smtp),  # num. of recips
16321    recipients => \@recipients,  # list of hashes
16322
16323    # addresses from mail header:
16324    !defined $h_sender ? () : (sender => $h_sender),
16325    $h_from       ? (author  => \@arr_h_from) : (),
16326    $h_to         ? (to_addr => \@arr_h_to) : (),
16327    $h_cc         ? (cc_addr => \@arr_h_cc) : (),
16328  # defined $from ? (from_raw => $from) : (),
16329    defined $subj ? (subject  => $subj) : (),
16330    defined $subj ? (subject_rot13 => rot13($subj)) : (),
16331
16332    defined $m_id ? (message_id => join(' ',@arr_m_id)) : (),
16333    @arr_refs     ? (references => \@arr_refs) : (),
16334
16335    defined $useragent ? (user_agent => $useragent) : (),
16336    !defined $policy_bank_path ? ()
16337                : (policy_banks => [ split(m{/}, $policy_bank_path) ]),
16338    $ip_trace_public ? (ip_trace => [ @$ip_trace_public ]) : (),
16339    !$trace_ref || !@$trace_ref ? ()
16340      : (ip_proto_trace => [ map( (!$_->{with} ? '' : $_->{with}.'://') .
16341                                  (!$_->{ip} ? 'x' : !$_->{port} ? $_->{ip}
16342                                     : '['.$_->{ip}.']:'.$_->{port}),
16343                                  @$trace_ref) ]),
16344    !$msginfo->msg_size ? ()
16345      : (size => Amavis::JSON::numeric(0+$msginfo->msg_size)),
16346    !$msginfo->body_digest ? ()
16347      : (digest_body => $msginfo->body_digest),
16348    content_type =>  # blocking ccat if blocked, main ccat otherwise
16349      $msginfo->setting_by_contents_category(\%ccat_display_names),
16350    defined $q_to   ? (quarantine => $q_to)   : (),
16351    defined $q_type ? (quar_type  => $q_type) : (),
16352    !defined $max_spam_level ? ()
16353      : (spam_score => Amavis::JSON::numeric(sprintf("%.3f",$max_spam_level))),
16354    $notification_type ? () : (dsn_sent => Amavis::JSON::boolean($dsn_sent==1)),
16355    originating => Amavis::JSON::boolean($msginfo->originating),
16356    defined $os_fp && $os_fp ne '' ? (os_fp => $os_fp) : (),
16357    defined $actions_performed ? (actions_performed => $actions_performed): (),
16358    defined $checks_performed  ? (checks_performed  => $checks_performed) : (),
16359    $vn ? (virusnames => unique_ref($vn)) : (),
16360    $vn ? (av_scan => \%scanners_report) : (),
16361  # %spam_test_names  ? (tests => { %spam_test_names }) : (),
16362    !%spam_test_names ? () : (
16363       tests => [ sort keys %spam_test_names ],  # alphabetically
16364       tests_spam => \@test_names_spam_topdown,  # > 0, largest first
16365       tests_ham  => \@test_names_ham_bottomup,  # < 0, smallest first
16366    ),
16367    $msginfo->is_auto ? (is_auto_resp => $true) : (), # is an auto-response
16368    $msginfo->is_mlist? (is_mlist => $true) : (), # is a mailing list
16369    $msginfo->is_bulk ? (is_bulk  => $true) : (), # bulk or m.list or auto-resp
16370    @dkim_sigs_valid  ? (dkim_valid_sig => \@dkim_sigs_valid) : (),
16371    @dkim_sigs_new    ? (dkim_new_sig   => \@dkim_sigs_new)   : (),
16372    defined $dkim_author_sig ? (dkim_author_sig => $dkim_author_sig) : (),
16373    !@smtp_status_code_list ? () : (smtp_code => \@smtp_status_code_list),
16374    !@queued_as_list        ? () : (queued_as => \@queued_as_list),
16375    action => \@destiny_list,
16376    message =>  # a brief report
16377      sprintf("%s %s %s %s -> %s",
16378              $msginfo->log_id,  join(',', @destiny_list),
16379              $msginfo->setting_by_contents_category(\%ccat_display_names),
16380              $sender_smtp, join(',', @rcpt_smtp)),
16381    time_unix =>  # UNIX time to millisecond precision
16382      Amavis::JSON::numeric(sprintf("%.3f", $rx_time)),
16383  # time_mjd =>   # Modified Julian Day to millisecond precision
16384  #   Amavis::JSON::numeric(sprintf("%14.8f", $mjd)),
16385    '@timestamp' => iso8601_utc_timestamp($rx_time,undef,undef,1,1),
16386    time_iso_week_date => sprintf("%04d-W%02d-%d",
16387                            $iso8601_year,  # ISO week-numbering year
16388                            $iso8601_wn,    # ISO week number 1..53
16389                            iso8601_weekday($rx_time)), # 1..7, Mo=1, localtime
16390    !%elapsed ? () : (elapsed => \%elapsed),
16391  );
16392  if (%elapsed) {
16393    # last-minute update of total elapsed time, cast to numeric
16394    my $el = $result{elapsed};
16395    $el->{Total} = get_time_so_far();
16396    $el->{Amavis} = $el->{Total}-($el->{SpamCheck}||0)-($el->{VirusCheck}||0);
16397    $el->{$_} = Amavis::JSON::numeric(sprintf("%.3f",$el->{$_})) for keys %$el;
16398  }
16399  \%result;
16400}
16401
16402# Last-minute update of total elapsed time
16403#
16404sub structured_report_update_time($) {
16405  my $report_ref = $_[0];
16406  if ($report_ref->{elapsed}) {
16407    # just Total, does not adjust $report_ref->{elapsed}{Amavis}
16408    $report_ref->{elapsed}{Total} =
16409      Amavis::JSON::numeric(sprintf("%.3f", get_time_so_far()));
16410  }
16411  $report_ref;
16412}
16413
16414sub build_and_save_structured_report($$) {
16415  my($msginfo, $notification_type) = @_;
16416  if ($redis_storage &&
16417      $redis_logging_queue_size_limit && c('redis_logging_key') ) {
16418    do_log(5,'build_and_save_structured_report on %s', $notification_type);
16419    eval {  # protect the new code just in case
16420      $redis_storage->save_structured_report(
16421        structured_report($msginfo, $notification_type),
16422        c('redis_logging_key'), $redis_logging_queue_size_limit);
16423      1;
16424    } or do {
16425      chomp $@; do_log(-1, 'save_structured_report failed: %s', $@);
16426    };
16427  }
16428}
16429
16430# Ensure we have $msginfo->$entity defined when we expect we'll need it,
16431#
16432sub ensure_mime_entity($) {
16433  my $msginfo = $_[0];
16434  my($ent,$mime_err);
16435  if (!defined($msginfo->mime_entity)) {
16436    my $msg = $msginfo->mail_text;
16437    if (IO::File->VERSION >= 1.10) {  # see mime_decode() for explanation
16438      my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
16439      $msg = $msg_str_ref  if ref $msg_str_ref;
16440    }
16441    ($ent,$mime_err) = mime_decode($msg, $msginfo->mail_tempdir,
16442                                   $msginfo->parts_root);
16443    $msginfo->mime_entity($ent);
16444    prolong_timer('mime_decode');
16445  }
16446  $mime_err;
16447}
16448
16449# Check if a message is a bounce, and if it is, try to obtain essential
16450# information from a header section of an attached original message,
16451# primarily the Message-ID.
16452#
16453sub inspect_a_bounce_message($) {
16454  my $msginfo = $_[0];
16455  my(%header_field,$bounce_type); my $is_true_bounce = 0;
16456  my $parts_root = $msginfo->parts_root;
16457  if (!defined($parts_root)) {
16458    do_log(5, 'inspect_dsn: no parts root');
16459  } else {
16460    my $sender = $msginfo->sender;
16461    my $structure_type = '?';
16462    my $top_main; my $top = $parts_root->children;
16463    for my $e (!$top ? () : @$top) {
16464      # take a main message component, ignoring preamble/epilogue MIME parts
16465      # and pseudo components such as a fabricated 'MAIL' (i.e. a copy of
16466      # entire message for the benefit of some virus scanners)
16467      my($name, $type) = ($e->name_declared, $e->type_declared);
16468      next if !defined $type && defined $name &&
16469              ($name eq 'preamble' || $name eq 'epilogue');
16470      next if $e->type_short eq 'MAIL' && defined $type &&
16471              $type =~ m{^message/(?:rfc822|global)\z}si;
16472      $top_main = $e; last;
16473    }
16474    my(@parts); my $fname_ind; my $plaintext = 0;
16475    if (defined $top_main) {  # one level only
16476      my $ch = $top_main->children;
16477      @parts = ($top_main, !$ch ? () : @$ch);
16478    }
16479    my(@t) =
16480      map { my $t = $_->type_declared; lc(ref $t ? $t->[0] : $t) } @parts;
16481    ll(5) && do_log(5, "inspect_dsn: parts: %s", join(", ",@t));
16482    my $fm = $msginfo->rfc2822_from;
16483    my(@rfc2822_from) = !defined $fm ? () : ref $fm ? @$fm : $fm;
16484    my $p0_report_type;
16485    $p0_report_type = $parts[0]->report_type  if @parts;
16486    $p0_report_type = lc $p0_report_type  if defined $p0_report_type;
16487
16488    if (  @parts >= 2 && @parts <= 4  &&
16489          $t[0] eq 'multipart/report' &&                         # RFC 6522
16490        ( $t[2] eq 'message/delivery-status' ||                  # RFC 3464
16491          $t[2] eq 'message/global-delivery-status' ||           # RFC 6533
16492          $t[2] eq 'message/disposition-notification' ||         # RFC 3798
16493          $t[2] eq 'message/global-disposition-notification' ||  # RFC 6533
16494          $t[2] eq 'message/feedback-report'                     # RFC 5965
16495        ) &&
16496          defined $p0_report_type && $t[2] eq 'message/'.$p0_report_type &&
16497          $t[3] =~ m{^ (?: text/rfc822-headers |                 # RFC 6522
16498                           message/(?: rfc822-headers | global-headers |
16499                                       rfc822 | global | partial )) \z}xs
16500          # message/rfc822-headers and message/partial are nonstandard
16501       )
16502    { # standard DSN or MDN or feedback-report
16503      $bounce_type = $t[2] eq 'message/disposition-notification'        ? 'MDN'
16504                   : $t[2] eq 'message/global-disposition-notification' ? 'MDN'
16505                   : $t[2] eq 'message/feedback-report' ? 'ARF' : 'DSN';
16506      $structure_type = 'standard ' . $bounce_type;
16507      $fname_ind = $#parts; $is_true_bounce = 1;
16508
16509    } elsif ( @parts == 5 &&
16510          $t[0]  eq 'multipart/report' &&
16511          $t[-2] eq 'message/delivery-status' &&
16512          defined $p0_report_type && $t[-2] eq 'message/'.$p0_report_type &&
16513          $t[-1] =~ m{^ (?: text/rfc822-headers |
16514                            message/(?: global-headers|rfc822|global )) \z}xs
16515       ) {  # almost standard DSN, has two leading plain text parts
16516      $bounce_type = 'DSN';  # BorderWare Security Platform
16517      $structure_type = 'standard ' . $bounce_type;
16518      $fname_ind = $#parts; $is_true_bounce = 1;
16519
16520    } elsif (  @parts >= 2 && @parts <= 4  &&
16521          $t[0] eq 'multipart/report' &&
16522          $t[2] eq 'message/delivery-status' &&
16523          defined $p0_report_type && $t[2] eq 'message/'.$p0_report_type &&
16524          $t[3] eq 'text/plain' ) {
16525      # nonstandard DSN, missing header, unless it is stashed in text/plain
16526      $fname_ind = 3; $structure_type = 'nostandard DSN-plain';
16527      $plaintext = 1; $bounce_type = 'DSN';
16528
16529    } elsif (@parts >= 3 && @parts <= 4 &&  # a root with 2 or 3 leaves
16530          $t[0] eq 'multipart/report' &&
16531          defined $p0_report_type && $p0_report_type eq 'delivery-status' &&
16532          $t[-1] =~ m{^ (?: text/rfc822-headers |
16533                            message/(?: global-headers|rfc822|global )) \z}xs)
16534    { # not quite std. DSN (missing message/delivery-status), but recognizable
16535      $fname_ind = -1; $is_true_bounce = 1; $bounce_type = 'DSN';
16536      $structure_type = 'DSN, missing delivery-status part';
16537
16538    } elsif (@parts >= 3 && @parts <= 5 &&
16539          $t[0] eq 'multipart/mixed' &&
16540          $t[-1] =~ m{^ (?: text/rfc822-headers |
16541                            message/(?: global-headers|rfc822|global|
16542                                        rfc822-headers )) \z}xs &&
16543        ( $rfc2822_from[0] =~ /^MAILER-DAEMON(?:\@|\z)/si ||
16544          $msginfo->get_header_field_body('subject') =~
16545                        /\b(?:Delivery Failure Notification|failure notice)\b/
16546        ) ) {
16547      # qmail, msn?, mailman, C/R
16548      $fname_ind = -1;
16549      $structure_type = 'multipart/mixed(' . $msginfo->is_bulk . ')';
16550
16551    } elsif ( $msginfo->is_auto && $sender eq '' &&
16552                                # notify@yahoogroups.com notify@yahoogroupes.fr
16553              $rfc2822_from[0] =~ /^notify\@yahoo/si &&
16554              @parts >= 3 && @parts <= 5 &&
16555              $t[0] eq 'multipart/mixed' &&
16556              $t[-1] =~ m{^ (?: text/rfc822-headers |
16557                                message/(?: global-headers|rfc822|global ))
16558                          \z}xs ) {
16559      $fname_ind = -1;
16560      $structure_type = 'multipart/mixed(yahoogroups)';
16561
16562    } elsif ( $msginfo->is_auto && $sender eq '' &&
16563              @parts == 1 && $t[0] ne 'multipart/report' &&
16564              $rfc2822_from[0] =~ /^(?:MAILER-DAEMON|postmaster)(?:\@|\z)/si
16565            ) {
16566      # nonstructured, possibly a non-standard bounce (qmail, gmail.com, ...)
16567      $fname_ind = 0; $plaintext = 1;
16568      $structure_type = 'nonstructured(' . $msginfo->is_auto . ')';
16569
16570#   } elsif ( $msginfo->is_auto && $sender eq '' &&
16571#             ( grep($_->recip_addr eq 'xxx@example.com',  # victim
16572#                    @{$msginfo->per_recip_data}) ) ) {
16573#     # nonstructured, possibly a non-standard bounce
16574#     $fname_ind = 0; $plaintext = 1; $is_true_bounce = 1;
16575#     $structure_type = 'nonstructured, unknown';
16576#     $bounce_type = 'INFO';
16577
16578#   } elsif (@parts == 3 &&
16579#         $t[0] eq 'multipart/mixed' &&
16580#         $t[-1] eq 'application/octet-stream' &&
16581#         $parts[-1]->name_declared =~ /\.eml\z/) {
16582#     # MDaemon;  too permissive! test for postmaster or mailer-daemon ?
16583#     $fname_ind = -1;
16584#     $structure_type = 'multipart/mixed with binary .eml';
16585#   } elsif ( $msginfo->is_auto && @parts == 2 &&
16586#             $t[0] eq 'multipart/mixed' && $t[1] eq 'text/plain' ) {
16587#     # nonstructured, possibly a broken bounce
16588#     $fname_ind = 1; $plaintext = 1;
16589#     $structure_type = $t[0] .' with '. $t[1] .'(' . $msginfo->is_auto .')';
16590#   } elsif ( $msginfo->is_auto && @parts == 3 &&
16591#             $t[0] eq 'multipart/alternative' &&
16592#             $t[1] eq 'text/plain' && $t[2] eq 'text/html' ) {
16593#     # text/plain+text/html, possibly a challenge CR message
16594#     $fname_ind = 1; $plaintext = 1;
16595#     $structure_type = $t[0] .' with '. $t[1] .'(' . $msginfo->is_auto .')';
16596    }
16597
16598    if (defined $fname_ind && defined $parts[$fname_ind]) {
16599      # we probably have a header section from original mail, scan it
16600      $fname_ind = $#parts  if $fname_ind == -1;
16601      my $fname = $parts[$fname_ind]->full_name;
16602      ll(5) && do_log(5,'inspect_dsn: struct: "%s", basenm(%s): %s, fname: %s',
16603        $structure_type, $fname_ind, $parts[$fname_ind]->base_name, $fname);
16604      if (defined $fname) {
16605        my(%collectable_header_fields);
16606        $collectable_header_fields{lc($_)} = 1
16607          for qw(From To Return-Path Message-ID Date Received Subject
16608                 MIME-Version Content-Type);
16609        my $fh = IO::File->new;
16610        $fh->open($fname,'<') or die "Can't open file $fname: $!";
16611        binmode($fh,':bytes') or die "Can't cancel :utf8 mode: $!";
16612        my $have_header_fields_cnt = 0; my $nonheader_cnt = 0;
16613        my($curr_head,$ln); my $nr = 0; my $eof = 0; local($1,$2);
16614        my $line_limit = $plaintext ? 200 : 1000;
16615        for (;;) {
16616          if ($eof) {
16617            $ln = "\n";  # fake a missing header/body separator line
16618          } else {
16619            $! = 0; $ln = $fh->getline;
16620            if (!defined($ln)) {
16621              $eof = 1; $ln = "\n";
16622              $! == 0  or                # returning EBADF at EOF is a perl bug
16623                $! == EBADF ? do_log(1,"Error reading mail header section: $!")
16624                            : die "Error reading mail header section: $!";
16625            }
16626          }
16627          last  if ++$nr > $line_limit;  # safety measure
16628          if ($ln =~ /^[ \t]/) {  # folded
16629            $curr_head .= $ln  if length($curr_head) < 2000;  # safety measure
16630          } else {  # a new header field, process previous if any
16631            if (defined $curr_head) {
16632              $curr_head =~ s/^[> ]+//  if $plaintext;
16633              # be more conservative on accepted h.f.name than RFC 5322 allows
16634              # the '_' and '.' are quite rare, digits even rarer;
16635              # the longest non-X h.f.name is content-transfer-encoding (25)
16636              # the longest h.f.names in the wild are 59 chars, largest ever 77
16637              if ($curr_head !~ /^([a-zA-Z0-9._-]{1,60})[ \t]*:(.*)\z/s) {
16638                $nonheader_cnt++;
16639              } else {
16640                my $hfname = lc($1);
16641                if ($collectable_header_fields{$hfname}) {
16642                  $have_header_fields_cnt++  if !exists $header_field{$hfname};
16643                  $header_field{$hfname} = $2;
16644                }
16645              }
16646            }
16647            $curr_head = $ln;
16648            if (!$plaintext) {
16649              last  if $ln eq "\n" || substr($ln,0,2) eq '--';
16650            } elsif ($ln =~ /^\s*$/ || substr($ln,0,2) eq '--') {
16651              if (exists $header_field{'from'} &&
16652                  $have_header_fields_cnt >= 4 && $nonheader_cnt <= 1) {
16653                last;
16654              } else {  # reset, hope for the next paragraph to be a header
16655                $have_header_fields_cnt = 0; $nonheader_cnt = 0;
16656                %header_field = (); $curr_head = undef;
16657              }
16658            }
16659          }
16660        }
16661        defined $ln || $! == 0  or    # returning EBADF at EOF is a perl bug
16662          $! == EBADF ? do_log(1,"Error reading from %s: %s", $fname,$!)
16663                      : die "Error reading from $fname: $!";
16664        $fh->close or die "Error closing $fname: $!";
16665        my $thd = exists $header_field{'message-id'} ? 3 : 5;
16666        $is_true_bounce = 1  if exists $header_field{'from'} &&
16667                                $have_header_fields_cnt >= $thd;
16668        if ($is_true_bounce) {
16669          ll(5) && do_log(5, "inspect_dsn: plain=%s, got %d: %s",
16670                             $plaintext?"Y":"N", scalar(keys %header_field),
16671                             join(", ", sort keys %header_field));
16672          for (@header_field{keys %header_field})
16673            { s/\n(?=[ \t])//gs; s/^[ \t]+//; s/[ \t\n]+\z// }
16674          if (!defined($header_field{'message-id'}) &&
16675              $have_header_fields_cnt >= 5 && $nonheader_cnt <= 1) {
16676            $header_field{'message-id'} = '';  # fake: defined but empty
16677            do_log(5, "inspect_dsn: a header section with no Message-ID");
16678          } elsif (defined($header_field{'message-id'})) {
16679            $header_field{'message-id'} =
16680              (parse_message_id($header_field{'message-id'}))[0]
16681              if defined $header_field{'message-id'};
16682          }
16683        }
16684        section_time("inspect_dsn");
16685      }
16686    }
16687    $bounce_type = 'bounce'  if !defined $bounce_type;
16688    if ($is_true_bounce) {
16689      do_log(3, 'inspect_dsn: is a %s, struct: "%s", part(%s/%d), <%s>',
16690                $bounce_type, $structure_type,
16691                !defined($fname_ind) ? '-' : $fname_ind,  scalar(@parts),
16692                $sender)  if ll(3);
16693    } elsif ($msginfo->is_auto) {  # bounce likely, but contents unrecognizable
16694      do_log(3, 'inspect_dsn: possibly a %s, unrecognizable, '.
16695                'struct: "%s", parts(%s/%d): %s',
16696                $bounce_type, $structure_type,
16697                !defined($fname_ind) ? '-' : $fname_ind,  scalar(@parts),
16698                join(", ",@t))  if ll(3);
16699    } else {  # not a bounce
16700      do_log(3, 'inspect_dsn: not a bounce');
16701    }
16702  }
16703  $bounce_type = undef  if !$is_true_bounce;
16704  !$is_true_bounce ? () : (\%header_field,$bounce_type);
16705}
16706
16707# obtain authserv-id from an Authentication-Results header field
16708#
16709sub parse_authentication_results($) {
16710  local($_) = $_[0];
16711  tr/\n//d; local($1); my $comm_lvl = 0; my $authservid;
16712  while (!/\G \z/gcsx) {
16713    if (                    /\G \( /gcsx) { $comm_lvl++ }
16714    elsif ($comm_lvl > 0 && /\G \) /gcsx) { $comm_lvl-- }
16715    elsif ($comm_lvl > 0 && /\G(?: \\ . | [^()\\]+ )/gcsx) {}
16716    elsif (!$comm_lvl && /\G [ \t]+ /gcsx) {}
16717    elsif (!$comm_lvl && m{\G ( [^\x00-\x20\x7F()<>,;:"/?=\[\]\@\\]+ ) }gcsx)
16718      { $authservid = $1; last }  # token
16719    elsif (!$comm_lvl && m{\G " ( (?: \\ [\t\x20-\x7E] |
16720                                      [\t\x20\x21\x23-\x5B\x5D-\x7E] |
16721                                      [\xC0-\xF4][\x80-\xBF]{1,3}
16722                                  )* ) " }gcsx)  # qcontent (relaxed for UTF-8)
16723      { $authservid = $1; $authservid =~ s{\\(.)}{$1}gsx; last }
16724    else { last };  # syntax error
16725  }
16726  $authservid;
16727}
16728
16729sub add_forwarding_header_edits_common($$$$$$) {
16730  my($msginfo, $hdr_edits, $hold, $any_undecipherable,
16731     $virus_presence_checked, $spam_presence_checked) = @_;
16732  my $use_our_hdrs = cr('prefer_our_added_header_fields');
16733  my $allowed_hdrs = cr('allowed_added_header_fields');
16734  if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Hold')}) {
16735    # discard existing X-Amavis-Hold header field, only allow our own
16736    $hdr_edits->delete_header('X-Amavis-Hold');
16737    if (defined $hold && $hold ne '') {
16738      $hdr_edits->add_header('X-Amavis-Hold', $hold);
16739      do_log(0, "Inserting header field: X-Amavis-Hold: %s", $hold);
16740    }
16741  }
16742  if (c('enable_dkim_verification') &&
16743      $allowed_hdrs && $allowed_hdrs->{lc('Authentication-Results')}) {
16744
16745    # RFC 7601: For security reasons, any MTA conforming to this specification
16746    # MUST delete any discovered instance of this header field that claims,
16747    # by virtue of its authentication service identifier, to have been added
16748    # within its trust boundary but that did not come directly from another
16749    # trusted MTA. [...] For simplicity and maximum security, a border MTA
16750    # could remove all instances of this header field on mail crossing into
16751    # its trust boundary. [...] (Hmmm...!?) However, an MTA MUST remove such
16752    # a header field if the [SMTP] connection relaying the message is not from
16753    # a trusted internal MTA.
16754    my $authservid = c('myauthservid');
16755    $authservid = c('myhostname') if !defined $authservid || $authservid eq '';
16756    $authservid = idn_to_ascii($authservid);
16757    # delete header field if its authserv-id matches ours or is unparseable
16758    $hdr_edits->edit_header('Authentication-Results',
16759      sub { my($h,$b) = @_;
16760            my $aid = parse_authentication_results($b);
16761            if (defined $aid) { $aid =~ s{/.*}{}s; $authservid =~ s{/.*}{}s };
16762            !defined $aid || lc($aid) eq lc($authservid) ? (undef,0) : ($b,1);
16763           } );
16764    # [...] For simplicity and maximum security, a border MTA could remove all
16765    # instances of this header field on mail crossing into its trust boundary.
16766    # $hdr_edits->delete_header('Authentication-Results');
16767  }
16768
16769  # example on how to remove subject tag inserted by some other MTA:
16770  # $hdr_edits->edit_header('Subject',
16771  #          sub { my($h,$s)=@_; $s=~s/^\s*\*\*\* Spam \*\*\*(.*)/$1/si; $s });
16772  if ($extra_code_antivirus) {
16773  # $hdr_edits->delete_header('X-Amavis-Alert');  # it does not hurt to keep it
16774    my $am_hdr_fld_head = c('X_HEADER_TAG');
16775    my $am_hdr_fld_body = c('X_HEADER_LINE');
16776    $hdr_edits->delete_header($am_hdr_fld_head)
16777      if c('remove_existing_x_scanned_headers') &&
16778         defined $am_hdr_fld_body && $am_hdr_fld_body ne '' &&
16779         defined $am_hdr_fld_head && $am_hdr_fld_head =~ /^[!-9;-\176]+\z/;
16780  }
16781  my $myhost = c('myhostname');
16782  $myhost = $msginfo->smtputf8 ? idn_to_utf8($myhost) : idn_to_ascii($myhost);
16783  for ('X-Spam-Checker-Version') {
16784    if ($extra_code_antispam_sa &&
16785        $allowed_hdrs && $allowed_hdrs->{lc $_} &&
16786        $use_our_hdrs && $use_our_hdrs->{lc $_}) {
16787      no warnings 'once';
16788      $hdr_edits->add_header($_,
16789        sprintf("SpamAssassin %s (%s) on %s",
16790                Mail::SpamAssassin::Version(),
16791                $Mail::SpamAssassin::SUB_VERSION, $myhost));
16792    }
16793  }
16794  $hdr_edits;
16795}
16796
16797# Prepare header edits for the first not-yet-done recipient.
16798# Inspect remaining recipients, returning the list of recipient objects
16799# that are receiving the same set of header edits (so the message may be
16800# delivered to them in one SMTP transaction).
16801#
16802sub add_forwarding_header_edits_per_recip($$$$$$$) {
16803  my($msginfo, $hdr_edits, $hold, $any_undecipherable,
16804     $virus_presence_checked, $spam_presence_checked, $filter) = @_;
16805  my(@recip_cluster);
16806  my(@per_recip_data) = grep(!$_->recip_done && (!$filter || &$filter($_)),
16807                             @{$msginfo->per_recip_data});
16808  my $per_recip_data_len = scalar(@per_recip_data);
16809  my $first = 1; my $cluster_key; my $cluster_full_spam_status;
16810  my $use_our_hdrs = cr('prefer_our_added_header_fields');
16811  my $allowed_hdrs = cr('allowed_added_header_fields');
16812  my $x_header_tag = c('X_HEADER_TAG');
16813  my $adding_x_header_tag =
16814    $x_header_tag =~ /^[!-9;-\176]+\z/ && c('X_HEADER_LINE') ne '' &&
16815    $allowed_hdrs && $allowed_hdrs->{lc($x_header_tag)};
16816  my $mail_id = $msginfo->mail_id;
16817  my $os_fp = $msginfo->client_os_fingerprint;
16818  if (defined($os_fp) && $os_fp ne '' && $msginfo->client_addr ne '')
16819    { $os_fp .= ', ['. $msginfo->client_addr . ']:' . $msginfo->client_port }
16820  my(@headers_to_be_removed);  # header fields that may need to be removed
16821  if ($extra_code_antispam) {
16822    @headers_to_be_removed = qw(
16823        X-Spam-Status X-Spam-Level X-Spam-Flag X-Spam-Score
16824        X-Spam-Report X-Spam-Checker-Version X-Spam-Tests);
16825    @headers_to_be_removed =
16826      grep(defined $msginfo->get_header_field2($_), @headers_to_be_removed);
16827  }
16828
16829  my $header_tagged = 0;
16830  for my $r (@per_recip_data) {
16831    my $spam_level    = $r->spam_level;
16832    my $recip         = $r->recip_addr;
16833    my $is_local      = $r->recip_is_local;
16834    my $blacklisted   = $r->recip_blacklisted_sender;
16835    my $whitelisted   = $r->recip_whitelisted_sender;
16836    my $bypassed      = $r->bypass_spam_checks;
16837    my $do_tag        = $r->is_in_contents_category(CC_CLEAN,1);
16838    my $do_tag2       = $r->is_in_contents_category(CC_SPAMMY);
16839    my $do_kill       = $r->is_in_contents_category(CC_SPAM);
16840    my $do_tag_badh   = $r->is_in_contents_category(CC_BADH);
16841    my $do_tag_banned = $r->is_in_contents_category(CC_BANNED);
16842    my $do_tag_virus  = $r->is_in_contents_category(CC_VIRUS);
16843    my $mail_mangle   = $r->mail_body_mangle;
16844    my $do_tag_virus_checked =
16845                        $adding_x_header_tag && !$r->bypass_virus_checks;
16846    my $do_rem_hdr = @headers_to_be_removed &&
16847                     lookup2(0,$recip,ca('remove_existing_spam_headers_maps'));
16848    my $do_p0f = $is_local && defined($os_fp) && $os_fp ne '' &&
16849               $allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-OS-Fingerprint')};
16850    my $pp_age;
16851    if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-PenPals')}) {
16852      $pp_age = $r->recip_penpals_age;
16853      $pp_age = format_time_interval($pp_age)  if defined $pp_age;
16854    }
16855    my($tag_level,$tag2_level,$subject_tag);
16856    if ($extra_code_antispam && !$bypassed) {
16857      $tag_level  = lookup2(0,$recip, ca('spam_tag_level_maps'));
16858      $tag2_level = lookup2(0,$recip, ca('spam_tag2_level_maps'));
16859    }
16860    if ($is_local) {   #  || c('warn_offsite')
16861      my(@subj_maps_pairs) = $r->setting_by_main_contents_category_all(
16862                                               cr('subject_tag_maps_by_ccat'));
16863      for my $pair (@subj_maps_pairs) {
16864        my($cc,$map_ref) = @$pair;
16865        next  if !ref($map_ref);
16866        $subject_tag = lookup2(0,$recip,$map_ref);
16867        # take the first nonempty string
16868        last  if defined $subject_tag && $subject_tag ne '';
16869      }
16870    }
16871    my $myhost = c('myhostname');
16872    $myhost = $msginfo->smtputf8 ? idn_to_utf8($myhost) :idn_to_ascii($myhost);
16873    $subject_tag = ''  if !defined $subject_tag;
16874    if ($subject_tag ne '') {  # expand subject template
16875      # just implement a small subset of macro-lookalikes, not true macro calls
16876      # btw, the '0+' is there to trim trailing zeroes
16877      $subject_tag =~
16878       s{_(SCORE|REQD|YESNO|YESNOCAPS|HOSTNAME|DATE|U|LOGID|MAILID)_}
16879        {  $1 eq 'SCORE'     ? (0+sprintf("%.3f",$spam_level))
16880         : $1 eq 'REQD'      ? (!defined($tag2_level) ? '-' :
16881                                0+sprintf("%.3f",$tag2_level))
16882         : $1 eq 'YESNO'     ? ($do_tag2 ? 'Yes' : 'No')
16883         : $1 eq 'YESNOCAPS' ? ($do_tag2 ? 'YES' : 'NO')
16884         : $1 eq 'HOSTNAME'  ? $myhost   #** characters or octets?
16885         : $1 eq 'DATE'      ? rfc2822_timestamp($msginfo->rx_time)
16886         : $1 eq 'U'         ? iso8601_utc_timestamp($msginfo->rx_time)
16887         : $1 eq 'LOGID'     ? $msginfo->log_id
16888         : $1 eq 'MAILID'    ? $mail_id||''
16889         : '_'.$1.'_' }xgse;
16890    }
16891
16892    # normalize
16893    $_ = $_?1:0  for ($do_tag_virus_checked, $do_tag_virus, $do_tag_banned,
16894                      $do_tag_badh, $do_tag, $do_tag2, $do_p0f, $do_rem_hdr,
16895                      $is_local);
16896    my($spam_level_bar, $full_spam_status);
16897    if ($is_local && ($do_tag || $do_tag2)) {  # prepare status and level bar
16898      # spam-related header fields should _not_ be inserted for:
16899      #  - nonlocal recipients (outgoing mail), as a matter of courtesy
16900      #    to our users;
16901      #  - recipients matching bypass_spam_checks: even though spam checking
16902      #    may have been done for other reasons, these recipients do not expect
16903      #    such header fields, so let's pretend the check has not been done
16904      #    and not insert spam-related header fields for them;
16905      #  - everyone when the spam level is below the tag level
16906      #    or the sender was whitelisted and tag level is below -10
16907      #    (undefined tag level is treated as lower than any spam score).
16908      my $autolearn_status = $msginfo->supplementary_info('AUTOLEARN');
16909      my $slc = c('sa_spam_level_char');
16910      if (defined $slc && $slc ne '') {
16911        my $bar_len = $whitelisted || $bypassed ? 0 : $blacklisted ? 64
16912                    : !defined $spam_level ? 0
16913                    : $spam_level > 64 ? 64 : $spam_level;
16914        $spam_level_bar = $bar_len < 1 ? '' : $slc x int $bar_len;
16915      }
16916      my $spam_tests = $r->spam_tests;
16917      $spam_tests = !$spam_tests ? '' : join(',',map($$_,@$spam_tests));
16918      # allow header field wrapping at any comma
16919      my $s = $spam_tests;  $s =~ s/,/,\n /g;
16920      $full_spam_status = sprintf(
16921        "%s,\n score=%s\n %s%s%stests=[%s]\n autolearn=%s",
16922        $do_tag2 ? 'Yes' : 'No',
16923        !defined $spam_level ? 'x' : 0+sprintf("%.3f",$spam_level),
16924        !defined $tag_level || $tag_level eq '' ? ''
16925                                   : sprintf("tagged_above=%s\n ",$tag_level),
16926        !defined $tag2_level  ? '' : sprintf("required=%s\n ",  $tag2_level),
16927        join('', $blacklisted ? "BLACKLISTED\n " : (),
16928                 $whitelisted ? "WHITELISTED\n " : ()),
16929        $s, $autolearn_status||'unavailable');
16930    }
16931
16932    my $key = join("\000", map {defined $_ ? $_ : ''} (
16933      $do_tag_virus_checked, $do_tag_virus, $do_tag_banned, $do_tag_badh,
16934      $do_tag && $is_local, $do_tag2 && $is_local, $subject_tag, $do_rem_hdr,
16935      $spam_level_bar, $full_spam_status, $mail_mangle, $do_p0f, $pp_age) );
16936    if ($first) {
16937      if (ll(4)) {
16938        my $sl = !defined($spam_level) ? 'x'
16939                   : 0+sprintf("%.3f",$spam_level);  # trim fraction
16940        do_log(4, "headers CLUSTERING: NEW CLUSTER <%s>: score=%s, ".
16941          "tag=%s, tag2=%s, local=%s, bl=%s, s=%s, mangle=%s",  $recip,
16942          $sl, $do_tag, $do_tag2, $is_local, $blacklisted, $subject_tag,
16943          $mail_mangle);
16944      }
16945      $cluster_key = $key; $cluster_full_spam_status = $full_spam_status;
16946    } elsif ($key eq $cluster_key) {
16947      do_log(5,"headers CLUSTERING: <%s> joining cluster", $recip);
16948    } else {
16949      do_log(5,"headers CLUSTERING: skipping <%s> (t=%s, t2=%s, r=%s, l=%s)",
16950               $recip,$do_tag,$do_tag2,$do_rem_hdr,$is_local);
16951      next;  # this recipient will be handled in some later pass
16952    }
16953
16954    if ($first) {  # insert header fields required for the new cluster
16955      my(%header_field_provided);  # mainly applies to spam header fields
16956      if ($do_rem_hdr) {
16957        $hdr_edits->delete_header($_)  for @headers_to_be_removed;
16958      }
16959      if ($is_local && defined $msginfo->quarantined_to && defined $mail_id) {
16960        $hdr_edits->add_header('X-Quarantine-ID', '<'.$mail_id.'>')
16961          if $allowed_hdrs && $allowed_hdrs->{lc('X-Quarantine-ID')};
16962      }
16963      if ($mail_mangle) {  # mail body modified, invalidates DKIM signatures
16964        if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Modified')}) {
16965          $hdr_edits->add_header('X-Amavis-Modified',
16966                sprintf("Mail body modified (%s) - %s",
16967                  length($mail_mangle) > 1 ? "using $mail_mangle" : "defanged",
16968                  $myhost ));
16969        }
16970      }
16971      if ($do_tag_virus_checked) {
16972        $hdr_edits->add_header(c('X_HEADER_TAG'), c('X_HEADER_LINE'));
16973      }
16974      if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Alert')}) {
16975        if ($do_tag_virus) {
16976          my $virusname_list = $msginfo->virusnames;
16977          $hdr_edits->add_header('X-Amavis-Alert',
16978            "INFECTED, message contains virus: " .
16979            (!$virusname_list ? '' : join(", ",@$virusname_list)) );
16980          $header_tagged = 1;
16981        }
16982        if ($do_tag_banned) {
16983          $hdr_edits->add_header('X-Amavis-Alert',
16984                       'BANNED, message contains ' . $r->banning_reason_short);
16985          $header_tagged = 1;
16986        }
16987        if ($do_tag_badh) {
16988          $hdr_edits->add_header('X-Amavis-Alert',
16989                       'BAD HEADER SECTION, ' . $bad_headers[0]);
16990        # $header_tagged = 1;  # not this one, it is mostly harmless
16991        }
16992      }
16993
16994      if ($is_local && $allowed_hdrs && $use_our_hdrs) {
16995        for ('X-Spam-Checker-Version') {
16996          if ($extra_code_antispam_sa &&
16997              $allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
16998            # a hack instead of making %header_field_provided global:
16999            # just mark it as already provided, this header field was
17000            # already inserted by add_forwarding_header_edits_common()
17001            $header_field_provided{lc $_} = 1;
17002          }
17003        }
17004        for ('X-Spam-Flag') {
17005          if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17006            $hdr_edits->add_header($_, $do_tag2 ? 'YES' : 'NO')  if $do_tag;
17007            $header_field_provided{lc $_} = 1;
17008            $header_tagged = 1  if $do_tag2;  # SPAMMY
17009          }
17010        }
17011        for ('X-Spam-Score') {
17012          if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17013            if ($do_tag) {
17014              my $score = 0+$spam_level;
17015              $score = max(64,$score)  if $blacklisted;  # not below 64 if bl
17016              $score = min( 0,$score)  if $whitelisted;  # not above  0 if wl
17017              $hdr_edits->add_header($_, 0+sprintf("%.3f",$score));
17018            }
17019            $header_field_provided{lc $_} = 1;
17020          }
17021        }
17022        for ('X-Spam-Level') {
17023          if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17024            if ($do_tag && defined $spam_level_bar) {
17025              $hdr_edits->add_header($_, $spam_level_bar);
17026            }
17027            $header_field_provided{lc $_} = 1;
17028          }
17029        }
17030        for ('X-Spam-Status') {
17031          if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17032            $hdr_edits->add_header($_, $full_spam_status, 1)  if $do_tag;
17033            $header_field_provided{lc $_} = 1;
17034          }
17035        }
17036        for ('X-Spam-Report') {
17037          # SA reports may contain any octet, i.e. 8-bit data from a mail
17038          # that is reported by a matching rule; no charset is associated, so
17039          # it doesn't make sense to RFC 2047 -encode it, so just sanitize it
17040          if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17041            if ($do_tag2) {
17042              my $report = $r->spam_report;
17043              $report = $msginfo->spam_report  if !defined $report;
17044              if (defined $report && $report ne '') {
17045                $hdr_edits->add_header($_, "\n".sanitize_str($report,1), 2);
17046              }
17047            }
17048            $header_field_provided{lc $_} = 1;
17049          }
17050        }
17051      }
17052
17053      if ($is_local && $allowed_hdrs) {
17054        # add remaining header fields as provided by spam scanners
17055        my $sa_header = $msginfo->supplementary_info(
17056                          $do_tag2 ? 'ADDEDHEADERSPAM' : 'ADDEDHEADERHAM');
17057        if (defined $sa_header && $sa_header ne '') {
17058          for my $hf (split(/^(?![ \t])/m, $sa_header, -1)) {
17059            local($1,$2);
17060            if ($hf =~ /^([!-9;-\176]+)[ \t]*:(.*)\z/s) {
17061              my($hf_name,$hf_body) = ($1,$2);
17062              my $hf_name_lc = lc $hf_name; chomp($hf_body);
17063              if ($header_field_provided{$hf_name_lc}) {
17064                do_log(5,'fwd: scanner provided a header field %s, but we '.
17065                         'preferred our own', $hf_name);
17066              } elsif (!$allowed_hdrs->{$hf_name_lc}) {
17067                do_log(5,'fwd: scanner provided a header field %s, inhibited '.
17068                         'by %%allowed_added_header_fields', $hf_name);
17069              } else {
17070                do_log(5,'fwd: scanner provided a header field %s, inserting',
17071                         $hf_name);
17072                $hdr_edits->add_header($hf_name, $hf_body, 2);
17073              }
17074            }
17075          }
17076        }
17077        for my $pair ( ['DSPAMRESULT',    'X-DSPAM-Result'],
17078                       ['DSPAMSIGNATURE', 'X-DSPAM-Signature'],
17079                       ['CRM114STATUS',   'X-CRM114-Status'],
17080                       ['CRM114CACHEID',  'X-CRM114-CacheID'] ) {
17081          my($suppl_attr_name, $hf_name) = @$pair;
17082          my $suppl_attr_val = $msginfo->supplementary_info($suppl_attr_name);
17083          if (defined $suppl_attr_val && $suppl_attr_val ne '') {
17084            if (!$allowed_hdrs->{lc $hf_name}) {
17085              do_log(5,'fwd: scanner provided a tag/field %s, '.
17086                       'inhibited by %%allowed_added_header_fields', $hf_name);
17087            } else {
17088              do_log(5,'fwd: scanner provided a tag/field %s, '.
17089                       'inserting', $hf_name);
17090              $hdr_edits->add_header($hf_name,
17091                                     sanitize_str($suppl_attr_val), 2);
17092            }
17093          }
17094        }
17095      }
17096
17097      $hdr_edits->add_header('X-Amavis-OS-Fingerprint',
17098                             sanitize_str($os_fp))  if $do_p0f;
17099      $hdr_edits->add_header('X-Amavis-PenPals',
17100                             'age '.$pp_age)  if defined $pp_age;
17101      if ($is_local && c('enable_dkim_verification') &&
17102          $allowed_hdrs && $allowed_hdrs->{lc('Authentication-Results')}) {
17103        for my $h (Amavis::DKIM::generate_authentication_results($msginfo,0)) {
17104          $hdr_edits->add_header('Authentication-Results', $h, 1);
17105        }
17106      }
17107      if ($subject_tag ne '') {
17108        if (defined $msginfo->get_header_field2('subject')) {
17109          $hdr_edits->edit_header('Subject',
17110                        sub { local($1,$2);
17111                              $_[1] =~ /^([ \t]?)(.*)\z/s; my $subj = $2;
17112                              $subj = safe_decode_mime($subj);  # to characters
17113                              $subj =~ s/\Q$subject_tag\E//sg
17114                                if length($subject_tag) >= 3;  # precaution
17115                              safe_decode_utf8(
17116                                ' ' . safe_encode_utf8($subject_tag) .
17117                                      safe_encode_utf8($subj));
17118                            } );
17119        } else {  # no Subject header field present, insert one
17120          $subject_tag =~ s/[ \t]+\z//;  # trim
17121          $hdr_edits->add_header('Subject', $subject_tag);
17122          do_log(0,"INFO: no existing header field 'Subject', inserting it");
17123        }
17124        $header_tagged = 1;
17125      }
17126      if ($allowed_hdrs && $allowed_hdrs->{lc('Received')} &&
17127          grep($_->delivery_method ne '', @{$msginfo->per_recip_data})) {
17128        $hdr_edits->add_header('Received',
17129                               make_received_header_field($msginfo,1), 1);
17130      }
17131    }  # if $first
17132    push(@recip_cluster,$r);  $first = 0;
17133    $r->recip_tagged(1)  if $header_tagged;
17134
17135    my $delim = c('recipient_delimiter');
17136    if ($is_local) {
17137      # rewrite/replace recipient addresses, possibly with multiple recipients
17138      my $rewrite_map = $r->setting_by_contents_category(
17139                                              cr('addr_rewrite_maps_by_ccat'));
17140      my $rewrite = !ref $rewrite_map ? undef : lookup2(0,$recip,$rewrite_map);
17141      if ($rewrite ne '') {
17142        my(@replacements) =
17143          map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $rewrite));
17144        if (@replacements) {
17145          my $repl_addr = shift @replacements;
17146          my $modif_addr = replace_addr_fields($recip,$repl_addr,$delim);
17147          ll(5) && do_log(5,"addr_rewrite_maps: replacing <%s> by <%s>",
17148                            $recip,$modif_addr);
17149          $r->recip_addr_modified($modif_addr);
17150          for my $bcc (@replacements) {  # remaining addresses are extra Bcc
17151            my $new_addr = replace_addr_fields($recip,$bcc,$delim);
17152            ll(5) && do_log(5,"addr_rewrite_maps: recip <%s>, adding <%s>",
17153                              $recip,$new_addr);
17154            # my $clone = $r->clone;
17155            # $clone->recip_addr_modified($new_addr);
17156          }
17157        }
17158        $r->dsn_orcpt(join(';', orcpt_decode(';'.$r->recip_addr_smtp)))
17159          if !defined $r->dsn_orcpt;
17160      }
17161    }
17162    if ($is_local && defined $delim && $delim ne '') {
17163      # append address extensions to mailbox names if desired
17164      my $ext_map = $r->setting_by_contents_category(
17165                                            cr('addr_extension_maps_by_ccat'));
17166      my $ext = !ref($ext_map) ? undef : lookup2(0,$recip,$ext_map);
17167      if ($ext ne '') {
17168        $ext = substr($delim,0,1) . $ext;
17169        my $orig_extension;  my($localpart,$domain) = split_address($recip);
17170        ($localpart,$orig_extension) = split_localpart($localpart,$delim)
17171          if c('replace_existing_extension');  # strip existing extension
17172        my $new_addr = $localpart.$ext.$domain;
17173        if (ll(5)) {
17174          if (!defined($orig_extension)) {
17175            do_log(5, "appending addr ext '%s', giving '%s'", $ext,$new_addr);
17176          } else {
17177            do_log(5, "replacing addr ext '%s' by '%s', giving '%s'",
17178                       $orig_extension,$ext,$new_addr);
17179          }
17180        }
17181        # RFC 3461: If no ORCPT parameter was present in the RCPT command when
17182        # the message was received, an ORCPT parameter MAY be added to the
17183        # RCPT command when the message is relayed. If an ORCPT parameter is
17184        # added by the relaying MTA, it MUST contain the recipient address
17185        # from the RCPT command used when the message was received by that MTA.
17186        $r->dsn_orcpt(join(';', orcpt_decode(';'.$r->recip_addr_smtp)))
17187          if !defined $r->dsn_orcpt;
17188        $r->recip_addr_modified($new_addr);
17189        $r->recip_tagged(1);
17190      }
17191    }
17192  }
17193  my $done_all;
17194  if (@recip_cluster == $per_recip_data_len) {
17195    do_log(5,"headers CLUSTERING: done all %d recips in one go",
17196             $per_recip_data_len);
17197    $done_all = 1;
17198  } else {
17199    ll(4) && do_log(4, "headers CLUSTERING: got %d recips out of %d: %s",
17200                       scalar(@recip_cluster), $per_recip_data_len,
17201                       join(', ', map($_->recip_addr_smtp, @recip_cluster)));
17202  }
17203  if (ll(2) && defined($cluster_full_spam_status) && @recip_cluster) {
17204    my $s = $cluster_full_spam_status; $s =~ s/\n[ \t]/ /g;
17205    do_log(2, "spam-tag, %s -> %s, %s", $msginfo->sender_smtp,
17206              join(',', map($_->recip_addr_smtp, @recip_cluster)), $s);
17207  }
17208  ($hdr_edits, \@recip_cluster, $done_all);
17209}
17210
17211# Mail body mangling (defanging, sanitizing or adding disclaimers);
17212# Prepare mail body replacement for the first recipient
17213# in the @$per_recip_data list (which contains a subset of recipients
17214# with the same mail edits, to be dispatched next as one message)
17215#
17216sub prepare_modified_mail($$$$) {
17217  my($msginfo, $hold, $any_undecipherable, $per_recip_data) = @_;
17218  my $body_modified = 0;
17219  for my $r (@$per_recip_data) {  # a subset of recipients!
17220    my $recip = $r->recip_addr;
17221    my $mail_mangle = $r->mail_body_mangle;
17222    my $actual_mail_mangle;
17223    if (!$mail_mangle) {
17224      # skip
17225    } elsif ($mail_mangle =~ /^(?:null|nulldisclaimer)\z/i) {  # for testing
17226      $body_modified = 1; # pretend mail was modified while actually it was not
17227      $msginfo->mail_text_str(undef);
17228      section_time('mangle-'.$mail_mangle);
17229    } elsif (( lc $mail_mangle ne 'attach' &&
17230               ($enable_anomy_sanitizer || $altermime ne '') )
17231             || $mail_mangle =~ /^(?:anomy|altermime|disclaimer)\z/i) {
17232      do_log(2,"mangling by: %s, <%s>", $mail_mangle,$recip);
17233      my $orig_fn = $msginfo->mail_text_fn;
17234      my $repl_fn = $msginfo->mail_tempdir . '/email-repl.txt';
17235      my $file_position = $msginfo->skip_bytes;
17236      my $out_fh; my $repl_size; my $eval_stat;
17237      eval {
17238        $out_fh = IO::File->new;
17239        $out_fh->open($repl_fn, O_CREAT|O_EXCL|O_WRONLY, 0640)
17240          or die "Can't create file $repl_fn: $!";
17241        binmode($out_fh,':bytes') or die "Can't cancel :utf8 mode: $!";
17242        if (lc $mail_mangle eq 'anomy' && !$enable_anomy_sanitizer) {
17243          die 'Anomy requested, but $enable_anomy_sanitizer is false';
17244        } elsif ($enable_anomy_sanitizer &&
17245                 $mail_mangle !~ /^(?:altermime|disclaimer)\z/i) {
17246          $actual_mail_mangle = 'anomy';
17247          my $inp_fh = $msginfo->mail_text;
17248          $inp_fh->seek($file_position, 0) or die "Can't rewind mail file: $!";
17249          $enable_anomy_sanitizer  or die "Anomy disabled: $mail_mangle";
17250          my(@scanner_conf); my $e; my $engine = Anomy::Sanitizer->new;
17251          if ($e = $engine->error) { die $e }
17252          $engine->configure(@scanner_conf, @{ca('anomy_sanitizer_args')});
17253          if ($e = $engine->error) { die $e }
17254          my $ret = $engine->sanitize($inp_fh, $out_fh);
17255          if ($e = $engine->error) { die $e }
17256          # close flushes buffers, makes it possible to check file size below
17257          $out_fh->close or die "Can't close file $repl_fn: $!";
17258          # re-open as read-only
17259          $out_fh = IO::File->new;
17260          $out_fh->open($repl_fn,'<') or die "Can't open file $repl_fn: $!";
17261          binmode($out_fh,':bytes') or die "Can't cancel :utf8 mode: $!";
17262        } else {  # use altermime for adding disclaimers or defanging
17263          $actual_mail_mangle = 'altermime';
17264          $altermime ne ''  or die "altermime not available: $mail_mangle";
17265          # prepare arguments to altermime
17266          my(@altermime_args); my $disclaimer_options;
17267          if (lc($mail_mangle) ne 'disclaimer') {  # defang: no by-sender opts.
17268            @altermime_args = @{ca('altermime_args_defang')};
17269          } else {  # disclaimer
17270            @altermime_args = @{ca('altermime_args_disclaimer')};
17271            my $opt_maps = ca('disclaimer_options_bysender_maps');
17272            if ($opt_maps && @$opt_maps &&  # by sender options?
17273                grep(/_OPTION_/,@altermime_args))
17274            { # determine whose by-sender options to use
17275              my $fm = $msginfo->rfc2822_from;
17276              my $rf = $msginfo->rfc2822_resent_from;
17277              my $rs = $msginfo->rfc2822_resent_sender;
17278              my(@rfc2822_from) = !defined($fm) ? () : ref $fm ? @$fm : $fm;
17279              my(@rfc2822_resent_from, @rfc2822_resent_sender);
17280              @rfc2822_resent_from   = @$rf  if defined $rf;
17281              @rfc2822_resent_sender = @$rs  if defined $rs;
17282              # see comments in dkim_make_signatures
17283              my(@search_list);  # collects candidate originator addresses
17284              # author addresses go first
17285              push(@search_list, map([$_,'2822.From'], @rfc2822_from));
17286              # merge Resent-From and Resent-Sender addresses by resent blocks
17287              while (@rfc2822_resent_from || @rfc2822_resent_sender) {
17288                while (@rfc2822_resent_from) {
17289                  my $addr = shift(@rfc2822_resent_from);
17290                  last  if !defined $addr;  # undef delimits resent blocks
17291                  push(@search_list, [$addr, '2822.Resent-From']);
17292                }
17293                while (@rfc2822_resent_sender) {
17294                  my $addr = shift(@rfc2822_resent_sender);
17295                  last  if !defined $addr;  # undef delimits resent blocks
17296                  push(@search_list, [$addr, '2822.Resent-Sender']);
17297                }
17298              }
17299              push(@search_list, [$msginfo->rfc2822_sender, '2822.Sender']);
17300              push(@search_list, [$msginfo->sender,         '2821.mail_from']);
17301              #
17302              # find disclaimer options pertaining to the
17303              # most appropriate originator address
17304              my(%addr_seen);
17305              for my $pair (@search_list) {
17306                my($addr,$addr_src) = @$pair;
17307                next if !defined($addr) || $addr eq '';
17308                next if $addr_seen{$addr}++;
17309                do_log(5,"disclaimer options lookup (%s) %s", $addr_src,$addr);
17310                next if !lookup2(0,$addr, ca('local_domains_maps'));
17311                my($opt,$matchingkey) = lookup2(0,$addr,$opt_maps);
17312                if (defined $opt) {
17313                  $disclaimer_options = $opt;
17314                  do_log(3,"disclaimer options pertaining to (%s) %s: %s",
17315                            $addr_src, $addr, $disclaimer_options);
17316                  last;
17317                }
17318              }
17319              $disclaimer_options = ''  if !defined $disclaimer_options;
17320              s/_OPTION_/$disclaimer_options/gs  for @altermime_args;
17321            }
17322          }
17323          my $msg = $msginfo->mail_text;
17324          my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
17325          $msg = $msg_str_ref  if ref $msg_str_ref;
17326          # copy original mail to $repl_fn, altermime can't handle stdin well
17327          if (!defined $msg) {
17328            # empty mail
17329          } elsif (ref $msg eq 'SCALAR') {
17330            # do it in chunks, saves memory, cache friendly
17331            while ($file_position < length($$msg)) {
17332              $out_fh->print(substr($$msg,$file_position,16384))
17333                or die "Error writing to $repl_fn: $!";
17334              $file_position += 16384;  # may overshoot, no problem
17335            }
17336          } elsif ($msg->isa('MIME::Entity')) {
17337            die "sanitizing a MIME::Entity object is not implemented";
17338          } else {
17339            $msg->seek($file_position,0) or die "Can't rewind mail file: $!";
17340            my($nbytes,$buff);
17341            while (($nbytes = $msg->read($buff,16384)) > 0) {
17342              $out_fh->print($buff) or die "Error writing to $repl_fn: $!";
17343            }
17344            defined $nbytes or die "Error reading mail file: $!";
17345            undef $buff;  # release storage
17346          }
17347          $out_fh->close or die "Can't close file $repl_fn: $!";
17348          undef $out_fh;
17349          my($proc_fh,$pid) = run_command(undef, '&1', $altermime,
17350                                          "--input=$repl_fn", @altermime_args);
17351          my($r,$status) = collect_results($proc_fh,$pid,$altermime,16384,[0]);
17352          undef $proc_fh; undef $pid;
17353          do_log(2,"program %s said: %s",
17354                   $altermime, $$r)  if ref $r && $$r ne '';
17355          $status == 0 or die "Program $altermime failed: $status, $$r";
17356          $out_fh = IO::File->new;
17357          $out_fh->open($repl_fn,'<') or die "Can't open file $repl_fn: $!";
17358          binmode($out_fh,':bytes') or die "Can't cancel :utf8 mode: $!";
17359        }
17360        my $errn = lstat($repl_fn) ? 0 : 0+$!;
17361        if ($errn) { die "Replacement $repl_fn inaccessible: $!" }
17362        else { $repl_size = 0 + (-s _) }
17363        1;
17364      } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat };
17365      if (defined $eval_stat || !defined $repl_size || $repl_size <= 0) {
17366        # handle failure
17367        my $msg = defined $eval_stat ? $eval_stat
17368                                  : sprintf("replacement size %d", $repl_size);
17369        do_log(-1,"mangling by %s failed: %s, mail will pass unmodified",
17370                  $actual_mail_mangle, $msg);
17371        if (defined $out_fh) {
17372          $out_fh->close or do_log(-1,"Can't close %s: %s", $repl_fn,$!);
17373          undef $out_fh;
17374        }
17375        unlink($repl_fn) or do_log(-1,"Can't remove %s: %s", $repl_fn,$!);
17376        if ($actual_mail_mangle eq 'altermime') {  # check for leftover files
17377          my $repl_tmp_fn = $repl_fn . '.tmp';  # altermime's temporary file
17378          my $errn = lstat($repl_tmp_fn) ? 0 : 0+$!;
17379          if ($errn == ENOENT) {}  # fine, does not exist
17380          elsif ($errn) {
17381            do_log(-1,"Temporary file %s is inaccessible: %s",$repl_tmp_fn,$!);
17382          } else {  # cleanup after failing altermime
17383            unlink($repl_tmp_fn)
17384              or do_log(-1,"Can't remove %s: %s",$repl_tmp_fn,$!);
17385          }
17386        }
17387      } else {
17388        do_log(1,"mangling by %s (%s) done, new size: %d, orig %d bytes",
17389                 $actual_mail_mangle, $mail_mangle,
17390                 $repl_size, $msginfo->msg_size);
17391        # don't close or delete the original file, we'll still need it
17392        $msginfo->mail_text($out_fh); $msginfo->mail_text_fn($repl_fn);
17393        $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef);
17394        $msginfo->skip_bytes(0);
17395        $body_modified = 1;
17396      }
17397      section_time('mangle-'.$actual_mail_mangle);
17398
17399    } else {  # 'attach' (default) - poor-man's defanging of dangerous contents
17400      do_log(2,"mangling by built-in defanger: %s, <%s>", $mail_mangle,$recip);
17401      $actual_mail_mangle = 'attach';
17402      my(@explanation); my $spam_summary_inserted = 0;
17403      my(@df_pairs) =
17404        $r->setting_by_main_contents_category_all(cr('defang_maps_by_ccat'));
17405      for my $pair (@df_pairs) {  # collect all defanging reasons that apply
17406        my($cc,$mangle_map_ref) = @$pair;
17407        my $df = !defined($mangle_map_ref) ? undef
17408                 : !ref($mangle_map_ref) ? $mangle_map_ref  # compatibility
17409                 : lookup2(0,$recip,$mangle_map_ref, Label=>'Mangling2');
17410        # the $r->mail_body_mangle happens to be the first noteworthy $df
17411        do_log(4,'defang? ccat "%s": %s', $cc,$df);
17412        next  if !$df;
17413        my $ccm = ccat_maj($cc);
17414        if ($ccm==CC_VIRUS) {
17415          my $virusname_list = $msginfo->virusnames;
17416          push(@explanation, 'WARNING: contains virus ' .
17417               (!$virusname_list ? '' : join(", ",@$virusname_list)));
17418        }
17419        if ($ccm==CC_BANNED) {
17420          push(@explanation,
17421               "WARNING: banning rules detected suspect part(s),\n".
17422               "do not open unless you know what you are doing");
17423        }
17424        if ($ccm==CC_UNCHECKED) {
17425          if (defined $hold && $hold ne '') {
17426            push(@explanation,
17427                 "WARNING: NOT CHECKED FOR VIRUSES (mail bomb?):\n  $hold");
17428          } elsif ($any_undecipherable) {
17429            push(@explanation, "WARNING: contains undecipherable part");
17430          }
17431        }
17432        if ($ccm==CC_BADH) {
17433          my $bad = join(' ',@bad_headers);
17434          substr($bad,1000) = '...'  if length($bad) > 1000;
17435          push(@explanation, split(/\n/,
17436                     wrap_string('WARNING: bad headers - '.$bad, 78,'',' ') ));
17437        }
17438        push(@explanation, 'WARNING: oversized')  if $ccm==CC_OVERSIZED;
17439        if (!$spam_summary_inserted &&  # can be both CC_SPAMMY and CC_SPAM
17440            ($ccm==CC_SPAM || $ccm==CC_SPAMMY)) {
17441          push(@explanation, split(/\n/, $msginfo->spam_summary));
17442          $spam_summary_inserted = 1;
17443        }
17444      }
17445      my $s = join(' ',@explanation);
17446      do_log(1, "DEFANGING MAIL: %s",
17447                length($s) <= 150 ? $s : substr($s,0,150-3).'[...]');
17448      for (@explanation) { substr($_,100-3) = '...'  if length($_) > 100 }
17449      $_ .= "\n"  for (@explanation); # append newlines
17450      my $d = defanged_mime_entity($msginfo,\@explanation);
17451      $msginfo->mail_text($d);  # substitute mail with a rewritten version
17452      $msginfo->mail_text_fn(undef);  # remove filename information
17453      $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef);
17454      $msginfo->skip_bytes(0);
17455      $body_modified = 1; section_time('defang');
17456    }
17457    # actually the 'for' loop is bogus and runs only once, all recipients
17458    # listed in the argument are known to be using the same setting for
17459    # $r->mail_body_mangle, ensured by add_forwarding_header_edits_per_recip;
17460    # just exit the loop
17461    last;
17462  }
17463  $body_modified;
17464}
17465
17466sub do_quarantine($$$$;@) {
17467  shift(@_)  if $_[0]->isa('Amavis::In::Connection');  # for compatibility
17468  my($msginfo, $hdr_edits_inherited, $recips_ref,
17469     $quarantine_method, @snmp_id) = @_;
17470  if ($quarantine_method eq '') {
17471    do_log(5, 'quarantine disabled');
17472  } else {
17473    local($1);
17474    my $quar_m_protocol = !ref $quarantine_method ? $quarantine_method
17475                                                  : $quarantine_method->[0];
17476    $quar_m_protocol = lc $1  if $quar_m_protocol =~ /^([a-z][a-z0-9.+-]*):/si;
17477    my $quar_msg = Amavis::In::Message->new;
17478    $quar_msg->rx_time($msginfo->rx_time);      # copy the reception time
17479    $quar_msg->log_id($msginfo->log_id);        # use the same log_id
17480    $quar_msg->partition_tag($msginfo->partition_tag);  # same partition_tag
17481    $quar_msg->parent_mail_id($msginfo->mail_id);
17482    $quar_msg->mail_id(scalar generate_mail_id());
17483    $quar_msg->conn_obj($msginfo->conn_obj);
17484    $quar_msg->mail_id($msginfo->mail_id);      # use the same mail_id
17485    $quar_msg->body_type($msginfo->body_type);  # use the same BODY= type
17486    $quar_msg->header_8bit($msginfo->header_8bit);
17487    $quar_msg->body_8bit($msginfo->body_8bit);
17488    $quar_msg->msg_size($msginfo->msg_size);
17489    $quar_msg->body_digest($msginfo->body_digest);  # copy original digest
17490    $quar_msg->dsn_ret($msginfo->dsn_ret);
17491    $quar_msg->dsn_envid($msginfo->dsn_envid);
17492    $quar_msg->smtputf8($msginfo->smtputf8);
17493    $quar_msg->auth_submitter($msginfo->sender_smtp);
17494    $quar_msg->auth_user(c('amavis_auth_user'));
17495    $quar_msg->auth_pass(c('amavis_auth_pass'));
17496    $quar_msg->originating(0);  # disables DKIM signing
17497
17498    my($orig_env_sender_retained, $orig_env_recips_retained);
17499    my $mftq = c('mailfrom_to_quarantine');
17500    if (!defined $mftq || $quar_m_protocol =~ /^(?:bsmtp|sql)\z/) {
17501      # we keep the original envelope sender address if replacement sender
17502      # is not provided, or with quarantine methods which store to fixed
17503      # locations which do not depend on envelope
17504      $quar_msg->sender($msginfo->sender);  # original sender
17505      $quar_msg->sender_smtp($msginfo->sender_smtp);
17506      $orig_env_sender_retained = 1;
17507    } elsif (defined $mftq) {
17508      # have a replacement, and protocol is smtp, lmtp, pipe, local
17509      $quar_msg->sender($mftq);
17510      $mftq = qquote_rfc2821_local($mftq);
17511      $quar_msg->sender_smtp($mftq);
17512      $quar_msg->auth_submitter($mftq);
17513    }
17514    my(@recips);
17515    if (!$recips_ref || $quar_m_protocol =~ /^(?:bsmtp|sql)\z/) {
17516      # we keep the original envelope recipients if replacement recipients
17517      # are not provided, or with quarantine methods which store to fixed
17518      # locations which do not depend on envelope information
17519      for my $r (@{$msginfo->per_recip_data}) {
17520        my $recip_obj = Amavis::In::Message::PerRecip->new;
17521        # copy original recipient addresses and DSN info
17522        $recip_obj->recip_addr($r->recip_addr);
17523        $recip_obj->recip_addr_smtp($r->recip_addr_smtp);
17524        $recip_obj->dsn_orcpt($r->dsn_orcpt);
17525        $recip_obj->recip_destiny(D_PASS);
17526        $recip_obj->dsn_notify(['NEVER'])  if $orig_env_sender_retained;
17527        $recip_obj->delivery_method($quarantine_method);
17528        push(@recips,$recip_obj);
17529      }
17530      $orig_env_recips_retained = 1;
17531    } else {  # have a replacement, and protocol is smtp, lmtp, pipe, local
17532      # with these quarantine methods the envelope information is used to
17533      # determine where and how to store a quarantined message, and may not
17534      # reflect original envelope sender and recipients addresses
17535      for my $rec (@$recips_ref) {  # use recipients provided by a caller
17536        my $recip_obj = Amavis::In::Message::PerRecip->new;
17537        $recip_obj->recip_addr($rec);
17538        $recip_obj->recip_addr_smtp(qquote_rfc2821_local($rec));
17539        $recip_obj->recip_destiny(D_PASS);
17540        $recip_obj->dsn_notify(['NEVER'])  if $orig_env_sender_retained;
17541        $recip_obj->delivery_method($quarantine_method);
17542        push(@recips,$recip_obj);
17543      }
17544    }
17545    $quar_msg->per_recip_data(\@recips);
17546    my $hdr_edits = Amavis::Out::EditHeader->new;
17547    $hdr_edits->inherit_header_edits($hdr_edits_inherited);
17548    if (defined $msginfo->mail_id) {
17549      $hdr_edits->prepend_header('X-Quarantine-ID', '<'.$msginfo->mail_id.'>');
17550    }
17551    if ($quar_m_protocol ne 'bsmtp') {
17552      # NOTE: RFC 2821 mentions possible header flds X-SMTP-MAIL & X-SMTP-RCPT
17553      # Exim uses: Envelope-To,  Sendmail uses X-Envelope-To;
17554      # No need with bsmtp, which preserves the envelope.
17555      my(@blocked_recips) = map($_->recip_addr_smtp,
17556                            grep($_->recip_done, @{$msginfo->per_recip_data}));
17557      $hdr_edits->prepend_header('X-Envelope-To-Blocked',
17558        join(",\n ", @blocked_recips), 1);
17559      $hdr_edits->prepend_header('X-Envelope-To',
17560        join(",\n ", map($_->recip_addr_smtp, @{$msginfo->per_recip_data})),1);
17561    }
17562    # X-Envelope-* could be redundant with $orig_env_sender_retained, but
17563    # let's provide this information unconditionally (for the benefit of SQL)
17564    $hdr_edits->prepend_header('X-Envelope-From', $msginfo->sender_smtp);
17565    $hdr_edits->add_header('Received',
17566                           make_received_header_field($msginfo,1), 1);
17567    $quar_msg->header_edits($hdr_edits);
17568    $quar_msg->mail_text($msginfo->mail_text);  # use the same mail contents
17569    $quar_msg->mail_text_str($msginfo->mail_text_str);
17570    $quar_msg->body_start_pos($msginfo->body_start_pos);
17571    $quar_msg->skip_bytes($msginfo->skip_bytes);
17572    if (ll(5)) {
17573      my $quar_m_displ = !ref $quarantine_method ? $quarantine_method
17574                           : '(' . join(', ',@$quarantine_method) . ')';
17575      do_log(5,"DO_QUARANTINE, %s, %s -> %s",
17576               $quar_m_displ, $quar_msg->sender_smtp,
17577               join(', ', map($_->recip_addr_smtp,
17578                              @{$quar_msg->per_recip_data})) );
17579    }
17580    snmp_count('QuarMsgs');
17581    snmp_count( ['QuarMsgsSize', $quar_msg->msg_size, 'C64'] );
17582    mail_dispatch($quar_msg, 'Quar', 0);
17583    my($n_smtp_resp, $n_exit_code, $n_dsn_needed) =
17584      one_response_for_all($quar_msg, 0);  # check status
17585    if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) {  # ok
17586      @snmp_id = ('Other')  if !@snmp_id;
17587      for (unique_list(\@snmp_id)) {
17588        snmp_count('QuarMsgs'.$_);
17589        snmp_count( ['QuarMsgsSize'.$_, $quar_msg->msg_size, 'C64'] );
17590      }
17591      my $any_arch    = grep($_ eq 'Arch', @snmp_id);
17592      my $any_nonarch = grep($_ ne 'Arch', @snmp_id);
17593      my $act_perf = $msginfo->actions_performed;
17594      $msginfo->actions_performed($act_perf=[])  if !$act_perf;
17595      if ($any_nonarch && !grep($_ eq 'Quarantined', @$act_perf)) {
17596        push(@$act_perf, 'Quarantined');
17597      }
17598      if ($any_arch && !grep($_ eq 'Archived', @$act_perf)) {
17599        push(@$act_perf, 'Archived');
17600      }
17601    } elsif ($n_smtp_resp =~ /^4/) {
17602      snmp_count('QuarAttemptTempFails');
17603      die "temporarily unable to quarantine: $n_smtp_resp";
17604    } else {  # abort if quarantining not successful
17605      snmp_count('QuarAttemptFails');
17606      die "Can't quarantine: $n_smtp_resp";
17607    }
17608    my($q_ty, $q_to, @quar_type, @quar_to);
17609    $q_ty = $msginfo->quar_type;
17610    $q_to = $msginfo->quarantined_to;
17611    @quar_type = ref $q_ty ? @$q_ty : ( $q_ty )  if defined $q_ty;
17612    @quar_to   = ref $q_to ? @$q_to : ( $q_to )  if defined $q_to;
17613    my(%seen_q_ty);  $seen_q_ty{$_}=1 for @quar_type;
17614    my(%seen_q_to);  $seen_q_to{$_}=1 for @quar_to;
17615    for my $r (@{$quar_msg->per_recip_data}) {
17616      my $mbxname = $r->recip_mbxname;
17617      next if !defined $mbxname || $mbxname eq '';
17618      my $p = $quar_m_protocol;
17619      $p = $p eq 'smtp'  ? 'M' : $p eq 'lmtp' ? 'L' :
17620           $p eq 'bsmtp' ? 'B' : $p eq 'sql'  ? 'Q' :
17621           $p eq 'local' ? ($mbxname =~ /\@/  ? 'M' :
17622                            $mbxname =~ /\.gz\z/ ? 'Z' : 'F')
17623                         : '?';
17624      push(@quar_type,$p)     if !$seen_q_ty{$p}++;
17625      push(@quar_to,$mbxname) if !$seen_q_to{$mbxname}++;
17626    }
17627    # remember quarantine methods/protocols and locations (quarantined_to)
17628    $msginfo->quar_type(\@quar_type)  if @quar_type;
17629    $msginfo->quarantined_to(\@quar_to) if @quar_to;
17630    ll(5) && do_log(5, 'quar_types: %s, quar_to: %s',
17631                       join(',', @quar_type), join(', ', @quar_to));
17632    do_log(4, 'DO_QUARANTINE done');
17633  }
17634}
17635
17636# prepare header edits for the quarantined message
17637#
17638sub prepare_header_edits_for_quarantine($) {
17639  my $msginfo = $_[0];
17640
17641  my($blacklisted_any,$whitelisted_any) = (0,0);
17642  my($do_tag_any,$do_tag2_any,$do_kill_any) = (0,0,0);
17643  my($tag_level_min,$tag2_level_min,$kill_level_min);
17644  my(%all_spam_tests);
17645  my($min_spam_level, $max_spam_level) =
17646    minmax(map($_->spam_level, @{$msginfo->per_recip_data}));
17647  for my $r (@{$msginfo->per_recip_data}) {
17648    my $rec = $r->recip_addr;
17649    my $spam_level = $r->spam_level;
17650    if (ll(2)) {
17651      my $blocking_ccat = $r->blocking_ccat;
17652      my($rec_ccat_maj,$rec_ccat_min) = ccat_split(
17653              defined $blocking_ccat ? $blocking_ccat : $r->contents_category);
17654      my($ccat,$ccat_min) = ccat_split($msginfo->contents_category);
17655      do_log(2,"header_edits_for_quar: rec_bl_ccat=(%d,%d), ccat=(%d,%d) %s",
17656               $rec_ccat_maj, $rec_ccat_min, $ccat, $ccat_min, $rec)
17657               if $rec_ccat_maj != $ccat || $rec_ccat_min != $ccat_min;
17658    }
17659    my($tag_level,$tag2_level,$kill_level,$do_tag,$do_tag2,$do_kill);
17660    $do_tag  = $r->is_in_contents_category(CC_CLEAN,1);
17661    $do_tag2 = $r->is_in_contents_category(CC_SPAMMY);
17662    $do_kill = $r->is_in_contents_category(CC_SPAM);
17663    if (!$r->bypass_spam_checks && ($do_tag || $do_tag2 || $do_kill)) {
17664      # do the more expensive lookups only when needed
17665      $tag_level  = lookup2(0,$rec, ca('spam_tag_level_maps'));
17666      $tag2_level = lookup2(0,$rec, ca('spam_tag2_level_maps'));
17667      $kill_level = lookup2(0,$rec, ca('spam_kill_level_maps'));
17668    }
17669    # summarize
17670    $blacklisted_any = 1  if $r->recip_blacklisted_sender;
17671    $whitelisted_any = 1  if $r->recip_whitelisted_sender;
17672    $tag_level_min = $tag_level  if defined($tag_level) && $tag_level ne '' &&
17673                  (!defined($tag_level_min) || $tag_level < $tag_level_min);
17674    $tag2_level_min = $tag2_level  if defined($tag2_level) &&
17675                  (!defined($tag2_level_min) || $tag2_level < $tag2_level_min);
17676    $kill_level_min = $kill_level  if defined($kill_level) &&
17677                  (!defined($kill_level_min) || $kill_level < $kill_level_min);
17678    $do_tag_any  = 1  if $do_tag;
17679    $do_tag2_any = 1  if $do_tag2;
17680    $do_kill_any = 1  if $do_kill;
17681    my $spam_tests = $r->spam_tests;
17682    if ($spam_tests) {
17683      $all_spam_tests{$_} = 1  for split(/,/, join(',',map($$_,@$spam_tests)));
17684    }
17685  }
17686
17687  my(%header_field_provided);  # mainly applies to spam header fields
17688  my $use_our_hdrs = cr('prefer_our_added_header_fields');
17689  my $allowed_hdrs = cr('allowed_added_header_fields');
17690  my $hdr_edits = Amavis::Out::EditHeader->new;
17691
17692  if ($allowed_hdrs && $allowed_hdrs->{lc('X-Amavis-Alert')}) {
17693    if ($msginfo->is_in_contents_category(CC_VIRUS)) {
17694      my $virusname_list = $msginfo->virusnames;
17695      $hdr_edits->add_header('X-Amavis-Alert',
17696        "INFECTED, message contains virus: " .
17697        (!$virusname_list ? '' : join(", ",@$virusname_list)) );
17698    }
17699    if ($msginfo->is_in_contents_category(CC_BANNED)) {
17700      for my $r (@{$msginfo->per_recip_data}) {
17701        if (defined($r->banning_reason_short)) {
17702          $hdr_edits->add_header('X-Amavis-Alert',
17703                       'BANNED, message contains ' . $r->banning_reason_short);
17704          last;  # fudge: only the first recipient's banned hit will be shown
17705        }
17706      }
17707    }
17708    if ($msginfo->is_in_contents_category(CC_BADH)) {
17709      $hdr_edits->add_header('X-Amavis-Alert',
17710                             'BAD HEADER SECTION, '.$bad_headers[0]);
17711    }
17712  }
17713
17714  if ($allowed_hdrs) {
17715    for ('X-Amavis-OS-Fingerprint') {
17716      my $p0f = $msginfo->client_os_fingerprint;
17717      if (defined($p0f) && $p0f ne '' && $allowed_hdrs->{lc $_}) {
17718        $hdr_edits->add_header($_, sanitize_str($p0f));
17719      }
17720    }
17721  }
17722
17723  if ($allowed_hdrs && $use_our_hdrs) {
17724    my $spam_level_bar; my $slc = c('sa_spam_level_char');
17725    if (defined $slc && $slc ne '') {
17726      my $bar_len = $whitelisted_any ? 0 : $blacklisted_any ? 64
17727                  : !defined $max_spam_level ? 0
17728                  : $max_spam_level > 64 ? 64 : $max_spam_level;
17729      $spam_level_bar = $bar_len < 1 ? '' : $slc x int $bar_len;
17730    }
17731    # allow header field wrapping at any comma
17732    my $s = join(",\n ", sort keys %all_spam_tests);
17733    my $sl = 'x';
17734    if (defined $min_spam_level) {
17735      my $minsl = 0+sprintf("%.3f",$min_spam_level);
17736      my $maxsl = 0+sprintf("%.3f",$max_spam_level);
17737      $sl = $minsl eq $maxsl ? $minsl : "$minsl..$maxsl";
17738    }
17739    my $autolearn_status = $msginfo->supplementary_info('AUTOLEARN');
17740    my $full_spam_status = sprintf(
17741      "%s,\n score=%s\n tag=%s\n tag2=%s\n kill=%s\n ".
17742      "%stests=[%s]\n autolearn=%s",
17743      $do_tag2_any||$do_kill_any ? 'Yes' : 'No',  $sl,
17744      (map { !defined $_ ? 'x' : 0+sprintf("%.3f",$_) }
17745        ($tag_level_min, $tag2_level_min, $kill_level_min)),
17746      join('', $blacklisted_any ? "BLACKLISTED\n " : (),
17747               $whitelisted_any ? "WHITELISTED\n " : ()),
17748      $s, $autolearn_status||'unavailable');
17749    if (ll(2)) {
17750      # log entry semi-compatible with older log parsers
17751      my $s = $full_spam_status; $s =~ s/\n[ \t]/ /g;
17752      do_log(2,"header_edits_for_quar: %s -> %s, %s",  $msginfo->sender_smtp,
17753               join(',', qquote_rfc2821_local(@{$msginfo->recips})),  $s);
17754    }
17755
17756    for ('X-Spam-Flag') {
17757      if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17758        $hdr_edits->add_header($_, $do_tag2_any ? 'YES' : 'NO');
17759        $header_field_provided{lc $_} = 1;
17760      }
17761    }
17762    for ('X-Spam-Score') {
17763      if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17764        my $score = 0+$max_spam_level;
17765        $score = max(64,$score)  if $blacklisted_any;  # not below 64 if bl
17766        $score = min( 0,$score)  if $whitelisted_any;  # not above  0 if wl
17767        $hdr_edits->add_header($_, 0+sprintf("%.3f",$score));
17768        $header_field_provided{lc $_} = 1;
17769      }
17770    }
17771    for ('X-Spam-Level') {
17772      if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17773        $hdr_edits->add_header($_, $spam_level_bar) if defined $spam_level_bar;
17774        $header_field_provided{lc $_} = 1;
17775      }
17776    }
17777    for ('X-Spam-Status') {
17778      if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17779        $hdr_edits->add_header($_, $full_spam_status, 1);
17780        $header_field_provided{lc $_} = 1;
17781      }
17782    }
17783    for ('X-Spam-Report') {
17784      if ($allowed_hdrs->{lc $_} && $use_our_hdrs->{lc $_}) {
17785        my $report = $msginfo->spam_report;
17786        if (defined $report && $report ne '') {
17787          $hdr_edits->add_header($_, "\n".sanitize_str($report,1), 2);
17788        }
17789        $header_field_provided{lc $_} = 1;
17790      }
17791    }
17792  }
17793
17794  if ($allowed_hdrs) {
17795    # add remaining header fields as provided by spam scanners
17796    my $sa_header = $msginfo->supplementary_info(
17797                      $do_tag2_any ? 'ADDEDHEADERSPAM' : 'ADDEDHEADERHAM');
17798    if (defined $sa_header && $sa_header ne '') {
17799      for my $hf (split(/^(?![ \t])/m, $sa_header, -1)) {
17800        local($1,$2);
17801        if ($hf =~ /^([!-9;-\176]+)[ \t]*:(.*)\z/s) {
17802          my($hf_name,$hf_body) = ($1,$2);
17803          my $hf_name_lc = lc $hf_name; chomp($hf_body);
17804          if ($header_field_provided{$hf_name_lc}) {
17805            do_log(5,'quar: scanner provided a header field %s, but we '.
17806                     'preferred our own', $hf_name);
17807          } elsif (!$allowed_hdrs->{$hf_name_lc}) {
17808            do_log(5,'quar: scanner provided a header field %s, '.
17809                     'inhibited by %%allowed_added_header_fields', $hf_name);
17810          } else {
17811            do_log(5,'quar: scanner provided a header field %s, inserting',
17812                     $hf_name);
17813            $hdr_edits->add_header($hf_name, $hf_body, 2);
17814          }
17815        }
17816      }
17817    }
17818    for my $pair ( ['DSPAMRESULT',    'X-DSPAM-Result'],
17819                   ['DSPAMSIGNATURE', 'X-DSPAM-Signature'],
17820                   ['CRM114STATUS',   'X-CRM114-Status'],
17821                   ['CRM114CACHEID',  'X-CRM114-CacheID'] ) {
17822      my($suppl_attr_name, $hf_name) = @$pair;
17823      my $suppl_attr_val = $msginfo->supplementary_info($suppl_attr_name);
17824      if (defined $suppl_attr_val && $suppl_attr_val ne '') {
17825        if (!$allowed_hdrs->{lc $hf_name}) {
17826          do_log(5,'quar: scanner provided a tag/field %s, '.
17827                   'inhibited by %%allowed_added_header_fields', $hf_name);
17828        } else {
17829          do_log(5,'quar: scanner provided a tag/field %s, inserting',
17830                   $hf_name);
17831          $hdr_edits->add_header($hf_name,
17832                                 sanitize_str($suppl_attr_val), 2);
17833        }
17834      }
17835    }
17836  }
17837
17838  if (c('enable_dkim_verification') &&
17839      $allowed_hdrs && $allowed_hdrs->{lc('Authentication-Results')}) {
17840    for my $h (Amavis::DKIM::generate_authentication_results($msginfo,0)) {
17841      $hdr_edits->add_header('Authentication-Results', $h, 1);
17842    }
17843  }
17844
17845  section_time('quar-hdrs');
17846  $hdr_edits;
17847}
17848
17849# Quarantine according to contents and send admin & recip notif. as needed
17850# (this subroutine replaces the former subroutines do_virus and do_spam)
17851#
17852sub do_notify_and_quarantine($$) {
17853  my($msginfo, $virus_dejavu) = @_;
17854  my($mailfrom_admin, $hdrfrom_admin, $notify_admin_templ_ref) =
17855    map(scalar $msginfo->setting_by_contents_category(cr($_)),
17856        qw(mailfrom_notify_admin_by_ccat hdrfrom_notify_admin_by_ccat
17857           notify_admin_templ_by_ccat));
17858  safe_encode_utf8_inplace($mailfrom_admin); # to octets (if not already)
17859  safe_encode_utf8_inplace($hdrfrom_admin);  # to octets (if not already)
17860  my $qar_method = c('archive_quarantine_method');
17861  my(@ccat_names_pairs) =
17862    $msginfo->setting_by_main_contents_category_all(\%ccat_display_names);
17863  my($ccat,$ccat_min) = ccat_split($msginfo->contents_category);
17864  if (ll(3)) {
17865    my $ccat_name = ref $ccat_names_pairs[0] ? $ccat_names_pairs[0][1] :undef;
17866    do_log(3,"do_notify_and_quar: ccat=%s (%d,%d) (%s) ccat_block=(%s)".
17867             ", qar_mth=%s", $ccat_name, $ccat, $ccat_min,
17868             join(', ', map(sprintf('"%s":%s', $_->[0], $_->[1]),
17869                            @ccat_names_pairs)),
17870             $msginfo->blocking_ccat, $qar_method);
17871  }
17872  my $virusname_list = $msginfo->virusnames;
17873  my $newvirus_admin_maps_ref =
17874     $virusname_list && @$virusname_list && !$virus_dejavu ?
17875       ca('newvirus_admin_maps') : undef;
17876
17877  my $archive_any = 0;  my $archive_transparent = 1;
17878  if (defined $qar_method && $qar_method ne '') {  # archiving quarantine
17879    # test if @archive_quarantine_to_maps for all recipients yields
17880    # a magic placeholder '%a', indicating we want transparent archiving
17881    # which retains unmodified envelope recipient addresses
17882    my $aqtm = ca('archive_quarantine_to_maps');
17883    for my $r (@{$msginfo->per_recip_data}) {
17884      my $q = lookup2(0, $r->recip_addr, $aqtm);
17885      $archive_any = 1          if  defined $q && $q ne '';
17886      $archive_transparent = 0  if !defined $q || $q ne '%a';
17887      last if $archive_any && !$archive_transparent;
17888    }
17889  }
17890  my(@q_tuples, @a_addr);  # per-recip quarantine address(es) and admins
17891  for my $r (@{$msginfo->per_recip_data}) {
17892    my $rec = $r->recip_addr;
17893    my $blacklisted = $r->recip_blacklisted_sender;
17894    my $whitelisted = $r->recip_whitelisted_sender;
17895    my $spam_level  = $r->spam_level;
17896
17897#   an alternative approach to determining which quarantine and notif. to take
17898#   my(@qmqta_tuples) = $r->setting_by_main_contents_category_all(
17899#     cr('quarantine_method_by_ccat'), cr('quarantine_to_maps_by_ccat'),
17900#     cr('admin_maps_by_ccat') );
17901#   my $qq;  # quarantine (pseudo) address associated with the recipient
17902#   my $quarantining_reason_ccat;
17903#   for my $tuple (@qmqta_tuples) {
17904#     my($cc, $q_method, $quarantine_to_maps_ref, $admin_maps_ref) = @$tuple;
17905#     if (defined($q_method) && $q_method ne '' && $quarantine_to_maps_ref) {
17906#       my $q = lookup2(0,$rec,$quarantine_to_maps_ref);
17907#       if (defined $q && $q ne '')
17908#         { $qq = $q; $quarantining_reason_ccat = $cc; last }
17909#     }
17910#   }
17911#   my $aa;  # administrator's e-mail address
17912#   my $admin_notif_reason_ccat;
17913#   for my $tuple (@qmqta_tuples) {
17914#     my($cc, $q_method, $quarantine_to_maps_ref, $admin_maps_ref) = @$tuple;
17915#     if ($admin_maps_ref) {
17916#       my $a = lookup2(0,$rec,$admin_maps_ref);
17917#       if (defined $a && $a ne '')
17918#         { $aa = $a; $admin_notif_reason_ccat = $cc; last }
17919#     }
17920#   }
17921#   ($rec_ccat_maj,$rec_ccat_min) = ccat_split($quarantining_reason_ccat);
17922
17923    my $blocking_ccat = $r->blocking_ccat;
17924    my($rec_ccat_maj,$rec_ccat_min) = ccat_split(
17925              defined $blocking_ccat ? $blocking_ccat : $r->contents_category);
17926    my $q_method =
17927      $r->setting_by_contents_category(cr('quarantine_method_by_ccat'));
17928    my $quarantine_to_maps_ref =
17929      $r->setting_by_contents_category(cr('quarantine_to_maps_by_ccat'));
17930    # get per-recipient quarantine address(es) and admins
17931    if (!defined($q_method) || $q_method eq '') {
17932      do_log(5,"do_notify_and_quarantine: not quarantining, q_method off");
17933    } elsif (!$quarantine_to_maps_ref) {
17934      do_log(5,"do_notify_and_quarantine: not quarantining, null q_to maps");
17935    } else {
17936      my $q;  # quarantine (pseudo) address associated with the recipient
17937      $q = lookup2(0,$rec,$quarantine_to_maps_ref);
17938      if (defined $q && $q ne '' &&
17939          ($rec_ccat_maj==CC_SPAM || $rec_ccat_maj==CC_SPAMMY)) {
17940        # consider suppressing spam quarantine
17941        my $cutoff = lookup2(0,$rec, ca('spam_quarantine_cutoff_level_maps'));
17942        if (!defined $cutoff || $cutoff eq '') {
17943          # no cutoff, quarantining all
17944        } elsif ($blacklisted && !$whitelisted) {
17945          do_log(2,"do_notify_and_quarantine: cutoff, blacklisted");
17946          $q = '';  # disable quarantine on behalf of this recipient
17947        } elsif (($spam_level||0) >= $cutoff) {
17948          do_log(2,"do_notify_and_quarantine: spam level exceeds ".
17949                   "quarantine cutoff level %s", $cutoff);
17950          $q = '';  # disable quarantine on behalf of this recipient
17951        }
17952      }
17953      # keep original recipient when q_to is '%a' or with BSMTP;  some day
17954      # we may end up doing %k, %a, %l, %u, %e, %d placeholder replacements
17955      $q = $rec  if defined $q && $q ne '' &&
17956                    ($q eq '%a' || $q_method =~ /^bsmtp:/i);
17957      if (!defined($q) || $q eq '') {
17958        do_log(5,"do_notify_and_quarantine: not quarantining, q_to off");
17959      } else {
17960        my $ccat_name_major =
17961          $r->setting_by_contents_category(\%ccat_display_names_major);
17962        push(@q_tuples, [$q_method, $q, $ccat_name_major]);
17963      }
17964    }
17965    my $admin_maps_ref =
17966      $r->setting_by_contents_category(cr('admin_maps_by_ccat'));
17967    my $a;  # administrator's e-mail address
17968    $a = lookup2(0,$rec,$admin_maps_ref)  if $admin_maps_ref;
17969    if (defined $a && $a ne '' &&
17970        ($rec_ccat_maj==CC_SPAM || $rec_ccat_maj==CC_SPAMMY)) {
17971      # consider suppressing spam admin notifications
17972      my $cutoff = lookup2(0,$rec, ca('spam_notifyadmin_cutoff_level_maps'));
17973      if (!defined $cutoff || $cutoff eq '') {
17974        # no cutoff, sending administrator notifications
17975      } elsif ($blacklisted && !$whitelisted) {
17976        do_log(2,"do_notify_and_quarantine: spam admin cutoff, blacklisted");
17977        $a = '';  # disable admin notification on behalf of this recipient
17978      } elsif (($spam_level||0) >= $cutoff) {
17979        do_log(2,"do_notify_and_quarantine: spam level exceeds ".
17980                 "spam admin cutoff level %s", $cutoff);
17981        $a = '';  # disable admin notification on behalf of this recipient
17982      }
17983    }
17984    push(@a_addr, $a)  if defined $a && $a ne '' && !grep($_ eq $a, @a_addr);
17985    if (ccat_maj($r->contents_category)==CC_VIRUS && $newvirus_admin_maps_ref){
17986      $a = lookup2(0,$rec,$newvirus_admin_maps_ref);
17987      push(@a_addr, $a)  if defined $a && $a ne '' && !grep($_ eq $a, @a_addr);
17988    }
17989    if ($archive_any && !$archive_transparent) {  # archiving quarantine
17990      my $q = lookup2(0,$rec, ca('archive_quarantine_to_maps'));
17991      # keep original recipient when q_to is '%a' or with BSMTP
17992      $q = $rec  if defined $q && $q ne '' &&
17993                    ($q eq '%a' || $qar_method =~ /^bsmtp:/i);
17994      push(@q_tuples, [$qar_method, $q, 'Arch'])  if defined $q && $q ne '';
17995    }
17996  }  # endfor per_recip_data
17997
17998  if ($ccat == CC_SPAM) {
17999    my $sqbsm = ca('spam_quarantine_bysender_to_maps');
18000    if (@$sqbsm) {  # by-sender spam quarantine (hardly useful, rarely used)
18001      my $q = lookup2(0,$msginfo->sender, $sqbsm);
18002      if (defined $q && $q ne '') {
18003        my $msg_q_method = $msginfo->setting_by_contents_category(
18004                                              cr('quarantine_method_by_ccat'));
18005        push(@q_tuples, [$msg_q_method, $q, 'Spam'])
18006          if defined $msg_q_method && $msg_q_method ne '';
18007      }
18008    }
18009  }
18010
18011  section_time('notif-quar');
18012  if (@q_tuples || $archive_any) {
18013    if (!defined($msginfo->mail_id) && grep($_->[2] ne 'Arch', @q_tuples)) {
18014      # delayed mail_id generation - now we really need it
18015      $zmq_obj->register_proc(2,0,'G',$msginfo->log_id) if $zmq_obj; # generate
18016      $snmp_db->register_proc(2,0,'G',$msginfo->log_id) if $snmp_db;
18017      # create a mail_id unique to a database and save preliminary info to SQL
18018      generate_unique_mail_id($msginfo);
18019      section_time('gen_mail_id')  if $sql_storage;
18020    }
18021    # compatibility: replace quarantine method 'local:xxx'
18022    # with $notify_method when quarantine_to looks like an e-mail address
18023    my $notif_m = c('notify_method');
18024    for my $tuple (@q_tuples) {
18025      my($q_method,$q_to,$ccat_name) = @$tuple;
18026      $tuple->[0] = $notif_m  if $q_method =~ /^local:/i && $q_to =~ /\@/;
18027    }
18028    my $hdr_edits = prepare_header_edits_for_quarantine($msginfo);
18029    if (@q_tuples) {
18030      do_log(4,"do_notify_and_quarantine: quarantine %s",
18031               join(',', map($_->[1], @q_tuples)));
18032      my(@q_tuples_tmp) = @q_tuples;
18033      while (@q_tuples_tmp) {
18034        my($q_method,$q_to,$ccat_name) = @{$q_tuples_tmp[0]};
18035        my(@same_method_tuples) = grep($_->[0] eq $q_method, @q_tuples_tmp);
18036        @q_tuples_tmp =           grep($_->[0] ne $q_method, @q_tuples_tmp);
18037        my(@q_to) =    unique_list(map($_->[1], @same_method_tuples));
18038        # per-recipient blocking ccat names select snmp counter names
18039        my(@snmp_id) = unique_list(map($_->[2], @same_method_tuples));
18040        do_quarantine($msginfo, $hdr_edits, \@q_to, $q_method, @snmp_id);
18041      }
18042    }
18043    if ($archive_any && $archive_transparent) {
18044      # transparent archiving retains envelope recipient addresses
18045      do_log(4,"do_notify_and_quarantine: transparent archiving");
18046      do_quarantine($msginfo, $hdr_edits, undef, $qar_method, 'Arch');
18047    }
18048  }
18049  if (!@a_addr) {
18050    do_log(4,"skip admin notification, no administrators");
18051  } elsif (!ref($notify_admin_templ_ref) ||
18052           (ref($notify_admin_templ_ref) eq 'ARRAY' ?
18053              !@$notify_admin_templ_ref : $$notify_admin_templ_ref eq '')) {
18054    do_log(5,"skip admin notifications - empty template");
18055  } else {   # notify per-recipient administrators
18056    ll(5) && do_log(5, "Admin notifications to %s; sender: %s",
18057                       join(',',qquote_rfc2821_local(@a_addr)),
18058                       $msginfo->sender_smtp);
18059    $hdrfrom_admin = expand_variables($hdrfrom_admin);
18060    if (!defined $mailfrom_admin) {
18061      # defaults to email address in hdrfrom_notify_admin
18062      $mailfrom_admin =
18063        unquote_rfc2821_local( (parse_address_list($hdrfrom_admin))[0] );
18064    }
18065    my $notification = Amavis::In::Message->new;
18066    $notification->rx_time($msginfo->rx_time);  # copy the reception time
18067    $notification->log_id($msginfo->log_id);    # copy log id
18068    $notification->partition_tag($msginfo->partition_tag); # same partition_tag
18069    $notification->parent_mail_id($msginfo->mail_id);
18070    $notification->mail_id(scalar generate_mail_id());
18071    $notification->conn_obj($msginfo->conn_obj);
18072    $notification->originating(1);
18073    $notification->add_contents_category(CC_CLEAN,0);
18074    safe_encode_utf8_inplace($_) for @a_addr;  # make sure addrs are in octets
18075    if (grep( / [^\x00-\x7F] .*? \@ [^@]* \z/sx && is_valid_utf_8($_),
18076              ($mailfrom_admin, @a_addr) )) {
18077      # localpart is non-ASCII UTF-8, we must use SMTPUTF8
18078      $notification->smtputf8(1);
18079      do_log(2, 'admin notification requires SMTPUTF8');
18080    } else {
18081      $_ = mail_addr_idn_to_ascii($_)  for ($mailfrom_admin, @a_addr);
18082    }
18083    $notification->sender($mailfrom_admin);
18084    $notification->sender_smtp(qquote_rfc2821_local($mailfrom_admin));
18085    $notification->auth_submitter($notification->sender_smtp);
18086    $notification->auth_user(c('amavis_auth_user'));
18087    $notification->auth_pass(c('amavis_auth_pass'));
18088    $notification->recips([@a_addr]);
18089    my $notif_m = c('notify_method');
18090    $_->delivery_method($notif_m)  for @{$notification->per_recip_data};
18091    my(@rfc2822_from_admin) =
18092      map(unquote_rfc2821_local($_), parse_address_list($hdrfrom_admin));
18093    $notification->rfc2822_from($rfc2822_from_admin[0]);
18094#   if ($mailfrom_admin ne '')
18095#     { $_->dsn_notify(['NEVER'])  for @{$notification->per_recip_data} }
18096    my(%mybuiltins) = %builtins;  # make a local copy
18097    $mybuiltins{'f'} = safe_decode_utf8($hdrfrom_admin);  # From:
18098    $mybuiltins{'T'} =                                    # To:
18099      [ map(mail_addr_idn_to_ascii(qquote_rfc2821_local($_)), @a_addr) ];
18100    $notification->mail_text(
18101      build_mime_entity(expand($notify_admin_templ_ref,\%mybuiltins),
18102                        $msginfo, undef,undef,0, 1,0) );
18103#   $notification->body_type('7BIT');  # '8BITMIME'
18104    my $hdr_edits = Amavis::Out::EditHeader->new;
18105    $notification->header_edits($hdr_edits);
18106    mail_dispatch($notification, 'Notif', 0);
18107    my($n_smtp_resp, $n_exit_code, $n_dsn_needed) =
18108      one_response_for_all($notification, 0);  # check status
18109    if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) {  # ok
18110      build_and_save_structured_report($notification,'NOTIF');
18111    } elsif ($n_smtp_resp =~ /^4/) {
18112      die "temporarily unable to notify admin: $n_smtp_resp";
18113    } else {
18114      do_log(-1, "FAILED to notify admin: %s", $n_smtp_resp);
18115    }
18116    # $notification->purge;
18117  }
18118  # recipient notifications
18119  my $wrmbc = cr('warnrecip_maps_by_ccat');
18120  for my $r (@{$msginfo->per_recip_data}) {
18121    my $rec = $r->recip_addr;
18122  # if ($r->is_in_contents_category(CC_SPAM)) {
18123  #   if ($wrmbc->{&CC_VIRUS}) {
18124  #     $wrmbc = { %$wrmbc };  # copy
18125  #     delete $wrmbc->{&CC_VIRUS};
18126  #     do_log(5,"disabling virus recipient notifications for infected spam");
18127  #   }
18128  # }
18129    my $warnrecip_maps_ref = $r->setting_by_contents_category($wrmbc);
18130    my $wr; my $notify_recips_templ_ref;
18131    $wr = lookup2(0,$rec,$warnrecip_maps_ref)  if $warnrecip_maps_ref;
18132    if ($wr) {
18133      $notify_recips_templ_ref =
18134        $r->setting_by_contents_category(cr('notify_recips_templ_by_ccat'));
18135      if (!ref($notify_recips_templ_ref) ||
18136               (ref($notify_recips_templ_ref) eq 'ARRAY' ?
18137                !@$notify_recips_templ_ref : $$notify_recips_templ_ref eq '')){
18138        do_log(5,"skip recipient notifications - empty template");
18139        $wr = 0;  # do not send empty notifications
18140      } elsif (!c('warn_offsite') && !$r->recip_is_local) {
18141        do_log(5,"skip recipient notifications - nonlocal recipient");
18142        $wr = 0;  # do not notify foreign recipients
18143#     } elsif ($r->recip_destiny == D_PASS) {
18144#       do_log(5,"skip recipient notifications - mail will be delivered");
18145#       $wr = 0;  # do not notify recips which will be getting a message anyway
18146#     } elsif ($msginfo->sender eq '') {  # (not general enough)
18147#       do_log(5,"skip recipient notifications for null sender");
18148#       $wr = 0;
18149      }
18150    }
18151    if ($wr) {  # warn recipient
18152      my $mailfrom_recip =
18153        $r->setting_by_contents_category(cr('mailfrom_notify_recip_by_ccat'));
18154      my $hdrfrom_recip =
18155        $r->setting_by_contents_category(cr('hdrfrom_notify_recip_by_ccat'));
18156      # make sure it's in octets
18157      safe_encode_utf8_inplace($mailfrom_recip); # to octets (if not already)
18158      safe_encode_utf8_inplace($hdrfrom_recip);  # to octets (if not already)
18159      $hdrfrom_recip = expand_variables($hdrfrom_recip);
18160      if (!defined $mailfrom_recip) {
18161        # defaults to email address in hdrfrom_notify_recip
18162        $mailfrom_recip =
18163          unquote_rfc2821_local( (parse_address_list($hdrfrom_recip))[0] );
18164      }
18165      my $notification = Amavis::In::Message->new;
18166      $notification->rx_time($msginfo->rx_time);  # copy the reception time
18167      $notification->log_id($msginfo->log_id);    # copy log id
18168      $notification->partition_tag($msginfo->partition_tag); # same partition
18169      $notification->parent_mail_id($msginfo->mail_id);
18170      $notification->mail_id(scalar generate_mail_id());
18171      $notification->conn_obj($msginfo->conn_obj);
18172      $notification->originating(1);
18173      $notification->add_contents_category(CC_CLEAN,0);
18174      if (grep( / [^\x00-\x7F] .*? \@ [^@]* \z/sx && is_valid_utf_8($_),
18175                ($mailfrom_recip, $rec) )) {
18176        # localpart is non-ASCII UTF-8, we must use SMTPUTF8
18177        do_log(2, 'recipient notification requires SMTPUTF8');
18178        $notification->smtputf8(1);
18179      } else {
18180        $_ = mail_addr_idn_to_ascii($_)  for ($mailfrom_recip, $rec);
18181      }
18182      $notification->sender($mailfrom_recip);
18183      $notification->sender_smtp(qquote_rfc2821_local($mailfrom_recip));
18184      $notification->auth_submitter($notification->sender_smtp);
18185      $notification->auth_user(c('amavis_auth_user'));
18186      $notification->auth_pass(c('amavis_auth_pass'));
18187      $notification->recips([$rec]);
18188      my $notif_m = c('notify_method');
18189      $_->delivery_method($notif_m)  for @{$notification->per_recip_data};
18190      my(@rfc2822_from_recip) =
18191        map(unquote_rfc2821_local($_), parse_address_list($hdrfrom_recip));
18192      $notification->rfc2822_from($rfc2822_from_recip[0]);
18193#     if ($mailfrom_recip ne '')
18194#       { $_->dsn_notify(['NEVER'])  for @{$notification->per_recip_data} }
18195
18196      my(@b);  @b = @{$r->banned_parts}  if defined $r->banned_parts;
18197      my $b_chopped = @b > 2;  @b = (@b[0,1],'...')  if $b_chopped;
18198      s/[ \t]{6,}/ ... /g  for @b;
18199      my(%mybuiltins) = %builtins;  # make a local copy
18200      $mybuiltins{'banned_parts'} = \@b;         # list of banned parts
18201      $mybuiltins{'F'} = $r->banning_reason_short;  # just one name & comment
18202      $mybuiltins{'banning_rule_comment'} =
18203        !defined($r->banning_rule_comment) ? undef
18204                                       : unique_ref($r->banning_rule_comment);
18205      $mybuiltins{'banning_rule_rhs'} =
18206        !defined($r->banning_rule_rhs) ? undef
18207                                       : unique_ref($r->banning_rule_rhs);
18208      $mybuiltins{'f'} = safe_decode_utf8($hdrfrom_recip);  # From:
18209      $mybuiltins{'T'} = mail_addr_idn_to_ascii(qquote_rfc2821_local($rec));
18210      $notification->mail_text(
18211        build_mime_entity(expand($notify_recips_templ_ref,\%mybuiltins),
18212                          $msginfo, undef,undef,0, 0,0) );
18213#     $notification->body_type('7BIT');  # '8BITMIME'
18214      my $hdr_edits = Amavis::Out::EditHeader->new;
18215      $notification->header_edits($hdr_edits);
18216      mail_dispatch($notification, 'Notif', 0);
18217      my($n_smtp_resp, $n_exit_code, $n_dsn_needed) =
18218        one_response_for_all($notification, 0);  # check status
18219      if ($n_smtp_resp =~ /^2/ && !$n_dsn_needed) {  # ok
18220        build_and_save_structured_report($notification,'NOTIF');
18221      } elsif ($n_smtp_resp =~ /^4/) {
18222        die "temporarily unable to notify recipient rec: $n_smtp_resp";
18223      } else {
18224        do_log(-1, "FAILED to notify recipient %s: %s", $rec,$n_smtp_resp);
18225      }
18226      # $notification->purge;
18227    }
18228  }
18229  do_log(5, "do_notify_and_quarantine - done");
18230}
18231
18232# Calculate a message body digest;
18233# While at it, also get message size, verify DKIM signatures, check for 8-bit
18234# data, collect entropy, and store original header section since we need it
18235# for the %H macro, and MIME::Tools may modify its copy.
18236#
18237sub get_body_digest($$) {
18238  my($msginfo, $alg) = @_;
18239  my($remaining_time, $dkim_deadline) =   # sanity limit for DKIM verification
18240    get_deadline('get_body_digest', 0.5, 8, 30);
18241  prolong_timer('digest_pre');  # restart the timer
18242  my($hctx,$bctx);
18243  # choose a message digest: MD5: 128 bits (32 hex), SHA family: 160..512 bits
18244  if (uc $alg eq 'MD5') { $hctx = Digest::MD5->new; $bctx = Digest::MD5->new }
18245  else { $hctx = Digest::SHA->new($alg); $bctx = Digest::SHA->new($alg) }
18246  my $dkim_verifier;
18247  if (c('enable_dkim_verification')) {
18248    if (!defined $dns_resolver && Mail::DKIM::Verifier->VERSION >= 0.40) {
18249      # Create a persistent DNS resolver object for the benefit
18250      # of Mail::DKIM::Verifier; this avoids repeating initializations
18251      # with each request, and allows us to turn on EDNS.
18252      # The controversial need for 'config_file' option was debated in
18253      # [rt.cpan.org #96608] https://rt.cpan.org/Ticket/Display.html?id=96608
18254      # With Net::DNS 1.03 the semantics of a "retry" option has changed:
18255      # [rt.cpan.org #109183] https://rt.cpan.org/Ticket/Display.html?id=109183
18256      $dns_resolver = Net::DNS::Resolver->new(
18257        config_file => '/etc/resolv.conf',
18258        defnames => 0, force_v4 => !$have_inet6,
18259        retry => 2,  # number of times to try the query (not REtries)
18260        persistent_udp => 1,
18261        tcp_timeout => 3, udp_timeout => 3, retrans => 2,  # seconds
18262      );
18263      if (!$dns_resolver) {
18264        do_log(-1, "Failed to create a Net::DNS::Resolver object");
18265        $dns_resolver = 0;  # defined but false
18266      } else {
18267        # RFC 2460 (for IPv6) requires that a minimal MTU is 1280 bytes,
18268        # taking away 40 bytes for a basic IP header gives 1240;
18269        # RFC 3226: minimum of 1220 for RFC 2535 compliant servers
18270        # RFC 6891: choosing between 1280 and 1410 bytes for IP (v4 or v6)
18271        # over Ethernet would be reasonable.
18272        my $payload_size = 1220;  # a conservative default
18273        # RFC 6891 (ex RFC 2671) - EDNS0, set requestor's UDP payload size
18274        $dns_resolver->udppacketsize($payload_size)  if $payload_size > 512;
18275        ll(5) && do_log(5, "DNS resolver created, UDP payload size %s, NS: %s",
18276                           $dns_resolver->udppacketsize,
18277                           join(', ',$dns_resolver->nameservers) );
18278        Mail::DKIM::DNS::resolver($dns_resolver);
18279      }
18280    }
18281    $dkim_verifier = Mail::DKIM::Verifier->new;
18282  }
18283# section_time('digest_init');
18284
18285  my($header_size, $body_size, $h_8bit, $b_8bit) = (0) x 4;
18286  my $orig_header = [];  # array of header fields, with folding and trailing NL
18287  my $orig_header_fields = {};
18288  my $sanity_limit =   4*1024*1024;  #   4 MiB header size sanity limit
18289  my $dkim_sanity_limit = 256*1024;  # 256 KiB header size sanity limit
18290
18291  my $msg = $msginfo->mail_text;
18292  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
18293  $msg = $msg_str_ref  if ref $msg_str_ref;
18294  my $pos = 0;
18295
18296  if (!defined $msg) {
18297    # empty mail
18298    $msginfo->body_start_pos(0);
18299
18300  } elsif (ref $msg eq 'SCALAR') {
18301    do_log(5, "get_body_digest: reading header section from memory");
18302    my $header;
18303    $pos = min($msginfo->skip_bytes, length($$msg));
18304    if ($pos >= length($$msg)) {  # empty message
18305      $header = ''; $pos = length($$msg);
18306    } elsif (substr($$msg,$pos,1) eq "\n") {  # empty header section
18307      $header = ''; $pos++;
18308    } else {
18309      my $ind = index($$msg, "\n\n", $pos);  # find header/body separator
18310      $header = $ind < 0 ? substr($$msg, $pos)
18311                         : substr($$msg, $pos, $ind+1-$pos);
18312      $h_8bit = 1  if $header =~ tr/\x00-\x7F//c;
18313      $hctx->add($header);
18314      $pos = $ind < 0 ? length($$msg) : $ind+2;
18315    }
18316    # $pos now points to the first byte of a body
18317    $msginfo->body_start_pos($pos);
18318    local($1); my($j,$k,$ln);
18319    for ($j = 0; $j < length($header); $j = $k+1) {
18320      $k = index($header, "\n", $j);
18321      $ln = $k < 0 ? substr($header, $j) : substr($header, $j, $k-$j+1);
18322      if ($ln =~ /^[ \t]/) {  # header field continuation
18323        $$orig_header[-1] .= $ln;  # includes NL
18324      } else {  # starts a new header field
18325        push(@$orig_header, $ln);  # includes NL
18326        if ($ln =~ /^([^: \t]+)[ \t]*:/) {
18327          # remember array index of each occurrence of a header field, top down
18328          my $curr_entry = $orig_header_fields->{lc($1)};
18329          if (!defined $curr_entry) {
18330            # optimized: if there is only one element, it is stored as itself
18331            $orig_header_fields->{lc($1)} = $#$orig_header;
18332          } elsif (ref $curr_entry) {  # already an arrayref, append
18333            push(@{$orig_header_fields->{lc($1)}}, $#$orig_header);
18334          } else {  # was a single element as a scalar, now there are two
18335            $orig_header_fields->{lc($1)} = [ $curr_entry, $#$orig_header ];
18336          }
18337        }
18338      }
18339      last if $k < 0;
18340    }
18341
18342    $header =~ s{\n}{\015\012}gs;    # needed for DKIM and for size
18343    $header_size = length($header);  # size includes CRLF (RFC 1870)
18344    if (defined $dkim_verifier) {
18345      do_log(5, "get_body_digest: feeding header section to DKIM verifier");
18346      eval {
18347        $dkim_verifier->PRINT($header)
18348          or die "Error writing mail header to DKIM: $!";
18349        1;
18350      } or do {
18351        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
18352        do_log(-1,"Error feeding header to DKIM verifier: %s",$eval_stat);
18353        undef $dkim_verifier;
18354      };
18355    }
18356
18357  } elsif ($msg->isa('MIME::Entity')) {
18358    die "get_body_digest: reading from a MIME::Entity object not implemented";
18359
18360  } else {  # a file handle assumed
18361    do_log(5, "get_body_digest: reading header section from a file");
18362    $pos = $msginfo->skip_bytes;  # should be 0, but anyway...
18363    $msg->seek($pos,0)  or die "Can't rewind mail file: $!";
18364
18365    # read mail header section
18366    local($1); my $ln;
18367    for ($! = 0; defined($ln=$msg->getline); $! = 0) {
18368      $pos += length($ln);
18369      last  if $ln eq "\n";
18370      $hctx->add($ln);
18371      $h_8bit = 1  if !$h_8bit && ($ln =~ tr/\x00-\x7F//c);
18372      if ($ln =~ /^[ \t]/) {  # header field continuation
18373        $$orig_header[-1] .= $ln; # including NL
18374      } else {  # starts a new header field
18375        push(@$orig_header,$ln);  # including NL
18376        if ($ln =~ /^([^: \t]+)[ \t]*:/) {
18377          # remember array index of each occurrence of a header field, top down
18378          my $curr_entry = $orig_header_fields->{lc($1)};
18379          if (!defined $curr_entry) {
18380            # optimized: if there is only one element, it is stored as itself
18381            $orig_header_fields->{lc($1)} = $#$orig_header;
18382          } elsif (ref $curr_entry) {  # already an arrayref, append
18383            push(@{$orig_header_fields->{lc($1)}}, $#$orig_header);
18384          } else {  # was a single element as a scalar, now there are two
18385            $orig_header_fields->{lc($1)} = [ $curr_entry, $#$orig_header ];
18386          }
18387        }
18388      }
18389      chomp($ln);
18390      if (!defined $dkim_verifier) {
18391        # don't bother
18392      } elsif ($header_size > $dkim_sanity_limit) {
18393        do_log(-1,"Stopped feeding header to DKIM verifier: ".
18394                   "%.0f KiB sanity limit exceeded", $dkim_sanity_limit/1024);
18395        undef $dkim_verifier;
18396      } elsif (Time::HiRes::time > $dkim_deadline) {
18397        do_log(-1,"Stopped feeding header to DKIM verifier: deadline exceeded");
18398        undef $dkim_verifier;
18399      } else {
18400        eval {
18401          $dkim_verifier->PRINT($ln."\015\012")
18402            or die "Error writing mail header to DKIM: $!";
18403          1;
18404        } or do {
18405          my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
18406          do_log(-1,"Error feeding header line to DKIM verifier: %s",
18407                    $eval_stat);
18408          undef $dkim_verifier;
18409        };
18410      }
18411      $header_size += length($ln)+2;  # size includes CRLF (RFC 1870)
18412      # exceeded $sanity_limit will break DKIM signatures, too bad...
18413      last  if $header_size > $sanity_limit;
18414    }
18415    defined $ln || $! == 0  or        # returning EBADF at EOF is a perl bug
18416      $! == EBADF ? do_log(0,"Error reading mail header section: $!")
18417                  : die "Error reading mail header section: $!";
18418    $msginfo->body_start_pos($pos);
18419  }
18420  add_entropy($hctx->digest);
18421
18422  if (defined $dkim_verifier) {
18423    do_log(5, "get_body_digest: sending h/b separator to DKIM");
18424    eval {
18425      # h/b separator will trigger signature pre-processing in DKIM module
18426      $dkim_verifier->PRINT("\015\012")
18427        or die "Error writing h/b separator to DKIM: $!";
18428      1;
18429    } or do {
18430      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
18431      do_log(-1,"Error feeding h/b separ to DKIM verifier: %s", $eval_stat);
18432      undef $dkim_verifier;
18433    };
18434  }
18435
18436  $header_size += 2;  # include a separator CRLF line in a header section size
18437  untaint_inplace($header_size);  # length(tainted) stays tainted too
18438  section_time('digest_hdr');
18439  # a DNS lookup in Mail::DKIM older than 0.30 stops the timer!
18440  # The lookup is performed at a header/body separator line or at CLOSE, at
18441  # which point signatures become available through the $dkim_verifier object.
18442  prolong_timer('digest_hdr');  # restart timer if stopped
18443
18444  my(@dkim_signatures);
18445  @dkim_signatures = $dkim_verifier->signatures  if defined $dkim_verifier;
18446  # don't bother feeding body to DKIM if there are no signature header fields
18447  my $feed_dkim = @dkim_signatures > 0;
18448  if ($feed_dkim) {
18449    $msginfo->checks_performed({})  if !$msginfo->checks_performed;
18450    $msginfo->checks_performed->{D} = 1;
18451  }
18452
18453  if (!defined $msg) {
18454    # empty mail
18455
18456  } elsif (ref $msg eq 'SCALAR') {
18457    ll(5) && do_log(5, "get_body_digest: reading mail body from memory, ".
18458                       "%d DKIM signatures", scalar @dkim_signatures);
18459    my($buff, $buff_l);
18460    while ($pos < length($$msg)) {
18461      # do it in chunks to avoid unnecessarily large memory use
18462      # for temporary variables
18463      $buff = substr($$msg,$pos,32768); $buff_l = length($buff);
18464      $pos += $buff_l;
18465      $bctx->add($buff);
18466      $b_8bit = 1  if !$b_8bit && ($buff =~ tr/\x00-\x7F//c);
18467      if (!$feed_dkim) {
18468        # count \n, compensating for CRLF (RFC 1870)
18469        $body_size += $buff_l + ($buff =~ tr/\n//);
18470      } else {
18471        $buff =~ s{\n}{\015\012}gs;
18472        $body_size += length($buff);
18473        eval {
18474          $dkim_verifier->PRINT($buff)
18475            or die "Error writing mail body to DKIM: $!";
18476          1;
18477        } or do {
18478          my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
18479          do_log(-1,"Error feeding body to DKIM verifier: %s",$eval_stat);
18480          undef $dkim_verifier;
18481        };
18482      }
18483    }
18484
18485  } elsif ($msg->isa('MIME::Entity')) {
18486    die "get_body_digest: reading from MIME::Entity is not implemented";
18487
18488  } else {
18489    #*** # only read further if not already at end-of-file
18490    ll(5) && do_log(5, "get_body_digest: reading mail body from a file, ".
18491                       "%d DKIM signatures", scalar @dkim_signatures);
18492    my($buff, $buff_l);
18493    while (($buff_l = $msg->read($buff,65536)) > 0) {
18494      $bctx->add($buff);
18495      $b_8bit = 1  if !$b_8bit && ($buff =~ tr/\x00-\x7F//c);
18496      if (!$feed_dkim) {
18497        # count \n, compensating for CRLF (RFC 1870)
18498        $body_size += $buff_l + ($buff =~ tr/\n//);
18499      } else {
18500        $buff =~ s{\n}{\015\012}gs;
18501        $body_size += length($buff);
18502        eval {
18503          $dkim_verifier->PRINT($buff)
18504            or die "Error writing mail body to DKIM: $!";
18505          1;
18506        } or do {
18507          my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
18508          do_log(-1,"Error feeding body to DKIM verifier: %s",$eval_stat);
18509          undef $dkim_verifier;
18510        };
18511      }
18512    }
18513    defined $buff_l  or die "Error reading mail body: $!";
18514  }
18515  if (defined $dkim_verifier) {
18516    eval {
18517      # this will trigger signature verification in the DKIM module
18518      $dkim_verifier->CLOSE or die "Can't close dkim_verifier: $!";
18519      1;
18520    } or do {
18521      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
18522      do_log(-1,"Error closing DKIM verifier: %s",$eval_stat);
18523      undef $dkim_verifier;
18524    };
18525    @dkim_signatures = $dkim_verifier->signatures  if defined $dkim_verifier;
18526  }
18527  prolong_timer('digest_body');  # restart timer if stopped
18528
18529  my $body_digest = untaint($bctx->digest);
18530  add_entropy($body_digest);
18531
18532  # store information obtained
18533  if (@dkim_signatures) {
18534    if (@dkim_signatures > 50) {  # sanity
18535      do_log(-1, "Too many DKIM or DK signatures (%d), truncating to 50",
18536                 scalar(@dkim_signatures));
18537      $#dkim_signatures = 49;
18538    }
18539    $msginfo->dkim_signatures_all(\@dkim_signatures);
18540  }
18541
18542  if (ll(5)) {
18543    my $mail_size_old = $msginfo->msg_size;
18544    my $mail_size_new = $header_size + $body_size;
18545    if (defined($mail_size_old) && $mail_size_new != $mail_size_old) {
18546      # copy_smtp_data() provides a message size which is not adjusted for
18547      # dot-destuffing - for speed.  We finely adjust the message size here,
18548      # now that we have the necessary information available.
18549      do_log(5, "get_body_digest: message size adjusted %d -> %d, ".
18550                "header+sep %d, body %d",
18551                $mail_size_old, $mail_size_new, $header_size, $body_size);
18552    } else {
18553      do_log(5, "get_body_digest: message size %d, header+sep %d, body %d",
18554                $mail_size_new, $header_size, $body_size);
18555    }
18556  }
18557  $msginfo->msg_size($header_size + $body_size);
18558  $msginfo->orig_header_fields($orig_header_fields);  # stores just indices
18559  $msginfo->orig_header($orig_header); # header section, without separator line
18560  $msginfo->orig_header_size($header_size);  # size includes a separator line!
18561  $msginfo->orig_body_size($body_size);
18562  my $body_digest_hex = unpack('H*', $body_digest);  # high nybble first
18563  # store hex-encoded to retain backward compatibility with pre-2.8.0
18564  $msginfo->body_digest($body_digest_hex);
18565  $msginfo->header_8bit($h_8bit ? 1 : 0);
18566  $msginfo->body_8bit($b_8bit ? 1 : 0);
18567  # check for 8-bit characters and adjust body type if necessary (RFC 6152)
18568  my $bt_orig = $msginfo->body_type;
18569  $bt_orig = defined $bt_orig ? uc $bt_orig : '';
18570  if ($h_8bit || $b_8bit) {
18571    # just keep original label whatever it is (garbage-in - garbage-out);
18572    # keeping 8-bit mail unlabeled might avoid breaking DKIM in transport
18573    # (labeling as 8-bit may invoke 8>7 downgrades in MTA, breaking signatures)
18574  } elsif ($bt_orig eq '') {  # unlabeled on reception
18575    $msginfo->body_type('7BIT');  # safe to label as all-ASCII
18576  } elsif ($bt_orig eq '8BITMIME') {  # redundant (quite common)
18577    $msginfo->body_type('7BIT');  # turn a redundant 8BITMIME into 7BIT
18578  }
18579  if (ll(4)) {
18580    my $remark =
18581      ($bt_orig eq ''         &&              $b_8bit)  ? ", but 8-bit body"
18582    : ($bt_orig eq ''         &&              $h_8bit)  ? ", but 8-bit header"
18583    : ($bt_orig eq '7BIT'     &&  ($h_8bit || $b_8bit)) ? " inappropriately"
18584    : ($bt_orig eq '8BITMIME' && !($h_8bit || $b_8bit)) ? " unnecessarily"
18585    : ", good";
18586    do_log(4, "body type (8bit-MIMEtransport): %s%s (h=%s, b=%s)",
18587           $bt_orig eq '' ? 'unlabeled' : "labeled $bt_orig",
18588           $remark, $h_8bit, $b_8bit);
18589  }
18590  do_log(3, "body hash: %s", $body_digest_hex);
18591  section_time(defined $dkim_verifier ? 'digest_body_dkim' : 'digest_body');
18592  $body_digest_hex;
18593}
18594
18595sub find_program_path($$) {
18596  my($fv_list, $path_list_ref) = @_;
18597  $fv_list = [$fv_list]  if !ref $fv_list;
18598  my $found;
18599  for my $fv (@$fv_list) {  # search through alternatives
18600    my(@fv_cmd) = split(' ',$fv);
18601    my $cmd = $fv_cmd[0];
18602    if (!@fv_cmd) {
18603      # empty, not available
18604    } elsif ($cmd =~ m{^/}s) {  # absolute path
18605      my $errn = stat($cmd) ? 0 : 0+$!;
18606      if ($errn == ENOENT) {
18607        # file does not exist
18608      } elsif ($errn) {
18609        do_log(-1, "find_program_path: %s inaccessible: %s", $cmd,$!);
18610      } elsif (-d _) {
18611        do_log(0, "find_program_path: %s is a directory", $cmd);
18612      } elsif (!-x _) {
18613        do_log(0, "find_program_path: %s is not executable", $cmd);
18614      } else {
18615        $found = join(' ', @fv_cmd);
18616      }
18617    } elsif ($cmd =~ m{/}s) {  # relative path
18618      die "find_program_path: relative paths not implemented: @fv_cmd\n";
18619    } else {                   # walk through the specified PATH
18620      for my $p (@$path_list_ref) {
18621        my $errn = stat("$p/$cmd") ? 0 : 0+$!;
18622        if ($errn == ENOENT) {
18623          # file does not exist
18624        } elsif ($errn) {
18625          do_log(-1, "find_program_path: %s/%s inaccessible: %s", $p,$cmd,$!);
18626        } elsif (-d _) {
18627          do_log(0, "find_program_path: %s/%s is a directory", $p,$cmd);
18628        } elsif (!-x _) {
18629          do_log(0, "find_program_path: %s/%s is not executable", $p,$cmd);
18630        } else {
18631          $found = $p . '/' . join(' ', @fv_cmd);
18632          last;
18633        }
18634      }
18635    }
18636    last  if defined $found;
18637  }
18638  $found;
18639}
18640
18641sub find_external_programs($) {
18642  my $path_list_ref = $_[0];
18643  for my $f (qw($file $altermime)) {
18644    my $g = $f;  $g =~ s/\$/Amavis::Conf::/;  my $fv_list = eval('$' . $g);
18645    my $found = find_program_path($fv_list, $path_list_ref);
18646    { no strict 'refs'; $$g = $found }  # NOTE: a symbolic reference
18647    if (!defined $found) { do_log(0,"No %-19s not using it", "$f,") }
18648    else {
18649      do_log(1, "Found %-16s at %s%s", $f,
18650             $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '',
18651             $found);
18652    }
18653  }
18654  # map program name path hints to full paths for decoders
18655  my(%any_st);
18656  for my $f (@{ca('decoders')}) {
18657    next  if !defined $f || !ref $f;  # empty, skip
18658    my $short_types = $f->[0];
18659    if (!defined $short_types || (ref $short_types && !@$short_types)) {
18660      undef $f; next;
18661    }
18662    my(@tried,@found);  my $any = 0;
18663    for my $d (@$f[2..$#$f]) {  # all but the first two elements are programs
18664      # find the program, allow one level of indirection
18665      my $dd = (ref $d eq 'SCALAR' || ref $d eq 'REF') ? $$d : $d;
18666      my $found = find_program_path($dd, $path_list_ref);
18667      if (defined $found) {
18668        $any = 1; $d = $dd = $found; push(@found,$dd);
18669      } else {
18670        push(@tried, !ref($dd) ? $dd : join(", ",@$dd))  if $dd ne '';
18671        undef $d;
18672      }
18673    }
18674    my $any_in_use;
18675    for my $short_type (ref $short_types ? @$short_types : $short_types) {
18676      my $is_a_backup = $any_st{$short_type};
18677      my($ll,$tier) = !$is_a_backup ? (1,'') : (2,' (backup, not used)');
18678      if (@$f <= 2) {  # no external programs specified
18679        if (!$is_a_backup) { $any_in_use = 1; $any_st{$short_type} = 1 }
18680        do_log($ll, "Internal decoder for .%-4s%s", $short_type,$tier);
18681      } elsif (!$any) {  # external programs specified but none found
18682        do_log(0, "No ext program for   .%s, tried: %s",
18683          $short_type, join('; ',@tried))  if @tried && !$is_a_backup;
18684      } else {
18685        if (!$is_a_backup) { $any_in_use = 1; $any_st{$short_type} = 1 }
18686        do_log($ll, "Found decoder for    .%-4s at %s%s%s", $short_type,
18687            $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '',
18688            join('; ',@found), $tier);
18689      }
18690      # defined but false, collect a list of tried short types as hash keys
18691      $any_st{$short_type} = 0  if !defined $any_st{$short_type};
18692    }
18693    if (!$any_in_use) {
18694      undef $f;  # discard a backup entry
18695    } else {
18696      # turn array (in the first element) into a hash
18697      $f->[0] = { map(($_,1), @$short_types) }  if ref $short_types;
18698    }
18699  }
18700  for my $short_type (sort grep(!$any_st{$_}, keys %any_st)) {
18701    do_log(0, "No decoder for       .%-4s",  $short_type);
18702  }
18703  # map program name hints to full paths - av scanners
18704  my $tier = 'primary';  # primary, secondary, ...   av scanners
18705  for my $f (@{ca('av_scanners')}, "\000", @{ca('av_scanners_backup')}) {
18706    if ($f eq "\000") {   # next tier
18707      $tier = 'secondary';
18708    } elsif (!defined $f || !ref $f) {
18709      # empty, skip
18710    } elsif (ref($f->[1]) eq 'CODE') {
18711      do_log(0, "Using %s internal av scanner code for %s", $tier,$f->[0]);
18712    } else {
18713      my $found = $f->[1] = find_program_path($f->[1], $path_list_ref);
18714      if (!defined $found) {
18715        do_log(3, "No %s av scanner: %s", $tier, $f->[0]);
18716        undef $f;  # release its storage
18717      } else {
18718        do_log(0, "Found %s av scanner %-11s at %s%s", $tier, $f->[0],
18719              $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '',
18720              $found);
18721      }
18722    }
18723  }
18724  for my $f (@{ca('spam_scanners')}) {
18725    if (!defined $f || !ref $f) {
18726      # empty, skip
18727    } elsif ($f->[1] ne 'Amavis::SpamControl::ExtProg') {
18728      do_log(5, "Using internal spam scanner code for %s", $f->[0]);
18729    } else {  # using the Amavis::SpamControl::ExtProg interface module
18730      my $found = $f->[2] = find_program_path($f->[2], $path_list_ref);
18731      if (!defined $found) {
18732        do_log(3, "No spam scanner:   %s", $f->[0]);
18733        undef $f;  # release its storage
18734      } else {
18735        do_log(0, "Found spam scanner %-11s at %s%s", $f->[0],
18736              $daemon_chroot_dir ne '' ? "(chroot: $daemon_chroot_dir/) " : '',
18737              $found);
18738      }
18739    }
18740  }
18741}
18742
18743# Fetch remaining modules, all must be loaded before chroot and fork occurs
18744#
18745sub fetch_modules_extra() {
18746  my(@modules,@optmodules);
18747  if ($extra_code_sql_base) {
18748    push(@modules, 'DBI');
18749    push(@optmodules, 'DBI::Const::GetInfoType', 'DBI::Const::GetInfo::ANSI');
18750    for (@lookup_sql_dsn, @storage_sql_dsn) {
18751      my(@dsn) = split(/:/, $_->[0], -1);
18752      push(@modules, 'DBD::'.$dsn[1])  if uc($dsn[0]) eq 'DBI';
18753    }
18754  }
18755  push(@modules, qw(Net::LDAP Net::LDAP::Util Net::LDAP::Search
18756                    Net::LDAP::Bind Net::LDAP::Extension)) if $extra_code_ldap;
18757  if ($extra_code_dkim ||
18758      c('tls_security_level_in') || c('tls_security_level_out')) {
18759    push(@modules, qw(Crypt::OpenSSL::RSA));
18760  }
18761  if (c('tls_security_level_in') || c('tls_security_level_out')) {
18762    push(@modules, qw(IO::Socket::SSL
18763                      Net::SSLeay auto::Net::SSLeay::ssl_write_all
18764                      auto::Net::SSLeay::ssl_read_until
18765                      auto::Net::SSLeay::dump_peer_certificate));
18766  }
18767  push(@modules, qw(Net::DNS::RR::TXT Text::ParseWords
18768         auto::Crypt::OpenSSL::RSA::new_public_key))  if $extra_code_dkim;
18769  push(@modules, 'Anomy::Sanitizer')  if $enable_anomy_sanitizer;
18770  Amavis::Boot::fetch_modules('REQUIRED ADDITIONAL MODULES', 1, @modules);
18771
18772  push(@optmodules, qw(
18773    bytes bytes_heavy.pl utf8 utf8_heavy.pl
18774    Encode Encode::Byte Encode::MIME::Header Encode::Unicode::UTF7
18775    Encode::CN Encode::TW Encode::KR Encode::JP
18776    unicore::To::Lower.pl unicore::To::Upper.pl
18777    unicore::To::Fold.pl unicore::To::Title.pl unicore::To::Digit.pl
18778    unicore::lib::Perl::Alnum.pl unicore::lib::Perl::SpacePer.pl
18779    unicore::lib::Perl::Word.pl
18780    unicore::lib::Alpha::Y.pl unicore::lib::Nt::De.pl
18781  ));
18782
18783  if (@Amavis::Conf::decoders &&
18784      grep { exists $policy_bank{$_}{'bypass_decode_parts'} &&
18785             !do { my $v = $policy_bank{$_}{'bypass_decode_parts'};
18786                   !ref $v ? $v : $$v } } keys %policy_bank)
18787  { # at least one bypass_decode_parts is explicitly false
18788    push(@modules, qw(Archive::Zip));
18789  # push(@modules, qw(Convert::TNEF Convert::UUlib Archive::Tar));
18790  }
18791
18792  push(@optmodules, $] >= 5.012000 ? qw(unicore::Heavy.pl)
18793         : qw(unicore::Canonical.pl unicore::Exact.pl unicore::PVA.pl));
18794  # unicore::lib::Perl::Word.pl unicore::lib::Perl::SpacePer.pl
18795  # unicore::lib::Perl::Alnum.pl unicore::lib::Alpha::Y.pl
18796  # unicore::lib::Nt::De.pl unicore::lib::Hex::Y.pl
18797
18798  push(@optmodules, qw(Unix::Getrusage));
18799  push(@optmodules, 'Authen::SASL')  if $extra_code_ldap &&
18800                                        !grep($_ eq 'Authen::SASL', @modules);
18801  push(@optmodules, defined($min_servers) ? 'Net::Server::PreFork'
18802                                       : 'Net::Server::PreForkSimple');
18803  push(@optmodules, @additional_perl_modules);
18804  my $missing;
18805  $missing = Amavis::Boot::fetch_modules('PRE-COMPILE OPTIONAL MODULES', 0,
18806                                         @optmodules)  if @optmodules;
18807  do_log(2, 'INFO: no optional modules: %s', join(' ',@$missing))
18808    if ref $missing && @$missing;
18809  # require minimal version 0.32, Net::LDAP::Util::escape_filter_value() needed
18810  Net::LDAP->VERSION(0.32)  if $extra_code_ldap;
18811  # needed a working last_insert_id in the past, no longer so but nevertheless:
18812  DBI->VERSION(1.43)  if $extra_code_sql_base;
18813  MIME::Entity->VERSION != 5.419
18814    or die "MIME::Entity 5.419 breaks quoted-printable encoding, ".
18815           "please upgrade to 5.420 or later (or use 5.418)";
18816  # load optional modules SAVI and Mail::ClamAV if available and requested
18817  if ($extra_code_antivirus) {
18818    my $clamav_module_ok;
18819    for my $entry (@{ca('av_scanners')}, @{ca('av_scanners_backup')}) {
18820      if (ref($entry) ne 'ARRAY') {
18821        # none
18822      } elsif ($entry->[0] eq 'Sophos SAVI') {
18823        if (defined(eval { require SAVI }) && SAVI->VERSION(0.30) &&
18824            Amavis::AV::sophos_savi_init(@$entry)) {}  # ok, loaded
18825        else { undef $entry->[1] }  # disable entry
18826      } elsif ($entry->[0] =~ /^Mail::ClamAV/) {
18827        if (!defined($clamav_module_ok)) {
18828          $clamav_module_ok = eval { require Mail::ClamAV };
18829          $clamav_module_ok = 0  if !defined $clamav_module_ok;
18830        }
18831        undef $entry->[1]  if !$clamav_module_ok;  # disable entry
18832      }
18833    }
18834  }
18835}
18836
18837sub usage() {
18838  my $myprogram_name = c('myprogram_name');
18839  return <<"EOD";
18840Usage:
18841  $myprogram_name
18842    [-u user] [-g group]
18843    [-i instance_name] {-c config_file}
18844    [-d log_level,area,...] [-X magic1,magic2,...]
18845    [-m max_servers] {-p listen_port_or_socket}
18846    [-L lock_file] [-P pid_file] [-H home_dir]
18847    [-D db_home_dir | -D ''] [-Q quarantine_dir | -Q '']
18848    [-R chroot_dir | -R ''] [-S helpers_home_dir] [-T tempbase_dir]
18849    ( [start] | stop | reload | restart | debug | debug-sa | foreground |
18850      showkeys {domains} | testkeys {domains} | genrsa file_name [nbits]
18851      convert_keysfile file_name )
18852  where area is a SpamAssassin debug area, e.g. all,util,rules,plugin,dkim,dcc
18853or:
18854  $myprogram_name (-h | -V)  ... show help or version, then exit
18855EOD
18856}
18857
18858# drop privileges
18859#
18860sub drop_priv($$) {
18861  my($desired_user,$desired_group) = @_;
18862  local($1);
18863  my($username,$passwd,$uid,$gid) =
18864    $desired_user=~/^(\d+)$/ ? (undef,undef,$1,undef) :getpwnam($desired_user);
18865  defined $uid or die "drop_priv: No such username: $desired_user\n";
18866  if (!defined($desired_group) || $desired_group eq '') {
18867    $desired_group = $gid;  # for logging purposes
18868  } else {
18869    $gid = $desired_group=~/^(\d+)$/ ? $1 : getgrnam($desired_group);
18870  }
18871  defined $gid or die "drop_priv: No such group: $desired_group\n";
18872  $( = $gid;  $) = "$gid $gid";   # real and effective GID
18873  POSIX::setgid($gid) or die "drop_priv: Can't setgid to $gid: $!";
18874  POSIX::setuid($uid) or die "drop_priv: Can't setuid to $uid: $!";
18875  $> = $uid; $< = $uid;  # just in case
18876# print STDERR "desired user=$desired_user ($uid), current: EUID: $> ($<)\n";
18877# print STDERR "desired group=$desired_group ($gid), current: EGID: $) ($()\n";
18878  $> != 0 or die "drop_priv: Still running as root, aborting\n";
18879  $< != 0 or die "Effective UID changed, but Real UID is 0, aborting\n";
18880}
18881
18882#
18883# Main program starts here
18884#
18885
18886stir_random();
18887add_entropy($], @INC, %ENV);
18888delete @ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};
18889
18890STDERR->autoflush(1);
18891STDERR->fcntl(F_SETFL, O_APPEND)
18892  or warn "Error setting O_APPEND on STDERR: $!";
18893
18894umask(0027);  # set our preferred umask
18895POSIX::setlocale(LC_TIME,'C');  # English dates required in syslog and RFC 5322
18896
18897# using Net::Server internal mechanism for a restart on HUP
18898$warm_restart = defined $ENV{BOUND_SOCKETS} && $ENV{BOUND_SOCKETS} ne '' ?1:0;
18899
18900update_current_log_level();
18901
18902# Read dynamic source code, and logging and notification message templates
18903# from the end of this file (pseudo file handle DATA)
18904#
18905$Amavis::Conf::notify_spam_admin_templ  = '';  # not used
18906$Amavis::Conf::notify_spam_recips_templ = '';  # not used
18907do {
18908  local($/) = "__DATA__\n";   # set line terminator to this string
18909  for (
18910    $extra_code_zmq, $extra_code_db,
18911    $extra_code_sql_lookup, $extra_code_ldap,
18912    $extra_code_in_ampdp, $extra_code_in_smtp, $extra_code_in_courier,
18913    $extra_code_out_smtp, $extra_code_out_pipe,
18914    $extra_code_out_bsmtp, $extra_code_out_local,
18915    $extra_code_p0f, $extra_code_redis,
18916    $extra_code_sql_base, $extra_code_sql_log, $extra_code_sql_quar,
18917    $extra_code_antivirus, $extra_code_antispam,
18918    $extra_code_antispam_extprog, $extra_code_antispam_rspamc,
18919    $extra_code_antispam_spamc, $extra_code_antispam_sa,
18920    $extra_code_unpackers, $extra_code_dkim, $extra_code_tools)
18921  { $_ = <Amavis::DATA>;
18922    defined($_) or die "Error reading optional code from the source file: $!";
18923    chomp($_);
18924  }
18925  binmode(\*Amavis::DATA, ':encoding(UTF-8)')
18926    or die "Can't set \*DATA encoding to UTF-8: $!";
18927  for (
18928    $Amavis::Conf::log_short_templ,
18929    $Amavis::Conf::log_verbose_templ,
18930    $Amavis::Conf::log_recip_templ,
18931    $Amavis::Conf::notify_sender_templ,
18932    $Amavis::Conf::notify_virus_sender_templ,
18933    $Amavis::Conf::notify_virus_admin_templ,
18934    $Amavis::Conf::notify_virus_recips_templ,
18935    $Amavis::Conf::notify_spam_sender_templ,
18936    $Amavis::Conf::notify_spam_admin_templ,
18937    $Amavis::Conf::notify_release_templ,
18938    $Amavis::Conf::notify_report_templ,
18939    $Amavis::Conf::notify_autoresp_templ)
18940  { $_ = <Amavis::DATA>;
18941    defined($_) or die "Error reading templates from the source file: $!";
18942    chomp($_);
18943  }
18944}; # restore line terminator
18945close(\*Amavis::DATA) or die "Error closing *Amavis::DATA: $!";
18946# close(STDIN)        or die "Error closing STDIN: $!";
18947# note: don't close STDIN just yet to prevent some other file taking up fd 0
18948
18949{ local($1);
18950  s/^(.*?)[\r\n]+\z/$1/s  # discard trailing NL
18951    for ($Amavis::Conf::log_short_templ,
18952         $Amavis::Conf::log_verbose_templ,
18953         $Amavis::Conf::log_recip_templ);
18954};
18955$Amavis::Conf::log_templ = $Amavis::Conf::log_short_templ;
18956
18957# Consider dropping privileges early, before reading a config file.
18958# This is only possible if running under chroot will not be needed.
18959#
18960my $desired_group;                      # defaults to $desired_user's group
18961my $desired_user;                       # username or UID
18962if ($> != 0) { $desired_user = $> }     # use effective UID if not root
18963
18964# collect and parse command line options
18965my($log_level_override, $max_servers_override);
18966my($myhome_override, $tempbase_override, $helpers_home_override);
18967my($quarantinedir_override, $db_home_override, $daemon_chroot_dir_override);
18968my($lock_file_override, $pid_file_override);
18969my(@listen_sockets_override, $listen_sockets_overridden);
18970my(@argv) = @ARGV;  # preserve @ARGV, may modify @argv
18971while (@argv >= 2 && $argv[0] =~ /^-[ugdimcpDHLPQRSTX]\z/ ||
18972       @argv >= 1 && $argv[0] =~ /^-/) {
18973  my($opt,$val);
18974  $opt = shift @argv;
18975  $val = shift @argv  if $opt !~ /^-[hV-]\z/;  # these take no arguments
18976  if ($opt eq '--') {
18977    last;
18978  } elsif ($opt eq '-h') {  # -h  (help)
18979    die "$myversion\n\n" . usage();
18980  } elsif ($opt eq '-V') {  # -V  (version)
18981    die "$myversion\n";
18982  } elsif ($opt eq '-X') {  # -X  (magic options: debugging, testing, ...)
18983    $i_know_what_i_am_doing{$_} = 1  for split(/\s*,\s*/, $val);
18984  } elsif ($opt eq '-u') {  # -u username
18985    if ($> == 0) { $desired_user = $val }
18986    else { print STDERR "Ignoring option -u when not running as root\n" }
18987  } elsif ($opt eq '-g') {  # -g group
18988    print STDERR "NOTICE: Option -g may not achieve desired result when ".
18989                 "running as non-root\n"  if $> != 0 && $val ne $desired_group;
18990    $desired_group = $val;
18991  } elsif ($opt eq '-i') {  # -i instance_name, may be of use to a .conf file
18992    $val =~ /^[a-z0-9._+-]*\z/i  or die "Special chars in option -i $val\n";
18993    $instance_name = untaint($val);  # not used by amavisd directly
18994  } elsif ($opt eq '-d') {  # -d log_level or -d SAdbg1,SAdbg2,..,SAdbg3
18995    $log_level_override = untaint($val);
18996  } elsif ($opt eq '-m') {  # -m max_servers
18997    $val =~ /^\+?\d+\z/  or die "Option -m requires a numeric argument\n";
18998    $max_servers_override = untaint($val);
18999  } elsif ($opt eq '-c') {  # -c config_file
19000    push(@config_files, untaint($val))  if $val ne '';
19001  } elsif ($opt eq '-p') {  # -p port_or_socket
19002    $listen_sockets_overridden = 1;  # may disable all sockets by -p ''
19003    push(@listen_sockets_override, untaint($val))  if $val ne '';
19004  } elsif ($opt eq '-D') {  # -D db_home_dir, empty string turns off db use
19005    $db_home_override = untaint($val);
19006  } elsif ($opt eq '-H') {  # -H home_dir
19007    $myhome_override = untaint($val)  if $val ne '';
19008  } elsif ($opt eq '-L') {  # -L lock_file
19009    $lock_file_override = untaint($val) if $val ne '';
19010  } elsif ($opt eq '-P') {  # -P pid_file
19011    $pid_file_override = untaint($val);  # empty disables pid_file
19012  } elsif ($opt eq '-Q') {  # -Q quarantine_dir, empty string disables quarant.
19013    $quarantinedir_override = untaint($val);
19014  } elsif ($opt eq '-R') {  # -R chroot_dir, empty string or '/' avoids chroot
19015    $daemon_chroot_dir_override = $val eq '/' ? '' : untaint($val);
19016  } elsif ($opt eq '-S') {  # -S helpers_home_dir for SA
19017    $helpers_home_override = untaint($val)  if $val ne '';
19018  } elsif ($opt eq '-T') {  # -T tempbase_dir
19019    $tempbase_override = untaint($val)  if $val ne '';
19020  } else {
19021    die "Error in parsing command line options: $opt\n\n" . usage();
19022  }
19023}
19024my $cmd = lc(shift @argv);
19025if ($cmd !~ /^(?:start|debug|debug-sa|foreground|reload|restart|stop|
19026                 showkeys?|testkeys?|genrsa|convert_keysfile)?\z/xs) {
19027  die "$myversion:\n  Unknown command line parameter: $cmd\n\n" . usage();
19028} elsif (@argv > 0 &&
19029         $cmd !~ /^(:?showkeys?|testkeys?|genrsa|convert_keysfile)/xs) {
19030  die sprintf("%s:\n  Only one command line parameter allowed: %s\n\n%s\n",
19031              $myversion, join(' ',@argv), usage());
19032}
19033
19034if (grep($_, values %i_know_what_i_am_doing)) {
19035  my(@known, @unknown);
19036  push(@{/^no_conf_file_writable_check\z/ ? \@known : \@unknown}, $_)
19037    for grep($i_know_what_i_am_doing{$_}, keys %i_know_what_i_am_doing);
19038  $unknown[0] = 'unknown: ' . $unknown[0]  if @unknown;
19039  warn sprintf("I know what I'm doing: %s\n", join(', ',@known,@unknown));
19040}
19041
19042# deal with debugging early, based on a command line arg
19043if ($cmd =~ /^(?:start|debug|debug-sa|foreground)?\z/) {
19044  $daemonize=0                  if $cmd eq 'foreground';
19045  $daemonize=0, $DEBUG=1        if $cmd eq 'debug';
19046  $daemonize=0, $sa_debug='all' if $cmd eq 'debug-sa';
19047}
19048
19049if (!defined($desired_user)) {
19050  # early dropping of privileges not requested
19051} elsif ($> != 0 && $< != 0) {
19052  # early dropping of privileges not needed
19053} elsif (defined $daemon_chroot_dir_override &&
19054         $daemon_chroot_dir_override ne '') {
19055  # early dropping of privs would prevent later chroot and is to be skipped
19056} else {
19057  # drop privileges early if a uid was specified on a command line, option -u
19058  drop_priv($desired_user,$desired_group);
19059}
19060
19061if ($cmd eq 'genrsa') {
19062  eval $extra_code_tools or die "Problem in Amavis::Tools code: $@";
19063  $extra_code_tools = 1; Amavis::Tools::generate_dkim_private_key(@argv);
19064  exit(0);
19065}
19066if ($cmd eq 'convert_keysfile') {
19067  eval $extra_code_tools or die "Problem in Amavis::Tools code: $@";
19068  $extra_code_tools = 1; Amavis::Tools::convert_dkim_keys_file(@argv);
19069  exit(0);
19070}
19071
19072# these settings must be overridden before and after read_config
19073# because some other settings in a config file may be derived from them
19074$Amavis::Conf::MYHOME   = $myhome_override    if defined $myhome_override;
19075$Amavis::Conf::TEMPBASE = $tempbase_override  if defined $tempbase_override;
19076$Amavis::Conf::QUARANTINEDIR = $quarantinedir_override
19077                                        if defined $quarantinedir_override;
19078$Amavis::Conf::helpers_home = $helpers_home   if defined $helpers_home;
19079$Amavis::Conf::daemon_chroot_dir = $daemon_chroot_dir_override
19080                                        if defined $daemon_chroot_dir_override;
19081
19082# some remaining initialization, possibly after dropping privileges by -u,
19083# but before reading configuration file
19084init_local_delivery_aliases();
19085init_builtin_macros();
19086$instance_name = ''  if !defined $instance_name;
19087
19088# convert arrayref to Amavis::Lookup::RE object, the Amavis::Lookup::RE module
19089# was not yet available during BEGIN phase
19090$Amavis::Conf::map_full_type_to_short_type_re =
19091  Amavis::Lookup::RE->new(@$Amavis::Conf::map_full_type_to_short_type_re);
19092
19093# default location of the config file if none specified
19094if (!@config_files) {
19095  @config_files = ( '/usr/local/etc/amavisd.conf' );
19096# # Debian/Ubuntu specific:
19097# @config_files = Amavis::Util::find_config_files('/usr/share/amavis/conf.d',
19098#                                                 '/etc/amavis/conf.d');
19099}
19100
19101# Read and evaluate config files, which may override default settings
19102Amavis::Conf::include_config_files(@config_files);
19103Amavis::Conf::supply_after_defaults();
19104update_current_log_level();
19105add_entropy($Amavis::Conf::myhostname, $Amavis::Conf::myversion_date);
19106
19107# not needed any longer, reclaim storage
19108undef $Amavis::Conf::log_short_templ;
19109undef $Amavis::Conf::log_verbose_templ;
19110
19111if (defined $desired_user && defined $daemon_user && $daemon_user ne '') {
19112  local($1);
19113  # compare the config file settings to current UID
19114  my($username,$passwd,$uid,$gid) =
19115    $daemon_user=~/^(\d+)$/ ? (undef,undef,$1,undef) : getpwnam($daemon_user);
19116  ($desired_user eq $daemon_user || $desired_user eq $uid)
19117    or warn sprintf("WARN: running under user '%s' (UID=%s), ".
19118                    "the config file specifies \$daemon_user='%s' (UID=%s)\n",
19119                   $desired_user, $>, $daemon_user, defined $uid ? $uid : '?');
19120}
19121
19122if ($> != 0 && $< != 0) {
19123  # dropping of privs is not needed
19124} elsif (defined $daemon_chroot_dir && $daemon_chroot_dir ne '') {
19125  # dropping of privs now would prevent later chroot and is to be skipped
19126} elsif (defined $daemon_user && $daemon_user ne '') {
19127  # drop privileges, unless needed for chrooting
19128  drop_priv($daemon_user,$daemon_group);
19129}
19130
19131# override certain config file options by command line arguments
19132$sa_debug='all'  if $cmd eq 'debug-sa';
19133my(@sa_debug_fac);  # list of SA debug facilities
19134if (defined $log_level_override) {
19135  for my $item (split(/[ \t]*,[ \t]*/, $log_level_override, -1)) {
19136    if ($item =~ /^[+-]?\d+\z/) { $Amavis::Conf::log_level = $item }
19137    elsif ($item =~ /^[A-Za-z0-9_-]+\z/) { push(@sa_debug_fac,$item) }
19138  }
19139  update_current_log_level();
19140}
19141$Amavis::Conf::MYHOME    = $myhome_override     if defined $myhome_override;
19142$Amavis::Conf::TEMPBASE  = $tempbase_override   if defined $tempbase_override;
19143$Amavis::Conf::QUARANTINEDIR = $quarantinedir_override
19144                                       if defined $quarantinedir_override;
19145$Amavis::Conf::helpers_home = $helpers_home     if defined $helpers_home;
19146$Amavis::Conf::daemon_chroot_dir = $daemon_chroot_dir_override
19147                                       if defined $daemon_chroot_dir_override;
19148if (defined $db_home_override) {
19149  if ($db_home_override =~ /^\s*\z/) { $enable_db = 0 }
19150  else { $Amavis::Conf::db_home = $db_home_override }
19151}
19152if (defined $max_servers_override && $max_servers_override ne '') {
19153  $Amavis::Conf::max_servers = $max_servers_override;
19154}
19155
19156if ($cmd =~ /^(?:showkeys?|testkeys?)\z/) {
19157  # useful for preparing DNS zone files and testing public keys in DNS
19158  eval $extra_code_dkim or die "Problem in Amavis::DKIM code: $@";
19159  $extra_code_dkim = 1; Amavis::DKIM::dkim_key_postprocess();
19160  eval $extra_code_tools or die "Problem in Amavis::Tools code: $@";
19161  $extra_code_tools = 1;  # release memory occupied by the source code
19162  Amavis::Tools::show_or_test_dkim_public_keys($cmd,\@argv);
19163  exit(0);
19164}
19165undef $extra_code_tools;  # no longer needed
19166
19167for ($unix_socketname, $inet_socket_port) {
19168  push(@listen_sockets, ref $_ ? @$_ : $_)  if defined $_ && $_ ne '';
19169}
19170@listen_sockets = @listen_sockets_override  if $listen_sockets_overridden;
19171for my $s (@listen_sockets) {
19172  # convert to a Net::Server::Proto syntax
19173  local($1);
19174  if    ($s =~ m{^unix:(/\S+)\z}s) { $s = "$1|unix" }
19175  elsif ($s =~ m{^inet:(.*)\z}s)   { $s = "$1/tcp" }
19176  elsif ($s =~ m{^inet6:(.*)\z}s)  { $s = "$1/tcp" }
19177  elsif ($s =~ m{^/\S+}s)          { $s = "$s|unix" }
19178  elsif ($s =~ m{^\d+\z}s)         { $s = "$s/tcp" }  # port number
19179  elsif ($s =~ m{^[^/|]+\z}s)      { $s = "$s/tcp" }  # almost anything goes
19180  elsif ($s =~ m{^.+\z}s)          { $s = "$s" }      # anything goes
19181  else { die "Socket specification syntax error: $s\n" }
19182}
19183@listen_sockets > 0  or die "No listen sockets or ports specified\n";
19184
19185# %modules_basic = %INC;  # helps to track missing modules in chroot
19186# compile optional modules if needed
19187
19188# NOTE: when releasing memory occupied by the source code, keep in mind:
19189# use undef(), see: http://www.perlmonks.org/?node_id=803515
19190
19191if (!$enable_zmq) {
19192  undef $extra_code_zmq;
19193} else {
19194  eval $extra_code_zmq
19195    or die "Problem in Amavis::ZMQ code: $@";
19196  # release memory occupied by the source code
19197  undef $extra_code_zmq; $extra_code_zmq = 1;
19198}
19199
19200if (!$enable_db) {
19201  undef $extra_code_db;
19202} else {
19203  eval $extra_code_db
19204    or die "Problem in Amavis::DB or Amavis::DB::SNMP code: $@";
19205  # release memory occupied by the source code
19206  undef $extra_code_db; $extra_code_db = 1;
19207}
19208
19209{ my $any_dkim_verification =
19210    scalar(grep { my $v = $policy_bank{$_}{'enable_dkim_verification'};
19211                  !ref $v ? $v : $$v } keys %policy_bank);
19212  my $any_dkim_signing =
19213    scalar(grep { my $v = $policy_bank{$_}{'enable_dkim_signing'};
19214                  !ref $v ? $v : $$v } keys %policy_bank);
19215  if (!$any_dkim_verification && !$any_dkim_signing) {
19216    undef $extra_code_dkim;
19217  } else {
19218    eval $extra_code_dkim or die "Problem in Amavis::DKIM code: $@";
19219    # release memory occupied by the source code
19220    undef $extra_code_dkim; $extra_code_dkim = 1;
19221  }
19222  if ($any_dkim_signing) {
19223    Amavis::DKIM::dkim_key_postprocess();
19224  } else {  # release storage
19225    undef %dkim_signing_keys_by_domain;
19226    undef @dkim_signing_keys_list; undef @dkim_signing_keys_storage;
19227  }
19228}
19229
19230{ my(%needed_protocols_in);
19231  for my $bank_name (keys %policy_bank) {
19232    my $var = $policy_bank{$bank_name}{'protocol'};
19233    $var = $$var  if ref($var) eq 'SCALAR';  # allow one level of indirection
19234    $needed_protocols_in{$var} = 1  if defined $var;
19235  }
19236  # compatibility with older config files unaware of $protocol config variable
19237# $needed_protocols_in{'AM.CL'} = 1   # AM.CL is no longer supported
19238#   if grep(m{\|unix\z}i, @listen_sockets) &&
19239#     !grep($needed_protocols_in{$_}, qw(AM.PDP COURIER));
19240  $needed_protocols_in{'SMTP'} = 1
19241    if grep(m{/(?:tcp|ssleay|ssl)\z}i, @listen_sockets) &&
19242      !grep($needed_protocols_in{$_}, qw(SMTP LMTP QMQPqq));
19243  if ($needed_protocols_in{'AM.PDP'} || $needed_protocols_in{'AM.CL'}) {
19244    eval $extra_code_in_ampdp or die "Problem in the In::AMPDP code: $@";
19245    # release memory occupied by the source code
19246    undef $extra_code_in_ampdp; $extra_code_in_ampdp = 1;
19247  } else {
19248    undef $extra_code_in_ampdp;
19249  }
19250  if ($needed_protocols_in{'SMTP'} || $needed_protocols_in{'LMTP'}) {
19251    eval $extra_code_in_smtp or die "Problem in the In::SMTP code: $@";
19252    # release memory occupied by the source code
19253    undef $extra_code_in_smtp; $extra_code_in_smtp = 1;
19254  } else {
19255    undef $extra_code_in_smtp;
19256  }
19257  if ($needed_protocols_in{'COURIER'}) {
19258    eval $extra_code_in_courier or die "Problem in the In::Courier code: $@";
19259    # release memory occupied by the source code
19260    undef $extra_code_in_courier; $extra_code_in_courier = 1;
19261  } else {
19262    undef $extra_code_in_courier;
19263  }
19264  if ($needed_protocols_in{'QMQPqq'})  { die "In::QMQPqq code not available" }
19265}
19266
19267if (!@lookup_sql_dsn) { undef $extra_code_sql_lookup }
19268if (!@storage_sql_dsn) { undef $extra_code_sql_log }
19269if (!@storage_redis_dsn) { undef $extra_code_redis }
19270# sql quarantine depends on sql log
19271undef $extra_code_sql_quar  if !defined $extra_code_sql_log;
19272
19273{ my(%needed_protocols_out); local($1);
19274  for my $bank_name (keys %policy_bank) {
19275    for my $method_name (qw(
19276         forward_method notify_method resend_method
19277         release_method requeue_method
19278         os_fingerprint_method virus_quarantine_method
19279         banned_files_quarantine_method unchecked_quarantine_method
19280         spam_quarantine_method bad_header_quarantine_method
19281         clean_quarantine_method archive_quarantine_method )) {
19282      local($1); my $var = $policy_bank{$bank_name}{$method_name};
19283      $var = $$var  if ref($var) eq 'SCALAR';  # allow one level of indirection
19284      $needed_protocols_out{uc($1)} = 1  if $var =~ /^([a-z][a-z0-9.+-]*):/si;
19285    }
19286  }
19287  if (!$needed_protocols_out{'SMTP'} &&
19288      !$needed_protocols_out{'LMTP'}) { undef $extra_code_out_smtp }
19289  else {
19290    eval $extra_code_out_smtp or die "Problem in Amavis::Out::SMTP code: $@";
19291    # release memory occupied by the source code
19292    undef $extra_code_out_smtp; $extra_code_out_smtp = 1;
19293  }
19294  if (!$needed_protocols_out{'PIPE'}) { undef $extra_code_out_pipe }
19295  else {
19296    eval $extra_code_out_pipe or die "Problem in Amavis::Out::Pipe code: $@";
19297    # release memory occupied by the source code
19298    undef $extra_code_out_pipe; $extra_code_out_pipe = 1;
19299  }
19300  if (!$needed_protocols_out{'BSMTP'}) { undef $extra_code_out_bsmtp }
19301  else {
19302    eval $extra_code_out_bsmtp or die "Problem in Amavis::Out::BSMTP code: $@";
19303    # release memory occupied by the source code
19304    undef $extra_code_out_bsmtp; $extra_code_out_bsmtp = 1;
19305  }
19306  if (!$needed_protocols_out{'LOCAL'}) { undef $extra_code_out_local }
19307  else {
19308    eval $extra_code_out_local or die "Problem in Amavis::Out::Local code: $@";
19309    # release memory occupied by the source code
19310    undef $extra_code_out_local; $extra_code_out_local = 1;
19311  }
19312  if (!$needed_protocols_out{'SQL'}) { undef $extra_code_sql_quar }
19313  else {
19314    # deal with it in the next section
19315  }
19316  if (!$needed_protocols_out{'P0F'}) { undef $extra_code_p0f }
19317  else {
19318    eval $extra_code_p0f or die "Problem in OS_Fingerprint code: $@";
19319    # release memory occupied by the source code
19320    undef $extra_code_p0f; $extra_code_p0f = 1;
19321  }
19322}
19323
19324if (defined $extra_code_redis) {
19325  eval $extra_code_redis or die "Problem in Amavis Redis code: $@";
19326  # release memory occupied by the source code
19327  undef $extra_code_redis; $extra_code_redis = 1;
19328}
19329
19330if (!defined($extra_code_sql_log) && !defined($extra_code_sql_quar) &&
19331    !defined($extra_code_sql_lookup)) {
19332  undef $extra_code_sql_base;
19333} else {
19334  eval $extra_code_sql_base or die "Problem in Amavis SQL base code: $@";
19335  # release memory occupied by the source code
19336  undef $extra_code_sql_base; $extra_code_sql_base = 1;
19337}
19338if (defined $extra_code_sql_log) {
19339  eval $extra_code_sql_log or die "Problem in Amavis::SQL::Log code: $@";
19340  # release memory occupied by the source code
19341  undef $extra_code_sql_log; $extra_code_sql_log = 1;
19342}
19343if (defined $extra_code_sql_quar) {
19344  eval $extra_code_sql_quar
19345    or die "Problem in Amavis::SQL::Quarantine code: $@";
19346  # release memory occupied by the source code
19347  undef $extra_code_sql_quar; $extra_code_sql_quar = 1;
19348}
19349if (defined $extra_code_sql_lookup) {
19350  eval $extra_code_sql_lookup or die "Problem in Amavis SQL lookup code: $@";
19351  # release memory occupied by the source code
19352  undef $extra_code_sql_lookup; $extra_code_sql_lookup = 1;
19353}
19354
19355if (!grep { my $v = $policy_bank{$_}{'enable_ldap'};
19356            !ref $v ? $v : $$v } keys %policy_bank) {
19357  undef $extra_code_ldap;
19358} else {  # at least one enable_ldap is true
19359  eval $extra_code_ldap or die "Problem in Lookup::LDAP code: $@";
19360  # release memory occupied by the source code
19361  undef $extra_code_ldap; $extra_code_ldap = 1;
19362}
19363
19364my $bpvcm = ca('bypass_virus_checks_maps');
19365if (!@{ca('av_scanners')} && !@{ca('av_scanners_backup')}) {
19366  undef $extra_code_antivirus;
19367} elsif (@$bpvcm && !ref($bpvcm->[0]) && $bpvcm->[0]) {
19368  # do a simple-minded test to make it easy to turn off virus checks
19369  undef $extra_code_antivirus;
19370} else {
19371  eval $extra_code_antivirus or die "Problem in antivirus code: $@";
19372  # release memory occupied by the source code
19373  undef $extra_code_antivirus; $extra_code_antivirus = 1;
19374}
19375if (!$extra_code_antivirus) {  # release storage
19376  undef @Amavis::Conf::av_scanners; undef @Amavis::Conf::av_scanners_backup;
19377}
19378
19379my(%spam_scanners_used);
19380my $bpscm = ca('bypass_spam_checks_maps');
19381if (!@{ca('spam_scanners')}) {
19382  undef $extra_code_antispam;
19383} elsif (@$bpscm && !ref($bpscm->[0]) && $bpscm->[0]) {  # simple-minded
19384  undef $extra_code_antispam;
19385} else {
19386  eval $extra_code_antispam or die "Problem in antispam code: $@";
19387  # release memory occupied by the source code
19388  undef $extra_code_antispam; $extra_code_antispam = 1;
19389  for my $as (@{ca('spam_scanners')}) {
19390    next  if !ref $as || !defined $as->[1];
19391    my($scanner_name,$module) = @$as; $spam_scanners_used{$module} = 1;
19392  }
19393}
19394if (!$extra_code_antispam) { undef @Amavis::Conf::spam_scanners }
19395
19396# load required built-in spam scanning modules
19397if ($spam_scanners_used{'Amavis::SpamControl::ExtProg'}) {
19398  eval $extra_code_antispam_extprog or die "Problem in ExtProg code: $@";
19399  # release memory occupied by source code
19400  undef $extra_code_antispam_extprog; $extra_code_antispam_extprog = 1;
19401} else {
19402  undef $extra_code_antispam_extprog;
19403}
19404if ($spam_scanners_used{'Amavis::SpamControl::RspamdClient'}) {
19405  eval $extra_code_antispam_rspamc or die "Problem in rspamd client code: $@";
19406  # release memory occupied by source code
19407  undef $extra_code_antispam_rspamc; $extra_code_antispam_rspamc = 1;
19408} else {
19409  undef $extra_code_antispam_rspamc;
19410}
19411if ($spam_scanners_used{'Amavis::SpamControl::SpamdClient'}) {
19412  eval $extra_code_antispam_spamc or die "Problem in spamd client code: $@";
19413  # release memory occupied by source code
19414  undef $extra_code_antispam_spamc; $extra_code_antispam_spamc = 1;
19415} else {
19416  undef $extra_code_antispam_spamc;
19417}
19418if ($spam_scanners_used{'Amavis::SpamControl::SpamAssassin'}) {
19419  eval $extra_code_antispam_sa or die "Problem in antispam SA code: $@";
19420  # release memory occupied by the source code
19421  undef $extra_code_antispam_sa; $extra_code_antispam_sa = 1;
19422} else {
19423  undef $extra_code_antispam_sa;
19424}
19425
19426if (!grep { exists $policy_bank{$_}{'bypass_decode_parts'} &&
19427            !do { my $v = $policy_bank{$_}{'bypass_decode_parts'};
19428                  !ref $v ? $v : $$v } } keys %policy_bank) {
19429  undef $extra_code_unpackers;
19430} else {  # at least one bypass_decode_parts is explicitly false
19431  eval $extra_code_unpackers or die "Problem in Amavis::Unpackers code: $@";
19432  # release memory occupied by the source code
19433  undef $extra_code_unpackers; $extra_code_unpackers = 1;
19434}
19435
19436if ($enable_zmq && $extra_code_zmq && @zmq_sockets) {
19437  # better to catch and report potential ZMQ problems early before forking
19438  $zmq_obj = Amavis::ZMQ->new(@zmq_sockets);
19439  if ($zmq_obj && !$warm_restart && $cmd !~ /^(?:reload|stop)\z/) {
19440    sleep 1;  # a crude way to avoid a "slow joiner" syndrome  #***
19441    $zmq_obj->put_initial_snmp_data('FLUSH');
19442    $zmq_obj->register_proc(1,1,'FLUSH');
19443  }
19444}
19445
19446Amavis::Log::init($do_syslog, $logfile);  # initialize logging
19447Amavis::Log::log_to_stderr($cmd eq 'debug' || $cmd eq 'debug-sa' ? 1 : 0);
19448do_log(1, 'logging initialized, log level %s, %s%s', c('log_level'),
19449  $do_syslog ? sprintf("syslog: %s.%s",c('syslog_ident'),c('syslog_facility')):
19450    $logfile ne '' ? "logfile: $logfile" : "STDERR",
19451  !$enable_log_capture ? '' : ', log capture enabled');
19452do_log(2, 'ZMQ enabled: %s', Amavis::ZMQ::zmq_version())  if $zmq_obj;
19453sd_notify(0, "STATUS=Config files have been read, modules loaded.");
19454
19455# insist on a FQDN in $myhostname
19456my $myhn = idn_to_utf8(c('myhostname'));
19457$myhn =~ /[^.]\.[^.]+\.?\z/s || lc($myhn) eq 'localhost'
19458  or die <<"EOD";
19459  The value of variable \$myhostname is \"$myhn\", but should have been
19460  a fully qualified domain name; perhaps uname(3) did not provide such.
19461  You must explicitly assign a FQDN of this host to variable \$myhostname
19462  in amavisd.conf, or fix what uname(3) provides as a host's network name!
19463EOD
19464
19465$mail_id_size_bits > 0 &&
19466$mail_id_size_bits == int $mail_id_size_bits &&
19467$mail_id_size_bits % 24 == 0
19468  or die "\$mail_id_size_bits ($mail_id_size_bits) must be a multiple of 24\n";
19469
19470my $amavisd_pid;  # PID of the currently running amavisd daemon (not our pid)
19471my $amavisd_pid_by_mainpid;  # is $amavisd_pid provided by $ENV{MAINPID} ?
19472eval {  # is amavisd daemon already running?
19473  if (defined $ENV{MAINPID}) {  # provided by systemd.exec(5) ?
19474    local($1);
19475    if ($ENV{MAINPID} =~ /^\s* ( [0-9]{1,10} ) \s*\z/xs && $1 > 0) {
19476      $amavisd_pid = untaint($1);
19477      $amavisd_pid_by_mainpid = 1;
19478    }
19479  }
19480  my $pidf = defined $pid_file_override ? $pid_file_override : $pid_file;
19481  if (defined $amavisd_pid) {
19482    if (defined $pidf && $pidf ne '') {
19483      do_log(2, 'Master PID [%s] provided by the MAINPID env.var, '.
19484                'not checking $pid_file', $amavisd_pid);
19485    } else {
19486      do_log(2, 'Master PID [%s] provided by the MAINPID env.var, '.
19487                'no $pid_file', $amavisd_pid);
19488    }
19489  } elsif (!defined $pidf || $pidf eq '') {
19490    do_log(2, 'no $pid_file configured, not checking it');
19491  } elsif ($warm_restart) {
19492    # skip pid file checking, let Net::Server handle it
19493  } else {
19494    my(@stat_list) = lstat($pidf);
19495    my $errn = @stat_list ? 0 : 0+$!;
19496    if ($errn == ENOENT) {
19497      die "The amavisd daemon is apparently not running, no PID file $pidf\n"
19498        if $cmd =~ /^(?:reload|restart|stop)\z/;
19499    } elsif ($errn != 0) {
19500      die "PID file $pidf is inaccessible: $!\n";
19501    } elsif (!-f _) {
19502      die "PID file $pidf is not a regular file\n";
19503    } else {  # find and validate PID of the currently running amavisd daemon
19504      my $ln; my $lcnt = 0; my $pidf_h = IO::File->new;
19505      $pidf_h->open($pidf,'<') or die "Can't open PID file $pidf: $!";
19506      for ($! = 0; defined($ln=$pidf_h->getline); $! = 0) {
19507        chomp($ln); $lcnt++; last if $lcnt > 100;
19508        $amavisd_pid = $ln  if $lcnt == 1 && $ln =~ /^\d{1,10}\z/;
19509      }
19510      defined $ln || $! == 0  or die "Error reading from file $pidf: $!";
19511      $pidf_h->close or die "Error closing file $pidf: $!";
19512      if ($lcnt <= 1 && !defined $amavisd_pid) {
19513        # empty or junk one-line pid file treated the same as nonexisting file
19514        die "The amavisd daemon is apparently not running, ".
19515            "empty PID file $pidf\n"  if $cmd =~ /^(?:reload|restart|stop)\z/;
19516        # prevent Net::Server from seeing this crippled file
19517        do_log(-1, "removing empty or crippled PID file %s", $pidf);
19518        unlink($pidf) or die "Can't remove PID file $pidf: $!";
19519        undef $amavisd_pid;
19520      } else {
19521        $lcnt <= 1           or die "More than one line in file $pidf";
19522        defined $amavisd_pid or die "Missing process ID in file $pidf";
19523        $amavisd_pid >= 1    or die "Invalid PID in file $pidf: [$amavisd_pid]";
19524          # note that amavisd under Docker may run as PID #1
19525      }
19526      my $mtime = $stat_list[9];
19527      if (defined $amavisd_pid && defined $mtime) {  # got a PID from a file
19528        # Is pid file older than system uptime? If so, it should be disregarded,
19529        # it must not prevent starting up amavisd after unclean shutdown.
19530        my $now = int(time); my($uptime,$uptime_fmt);  # sys uptime in seconds
19531        my(@prog_args); my(@progs) = ('/usr/bin/uptime','uptime');
19532        if (lc($^O) eq 'freebsd')
19533          { @progs = ('/sbin/sysctl','sysctl'); @prog_args = 'kern.boottime' }
19534        my $prog = find_program_path(\@progs, [split(/:/,$path,-1)] );
19535        if (!defined($prog)) {
19536          do_log(1,'No programs: %s',join(", ",@progs));
19537        } else {  # obtain system uptime
19538          my($proc_fh,$uppid);
19539          eval {
19540            ($proc_fh,$uppid) = run_command(undef,'/dev/null',$prog,@prog_args);
19541            for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
19542              local($1,$2,$3,$4); chomp($ln);
19543              if (defined $uptime) {}
19544              elsif ($ln =~ /{[^}]*\bsec\s*=\s*(\d+)[^}]*}/) {
19545                $uptime = $now - $1;
19546              # amazingly broken reports from uptime(1) soon after boot!
19547              } elsif ($ln =~ /\b up \s+ (?: (\d{1,4}) \s* days? )? [,\s]*
19548                           (\d{1,2}) : (\d{1,2}) (?: : (\d{1,2}))? (?! \d ) /ix
19549                  || $ln =~ /\b up (?:   \s*  \b (\d{1,4}) \s* days? )?
19550                                   (?: [,\s]* \b (\d{1,2}) \s* hrs?  )?
19551                                   (?: [,\s]* \b (\d{1,2}) \s* mins? )?
19552                                   (?: [,\s]* \b (\d{1,2}) \s* secs? )? /ix ) {
19553                $uptime = (($1*24 + $2)*60 + $3)*60 + $4;
19554              } elsif ($ln =~ /\b (\d{1,2}) \s* secs?/ix) {
19555                $uptime = $1;  # OpenBSD
19556              }
19557              $uptime_fmt = format_time_interval($uptime);
19558              do_log(5,"system uptime %s: %s", $uptime_fmt,$ln);
19559            }
19560            defined $ln || $! == 0  or die "Reading uptime: $!";
19561            my $err=0; $proc_fh->close or $err = $!;
19562            my $child_stat = defined $uppid && waitpid($uppid,0)>0 ? $? : undef;
19563            undef $proc_fh; undef $uppid;
19564            proc_status_ok($child_stat,$err)
19565              or die "Error running $prog: " .
19566                     exit_status_str($child_stat,$err) . "\n";
19567          } or do {
19568            my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
19569            do_log(1,"uptime: %s", $eval_stat);
19570          };
19571          if (defined $proc_fh) { $proc_fh->close }  # ignoring status
19572          if (defined $uppid) { waitpid($uppid,0) }  # ignoring status
19573        }
19574        if (!defined $uptime) {
19575          do_log(1,'Unable to determine system uptime, will trust PID file %s',
19576                   $pidf);
19577        } elsif ($now-$mtime <= $uptime+70) {
19578          do_log(1,'Valid PID file %s (younger than sys uptime %s)',
19579                   $pidf, $uptime_fmt);
19580        } else {  # must not kill an unrelated process which happens to have the
19581                  # same pid as amavisd had before a system shutdown or crash
19582          undef $amavisd_pid;
19583          do_log(1,'Ignoring stale PID file %s, older than system uptime %s',
19584                   $pidf, $uptime_fmt);
19585        }
19586      }
19587    }
19588  }
19589  if (defined $amavisd_pid) {
19590    untaint_inplace($amavisd_pid);
19591    if (!kill(0,$amavisd_pid)) {  # does a process exist?
19592      $! == ESRCH  or die "Can't send SIG 0 to process [$amavisd_pid]: $!";
19593      do_log(2, 'No such process [%s], supposedly the current amavisd '.
19594                'master process', $amavisd_pid);
19595      undef $amavisd_pid;  # process does not exist
19596    };
19597  }
19598
19599  if ($warm_restart) {
19600    # a semi-documented Net::Server mechanism for a restart on HUP;
19601    # assume we have just been reincarnated by exec as a result of a HUP,
19602    # so just ignore the command parameter and let Net::Server do the rest
19603  } elsif ($cmd =~ /^(?:start|debug|debug-sa|foreground)?\z/) {
19604    !defined($amavisd_pid)
19605      or die "The amavisd daemon is already running, PID: [$amavisd_pid]\n";
19606  } elsif ($cmd eq 'reload') {  # reload: send a HUP signal to a running daemon
19607    my $pidf = defined $pid_file_override ? $pid_file_override : $pid_file;
19608    if (!defined $amavisd_pid && (!defined $pidf || $pidf eq '')) {
19609      die "No PID file, cannot determine a process ID of a running daemon.\n" .
19610          "To reload an existing amavisd daemon send it a SIGHUP signal.\n";
19611    } elsif (!defined $amavisd_pid) {
19612      die "The amavisd daemon is apparently not running, cannot reload it.\n";
19613    } else {
19614      kill('HUP',$amavisd_pid) or $! == ESRCH
19615        or die "Can't SIGHUP amavisd[$amavisd_pid]: $!";
19616      my $msg = "Signalling a SIGHUP to a running daemon [$amavisd_pid]";
19617      do_log(2,"%s",$msg);
19618    # print STDOUT "$msg\n";
19619      exit(0);
19620    }
19621  } elsif ($cmd =~ /^(?:restart|stop)\z/) {  # stop or restart
19622    my $pidf = defined $pid_file_override ? $pid_file_override : $pid_file;
19623    if (!defined $amavisd_pid && (!defined $pidf || $pidf eq '')) {
19624      die "No PID file, cannot determine a process ID of a running daemon.\n" .
19625          "To stop an existing amavisd daemon send it a SIGTERM signal.\n";
19626    } elsif (!defined $amavisd_pid) {
19627      die "The amavisd daemon is apparently not running, cannot stop it.\n";
19628    } else {
19629      my($kill_sig_used, $killed_amavisd_pid);
19630      eval {  # first stop a running daemon
19631        $kill_sig_used = 'TERM';
19632        kill($kill_sig_used,$amavisd_pid) or $! == ESRCH
19633          or die "Can't SIG$kill_sig_used amavisd[$amavisd_pid]: $!";
19634        my $waited = 0; my $sigkill_sent = 0; my $delay = 1;  # seconds
19635        for (;;) {  # wait for the old running daemon to go away
19636          sleep($delay); $waited += $delay; $delay = 5;
19637          if (!kill(0,$amavisd_pid)) {  # is the old daemon still there?
19638            $! == ESRCH or die "Can't send SIG 0 to amavisd[$amavisd_pid]: $!";
19639            $killed_amavisd_pid = $amavisd_pid;    # old process is gone, done
19640            last;
19641          }
19642          if ($waited < 60 || $sigkill_sent) {
19643            do_log(2,"Waiting for the process [%s] to terminate",$amavisd_pid);
19644            print STDOUT
19645              "Waiting for the process [$amavisd_pid] to terminate\n";
19646          } else {  # use stronger hammer
19647            do_log(2,"Sending SIGKILL to amavisd[%s]",$amavisd_pid);
19648            print STDERR "Sending SIGKILL to amavisd[$amavisd_pid]\n";
19649            $kill_sig_used = 'KILL';
19650            kill($kill_sig_used,$amavisd_pid) or $! == ESRCH
19651              or warn "Can't SIGKILL amavisd[$amavisd_pid]: $!";
19652            $sigkill_sent = 1;
19653          }
19654        }
19655        1;
19656      } or do {
19657        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
19658        die "$eval_stat, can't $cmd the process\n";
19659      };
19660      my $msg = !defined($killed_amavisd_pid) ? undef :
19661                "Daemon [$killed_amavisd_pid] terminated by SIG$kill_sig_used";
19662      if ($cmd eq 'stop') {
19663        if (defined $msg) { do_log(2,"%s",$msg); print STDOUT "$msg\n" }
19664        exit(0);
19665      }
19666      if (defined $killed_amavisd_pid) {
19667        print STDOUT "$msg, waiting for dust to settle...\n";
19668        sleep 5;  # wait for TCP sockets to be released
19669      }
19670      print STDOUT "becoming a new daemon...\n";
19671    }
19672  } else {
19673    die "$myversion: Unknown command line parameter: $cmd\n\n" . usage();
19674  }
19675  1;
19676} or do {
19677  my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
19678  do_log(2,"%s", $eval_stat);
19679  die "$eval_stat\n";
19680};
19681$daemonize = 0  if $DEBUG;  # in case $DEBUG came from a config file
19682
19683# Set path, home and term explicitly.  Don't trust environment
19684$ENV{PATH} = $path          if defined $path && $path ne '';
19685$ENV{HOME} = $helpers_home  if defined $helpers_home && $helpers_home ne '';
19686$ENV{TERM} = 'dumb'; $ENV{COLUMNS} = '80'; $ENV{LINES} = '100';
19687{ my $msg = '';
19688  $msg .= ", instance=$instance_name" if $instance_name ne '';
19689  $msg .= ", nl=".sprintf('\\x%02X',ord("\n"))  if "\n" ne "\012";
19690  $msg .= ", Unicode aware";          # ensured by 'require 5.008'
19691  for (qw(PERLIO LC_ALL LANG LC_CTYPE LC_TIME LC_MESSAGES)) {
19692    $msg .= sprintf(', %s="%s"',
19693                    $_, $ENV{$_})  if defined $ENV{$_} && $ENV{$_} ne '';
19694  }
19695  do_log(0,"starting.%s %s at %s %s%s",
19696         !$warm_restart?'':' (warm)', $0,
19697         idn_to_utf8(c('myhostname')), $myversion, $msg);
19698}
19699# report version of Perl and process UID/GID
19700do_log(0, "perl=%s, user=%s, EUID: %s (%s);  group=%s, EGID: %s (%s)",
19701          $], $desired_user, $>, $<, $desired_group, $), $();
19702if ($warm_restart) {
19703  # a semi-documented Net::Server mechanism to let a restarted process
19704  # re-acquire sockets from its predecessor on a HUP
19705  my $str = $ENV{BOUND_SOCKETS};  $str =~ s/\n/, /gs;
19706  do_log(1,"warm restart on HUP [%s]: '%s', sockets: %s",
19707           $$, join(' ',$0,@ARGV), $str);
19708}
19709
19710# $SIG{USR2} = sub {
19711#   my $msg = Carp::longmess("SIG$_[0] received, backtrace:");
19712#   print STDERR "\n",$msg,"\n";  do_log(-1,"%s",$msg);
19713# };
19714
19715fetch_modules_extra();  # bring additional modules into memory and compile them
19716$spamcontrol_obj = Amavis::SpamControl->new  if $extra_code_antispam;
19717$spamcontrol_obj->init_pre_chroot  if $spamcontrol_obj;
19718
19719# log warnings and uncaught errors
19720$SIG{'__DIE__' } =
19721  sub { return if $^S || !defined $^S;
19722        my $m = $_[0]; chomp($m); do_log(-1,"_DIE: %s", $m);
19723      };
19724$SIG{'__WARN__'} =
19725  sub { my $m = $_[0]; chomp($m); do_log(2,"_WARN: %s", $m) };
19726# use Data::Dumper;
19727# my $m2 = Carp::longmess(); do_log(2,"%s",Dumper($m2));
19728
19729if (!defined $io_socket_module_name) {
19730  do_log(-1,"no INET or INET6 socket modules available");
19731} else {
19732  do_log(2,"socket module %s, protocol families available: %s",
19733           $io_socket_module_name,
19734           join(', ', !$have_inet4 ? () :'INET', !$have_inet6 ? () :'INET6'));
19735}
19736
19737# matches global unicast addresses
19738# (i.e. valid addresses except: local, private or multicast addresses)
19739# RFC 6890 (ex RFC 5735/3330), RFC 3513 (IPv6), RFC 4193 (ULA), RFC 6598 (CGN)
19740@public_networks_maps = (
19741  Amavis::Lookup::Label->new('public_nets'),
19742  Amavis::Lookup::IP->new(qw(
19743    !127.0.0.0/8 !::1 !0.0.0.0/8 !:: !169.254.0.0/16 !fe80::/10
19744    !10.0.0.0/8 !172.16.0.0/12 !192.168.0.0/16 !fc00::/7 !100.64.0.0/10
19745    !240.0.0.0/4 !224.0.0.0/4 !ff00::/8
19746    ::ffff:0:0/96 ::/0 )) );
19747
19748# set up Net::Server configuration
19749my(@bind_to);
19750{ # merge port numbers, unix sockets and default binding host address into
19751  # a unified list @listen_sockets, which will be passed on to Net::Server
19752  #
19753  local($1);
19754  @bind_to = ref $inet_socket_bind ? @$inet_socket_bind : $inet_socket_bind;
19755  $_ = !defined $_ || $_ eq '' ? '*' : /^\[(.*)\]\z/s ? $1 : $_  for @bind_to;
19756  @bind_to = ( '*' )  if !@bind_to;
19757  my(@merged_listen_sockets, @ignored);
19758  for (@listen_sockets) {
19759    # roughly mimic the Net::Server::Proto and Net::Server::Proto::TCP parsing
19760    if (m{^/} || m{[/|]unix\z}si) {
19761      push(@merged_listen_sockets, $_);  # looks like a Unix socket
19762    } elsif (m{^ \[ [^\]]* \] : }xs || m{^ [^/|:]* : }xs) {
19763      push(@merged_listen_sockets, $_);  # explicit host & port specified
19764    } else {  # assume port (or service) specification only, supply bind addr
19765      for my $bind_addr (@bind_to) {  # Cartesian product: bind_addr x port
19766        # need brackets around an IPv6 address (as per RFC 5952, RFC 3986)
19767        push(@merged_listen_sockets,
19768             $bind_addr =~ /:[0-9a-f]*:/i ? "[$bind_addr]:$_"
19769                                          : "$bind_addr:$_" );
19770      }
19771    }
19772  }
19773  # filter listen sockets according to protocol families available
19774  @listen_sockets = ();
19775  for (@merged_listen_sockets) {
19776    if (m{^/} || m{[/|]unix\z}si) {
19777      push(@listen_sockets, $_);  # looks like a Unix socket
19778    } elsif (m{^ \[ ( [^\]]* ) \] : }xs || m{^ ([^/|:]*) : }xs) {
19779      my $addr = $1;
19780      if ($addr =~ /:[0-9a-f]*:/i) {  # looks like an IPv6 address
19781        push(@{$have_inet6 ? \@listen_sockets : \@ignored}, $_);
19782      } elsif ($addr =~ /^\d+\.\d+\.\d+\.\d+\z/s) {  # an IPv4 address
19783        push(@{$have_inet4 ? \@listen_sockets : \@ignored}, $_);
19784      } else {  # can't tell without resolving, take it without checking
19785        push(@listen_sockets, $_);
19786      }
19787    }
19788  }
19789  do_log(2,"ignored due to unsupported protocol family: %s",
19790           join(', ',@ignored))  if @ignored;
19791  @listen_sockets or die "No listen sockets specified, aborting\n";
19792  do_log(2,"will bind to %s", join(', ',@listen_sockets));
19793}
19794
19795# better catch and report potential Redis problems early before forking
19796if ($extra_code_redis && @storage_redis_dsn) {
19797  eval {
19798    my $redis_storage_tmp = Amavis::Redis->new(@storage_redis_dsn);
19799    $redis_storage_tmp->connect; undef $redis_storage_tmp; 1;
19800  } or do {
19801    warn "Redis error, starting anyway: $@";
19802  };
19803}
19804
19805# DESTROY a ZMQ context (if any) of the main process,
19806# it would not survive across daemonization / forking,
19807# each child process needs to make its own context and sockets
19808undef $zmq_obj;
19809
19810my $server = Amavis->new({
19811    # command args to be used after HUP must be untainted, deflt: [$0,@ARGV]
19812  # commandline => ['/usr/local/sbin/amavisd','-c',$config_file[0] ],
19813  # commandline => [],  # disable
19814    commandline => [ map(untaint($_), ($0,@ARGV)) ],
19815    port => \@listen_sockets,  # listen on these sockets (Unix, inet, inet6)
19816    host => $bind_to[0],  # default bind, redundant, merged to @listen_sockets
19817    listen => $listen_queue_size, # undef for a default
19818    max_servers => $max_servers,  # number of pre-forked children
19819    !defined($min_servers) ? ()
19820    : ( min_servers       => $min_servers,
19821        min_spare_servers => $min_spare_servers,
19822        max_spare_servers => $max_spare_servers),
19823    max_requests => defined $max_requests && $max_requests > 0 ? $max_requests
19824                                               : 2E9,  # avoid default of 1000
19825    user       => ($> == 0 || $< == 0) ? $daemon_user  : undef,
19826    group      => ($> == 0 || $< == 0) ? $daemon_group : undef,
19827    pid_file   => $amavisd_pid_by_mainpid ? undef
19828                  : defined $pid_file_override ? $pid_file_override : $pid_file,
19829    # socket serialization lockfile
19830    lock_file  => defined $lock_file_override? $lock_file_override: $lock_file,
19831  # serialize  => 'flock',     # flock, semaphore, pipe
19832    background => $daemonize ? 1 : undef,
19833    setsid     => $daemonize ? 1 : undef,
19834    chroot     => $daemon_chroot_dir ne '' ? $daemon_chroot_dir : undef,
19835    no_close_by_child => 1,
19836    leave_children_open_on_hup => 1,
19837    # no_client_stdout introduced with Net::Server 0.92, but is broken in 0.92
19838    no_client_stdout => (Net::Server->VERSION >= 0.93 ? 1 : 0),
19839    # controls log level for Net::Server internal log messages:
19840    #   0=err, 1=warning, 2=notice, 3=info, 4=debug
19841    log_level  => ($DEBUG || c('log_level') >= 5) ? 4 : 2,
19842    log_file   => undef,  # method will be overridden by a call to do_log()
19843  # SSL_cert_file => "$MYHOME/cert/mail-cert.pem",
19844  # SSL_key_file  => "$MYHOME/cert/mail-key.pem",
19845});
19846
19847$0 = c('myprogram_name') . ' (master)';
19848sd_notify(0, "STATUS=Transferring control to Net::Server.");
19849
19850$server->run;  # transferring control to Net::Server
19851
19852# shouldn't get here
19853exit 1;
198541;  # make perlcritic happy
19855
19856# we read text (such as notification templates) from DATA sections
19857# to avoid any interpretations of special characters (e.g. \ or ') by Perl
19858#
19859
19860__DATA__
19861#
19862package Amavis::ZMQ;
19863use strict;
19864use re 'taint';
19865use warnings;
19866use warnings FATAL => qw(utf8 void);
19867no warnings 'uninitialized';
19868# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
19869
19870BEGIN {
19871  require Exporter;
19872  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
19873  $VERSION = '2.412';
19874  @ISA = qw(Exporter);
19875  import Amavis::Conf qw(:platform $myversion $nanny_details_level);
19876  import Amavis::Util qw(ll do_log do_log_safe
19877                         snmp_initial_oids snmp_counters_get);
19878}
19879
19880use vars qw($zmq_mod_name $zmq_mod_version $zmq_lib_version);
19881BEGIN {
19882  my($zmq_major, $zmq_minor, $zmq_patch);
19883  if (eval { require ZMQ::LibZMQ3 && require ZMQ::Constants }) {
19884    $zmq_mod_name = 'ZMQ::LibZMQ3';  # new interface module to zmq v3 or libxs
19885    import ZMQ::LibZMQ3;  import ZMQ::Constants qw(:all);
19886    ($zmq_major, $zmq_minor, $zmq_patch) = ZMQ::LibZMQ3::zmq_version();
19887  # *zmq_sendmsg   [native]                   # (socket,msgobj,flags)
19888  # *zmq_recvmsg   [native]                   # (socket,flags) -> msgobj
19889    *zmq_sendstr = sub {                      # (socket,string,flags)
19890      my $rv = zmq_send($_[0], $_[1], length $_[1], $_[2]||0);
19891      $rv == -1 ? undef : $rv;
19892    };
19893  } elsif (eval { require ZMQ::LibZMQ2 && require ZMQ::Constants }) {
19894    $zmq_mod_name = 'ZMQ::LibZMQ2';  # new interface module to zmq v2
19895    import ZMQ::LibZMQ2;  import ZMQ::Constants qw(:all);
19896    ($zmq_major, $zmq_minor, $zmq_patch) = ZMQ::LibZMQ2::zmq_version();
19897    # zmq v2/v3 incompatibile renaming
19898    *zmq_sendmsg = \&ZMQ::LibZMQ2::zmq_send;  # (socket,msgobj,flags)
19899    *zmq_recvmsg = \&ZMQ::LibZMQ2::zmq_recv;  # (socket,flags) -> msgobj
19900    *zmq_sendstr = sub {                      # (socket,string,flags)
19901      my $rv = zmq_send(@_);  $rv == -1 ? undef : $rv;
19902    };
19903  } elsif (eval { require ZeroMQ::Constants && require ZeroMQ::Raw }) {
19904    $zmq_mod_name = 'ZeroMQ';  # old interface module to zmq v2
19905    import ZeroMQ::Raw;  import ZeroMQ::Constants qw(:all);
19906    ($zmq_major, $zmq_minor, $zmq_patch) = ZeroMQ::version();
19907    # zmq v2/v3 incompatibile renaming
19908    *zmq_sendmsg = \&ZeroMQ::Raw::zmq_send;   # (socket,msgobj,flags)
19909    *zmq_recvmsg = \&ZeroMQ::Raw::zmq_recv;   # (socket,flags) -> msgobj
19910    *zmq_sendstr = sub {                      # (socket,string,flags)
19911      my $rv = zmq_send(@_);  $rv == -1 ? undef : $rv;
19912    };
19913  } else {
19914    die "Perl modules ZMQ::LibZMQ3 or ZMQ::LibZMQ2 or ZeroMQ not available\n";
19915  }
19916  $zmq_mod_version = $zmq_mod_name->VERSION;
19917  $zmq_lib_version = join('.', $zmq_major, $zmq_minor, $zmq_patch);
19918  1;
19919} # BEGIN
19920
19921sub zmq_version {
19922  sprintf("%s %s, lib %s",
19923          $zmq_mod_name, $zmq_mod_version, $zmq_lib_version);
19924};
19925
19926sub new {
19927  my($class,@socknames) = @_;
19928  my $self = { ctx => undef, sock => undef,
19929               inactivated => 0, socknames => [ @socknames ],
19930               base_timestamp => undef };
19931  bless $self, $class;
19932  $self->establish;
19933  $self;
19934}
19935
19936sub inactivate {
19937  my $self = $_[0];
19938  $self->{inactivated} = 1;
19939}
19940
19941use vars qw($zmq_in_establish);  # prevents loop if logging to zmq
19942
19943sub establish {
19944  my $self = $_[0];
19945  return  if $self->{inactivated} || $zmq_in_establish;
19946  my($ctx,$sock);
19947  eval {
19948    $zmq_in_establish = 1;
19949    $ctx = $self->{ctx};
19950    if (!$ctx) {
19951      $self->{sock} = undef;  # just in case
19952    # do_log(5,'zmq: zmq_init');
19953      $self->{ctx} = $ctx = zmq_init(1);
19954      $ctx or die "Error creating ZMQ context: $!";
19955    }
19956    $sock = $self->{sock};
19957    if (!$sock && $ctx) {  # connect to a socket
19958    # do_log(5,'zmq: zmq_socket');
19959      $self->{sock} = $sock = zmq_socket($ctx, ZMQ_PUB);
19960      if (!$sock) {
19961        die "Error creating ZMQ socket: $!";
19962      } else {
19963      # do_log(5,'zmq: zmq_setsockopt');
19964        zmq_setsockopt($sock, ZMQ_LINGER, 2000) != -1  # milliseconds
19965          or die "Error setting ZMQ_LINGER on a ZMQ socket: $!";
19966        my $hwm = defined &ZMQ_SNDHWM ? ZMQ_SNDHWM()
19967                : defined &ZMQ_HWM    ? ZMQ_HWM() : undef;
19968        if (defined $hwm) {
19969          zmq_setsockopt($sock, $hwm, 1000) != -1
19970            or die "Error setting highwater mark on a ZMQ socket: $!";
19971        }
19972        for my $sockspec (@{$self->{socknames}}) {
19973          my $sock_ipv4only = 1;  # a ZMQ default
19974          if (defined &ZMQ_IPV4ONLY && $sockspec =~ /:[0-9a-f]*:/i) {
19975            zmq_setsockopt($sock, ZMQ_IPV4ONLY(), 0) != -1
19976              or die "Error turning off ZMQ_IPV4ONLY on a ZMQ socket: $!";
19977            $sock_ipv4only = 0;
19978          }
19979        # do_log(5,'zmq: zmq_connect %s%s', $sockspec,
19980        #          $sock_ipv4only ? '' : ', IPv6 enabled');
19981          zmq_connect($sock, $sockspec) == 0
19982            or die "Error connecting ZMQ socket to $sockspec: $!";
19983        }
19984      }
19985    }
19986    1;
19987  } or do {  # clean up, disable, and resignal a failure
19988    zmq_close($sock)  if $sock;  # ignoring status
19989    zmq_term($ctx)    if $ctx;   # ignoring status
19990    undef $self->{sock}; undef $self->{ctx};
19991    $self->{inactivated} = 1; $zmq_in_establish = 0;
19992    chomp $@; die "zmq establish failed: $@\n";  # propagate the exception
19993  };
19994  $zmq_in_establish = 0;
19995  $sock;
19996}
19997
19998sub DESTROY {
19999  my $self = $_[0]; local($@,$!,$_);
20000  # can occur soon after fork, must not use context (like calling a logger)
20001  if (!$self->{inactivated}) {
20002    my $sock = $self->{sock};
20003    if ($sock) {
20004      zmq_setsockopt($sock, ZMQ_LINGER, 0);  # ignoring status
20005      zmq_close($sock);       # ignoring status
20006    }
20007    my $ctx = $self->{ctx};
20008    zmq_term($ctx)  if $ctx;  # ignoring status
20009  }
20010  undef $self->{sock}; undef $self->{ctx};
20011  %{$self} = ();  # then ditch the rest
20012}
20013
20014sub register_proc {
20015  my($self, $details_level, $reset_timestamp, $state, $task_id) = @_;
20016  my $sock = $self->{sock};  # = $self->establish;
20017  return if !$sock;
20018# if (!defined $state || $details_level <= $nanny_details_level) {
20019  if (1) {
20020    my $pid = $$;
20021    my $msg;
20022    my $now = Time::HiRes::time;
20023    if ($reset_timestamp || !$self->{base_timestamp}) {
20024      $self->{base_timestamp} = $now;
20025      $msg = sprintf('am.st %d %014.3f ', $pid, $now);
20026    } else {
20027      my $dt = $now - $self->{base_timestamp};
20028      $msg = sprintf('am.st %d %d ', $pid, $dt <= 0 ? 0 : int($dt*1000 + 0.5));
20029    }
20030    if (!defined $state) {
20031      $msg .= 'exiting';
20032    } else {
20033      $state = '-'  if $state eq ' ' || $state eq '';  # simplifies parsing
20034      $msg .= $state;
20035      $msg .= ' ' . $task_id  if defined $task_id;
20036    }
20037  # do_log(5,'zmq: register_proc: %s', $msg);
20038    defined zmq_sendstr($sock, $msg)
20039      or die "Error sending a ZMQ message: $!";
20040  }
20041}
20042
20043sub write_log {
20044# my($self, $level, $errmsg) = @_;
20045  my $self = $_[0];
20046  my $sock = $self->{sock};  # = $self->establish;
20047  return if !$sock;
20048  my $level = $_[1];
20049  my $nstars = 6 - $level;
20050  $nstars = 7 if $nstars > 7;
20051  $nstars = 1 if $nstars < 1;
20052  # ignoring status to prevent a logging loop
20053  zmq_sendstr($sock, sprintf('am.log.%s %s %014.3f %s', '*' x $nstars, $$,
20054                             Time::HiRes::time, $_[2]));
20055}
20056
20057# insert startup time SNMP entry, called from the master process at startup
20058#
20059sub put_initial_snmp_data {
20060  my($self,$flush) = @_;
20061  my $sock = $self->{sock};  # = $self->establish;
20062  return if !$sock;
20063# do_log(5,'zmq: publishing initial snmp data');
20064  if ($flush) {
20065  # do_log(5,'zmq: sending am.snmp FLUSH');
20066    defined zmq_sendstr($sock, 'am.snmp FLUSH')
20067      or die "Error sending a ZMQ flush message: $!";
20068  }
20069  my $list_ref = snmp_initial_oids();
20070  my $list_ind_last = $#{$list_ref};
20071  for my $obj_ind (0 .. $list_ind_last) {
20072    my($key,$type,$val) = @{$list_ref->[$obj_ind]};
20073    my $more = $obj_ind < $list_ind_last;
20074    my $msg = sprintf('am.snmp %s %s %s', $key, $type, $val);
20075  # do_log(5,'zmq: sending %s %s', $more?'M':' ', $msg);
20076    defined zmq_sendstr($sock, $msg, $more ? ZMQ_SNDMORE : 0)
20077      or die "Error sending a ZMQ message: $!";
20078  };
20079}
20080
20081sub update_snmp_variables {
20082  my $self = $_[0];
20083  my $sock = $self->{sock};  # = $self->establish;
20084  return if !$sock;
20085  my $msg;
20086  my $snmp_var_names_ref = snmp_counters_get();
20087  if (defined $snmp_var_names_ref && @$snmp_var_names_ref) {
20088    do_log(4,'zmq: updating snmp variables');
20089    for my $key (@$snmp_var_names_ref) {
20090      my($snmp_var_name, $val, $type) = ref $key ? @$key : ($key);
20091      if ($snmp_var_name eq 'entropy') {
20092        next;                        # don't broadcast entropy
20093      } elsif (!defined $type || $type eq '') {  # a counter, same as C32
20094        $type = 'C32';
20095        $val = 1  if !defined $val;  # by default a counter increments by 1
20096        next if $val < 0;            # a counter is supposed to be unsigned
20097      } elsif ($type eq 'C32' || $type eq 'C64') {  # a counter
20098        $val = 1  if !defined $val;  # by default a counter increments by 1
20099        next if $val < 0;            # a counter is supposed to be unsigned
20100      } elsif ($type eq 'INT') {     # an integer
20101        # no limitations here, sprintf will convert it to a string
20102      } elsif ($type eq 'TIM') {     # TimeTicks
20103        next if $val < 0;            # non-decrementing
20104      }
20105      if (defined $msg) {  # send assembled message from previous iteration
20106      # do_log(5,'zmq: sending M %s', $msg);
20107        defined zmq_sendstr($sock, $msg, ZMQ_SNDMORE)
20108          or die "Error sending a ZMQ message: $!";
20109      }
20110      $msg = sprintf('am.snmp %s %s %s', $snmp_var_name, $type, $val);
20111    }
20112    if (defined $msg) {  # last chunk of a multi-part message
20113    # do_log(5,'zmq: sending   %s', $msg);
20114      defined zmq_sendstr($sock, $msg, 0)
20115        or die "Error sending a ZMQ message: $!";
20116    }
20117  }
20118}
20119
201201;
20121
20122__DATA__
20123#
20124package Amavis::DB::SNMP;
20125use strict;
20126use re 'taint';
20127use warnings;
20128use warnings FATAL => qw(utf8 void);
20129no warnings 'uninitialized';
20130# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
20131
20132BEGIN {
20133  require Exporter;
20134  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
20135  $VERSION = '2.412';
20136  @ISA = qw(Exporter);
20137  import Amavis::Conf qw(:platform $myversion $nanny_details_level);
20138  import Amavis::Util qw(ll do_log do_log_safe
20139                         snmp_initial_oids snmp_counters_get
20140                         add_entropy fetch_entropy_bytes);
20141}
20142
20143use BerkeleyDB;
20144use MIME::Base64;
20145use Time::HiRes ();
20146
20147# open existing databases (called by each child process)
20148#
20149sub new {
20150  my($class,$db_env) = @_; $! = 0; my $env = $db_env->get_db_env;
20151  defined $env or die "BDB get_db_env (dbS/dbN): $BerkeleyDB::Error, $!.";
20152  $! = 0; my $dbs = BerkeleyDB::Hash->new(-Filename=>'snmp.db', -Env=>$env);
20153  defined $dbs or die "BDB no dbS: $BerkeleyDB::Error, $!.";
20154  $! = 0; my $dbn = BerkeleyDB::Hash->new(-Filename=>'nanny.db',-Env=>$env);
20155  defined $dbn or die "BDB no dbN: $BerkeleyDB::Error, $!.";
20156  bless { 'db_snmp'=>$dbs, 'db_nanny'=>$dbn }, $class;
20157}
20158
20159sub DESTROY {
20160  my $self = $_[0];
20161  local($@,$!,$_); my $myactualpid = $$;
20162  if (defined($my_pid) && $myactualpid != $my_pid) {
20163    do_log_safe(5,"Amavis::DB::SNMP DESTROY skip, clone [%s] (born as [%s])",
20164                  $myactualpid, $my_pid);
20165  } else {
20166    do_log_safe(5,"Amavis::DB::SNMP DESTROY called");
20167    for my $db_name ('db_snmp', 'db_nanny') {
20168      my $db = $self->{$db_name};
20169      if (defined $db) {
20170        eval {
20171          $db->db_close==0 or die "db_close: $BerkeleyDB::Error, $!.";  1;
20172        } or do { $@ = "errno=$!"  if $@ eq '' };
20173        if ($@ ne '' && $@ !~ /\bDatabase is already closed\b/)
20174          { warn "[$myactualpid] BDB S+N DESTROY INFO ($db_name): $@" }
20175        undef $db;
20176      }
20177    }
20178  }
20179}
20180
20181#sub lock_stat($) {
20182# my $label = $_[0];
20183# my $s = qx'/usr/local/bin/db_stat-4.2 -c -h /var/amavis/db | /usr/local/bin/perl -ne \'$a{$2}=$1 if /^(\d+)\s+Total number of locks (requested|released)/; END {printf("%d, %d\n",$a{requested}, $a{requested}-$a{released})}\'';
20184# do_log(0, "lock_stat %s: %s", $label,$s);
20185#}
20186
20187# insert startup time SNMP entry, called from the master process at startup
20188# (a classical subroutine, not a method)
20189#
20190sub put_initial_snmp_data($) {
20191  my $db = $_[0];
20192  my($eval_stat,$interrupt); $interrupt = '';
20193  { my $cursor;
20194    my $h1 = sub { $interrupt = $_[0] };
20195    local(@SIG{qw(INT HUP TERM TSTP QUIT ALRM USR1 USR2)}) = ($h1) x 8;
20196    eval {  # ensure cursor will be unlocked even in case of errors or signals
20197      $cursor = $db->db_cursor(DB_WRITECURSOR);  # obtain write lock
20198      defined $cursor or die "BDB S db_cursor: $BerkeleyDB::Error, $!.";
20199      my $list_ref = snmp_initial_oids();
20200      for my $obj (@$list_ref) {
20201        my($key,$type,$val) = @$obj;
20202        $cursor->c_put($key, sprintf("%s %s",$type,$val), DB_KEYLAST) == 0
20203          or die "BDB S c_put: $BerkeleyDB::Error, $!.";
20204      };
20205      $cursor->c_close==0 or die "BDB S c_close: $BerkeleyDB::Error, $!.";
20206      undef $cursor;  1;
20207    } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
20208    $cursor->c_close  if defined $cursor;  # unlock, ignoring status
20209    undef $cursor;
20210  };  # restore signal handlers
20211  if ($interrupt ne '') { kill($interrupt,$$) }  # resignal, ignoring status
20212  elsif (defined $eval_stat) {
20213    chomp $eval_stat;
20214    die "put_initial_snmp_data: BDB S $eval_stat\n";
20215  }
20216}
20217
20218sub update_snmp_variables {
20219  my $self = $_[0];
20220  do_log(5,"updating snmp variables in BDB");
20221  my $snmp_var_names_ref = snmp_counters_get();
20222  my($eval_stat,$interrupt); $interrupt = '';
20223  if (defined $snmp_var_names_ref && @$snmp_var_names_ref) {
20224    my $db = $self->{'db_snmp'}; my $cursor;
20225    my $h1 = sub { $interrupt = $_[0] };
20226    local(@SIG{qw(INT HUP TERM TSTP QUIT ALRM USR1 USR2)}) = ($h1) x 8;
20227    eval {  # ensure cursor will be unlocked even in case of errors or signals
20228      $cursor = $db->db_cursor(DB_WRITECURSOR);  # obtain write lock
20229      defined $cursor or die "db_cursor: $BerkeleyDB::Error, $!.";
20230      for my $key (@$snmp_var_names_ref) {
20231        my($snmp_var_name,$arg,$type) = ref $key ? @$key : ($key);
20232        $type = 'C32'  if !defined($type) || $type eq '';
20233        if ($type eq 'C32' || $type eq 'C64') {  # a counter
20234          if (!defined($arg)) { $arg = 1 } # by default counter increments by 1
20235          elsif ($arg < 0)    { $arg = 0 } # counter is supposed to be unsigned
20236        } elsif ($type eq 'TIM') {  # TimeTicks
20237          if    ($arg < 0)    { $arg = 0 } # non-decrementing
20238        }
20239        my($val,$flags); local($1);
20240        my $stat = $cursor->c_get($snmp_var_name,$val,DB_SET);
20241        if ($stat==0) {  # exists, update it (or replace it)
20242          if    ($type eq 'C32' && $val=~/^C32 (\d+)\z/) { $val = $1+$arg }
20243          elsif ($type eq 'C64' && $val=~/^C64 (\d+)\z/) { $val = $1+$arg }
20244          elsif ($type eq 'TIM' && $val=~/^TIM (\d+)\z/) { $val = $1+$arg }
20245          elsif ($type eq 'INT' && $val=~/^INT ([+-]?\d+)\z/) { $val = $arg }
20246          elsif ($type=~/^(STR|OID)\z/ && $val=~/^\Q$type\E (.*)\z/) {
20247            if ($snmp_var_name ne 'entropy') { $val = $arg }
20248            else {  # blend-in entropy
20249              $val = $1; add_entropy($val, Time::HiRes::gettimeofday);
20250              $val = fetch_entropy_bytes(18);  # 18 bytes
20251              $val = encode_base64($val,'');   # 18*8/6 = 24 chars
20252              $val =~ tr{+/}{-_};  # base64 -> RFC 4648 base64url [A-Za-z0-9-_]
20253            }
20254          }
20255          else {
20256            do_log(-2,"WARN: variable syntax? %s: %s, clearing",
20257                      $snmp_var_name,$val);
20258            $val = 0;
20259          }
20260          $flags = DB_CURRENT;
20261        } else {  # create new entry
20262          $stat==DB_NOTFOUND  or die "c_get: $BerkeleyDB::Error, $!.";
20263          $flags = DB_KEYLAST; $val = $arg;
20264        }
20265        my $fmt = $type eq 'C32' ? "%010d" : $type eq 'C64' ? "%020.0f"
20266                : $type eq 'INT' ? "%010d" : undef;
20267        # format for INT should really be %011d, but keep compatibility for now
20268        my $str = defined($fmt) ? sprintf($fmt,$val) : $val;
20269        $cursor->c_put($snmp_var_name, $type.' '.$str, $flags) == 0
20270          or die "c_put: $BerkeleyDB::Error, $!.";
20271      }
20272      $cursor->c_close==0 or die "c_close: $BerkeleyDB::Error, $!.";
20273      undef $cursor;  1;
20274    } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
20275    if (defined $db) {
20276      $cursor->c_close  if defined $cursor;  # unlock, ignoring status
20277      undef $cursor;
20278#     if (!defined($eval_stat)) {
20279#       my $stat; $db->db_sync();  # not really needed
20280#       $stat==0 or warn "BDB S db_sync,status $stat: $BerkeleyDB::Error, $!.";
20281#     }
20282    }
20283  };  # restore signal handlers
20284  delete $self->{'cnt'};
20285  if ($interrupt ne '') { kill($interrupt,$$) }  # resignal, ignoring status
20286  elsif (defined $eval_stat) {
20287    chomp $eval_stat;
20288    die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
20289    die "update_snmp_variables: BDB S $eval_stat\n";
20290  }
20291}
20292
20293sub read_snmp_variables {
20294  my($self,@snmp_var_names) = @_;
20295  my($eval_stat,$interrupt); $interrupt = '';
20296  my $db = $self->{'db_snmp'}; my $cursor; my(@values);
20297  { my $h1 = sub { $interrupt = $_[0] };
20298    local(@SIG{qw(INT HUP TERM TSTP QUIT ALRM USR1 USR2)}) = ($h1) x 8;
20299    eval {  # ensure cursor will be unlocked even in case of errors or signals
20300      $cursor = $db->db_cursor;  # obtain read lock
20301      defined $cursor or die "db_cursor: $BerkeleyDB::Error, $!.";
20302      for my $cname (@snmp_var_names) {
20303        my $val; my $stat = $cursor->c_get($cname,$val,DB_SET);
20304        push(@values, $stat==0 ? $val : undef);
20305        $stat==0 || $stat==DB_NOTFOUND or die "c_get: $BerkeleyDB::Error, $!.";
20306      }
20307      $cursor->c_close==0 or die "c_close: $BerkeleyDB::Error, $!.";
20308      undef $cursor;  1;
20309    } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
20310    if (defined $db) {
20311      $cursor->c_close  if defined $cursor;  # unlock, ignoring status
20312      undef $cursor;
20313    }
20314  };  # restore signal handlers
20315  if ($interrupt ne '') { kill($interrupt,$$) }  # resignal, ignoring status
20316  elsif (defined $eval_stat) {
20317    chomp $eval_stat;
20318    die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
20319    die "read_snmp_variables: BDB S $eval_stat\n";
20320  }
20321  for my $val (@values) {
20322    if (!defined($val)) {}  # keep undefined
20323    elsif ($val =~ /^(?:C32|C64) (\d+)\z/)  { $val = 0+$1 }
20324    elsif ($val =~ /^(?:INT) ([+-]?\d+)\z/) { $val = 0+$1 }
20325    elsif ($val =~ /^(?:STR|OID) (.*)\z/)   { $val = $1 }
20326    else { do_log(-2,"WARN: counter syntax? %s", $val); undef $val }
20327  }
20328  \@values;
20329}
20330
20331sub register_proc {
20332  my($self, $details_level, $reset_timestamp, $state, $task_id) = @_;
20333  my $eval_stat; my $interrupt = '';
20334  if (!defined($state) || $details_level <= $nanny_details_level) {
20335    $task_id = ''  if !defined $task_id;
20336    my $db = $self->{'db_nanny'}; my $key = sprintf("%05d",$$);
20337    my $cursor; my $val;
20338    my $h1 = sub { $interrupt = $_[0] };
20339    local(@SIG{qw(INT HUP TERM TSTP QUIT ALRM USR1 USR2)}) = ($h1) x 8;
20340    eval {  # ensure cursor will be unlocked even in case of errors or signals
20341      $cursor = $db->db_cursor(DB_WRITECURSOR);  # obtain write lock
20342      defined $cursor or die "db_cursor: $BerkeleyDB::Error, $!.";
20343      my $stat = $cursor->c_get($key,$val,DB_SET);
20344      $stat==0 || $stat==DB_NOTFOUND or die "c_get: $BerkeleyDB::Error, $!.";
20345      if ($stat==0 && !defined $state) {  # remove existing entry
20346        $cursor->c_del==0 or die "c_del: $BerkeleyDB::Error, $!.";
20347      } elsif (defined $state) {  # add new, or update existing entry
20348        my $timestamp; local($1);
20349        # keep its timestamp when updating existing record
20350        $timestamp = $1  if $stat==0 && $val=~/^(\d+(?:\.\d*)?) /s;
20351        $timestamp = sprintf("%014.3f", Time::HiRes::time)
20352                       if !defined($timestamp) || $reset_timestamp;
20353        my $new_val = sprintf("%s %-14s", $timestamp, $state.$task_id);
20354        $cursor->c_put($key, $new_val,
20355                       $stat==0 ? DB_CURRENT : DB_KEYLAST ) == 0
20356          or die "c_put: $BerkeleyDB::Error, $!.";
20357      }
20358      $cursor->c_close==0 or die "c_close: $BerkeleyDB::Error, $!.";
20359      undef $cursor;  1;
20360    } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
20361    if (defined $db) {
20362      $cursor->c_close  if defined $cursor;  # unlock, ignoring status
20363      undef $cursor;
20364#     if (!defined($eval_stat)) {
20365#       my $stat = $db->db_sync();  # not really needed
20366#       $stat==0 or warn "BDB N db_sync,status $stat: $BerkeleyDB::Error, $!.";
20367#     }
20368    }
20369  };  # restore signal handlers
20370  if ($interrupt ne '') {
20371    kill($interrupt,$$);  # resignal, ignoring status
20372  } elsif (defined $eval_stat) {
20373    chomp $eval_stat;
20374    do_log_safe(5, "register_proc: BDB N %s", $eval_stat);
20375    die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
20376    die "register_proc: BDB N $eval_stat\n";
20377  }
20378}
20379
203801;
20381
20382#
20383package Amavis::DB;
20384use strict;
20385use re 'taint';
20386use warnings;
20387use warnings FATAL => qw(utf8 void);
20388# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
20389
20390BEGIN {
20391  require Exporter;
20392  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
20393  $VERSION = '2.412';
20394  @ISA = qw(Exporter);
20395  import Amavis::Conf qw($db_home $daemon_chroot_dir);
20396  import Amavis::Util qw(untaint ll do_log);
20397}
20398
20399use BerkeleyDB;
20400
20401# create new databases, then close them (called by the parent process)
20402# (called only if $db_home is nonempty)
20403#
20404sub init($$) {
20405  my($predelete_nanny, $predelete_snmp) = @_;
20406  my $name = $db_home;
20407  $name = "$daemon_chroot_dir $name"  if $daemon_chroot_dir ne '';
20408  if ($predelete_nanny || $predelete_snmp) {  # delete existing db files first?
20409    local(*DIR);
20410    opendir(DIR,$db_home) or die "db_init: Can't open directory $name: $!";
20411    # modifying a directory while traversing it can cause surprises, avoid;
20412    # avoid slurping the whole directory contents into memory
20413    my($f, @rmfiles);
20414    while (defined($f = readdir(DIR))) {
20415      next  if $f eq '.' || $f eq '..';
20416      if      ($f =~ /^(__db\.)?nanny\.db\z/) {
20417        push(@rmfiles, $f)  if $predelete_nanny;
20418      } elsif ($f =~ /^(__db\.)?snmp\.db\z/) {
20419        push(@rmfiles, $f)  if $predelete_snmp;
20420      } elsif ($f =~ /^__db\.\d+\z/s) {
20421        push(@rmfiles, $f)  if $predelete_nanny && $predelete_snmp;
20422      } elsif ($f =~ /^(?:cache-expiry|cache)\.db\z/s) {
20423        push(@rmfiles, $f);  # old databases, no longer used since 2.7.0-pre9
20424      }
20425    }
20426    closedir(DIR) or die "db_init: Error closing directory $name: $!";
20427    do_log(1, 'Deleting db files %s in %s', join(',',@rmfiles), $name);
20428    for my $f (@rmfiles) {
20429      my $fname = $db_home . '/' . untaint($f);
20430      unlink($fname) or die "db_init: Can't delete file $fname: $!";
20431    }
20432    undef @rmfiles;  # release storage
20433  }
20434  $! = 0; my $env = BerkeleyDB::Env->new(-Home=>$db_home, -Mode=>0640,
20435    -Flags=> DB_CREATE | DB_INIT_CDB | DB_INIT_MPOOL);
20436  defined $env
20437    or die "BDB can't create db env. at $db_home: $BerkeleyDB::Error, $!.";
20438  do_log(1, 'Creating db in %s/; BerkeleyDB %s, libdb %s',
20439            $name, BerkeleyDB->VERSION, $BerkeleyDB::db_version);
20440  $! = 0; my $dbs = BerkeleyDB::Hash->new(
20441    -Filename=>'snmp.db', -Flags=>DB_CREATE, -Env=>$env );
20442  defined $dbs or die "db_init: BDB no dbS: $BerkeleyDB::Error, $!.";
20443  $! = 0; my $dbn = BerkeleyDB::Hash->new(
20444    -Filename=>'nanny.db', -Flags=>DB_CREATE, -Env=>$env );
20445  defined $dbn or die "db_init: BDB no dbN: $BerkeleyDB::Error, $!.";
20446
20447  Amavis::DB::SNMP::put_initial_snmp_data($dbs)  if $predelete_snmp;
20448  for my $db ($dbs, $dbn) {
20449    $db->db_close==0 or die "db_init: BDB db_close: $BerkeleyDB::Error, $!.";
20450  }
20451}
20452
20453# open an existing databases environment (called by each child process)
20454#
20455sub new {
20456  my $class = $_[0]; my $env;
20457  if (defined $db_home) {
20458    $! = 0; $env = BerkeleyDB::Env->new(
20459      -Home=>$db_home, -Mode=>0640, -Flags=> DB_INIT_CDB | DB_INIT_MPOOL);
20460    defined $env
20461      or die "BDB can't connect db env. at $db_home: $BerkeleyDB::Error, $!.";
20462  }
20463  bless \$env, $class;
20464}
20465
20466sub get_db_env { my $self = $_[0]; $$self }
20467
204681;
20469
20470__DATA__
20471#
20472package Amavis::Lookup::SQLfield;
20473use strict;
20474use re 'taint';
20475use warnings;
20476use warnings FATAL => qw(utf8 void);
20477no warnings 'uninitialized';
20478# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
20479
20480BEGIN {
20481  require Exporter;
20482  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
20483  $VERSION = '2.412';
20484  @ISA = qw(Exporter);
20485  import Amavis::Util qw(ll do_log);
20486  import Amavis::Conf qw($trim_trailing_space_in_lookup_result_fields);
20487}
20488
20489# the sub new() is already declared in the always-loaded code section
20490
20491# fieldtype: B=boolean, N=numeric, S=string,
20492#            N-: numeric, nonexistent field returns undef without complaint
20493#            S-: string,  nonexistent field returns undef without complaint
20494#            B-: boolean, nonexistent field returns undef without complaint
20495#            B0: boolean, nonexistent field treated as false
20496#            B1: boolean, nonexistent field treated as true
20497
20498sub lookup_sql_field($$$%) {
20499  my($self, $addr, $get_all, %options) = @_;
20500  my(@result, @matchingkey, $sql_query, $field);
20501  if ($self) { $sql_query = $self->{sql_query}; $field = $self->{fieldname} }
20502  $sql_query = $Amavis::sql_lookups  if !defined $sql_query;  # global default
20503  if (!defined $self) {
20504    do_log(5, 'lookup_sql_field - no field query object, "%s" no match',$addr);
20505  } elsif (!defined $field || $field eq '') {
20506    do_log(5, 'lookup_sql_field() - no field name, "%s" no match', $addr);
20507  } elsif (!defined $sql_query) {
20508    do_log(5, 'lookup_sql_field(%s) - no sql_lookups object, "%s" no match',
20509              $field, $addr);
20510  } else {
20511    my(@result_attr_names) = !ref $field ? ( $field )
20512                            : ref $field eq 'ARRAY' ? @$field
20513                            : ref $field eq 'HASH' ? keys %$field : ();
20514    my(%attr_name_to_sqlfield_name) =
20515                              ref $field eq 'HASH' ? %$field
20516                            : map( ($_,$_), @result_attr_names);
20517    my $fieldtype = $self->{fieldtype};
20518    $fieldtype = 'S-'  if !defined $fieldtype;
20519    my($res_ref,$mk_ref) = $sql_query->lookup_sql($addr,1, %options,
20520              !exists($self->{args}) ? () : (ExtraArguments => $self->{args}));
20521    if (!defined $res_ref || !@$res_ref) {
20522      ll(5) && do_log(5, 'lookup_sql_field(%s), "%s" no matching records',
20523        join(',', map(lc($_) eq lc($attr_name_to_sqlfield_name{$_}) ? $_
20524                        : $_ . '/' . $attr_name_to_sqlfield_name{$_},
20525                      @result_attr_names)),  $addr);
20526    } else {
20527      my %nosuchfield;
20528      for my $ind (0 .. $#$res_ref) {
20529        my($any_field_matches, @match_values_by_ind);
20530        my $h_ref = $res_ref->[$ind];  my $mk = $mk_ref->[$ind];
20531        for my $result_attr_ind (0 .. $#result_attr_names) {
20532          my $result_attr_name = $result_attr_names[$result_attr_ind];
20533          next  if !defined $result_attr_name;
20534          my $fieldname = $attr_name_to_sqlfield_name{$result_attr_name};
20535          next  if !defined $fieldname || $fieldname eq '';
20536          my $match;
20537          if (!exists($h_ref->{$fieldname})) {
20538            $nosuchfield{$fieldname} = 1;
20539            # record found, but no field with that name in the table
20540            # fieldtype: B0: boolean, nonexistent field treated as false,
20541            #            B1: boolean, nonexistent field treated as true
20542            if ($fieldtype =~ /^.-/s) {   # allowed to not exist
20543              # this type is almost universally in use now, continue searching
20544            } elsif ($fieldtype =~ /^B1/) {  # defaults to true
20545              # only used for the 'local' field
20546              $match = 1;  # nonexistent field treated as 1
20547            } elsif ($fieldtype =~ /^B0/) {  # boolean, defaults to false
20548              # no longer in use
20549              $match = 0;  # nonexistent field treated as 0
20550            } else {
20551              # treated as 'no match', returns undef
20552            }
20553          } else {  # field exists
20554            # fieldtype: B=boolean, N=numeric, S=string
20555            $match = $h_ref->{$fieldname};
20556            if (!defined $match) {
20557              # NULL field values represented as undef
20558            } elsif ($fieldtype =~ /^B/) {  # boolean
20559              # convert values 'N', 'F', '0', ' ' and "\000" to 0
20560              # to allow value to be used directly as a Perl boolean
20561              $match = 0  if $match =~ /^([NnFf ]|0+|\000+)\ *\z/;
20562            } elsif ($fieldtype =~ /^N/) {  # numeric
20563              $match = $match + 0;  # convert string into a number
20564            } elsif ($fieldtype =~ /^S/) {  # string
20565              $match =~ s/ +\z//  # trim trailing spaces
20566                if $trim_trailing_space_in_lookup_result_fields;
20567            }
20568          }
20569          $match_values_by_ind[$result_attr_ind] = $match;
20570          $any_field_matches = 1  if defined $match;
20571        }
20572        ll(5) && do_log(5, 'lookup_sql_field(%s) rec=%d, "%s" result: %s',
20573          join(',',  map(lc($_) eq lc($attr_name_to_sqlfield_name{$_}) ? $_
20574                           : $_ . '/' . $attr_name_to_sqlfield_name{$_},
20575                         @result_attr_names)),
20576          $ind, $addr,
20577          join(', ', map(defined $_ ? '"'.$_.'"' : 'undef',
20578                         @match_values_by_ind)) );
20579        if ($any_field_matches) {
20580          push(@matchingkey, $mk);
20581          push(@result, !ref $field ? $match_values_by_ind[0] :
20582                 { map( ($result_attr_names[$_], $match_values_by_ind[$_]),
20583                        grep(defined $match_values_by_ind[$_],
20584                             (0 .. $#result_attr_names) )) } );
20585          last  if !$get_all;
20586        }
20587      }
20588      do_log(5, 'lookup_sql_field, no such fields: %s',
20589             join(', ', keys %nosuchfield))  if ll(5) && %nosuchfield;
20590    }
20591  }
20592  if (!$get_all) { !wantarray ? $result[0] : ($result[0], $matchingkey[0]) }
20593  else           { !wantarray ? \@result   : (\@result,   \@matchingkey)   }
20594}
20595
205961;
20597
20598#
20599package Amavis::Lookup::SQL;
20600use strict;
20601use re 'taint';
20602use warnings;
20603use warnings FATAL => qw(utf8 void);
20604# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
20605
20606BEGIN {
20607  require Exporter;
20608  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
20609  $VERSION = '2.412';
20610  @ISA = qw(Exporter);
20611  import Amavis::Conf qw(:platform :confvars c cr ca);
20612  import Amavis::Timing qw(section_time);
20613  import Amavis::Util qw(untaint untaint_inplace snmp_count
20614                         ll do_log do_log_safe);
20615  import Amavis::rfc2821_2822_Tools qw(make_query_keys);
20616  import Amavis::Out::SQL::Connection ();
20617}
20618
20619use DBI qw(:sql_types);
20620
20621# return a new Lookup::SQL object to contain DBI handle and prepared selects
20622#
20623sub new {
20624  my($class, $conn_h, $clause_name) = @_;
20625  if ($clause_name eq '') { undef }
20626  else {
20627    # $clause_name is a key into %sql_clause of the currently selected
20628    # policy bank; one level of indirection is allowed in %sql_clause result,
20629    # the resulting SQL clause may include %k, %a, %l, %u, %e, %d placeholders,
20630    # to be expanded
20631    bless { conn_h => $conn_h, incarnation => 0, clause_name => $clause_name },
20632          $class;
20633  }
20634}
20635
20636sub DESTROY {
20637  my $self = $_[0]; local($@,$!,$_);
20638  do_log_safe(5,"Amavis::Lookup::SQL DESTROY called");
20639}
20640
20641sub init {
20642  my $self = $_[0];
20643  if ($self->{incarnation} != $self->{conn_h}->incarnation) {  # invalidated?
20644    $self->{incarnation} = $self->{conn_h}->incarnation;
20645    $self->clear_cache;  # db handle has changed, invalidate cache
20646  }
20647  $self;
20648}
20649
20650sub clear_cache {
20651  my $self = $_[0];
20652  delete $self->{cache};
20653}
20654
20655# lookup_sql() performs a lookup for an e-mail address against a SQL map.
20656# If a match is found it returns whatever the query returns (a reference
20657# to a hash containing values of requested fields), otherwise returns undef.
20658# A match aborts further fetching sequence, unless $get_all is true.
20659#
20660# The $addr may be a string of octets (assumed to be UTF-8 encoded)
20661# or a string of characters which gets first encoded to UTF-8 octets.
20662# International domain name (IDN) in $addr will be converted to ACE
20663# and lowercased. International domain names in SQL are expected to be
20664# encoded in ASCII-compatible encoding (ACE).
20665#
20666# SQL lookups (e.g. for user+foo@example.com) are performed in order
20667# which can be requested by 'ORDER BY' in the SELECT statement, otherwise
20668# the order is unspecified, which is only useful if only specific entries
20669# exist in a database (e.g. only full addresses, not domains).
20670#
20671# The following order is recommended, going from specific to more general:
20672#  - lookup for user+foo@example.com
20673#  - lookup for user@example.com (only if $recipient_delimiter nonempty)
20674#  - lookup for user+foo ('naked lookup' (i.e. no '@'): only if local)
20675#  - lookup for user  ('naked lookup': local and $recipient_delimiter nonempty)
20676#  - lookup for @sub.example.com
20677#  - lookup for @.sub.example.com
20678#  - lookup for @.example.com
20679#  - lookup for @.com
20680#  - lookup for @.       (catchall)
20681# NOTE:
20682#  this is different from hash and ACL lookups in two important aspects:
20683#    - a key without '@' implies a mailbox (=user) name, not domain name;
20684#    - a naked mailbox name (i.e. no '@' in the query) lookups are only
20685#      performed when the e-mail address (usually its domain part) matches
20686#      static local_domains* lookups.
20687#
20688# Domain part is always lowercased when constructing a key,
20689# localpart is lowercased unless $localpart_is_case_sensitive is true.
20690#
20691sub lookup_sql($$$%) {
20692  my($self, $addr,$get_all,%options) = @_;
20693  my(@matchingkey,@result);
20694  my $extra_args = $options{ExtraArguments};
20695  my $sel; my $sql_cl_r = cr('sql_clause');
20696  my $clause_name = $self->{clause_name};
20697  $sel = $sql_cl_r->{$clause_name}  if defined $sql_cl_r;
20698  $sel = $$sel  if ref $sel eq 'SCALAR';  # allow one level of indirection
20699  if (!defined($sel) || $sel eq '') {
20700    ll(4) && do_log(4,"lookup_sql disabled for clause: %s", $clause_name);
20701    return(!wantarray ? undef : (undef,undef));
20702  } elsif (!defined $extra_args &&
20703           exists $self->{cache} && exists $self->{cache}->{$addr})
20704  { # cached ?
20705    my $c = $self->{cache}->{$addr}; @result = @$c if ref $c;
20706    @matchingkey = map('/cached/',@result); # will do for now, improve some day
20707#   if (!ll(5)) {}# don't bother preparing log report which will not be printed
20708#   elsif (!@result) { do_log(5,'lookup_sql (cached): "%s" no match', $addr) }
20709#   else {
20710#     for my $m (@result) {
20711#       do_log(5, "lookup_sql (cached): \"%s\" matches, result=(%s)",
20712#         $addr, join(", ", map { sprintf("%s=>%s", $_,
20713#                                 !defined($m->{$_})?'-':'"'.$m->{$_}.'"'
20714#                                        ) } sort keys(%$m) ) );
20715#     }
20716#   }
20717    if (!$get_all) {
20718      return(!wantarray ? $result[0] : ($result[0], $matchingkey[0]));
20719    } else {
20720      return(!wantarray ? \@result   : (\@result,   \@matchingkey));
20721    }
20722  }
20723  my $is_local;  # not looked up in SQL and LDAP to avoid recursion!
20724  $is_local = Amavis::Lookup::lookup(0,$addr,
20725                                     grep(ref ne 'Amavis::Lookup::SQL' &&
20726                                          ref ne 'Amavis::Lookup::SQLfield' &&
20727                                          ref ne 'Amavis::Lookup::LDAP' &&
20728                                          ref ne 'Amavis::Lookup::LDAPattr',
20729                                          @{ca('local_domains_maps')}));
20730  my($keys_ref,$rhs_ref) = make_query_keys($addr,
20731                                    $sql_lookups_no_at_means_domain,$is_local);
20732  if (!$sql_allow_8bit_address) { s/[^\040-\176]/?/gs for @$keys_ref }
20733  my $n = scalar(@$keys_ref);  # number of keys
20734  my(@extras_tmp,@pos_args); local($1);
20735  @extras_tmp = @$extra_args  if $extra_args;
20736  my $sel_taint = substr($sel,0,0);  # taintedness
20737  my $datatype = $sql_allow_8bit_address ? SQL_VARBINARY : SQL_VARCHAR;
20738
20739  # substitute %k for a list of keys, %a for unmodified full mail address,
20740  # %l for full unmodified localpart, %u for lowercased username (a localpart
20741  # without extension), %e for lowercased extension, %d for lowercased domain,
20742  # and ? for each extra argument
20743  $sel =~ s{ ( %[kaluedL] | \? ) }
20744           { push(@pos_args,
20745                  $1 eq '%k' ? map([$_,$datatype], @$keys_ref)
20746                : $1 eq '%a' ? [$rhs_ref->[0], $datatype] #full addr
20747                : $1 eq '%l' ? [$rhs_ref->[1], $datatype] #localpart
20748                : $1 eq '%u' ? [$rhs_ref->[2], $datatype] #username
20749                : $1 eq '%e' ? [$rhs_ref->[3], $datatype] #extension
20750                : $1 eq '%d' ? [$rhs_ref->[4], $datatype] #domain
20751                   #*** (%L is experimental, incomplete)
20752                : $1 eq '%L' ? [($is_local?'1':'0'), SQL_BOOLEAN] #is local
20753                : shift @extras_tmp),
20754             $1 eq '%k' ? join(',', ('?') x $n) : '?' }xgse;
20755  $sel = untaint($sel) . $sel_taint;  # keep original clause taintedness
20756  ll(4) && do_log(4,"lookup_sql %s \"%s\", query args: %s",
20757                   $clause_name, $addr,
20758                   join(', ', map(!ref $_ ? '"'.$_.'"' : '['.join(',',@$_).']',
20759                                  @pos_args)) );
20760  ll(4) && do_log(4,"lookup_sql select: %s", $sel);
20761  my $a_ref; my $match = {}; my $conn_h = $self->{conn_h};
20762  $conn_h->begin_work_nontransaction;  # (re)connect if not connected
20763  my $driver = $conn_h->driver_name;  # only available when connected
20764  if ($driver eq 'Pg') {
20765    $datatype = { pg_type => DBD::Pg::PG_BYTEA() };
20766    for (@pos_args)
20767      { $_->[1] = $datatype  if ref($_) && $_->[1]==SQL_VARBINARY }
20768  }
20769  for (@pos_args) {
20770    if (ref $_) { untaint_inplace($_->[0]) } else { untaint_inplace($_) }
20771  }
20772  eval {
20773    snmp_count('OpsSqlSelect');
20774    $conn_h->execute($sel,@pos_args);  # do the query
20775    # fetch query results
20776    while ( defined($a_ref=$conn_h->fetchrow_arrayref($sel)) ) {
20777      my(@names) = @{$conn_h->sth($sel)->{NAME_lc}};
20778      $match = {}; @$match{@names} = @$a_ref;
20779      if ($clause_name eq 'sel_policy' && !exists $match->{'local'} &&
20780          defined $match->{'email'} && $match->{'email'} eq '@.') {
20781        # UGLY HACK to let a catchall (@.) imply that field 'local' has
20782        # a value undef (NULL) when that field is not present in the
20783        # database. This overrides B1 fieldtype default by an explicit
20784        # undef for '@.', causing a fallback to static lookup tables.
20785        # The purpose is to provide a useful default for local_domains
20786        # lookup if the field 'local' is not present in the SQL table.
20787        # NOTE: field names 'local' and 'email' are hardwired here!!!
20788        push(@names,'local'); $match->{'local'} = undef;
20789        do_log(5, 'lookup_sql: "%s" matches catchall, local=>undef', $addr);
20790      }
20791      push(@result, {%$match});  # copy hash
20792      push(@matchingkey, join(", ", map { sprintf("%s=>%s", $_,
20793                                !defined($match->{$_})?'-':'"'.$match->{$_}.'"'
20794                                ) } @names));
20795      last  if !$get_all;
20796    }
20797    $conn_h->finish($sel)  if defined $a_ref;  # only if not all read
20798    1;
20799  } or do {
20800    my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
20801    do_log(-1, "lookup_sql: %s, %s, %s", $err, $DBI::err, $DBI::errstr);
20802    die $err  if $err =~ /^timed out\b/;  # resignal timeout
20803    die $err;
20804  };
20805  if (!ll(4)) {
20806    # don't bother preparing log report which will not be printed
20807  } elsif (!@result) {
20808    do_log(4,'lookup_sql, "%s" no match', $addr);
20809  } else {
20810    do_log(4,'lookup_sql(%s) matches, result=(%s)', $addr,$_) for @matchingkey;
20811  }
20812  # save for future use, but only within processing of this message
20813  $self->{cache}->{$addr} = \@result;
20814  section_time('lookup_sql');
20815  if (!$get_all) { !wantarray ? $result[0] : ($result[0], $matchingkey[0]) }
20816  else           { !wantarray ? \@result   : (\@result,   \@matchingkey)   }
20817}
20818
208191;
20820
20821__DATA__
20822#^L
20823package Amavis::LDAP::Connection;
20824use strict;
20825use re 'taint';
20826use warnings;
20827use warnings FATAL => qw(utf8 void);
20828no warnings 'uninitialized';
20829# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
20830
20831use Net::LDAP;
20832use Net::LDAP::Util;
20833
20834BEGIN {
20835  require Exporter;
20836  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION
20837              $have_sasl $ldap_sys_default);
20838  $VERSION = '2.412';
20839  @ISA = qw(Exporter);
20840  $have_sasl = eval { require Authen::SASL };
20841  import Amavis::Conf qw(:platform :confvars c cr ca);
20842  import Amavis::Util qw(ll do_log do_log_safe);
20843  import Amavis::Timing qw(section_time);
20844}
20845
20846BEGIN {
20847  # must be in a separate BEGIN block to be able to see imported symbols
20848  $ldap_sys_default = {
20849    hostname       => 'localhost',
20850    localaddr      => undef,
20851    port           => undef,  # 389 or 636, default provided by Net::LDAP
20852    scheme         => undef,  # 'ldaps' or 'ldap', depending on hostname
20853    version        => 3,
20854    timeout        => 120,
20855    deref          => 'find',
20856    bind_dn        => undef,
20857    bind_password  => undef,
20858    tls            => 0,
20859    verify         => 'none',
20860    sslversion     => 'tlsv1',
20861    clientcert     => undef,
20862    clientkey      => undef,
20863    cafile         => undef,
20864    capath         => undef,
20865    sasl           => 0,
20866    sasl_mech      => undef,  # space-separated list of mech names
20867    sasl_auth_id   => undef,
20868  };
20869  1;
20870}
20871
20872sub new {
20873  my($class,$default) = @_;
20874  my $self = bless { ldap => undef }, $class;
20875  $self->{incarnation} = 1;
20876  for (qw(hostname localaddr port scheme inet6 version timeout
20877          base scope deref bind_dn bind_password
20878          tls verify sslversion clientcert clientkey cafile capath
20879          sasl sasl_mech sasl_auth_id)) {
20880    # replace undefined attributes with user values or defaults
20881    $self->{$_} = $default->{$_}          if !defined($self->{$_});
20882    $self->{$_} = $ldap_sys_default->{$_} if !defined($self->{$_});
20883  }
20884  if (!defined $self->{scheme}) {
20885    $self->{scheme} = $self->{hostname} =~ /^ldaps/i ? 'ldaps' : 'ldap';
20886  }
20887  $self;
20888}
20889
20890sub ldap {  # get/set ldap handle
20891  my $self = shift;
20892  !@_ ? $self->{ldap} : ($self->{ldap}=shift);
20893}
20894
20895sub DESTROY {
20896  my $self = $_[0]; local($@,$!,$_);
20897  do_log_safe(5,"Amavis::LDAP::Connection DESTROY called");
20898  # ignore failure, make perlcritic happy
20899  eval { $self->disconnect_from_ldap } or 1;
20900}
20901
20902sub incarnation { my $self = $_[0]; $self->{incarnation} }
20903sub in_transaction { 0 }
20904
20905sub begin_work {
20906  my $self = $_[0];
20907  do_log(5,"ldap begin_work");
20908  $self->ldap or $self->connect_to_ldap;
20909}
20910
20911sub connect_to_ldap {
20912  my $self = $_[0];
20913  my($bind_err,$start_tls_err);
20914  do_log(3,"Connecting to LDAP server");
20915  my $hostlist = ref $self->{hostname} eq 'ARRAY' ?
20916                     join(", ",@{$self->{hostname}}) : $self->{hostname};
20917  do_log(4,"connect_to_ldap: trying %s", $hostlist);
20918  my $ldap = Net::LDAP->new($self->{hostname},
20919                            localaddr => $self->{localaddr},
20920                            port    => $self->{port},
20921                            scheme  => $self->{scheme},
20922                            version => $self->{version},
20923                            timeout => $self->{timeout},
20924                            keepalive => 1,  # since Net::LDAP 0.53
20925                        # remaining keepalive* options need Socket::Linux and a
20926                        # patch at [rt.cpan.org #83039], otherwise are ignored
20927                            keepalive_idle => 240,
20928                            keepalive_interval => 30,
20929                            keepalive_probe => 10,
20930                            );
20931  if (!$ldap) {  # connect failed
20932    do_log(-1,"connect_to_ldap: unable to connect to host %s", $hostlist);
20933  } else {
20934    do_log(3,"connect_to_ldap: connected to %s", $hostlist);
20935  # $ldap->debug(12);   # debug output goes to STDERR
20936    if ($self->{tls}) { # TLS required
20937      my $mesg = $ldap->start_tls(verify => $self->{verify},
20938                                  sslversion => $self->{sslversion},
20939                                  clientcert => $self->{clientcert},
20940                                  clientkey => $self->{clientkey},
20941                                  cafile => $self->{cafile},
20942                                  capath => $self->{capath});
20943      if ($mesg->code) { # start TLS failed
20944        my $err = $mesg->error_name;
20945        do_log(-1,"connect_to_ldap: start TLS failed: %s", $err);
20946        $self->ldap(undef);
20947        $start_tls_err = 1;
20948      } else { # started TLS
20949        do_log(3,"connect_to_ldap: TLS version %s enabled", $mesg);
20950      }
20951    }
20952    if ($self->{bind_dn} || $self->{sasl}) {  # bind required
20953      my $sasl;
20954      my $passw = $self->{bind_password};
20955      if ($self->{sasl}) {  # using SASL to authenticate?
20956        $have_sasl or die "connect_to_ldap: SASL requested but no Authen::SASL";
20957        $sasl = Authen::SASL->new(mechanism => $self->{sasl_mech},
20958                                  callback => { user => $self->{sasl_auth_id},
20959                                                pass => $passw } );
20960      }
20961      my $mesg = $ldap->bind($self->{bind_dn},
20962                             $sasl          ? (sasl     => $sasl)
20963                           : defined $passw ? (password => $passw) : ());
20964      $passw = 'X' x length($passw)  if defined $passw;  # can't hurt
20965      if ($mesg->code) {  # bind failed
20966        my $err = $mesg->error_name;
20967        do_log(-1,"connect_to_ldap: bind failed: %s", $err);
20968        $self->ldap(undef);
20969        $bind_err = 1;
20970      } else {  # bind succeeded
20971        do_log(3,"connect_to_ldap: bind %s succeeded", $self->{bind_dn});
20972      }
20973    }
20974  }
20975  $self->ldap($ldap); $self->{incarnation}++;
20976  $ldap or die "connect_to_ldap: unable to connect";
20977  if ($start_tls_err) { die "connect_to_ldap: start TLS failed" }
20978  if ($bind_err)      { die "connect_to_ldap: bind failed" }
20979  section_time('ldap-connect');
20980  $self;
20981}
20982
20983sub disconnect_from_ldap {
20984  my $self = $_[0];
20985  return if !$self->ldap;
20986  do_log(4,"disconnecting from LDAP");
20987  $self->ldap->disconnect;
20988  $self->ldap(undef);
20989  1;
20990}
20991
20992sub do_search {
20993  my($self,$base,$scope,$filter) = @_;
20994  my($result,$error_name);
20995  $self->ldap or die "do_search: ldap not available";
20996  do_log(5,'lookup_ldap: searching base="%s", scope="%s", filter="%s"',
20997           $base, $scope, $filter);
20998  eval {
20999    $result = $self->{ldap}->search(base   => $base,
21000                                    scope  => $scope,
21001                                    filter => $filter,
21002                                    deref  => $self->{deref},
21003                                    );
21004    if ($result->code) {
21005      $error_name = $result->error_name;
21006      if ($error_name eq 'LDAP_NO_SUCH_OBJECT') {
21007        # probably alright, e.g. a foreign %d
21008        do_log(4, 'do_search failed in "%s": %s', $base, $error_name);
21009      } else {
21010        die $error_name."\n";
21011      }
21012    }
21013    1;
21014  } or do {
21015    my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
21016    die $err  if $err =~ /^timed out\b/;  # resignal timeout
21017    if ($err !~ /^LDAP_/) {
21018      die "do_search: $err";
21019    } elsif ($error_name !~ /^LDAP_(?:BUSY|UNAVAILABLE|UNWILLING_TO_PERFORM|
21020                             TIMEOUT|SERVER_DOWN|CONNECT_ERROR|OTHER|
21021                             LOCAL_ERROR|OPERATIONS_ERROR)\z/x) {
21022      die "do_search: failed: $error_name\n";
21023    } else {  # LDAP related error, worth retrying
21024      do_log(0, "NOTICE: do_search: trying again: %s", $error_name);
21025      $self->disconnect_from_ldap;
21026      $self->connect_to_ldap;
21027      $self->ldap or die "do_search: reconnect failed";
21028      do_log(5,
21029        'lookup_ldap: searching (again) base="%s", scope="%s", filter="%s"',
21030        $base, $scope, $filter);
21031      eval {
21032        $result = $self->{ldap}->search(base   => $base,
21033                                        scope  => $scope,
21034                                        filter => $filter,
21035                                        deref  => $self->{deref},
21036                                        );
21037        if ($result->code) { die $result->error_name, "\n"; }
21038        1;
21039      } or do {
21040        my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
21041        $self->disconnect_from_ldap;
21042        die $err  if $err =~ /^timed out\b/;  # resignal timeout
21043        die "do_search: failed again, $err";
21044      };
21045    };
21046  };
21047  $result;
21048}
21049
210501;
21051
21052#
21053package Amavis::Lookup::LDAPattr;
21054use strict;
21055use re 'taint';
21056
21057BEGIN {
21058  require Exporter;
21059  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
21060  $VERSION = '2.412';
21061  @ISA = qw(Exporter);
21062  import Amavis::Util qw(ll do_log);
21063  import Amavis::Conf qw($trim_trailing_space_in_lookup_result_fields);
21064}
21065
21066# the sub new() is already declared in the always-loaded code section
21067
21068# attrtype: B=boolean, N=numeric, S=string, L=list
21069#           N-: numeric, nonexistent field returns undef without complaint
21070#           S-: string,  nonexistent field returns undef without complaint
21071#           L-: list,    nonexistent field returns undef without complaint
21072#           B-: boolean, nonexistent field returns undef without complaint
21073#           B0: boolean, nonexistent field treated as false
21074#           B1: boolean, nonexistent field treated as true
21075
21076sub lookup_ldap_attr($$$%) {
21077  my($self, $addr, $get_all, %options) = @_;
21078  my(@result, @matchingkey, $ldap_query, $attr);
21079  if ($self) { $ldap_query = $self->{ldap_query}; $attr = $self->{attrname} }
21080  $ldap_query = $Amavis::ldap_lookups  if !defined $ldap_query;  # global dflt
21081  if (!defined $self) {
21082    do_log(5, 'lookup_ldap_attr - no attr query object, "%s" no match',$addr);
21083  } elsif (!defined $attr || $attr eq '') {
21084    do_log(5, 'lookup_ldap_attr() - no attribute name, "%s" no match', $addr);
21085  } elsif (!defined $ldap_query) {
21086    do_log(5, 'lookup_ldap_attr(%s) - no ldap_lookups object, "%s" no match',
21087              $attr, $addr);
21088  } else {
21089    # result attribute names are case-sensitive
21090    # LDAP attribute names are case-INsensitive
21091    my(@result_attr_names) = !ref $attr ? ( $attr )
21092                            : ref $attr eq 'ARRAY' ? @$attr
21093                            : ref $attr eq 'HASH' ? keys %$attr : ();
21094    my(%attr_name_to_ldapattr_name) =
21095                              ref $attr eq 'HASH' ? %$attr
21096                            : map( ($_,$_), @result_attr_names);
21097    my $attrtype = $self->{attrtype};
21098    $attrtype = 'S-'  if !defined $attrtype;
21099    my($res_ref,$mk_ref) = $ldap_query->lookup_ldap($addr,1, %options,
21100              !exists($self->{args}) ? () : (ExtraArguments => $self->{args}));
21101    if (!defined $res_ref || !@$res_ref) {
21102      ll(5) && do_log(5, 'lookup_ldap_attr(%s), "%s" no matching entries',
21103        join(',', map(lc($_) eq lc($attr_name_to_ldapattr_name{$_}) ? $_
21104                        : $_ . '/' . lc($attr_name_to_ldapattr_name{$_}),
21105                      @result_attr_names)),  $addr);
21106    } else {
21107      my %nosuchattr;
21108      for my $ind (0 .. $#$res_ref) {
21109        my($any_attr_matches, @match_values_by_ind);
21110        my $h_ref = $res_ref->[$ind];  my $mk = $mk_ref->[$ind];
21111        for my $result_attr_ind (0 .. $#result_attr_names) {
21112          my $result_attr_name = $result_attr_names[$result_attr_ind];
21113          next  if !defined $result_attr_name;
21114          my $attrname = $attr_name_to_ldapattr_name{$result_attr_name};
21115          next  if !defined $attrname || $attrname eq '';
21116          my $match;
21117          if (!exists($h_ref->{lc $attrname})) {
21118            $nosuchattr{$attrname} = 1;
21119            # LDAP entry found, but no attribute with that name in it
21120            if ($attrtype =~ /^.-/s) {      # allowed to not exist
21121              # this type is almost universally in use now, continue searching
21122            } elsif ($attrtype =~ /^B1/) {  # defaults to true
21123              # only used for the 'local' attr
21124              $match = 1;  # nonexistent attribute treated as 1
21125            } elsif ($attrtype =~ /^B0/) {  # boolean, defaults to false
21126              # no longer in use
21127              $match = 0;  # nonexistent attribute treated as 0
21128            } else {
21129              # treated as 'no match', returns undef
21130            }
21131          } else {  # attribute exists
21132            # attrtype: B=boolean, N=numeric, S=string
21133            $match = $h_ref->{lc $attrname};
21134            if (!defined $match) {
21135              # NULL attribute values represented as undef
21136            } elsif ($attrtype =~ /^B/) {  # boolean
21137              $match = $match eq 'TRUE' ? 1 : 0;  # convert TRUE|FALSE to 1|0
21138            } elsif ($attrtype =~ /^N/) {  # numeric
21139              $match = $match + 0;  # unify different numeric forms
21140            } elsif ($attrtype =~ /^S/) {  # string
21141              $match =~ s/ +\z//  # trim trailing spaces
21142                if $trim_trailing_space_in_lookup_result_fields;
21143            } elsif ($self->{attrtype} =~ /^L/) {  # list
21144              #$match = join(', ',@$match);
21145            }
21146          }
21147          $match_values_by_ind[$result_attr_ind] = $match;
21148          $any_attr_matches = 1  if defined $match;
21149        }
21150        ll(5) && do_log(5, 'lookup_ldap_attr(%s) rec=%d, "%s" result: %s',
21151              join(',', map(lc($_) eq lc($attr_name_to_ldapattr_name{$_}) ? $_
21152                              : $_ . '/' . lc($attr_name_to_ldapattr_name{$_}),
21153                            @result_attr_names)),
21154              $ind, $addr,
21155              join(', ', map(defined $_ ? '"'.$_.'"' : 'undef',
21156                             @match_values_by_ind)) );
21157        if ($any_attr_matches) {
21158          push(@matchingkey, $mk);
21159          push(@result, !ref $attr ? $match_values_by_ind[0] :
21160                 { map( ($result_attr_names[$_], $match_values_by_ind[$_]),
21161                        grep(defined $match_values_by_ind[$_],
21162                             (0 .. $#result_attr_names) )) } );
21163          last  if !$get_all;
21164        }
21165      }
21166      do_log(5, 'lookup_ldap_attr, no such attrs: %s',
21167             join(', ', keys %nosuchattr))  if ll(5) && %nosuchattr;
21168    }
21169  }
21170  if (!$get_all) { !wantarray ? $result[0] : ($result[0], $matchingkey[0]) }
21171  else           { !wantarray ? \@result   : (\@result,   \@matchingkey)   }
21172}
21173
211741;
21175
21176#
21177package Amavis::Lookup::LDAP;
21178use strict;
21179use re 'taint';
21180
21181BEGIN {
21182  require Exporter;
21183  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION
21184              $ldap_sys_default @ldap_attrs @mv_ldap_attrs);
21185  $VERSION = '2.412';
21186  @ISA = qw(Exporter);
21187  import Amavis::Conf qw(:platform :confvars c cr ca);
21188  import Amavis::Timing qw(section_time);
21189  import Amavis::Util qw(untaint untaint_inplace snmp_count
21190                         ll do_log do_log_safe idn_to_ascii);
21191  import Amavis::rfc2821_2822_Tools qw(make_query_keys split_address);
21192  import Amavis::LDAP::Connection ();
21193
21194  $ldap_sys_default = {
21195    base           => undef,
21196    scope          => 'sub',
21197    query_filter   => '(&(objectClass=amavisAccount)(mail=%m))',
21198  };
21199
21200  @ldap_attrs = qw(amavisLocal amavisMessageSizeLimit
21201    amavisVirusLover amavisSpamLover amavisUncheckedLover
21202    amavisBannedFilesLover amavisBadHeaderLover
21203    amavisBypassVirusChecks amavisBypassSpamChecks
21204    amavisBypassBannedChecks amavisBypassHeaderChecks
21205    amavisSpamTagLevel amavisSpamTag2Level amavisSpamKillLevel
21206    amavisSpamDsnCutoffLevel amavisSpamQuarantineCutoffLevel
21207    amavisSpamSubjectTag amavisSpamSubjectTag2 amavisSpamModifiesSubj
21208    amavisVirusQuarantineTo amavisSpamQuarantineTo amavisBannedQuarantineTo
21209    amavisUncheckedQuarantineTo amavisBadHeaderQuarantineTo
21210    amavisCleanQuarantineTo amavisArchiveQuarantineTo
21211    amavisAddrExtensionVirus amavisAddrExtensionSpam
21212    amavisAddrExtensionBanned amavisAddrExtensionBadHeader
21213    amavisWarnVirusRecip amavisWarnBannedRecip amavisWarnBadHeaderRecip
21214    amavisVirusAdmin amavisNewVirusAdmin amavisSpamAdmin
21215    amavisBannedAdmin amavisBadHeaderAdmin
21216    amavisBannedRuleNames amavisDisclaimerOptions
21217    amavisForwardMethod amavisSaUserConf amavisSaUserName
21218    amavisBlacklistSender amavisWhitelistSender
21219  );
21220  @mv_ldap_attrs = qw(amavisBlacklistSender amavisWhitelistSender);
21221  1;
21222}
21223
21224sub new {
21225  my($class,$default,$conn_h) = @_;
21226  my $self = bless {}, $class;
21227  $self->{conn_h} = $conn_h;  $self->{incarnation} = 0;
21228  for (qw(base scope query_filter)) {
21229    # replace undefined attributes with config values or defaults
21230    $self->{$_} = $default->{$_}          unless defined($self->{$_});
21231    $self->{$_} = $ldap_sys_default->{$_} unless defined($self->{$_});
21232  }
21233  $self;
21234}
21235
21236sub DESTROY {
21237  my $self = $_[0]; local($@,$!,$_);
21238  do_log_safe(5,"Amavis::Lookup::LDAP DESTROY called");
21239}
21240
21241sub init {
21242  my $self = $_[0];
21243  if ($self->{incarnation} != $self->{conn_h}->incarnation) {  # invalidated?
21244    $self->{incarnation} = $self->{conn_h}->incarnation;
21245    $self->clear_cache;  # db handle has changed, invalidate cache
21246  }
21247  $self;
21248}
21249
21250sub clear_cache {
21251  my $self = $_[0];
21252  delete $self->{cache};
21253}
21254
21255# The $addr may be a string of octets (assumed to be UTF-8 encoded)
21256# or a string of characters which gets first encoded to UTF-8 octets.
21257# International domain name (IDN) in $addr will be converted to ACE
21258# and lowercased. International domain names in LDAP are expected to be
21259# encoded in ASCII-compatible encoding (ACE).
21260#
21261sub lookup_ldap($$$%) {
21262  my($self,$addr,$get_all,%options) = @_;
21263  my(@result,@matchingkey,@tmp_result,@tmp_matchingkey);
21264  if (exists $self->{cache} && exists $self->{cache}->{$addr}) { # cached?
21265    my $c = $self->{cache}->{$addr}; @result = @$c if ref $c;
21266    @matchingkey = map('/cached/',@result); # will do for now, improve some day
21267#   if (!ll(5)) {
21268#     # don't bother preparing log report which will not be printed
21269#   } elsif (!@result) {
21270#     do_log(5,'lookup_ldap (cached): "%s" no match', $addr);
21271#   } else {
21272#     for my $m (@result) {
21273#       do_log(5, 'lookup_ldap (cached): "%s" matches, result=(%s)',
21274#         $addr, join(", ", map { sprintf("%s=>%s", $_,
21275#                                 !defined($m->{$_})?'-':'"'.$m->{$_}.'"'
21276#                                        ) } sort keys(%$m) ) );
21277#     }
21278#   }
21279    if (!$get_all) {
21280      return(!wantarray ? $result[0] : ($result[0], $matchingkey[0]));
21281    } else {
21282      return(!wantarray ? \@result   : (\@result,   \@matchingkey));
21283    }
21284  }
21285  my $is_local;  # not looked up in SQL and LDAP to avoid recursion!
21286  $is_local = Amavis::Lookup::lookup(0,$addr,
21287                                     grep(ref ne 'Amavis::Lookup::SQL' &&
21288                                          ref ne 'Amavis::Lookup::SQLfield' &&
21289                                          ref ne 'Amavis::Lookup::LDAP' &&
21290                                          ref ne 'Amavis::Lookup::LDAPattr',
21291                                          @{ca('local_domains_maps')}));
21292  my($keys_ref,$rhs_ref,@keys);
21293  ($keys_ref,$rhs_ref) = make_query_keys($addr,
21294                                   $ldap_lookups_no_at_means_domain,$is_local);
21295  @keys = @$keys_ref;
21296  unshift(@keys, '<>')  if $addr eq '';  # a hack for a null return path
21297  untaint_inplace($_) for @keys; # untaint keys
21298  $_ = Net::LDAP::Util::escape_filter_value($_) for @keys;
21299  # process %m
21300  my $filter = $self->{query_filter};
21301  my @filter_attr;  my $expanded_filter = '';
21302  for my $t ($filter =~ /\G( \( [^(=]+ = %m \) | [ \t0-9A-Za-z]+ | . )/xgs) {
21303    if ($t !~ m{ \( ([^(=]+) = %m \) }xs) { $expanded_filter .= $t }
21304    else {
21305      push(@filter_attr, $1);
21306      $expanded_filter .= '(|' . join('', map("($1=$_)", @keys)) . ')';
21307    }
21308  }
21309  $filter = $expanded_filter;
21310  # process %d
21311  my $base = $self->{base};
21312  if ($base =~ /%d/) {
21313    my($localpart,$domain) = split_address($addr);
21314    if ($domain) {
21315      untaint_inplace($domain); local($1);
21316      $domain =~ s/^\@?(.*?)\.*\z/$1/s;
21317      $domain = idn_to_ascii($domain);
21318      $base =~ s/%d/&Net::LDAP::Util::escape_dn_value($domain)/gse;
21319    }
21320  }
21321  # build hash of keys and array position
21322  my(%xref); my $key_num = 0;
21323  $xref{$_} = $key_num++ for @keys;
21324  #
21325  do_log(4,'lookup_ldap "%s", query keys: %s, base: %s, filter: %s',
21326    $addr,join(', ',map("\"$_\"",@keys)),$self->{base},$self->{query_filter});
21327  my $conn_h = $self->{conn_h};
21328  $conn_h->begin_work;  # (re)connect if not connected
21329  eval {
21330    snmp_count('OpsLDAPSearch');
21331    my(@entry);
21332    my $search_obj = $conn_h->do_search($base, $self->{scope}, $filter);
21333    @entry = $search_obj->entries  if $search_obj && !$search_obj->code;
21334    my(%mv_ldap_attrs) = map((lc($_), 1), @mv_ldap_attrs);
21335    for my $entry (@entry) {
21336      my $match = {};
21337      $match->{dn} = $entry->dn;
21338      for my $attr (@ldap_attrs) {
21339        my $value;
21340        do_log(9,'lookup_ldap: reading attribute "%s" from object', $attr);
21341        $attr = lc $attr;
21342        if ($mv_ldap_attrs{$attr}) {  # multivalued
21343          $value = $entry->get_value($attr, asref => 1);
21344        } else {
21345          $value = $entry->get_value($attr);
21346        }
21347        $match->{$attr} = $value  if defined $value;
21348      }
21349      my $pos;
21350      for my $attr (@filter_attr) {
21351        my $value = scalar($entry->get_value($attr));
21352        if (defined $value) {
21353          if (!exists $match->{'amavislocal'} && $value eq '@.') {
21354            # NOTE: see lookup_sql
21355            $match->{'amavislocal'} = undef;
21356            do_log(5, 'lookup_ldap: "%s" matches catchall, amavislocal=>undef',
21357                      $addr);
21358          }
21359          $pos = $xref{$value};
21360          last;
21361        }
21362      }
21363      my $key_str = join(", ",map {sprintf("%s=>%s",$_,!defined($match->{$_})?
21364        '-':'"'.$match->{$_}.'"')} keys(%$match));
21365      push(@tmp_result,      [$pos,{%$match}]); # copy hash
21366      push(@tmp_matchingkey, [$pos,$key_str]);
21367      last if !$get_all;
21368    }
21369    1;
21370  } or do {
21371    my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
21372    do_log(-1,"lookup_ldap: %s", $err);
21373    die $err;
21374  };
21375  @result      = map($_->[1], sort {$a->[0] <=> $b->[0]} @tmp_result);
21376  @matchingkey = map($_->[1], sort {$a->[0] <=> $b->[0]} @tmp_matchingkey);
21377  if (!ll(4)) {
21378    # don't bother preparing log report which will not be printed
21379  } elsif (!@result) {
21380    do_log(4,'lookup_ldap, "%s" no match', $addr);
21381  } else {
21382    do_log(4,'lookup_ldap(%s) matches, result=(%s)',$addr,$_) for @matchingkey;
21383  }
21384  # save for future use, but only within processing of this message
21385  $self->{cache}->{$addr} = \@result;
21386  section_time('lookup_ldap');
21387  if (!$get_all) { !wantarray ? $result[0] : ($result[0], $matchingkey[0]) }
21388  else           { !wantarray ? \@result   : (\@result,   \@matchingkey)   }
21389}
21390
213911;
21392
21393__DATA__
21394#
21395package Amavis::In::AMPDP;
21396use strict;
21397use re 'taint';
21398use warnings;
21399use warnings FATAL => qw(utf8 void);
21400no warnings 'uninitialized';
21401# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
21402
21403BEGIN {
21404  require Exporter;
21405  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
21406  $VERSION = '2.412';
21407  @ISA = qw(Exporter);
21408  import Amavis::Conf qw(:platform :confvars c cr ca);
21409  import Amavis::Util qw(ll do_log debug_oneshot dump_captured_log
21410                         untaint snmp_counters_init read_file
21411                         snmp_count proto_encode proto_decode
21412                         switch_to_my_time switch_to_client_time
21413                         am_id new_am_id add_entropy rmdir_recursively
21414                         generate_mail_id);
21415  import Amavis::Lookup qw(lookup lookup2);
21416  import Amavis::Lookup::IP qw(lookup_ip_acl normalize_ip_addr);
21417  import Amavis::Timing qw(section_time);
21418  import Amavis::rfc2821_2822_Tools;
21419  import Amavis::In::Message;
21420  import Amavis::In::Connection;
21421  import Amavis::IO::Zlib;
21422  import Amavis::Out::EditHeader qw(hdr);
21423  import Amavis::Out qw(mail_dispatch);
21424  import Amavis::Notify qw(msg_from_quarantine);
21425}
21426use subs @EXPORT;
21427
21428use Errno qw(ENOENT EACCES);
21429use IO::File ();
21430use Time::HiRes ();
21431use Digest::MD5;
21432use MIME::Base64;
21433
21434sub new($) { my $class = $_[0];  bless {}, $class }
21435
21436# used with sendmail milter and traditional (non-SMTP) MTA interface,
21437# but also to request a message release from a quarantine
21438#
21439sub process_policy_request($$$$) {
21440  my($self, $sock, $conn, $check_mail, $old_amcl) = @_;
21441  # $sock:       connected socket from Net::Server
21442  # $conn:       information about client connection
21443  # $check_mail: subroutine ref to be called with file handle
21444
21445  my(%attr);
21446  $0 = sprintf("%s (ch%d-P-idle)",
21447               c('myprogram_name'), $Amavis::child_invocation_count);
21448  ll(5) && do_log(5, "process_policy_request: %s, %s, fileno=%s",
21449                     $old_amcl, c('myprogram_name'), fileno($sock));
21450  if ($old_amcl) {
21451    # Accept a single request from traditional amavis helper program.
21452    # Receive TEMPDIR/SENDER/RCPTS/LDA/LDAARGS from client
21453    # Simple protocol: \2 means LDA follows; \3 means EOT (end of transmission)
21454    die "process_policy_request: old AM.CL protocol is no longer supported\n";
21455
21456  } else {  # new amavis helper protocol AM.PDP or a Postfix policy server
21457    # for Postfix policy server see Postfix docs SMTPD_POLICY_README
21458    my(@response); local($1,$2,$3);
21459    local($/) = "\012";  # set line terminator to LF (Postfix idiosyncrasy)
21460    my $ln;  # can accept multiple tasks
21461    switch_to_client_time("start receiving AM.PDP data");
21462    $conn->appl_proto('AM.PDP');
21463    for ($! = 0; defined($ln=$sock->getline); $! = 0) {
21464      my $end_of_request = $ln =~ /^\015?\012\z/ ? 1 : 0;  # end of request?
21465      switch_to_my_time($end_of_request ? 'rx entire AM.PDP request'
21466                                        : 'rx AM.PDP line');
21467      $0 = sprintf("%s (ch%d-P)",
21468                   c('myprogram_name'), $Amavis::child_invocation_count);
21469      Amavis::Timing::init(); snmp_counters_init();
21470      # must not use \r and \n, not the same as \015 and \012 on some platforms
21471      if ($end_of_request) {  # end of request
21472        section_time('got data');
21473        my $msg_size;
21474        eval {
21475          my($msginfo,$bank_names_ref) = preprocess_policy_query(\%attr,$conn);
21476          $Amavis::MSGINFO = $msginfo;  # ugly
21477          my $req = lc($attr{'request'});
21478          @response = $req eq 'smtpd_access_policy'
21479                        ? postfix_policy($msginfo,\%attr)
21480                  : $req =~ /^(?:release|requeue|report)\z/
21481                        ? dispatch_from_quarantine($msginfo, $req,
21482                                 $req eq 'report' ? 'abuse' : 'miscategorized')
21483                  : check_ampdp_policy($msginfo,$check_mail,0,$bank_names_ref);
21484          $msg_size = $msginfo->msg_size;
21485          undef $Amavis::MSGINFO;  # release global reference
21486          1;
21487        } or do {
21488          my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
21489          do_log(-2, "policy_server FAILED: %s", $err);
21490          @response = (proto_encode('setreply','450','4.5.0',"Failure: $err"),
21491                       proto_encode('return_value','tempfail'),
21492                       proto_encode('exit_code',sprintf("%d",EX_TEMPFAIL)));
21493          die $err  if $err =~ /^timed out\b/;  # resignal timeout
21494        # last;
21495        };
21496        $sock->print( join('', map($_."\015\012", (@response,'')) ))
21497          or die "Can't write response to socket: $!, fileno=".fileno($sock);
21498        %attr = (); @response = ();
21499        if (ll(2)) {
21500          my $rusage_report = Amavis::Timing::rusage_report();
21501          do_log(2,"size: %d, %s", $msg_size, Amavis::Timing::report());
21502          do_log(2,"size: %d, RUSAGE %s", $msg_size, $rusage_report)
21503            if defined $rusage_report;
21504        }
21505      } elsif ($ln =~ /^ ([^=\000\012]*?) (=|:[ \t]*)
21506                         ([^\012]*?) \015?\012 \z/xsi) {
21507        my $attr_name = proto_decode($1);
21508        my $attr_val  = proto_decode($3);
21509        if (!exists $attr{$attr_name}) {
21510          $attr{$attr_name} = $attr_val;
21511        } else {
21512          $attr{$attr_name} = [ $attr{$attr_name} ]  if !ref $attr{$attr_name};
21513          push(@{$attr{$attr_name}}, $attr_val);
21514        }
21515        my $known_attr = scalar(grep($_ eq $attr_name, qw(
21516          request protocol_state version_client protocol_name helo_name
21517          client_name client_address client_port client_source sender recipient
21518          delivery_care_of queue_id partition_tag mail_id secret_id quar_type
21519          mail_file tempdir tempdir_removed_by policy_bank requested_by) ));
21520        do_log(!$known_attr?1:3,
21521               "policy protocol: %s=%s", $attr_name,$attr_val);
21522      } else {
21523        do_log(-1, "policy protocol: INVALID AM.PDP ATTRIBUTE LINE: %s", $ln);
21524      }
21525      $0 = sprintf("%s (ch%d-P-idle)",
21526                   c('myprogram_name'), $Amavis::child_invocation_count);
21527      switch_to_client_time("receiving AM.PDP data");
21528    }
21529    defined $ln || $! == 0  or die "Read from client socket FAILED: $!";
21530    switch_to_my_time('end of AM.PDP session');
21531  };
21532  $0 = sprintf("%s (ch%d-P)",
21533               c('myprogram_name'), $Amavis::child_invocation_count);
21534}
21535
21536# Based on given query attributes describing a message to be checked or
21537# released, return a new Amavis::In::Message object with filled-in information
21538#
21539sub preprocess_policy_query($$) {
21540  my($attr_ref,$conn) = @_;
21541
21542  my $now = Time::HiRes::time;
21543  my $msginfo = Amavis::In::Message->new;
21544  $msginfo->rx_time($now);
21545  $msginfo->log_id(am_id());
21546  $msginfo->conn_obj($conn);
21547  $msginfo->originating(1);
21548  $msginfo->add_contents_category(CC_CLEAN,0);
21549  add_entropy(%$attr_ref);
21550
21551  # amavisd -> amavis-helper protocol query consists of any number of
21552  # the following lines, the response is terminated by an empty line.
21553  # The 'request=AM.PDP' is a required first field, the order of
21554  # remaining fields is arbitrary, but multivalued attributes such as
21555  # 'recipient' must retain their relative order.
21556  # Required AM.PDP fields are: request, tempdir, sender, recipient(s)
21557  #   request=AM.PDP
21558  #   version_client=n             (currently ignored)
21559  #   tempdir=/var/amavis/amavis-milter-MWZmu9Di
21560  #   tempdir_removed_by=client    (tempdir_removed_by=server is a default)
21561  #   mail_file=/var/amavis/am.../email.txt (defaults to tempdir/email.txt)
21562  #   sender=<foo@example.com>
21563  #   recipient=<bar1@example.net>
21564  #   recipient=<bar2@example.net>
21565  #   recipient=<bar3@example.net>
21566  #   delivery_care_of=server      (client or server, client is a default)
21567  #   queue_id=qid
21568  #   protocol_name=ESMTP
21569  #   helo_name=host.example.com
21570  #   client_address=10.2.3.4
21571  #   client_port=45678
21572  #   client_name=host.example.net
21573  #   client_source=LOCAL/REMOTE/[UNAVAILABLE]
21574  #     (matches local_header_rewrite_clients, see Postfix XFORWARD_README)
21575  #   policy_bank=SMTP_AUTH,TLS,ORIGINATING,MYNETS,...
21576  # Required 'release' or 'requeue' or 'report' fields are: request, mail_id
21577  #   request=release  (or request=requeue, or request=report)
21578  #   mail_id=xxxxxxxxxxxx
21579  #   secret_id=xxxxxxxxxxxx              (authorizes a release/report)
21580  #   partition_tag=xx                    (required if mail_id is not unique)
21581  #   quar_type=x                         F/Z/B/Q/M  (defaults to Q or F)
21582  #                                       file/zipfile/bsmtp/sql/mailbox
21583  #   mail_file=...  (optional: overrides automatics; $QUARANTINEDIR prepended)
21584  #   requested_by=<releaser@example.com> (optional: lands in Resent-From:)
21585  #   sender=<foo@example.com>            (optional: replaces envelope sender)
21586  #   recipient=<bar1@example.net>        (optional: replaces envelope recips)
21587  #   recipient=<bar2@example.net>
21588  #   recipient=<bar3@example.net>
21589  my(@recips); my(@bank_names);
21590  exists $attr_ref->{'request'} or die "Missing 'request' field";
21591  my $ampdp = $attr_ref->{'request'} =~
21592                               /^(?:AM\.CL|AM\.PDP|release|requeue|report)\z/i;
21593  local $1;
21594  @bank_names =
21595    map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $attr_ref->{'policy_bank'}))
21596    if defined $attr_ref->{'policy_bank'};
21597  my $d_co  = $attr_ref->{'delivery_care_of'};
21598  my $td_rm = $attr_ref->{'tempdir_removed_by'};
21599  $msginfo->client_delete(defined($td_rm) && lc($td_rm) eq 'client' ? 1 : 0);
21600  $msginfo->queue_id($attr_ref->{'queue_id'})
21601    if exists $attr_ref->{'queue_id'};
21602  $msginfo->client_proto($attr_ref->{'protocol_name'})
21603    if exists $attr_ref->{'protocol_name'};
21604  if (exists $attr_ref->{'client_address'}) {
21605    $msginfo->client_addr(normalize_ip_addr($attr_ref->{'client_address'}));
21606  }
21607  $msginfo->client_port($attr_ref->{'client_port'})
21608    if exists $attr_ref->{'client_port'};
21609  $msginfo->client_name($attr_ref->{'client_name'})
21610    if exists $attr_ref->{'client_name'};
21611  $msginfo->client_source($attr_ref->{'client_source'})
21612    if exists $attr_ref->{'client_source'}
21613       &&  uc($attr_ref->{'client_source'}) ne '[UNAVAILABLE]';
21614  $msginfo->client_helo($attr_ref->{'helo_name'})
21615    if exists $attr_ref->{'helo_name'};
21616# $msginfo->body_type('8BITMIME');
21617  $msginfo->requested_by(unquote_rfc2821_local($attr_ref->{'requested_by'}))
21618    if exists $attr_ref->{'requested_by'};
21619  if (exists $attr_ref->{'sender'}) {
21620    my $sender = $attr_ref->{'sender'};
21621    $sender = '<'.$sender.'>'  if $sender !~ /^<.*>\z/;
21622    $msginfo->sender_smtp($sender);
21623    $sender = unquote_rfc2821_local($sender);
21624    $msginfo->sender($sender);
21625  }
21626  if (exists $attr_ref->{'recipient'}) {
21627    my $r = $attr_ref->{'recipient'}; @recips = ();
21628    for my $addr (!ref($r) ? $r : @$r) {
21629      my $addr_quo = $addr;
21630      my $addr_unq = unquote_rfc2821_local($addr);
21631      $addr_quo = '<'.$addr_quo.'>'  if $addr_quo !~ /^<.*>\z/;
21632      my $recip_obj = Amavis::In::Message::PerRecip->new;
21633      $recip_obj->recip_addr($addr_unq);
21634      $recip_obj->recip_addr_smtp($addr_quo);
21635      $recip_obj->dsn_orcpt($addr_quo);
21636      $recip_obj->recip_destiny(D_PASS);  # default is Pass
21637      $recip_obj->delivery_method('')  if !defined($d_co) ||
21638                                          lc($d_co) eq 'client';
21639      push(@recips,$recip_obj);
21640    }
21641    $msginfo->per_recip_data(\@recips);
21642  }
21643  if (!exists $attr_ref->{'tempdir'}) {
21644    my $tempdir = Amavis::TempDir->new;
21645    $tempdir->prepare_dir;
21646    $msginfo->mail_tempdir($tempdir->path);
21647    # Save the Amavis::TempDir object from destruction by keeping a ref to it
21648    # in $msginfo. When $msginfo is destroyed, the temporary directory will be
21649    # automatically destroyed too. This is specific to AM.PDP requests without
21650    # a working directory provided by a caller, and different from usual
21651    # SMTP sessions which keep a per-process permanent reference to an
21652    # Amavis::TempDir object, which makes keeping it in mail_tempdir_obj
21653    # not necessary.
21654    $msginfo->mail_tempdir_obj($tempdir);
21655  } else {
21656    local($1,$2); my $tempdir = $attr_ref->{tempdir};
21657    $tempdir =~ m{^ (?: \Q$TEMPBASE\E | \Q$MYHOME\E )
21658                    (?: / (?! \.\. (?:\z|/)) [A-Za-z0-9_.-]+ )*
21659                    / [A-Za-z0-9_.-]+ \z}xso
21660      or die "Suspicious temporary directory name '$tempdir'";
21661    $msginfo->mail_tempdir(untaint($tempdir));
21662  }
21663  my $quar_type;
21664  my $p_mail_id;
21665  if (!$ampdp) {
21666    # don't bother with filenames
21667  } elsif ($attr_ref->{'request'} =~ /^(?:release|requeue|report)\z/i) {
21668    exists $attr_ref->{'mail_id'} or die "Missing 'mail_id' field";
21669    $msginfo->partition_tag($attr_ref->{'partition_tag'});  # may be undef
21670    $p_mail_id = $attr_ref->{'mail_id'};
21671    # amavisd almost-base64: 62 +, 63 -  (in use up to 2.6.4, dropped in 2.7.0)
21672    # RFC 4648 base64:       62 +, 63 /  (not used here)
21673    # RFC 4648 base64url:    62 -, 63 _
21674    $p_mail_id =~ m{^ [A-Za-z0-9] [A-Za-z0-9_+-]* ={0,2} \z}xs
21675      or die "Invalid mail_id '$p_mail_id'";
21676    $p_mail_id = untaint($p_mail_id);
21677    $msginfo->parent_mail_id($p_mail_id);
21678    $msginfo->mail_id(scalar generate_mail_id());
21679    if (!exists($attr_ref->{'secret_id'}) || $attr_ref->{'secret_id'} eq '') {
21680      die "Secret_id is required, but missing"  if c('auth_required_release');
21681    } else {
21682      # version 2.7.0 and later uses RFC 4648 base64url and id=b64(md5(sec)),
21683      # versions before 2.7.0 used almost-base64 and id=b64(md5(b64(sec)))
21684      { # begin block, 'last' exits it
21685        my $secret_b64 = $attr_ref->{'secret_id'};
21686        $secret_b64 = ''  if !defined $secret_b64;
21687        if (index($secret_b64,'+') < 0) {  # new or undetermined format
21688          local($_) = $secret_b64;  tr{-_}{+/};  # revert base64url to base64
21689          my $secret_bin = decode_base64($_);
21690          my $id_new_b64 = Digest::MD5->new->add($secret_bin)->b64digest;
21691          substr($id_new_b64, 12) = '';
21692          $id_new_b64 =~ tr{+/}{-_};  # base64 -> RFC 4648 base64url
21693          last  if $id_new_b64 eq $p_mail_id;  # exit enclosing block
21694        }
21695        if (index($secret_b64,'_') < 0) {  # old or undetermined format
21696          my $id_old_b64 = Digest::MD5->new->add($secret_b64)->b64digest;
21697          substr($id_old_b64, 12) = '';
21698          $id_old_b64 =~ tr{/}{-};  # base64 -> almost-base64
21699          last  if $id_old_b64 eq $p_mail_id;  # exit enclosing block
21700        }
21701        die "Secret_id $secret_b64 does not match mail_id $p_mail_id";
21702      };  # end block, 'last' arrives here
21703    }
21704    $quar_type = $attr_ref->{'quar_type'};
21705    if (!defined($quar_type) || $quar_type eq '') {
21706      # choose some reasonable default (simpleminded)
21707      $quar_type = c('spam_quarantine_method') =~ /^sql:/i ? 'Q' : 'F';
21708    }
21709    my $fn = $p_mail_id;
21710    if ($quar_type eq 'F' || $quar_type eq 'Z') {
21711      $QUARANTINEDIR ne '' or die "Config variable \$QUARANTINEDIR is empty";
21712      if ($attr_ref->{'mail_file'} ne '') {
21713        $fn = $attr_ref->{'mail_file'};
21714        $fn =~ m{^[A-Za-z0-9][A-Za-z0-9/_.+-]*\z}s && $fn !~ m{\.\.(?:/|\z)}
21715          or die "Unsafe filename '$fn'";
21716        $fn = $QUARANTINEDIR.'/'.untaint($fn);
21717      } else {  # automatically guess a filename - simpleminded
21718        if ($quarantine_subdir_levels < 1) { $fn = "$QUARANTINEDIR/$fn" }
21719        else { my $subd = substr($fn,0,1);   $fn = "$QUARANTINEDIR/$subd/$fn" }
21720        $fn .= '.gz'  if $quar_type eq 'Z';
21721      }
21722    }
21723    $msginfo->mail_text_fn($fn);
21724  } elsif (!exists $attr_ref->{'mail_file'}) {
21725    $msginfo->mail_text_fn($msginfo->mail_tempdir . '/email.txt');
21726  } else {
21727    # SECURITY: just believe the supplied file name, blindly untainting it
21728    $msginfo->mail_text_fn(untaint($attr_ref->{'mail_file'}));
21729  }
21730  my $fname = $msginfo->mail_text_fn;
21731  if ($ampdp && defined($fname) && $fname ne '') {
21732    my $fh;
21733    my $releasing = $attr_ref->{'request'}=~ /^(?:release|requeue|report)\z/i;
21734    new_am_id('rel-'.$msginfo->mail_id)  if $releasing;
21735    if ($releasing && $quar_type eq 'Q') {  # releasing from SQL
21736      do_log(5, "preprocess_policy_query: opening in sql: %s", $p_mail_id);
21737      my $obj = $Amavis::sql_storage;
21738      $Amavis::extra_code_sql_quar && $obj
21739        or die "SQL quarantine code not enabled (3)";
21740      my $conn_h = $obj->{conn_h}; my $sql_cl_r = cr('sql_clause');
21741      my $sel_msg  = $sql_cl_r->{'sel_msg'};
21742      my $sel_quar = $sql_cl_r->{'sel_quar'};
21743      if (!defined($msginfo->partition_tag) &&
21744          defined($sel_msg) && $sel_msg ne '') {
21745        do_log(5, "preprocess_policy_query: missing partition_tag in request,".
21746                  " fetching msgs record for mail_id=%s", $p_mail_id);
21747        # find a corresponding partition_tag if missing from a release request
21748        $conn_h->begin_work_nontransaction;  #(re)connect if necessary
21749        $conn_h->execute($sel_msg, $p_mail_id);
21750        my $a_ref; my $cnt = 0; my $partition_tag;
21751        while ( defined($a_ref=$conn_h->fetchrow_arrayref($sel_msg)) ) {
21752          $cnt++;
21753          $partition_tag = $a_ref->[0]  if !defined $partition_tag;
21754          ll(5) && do_log(5, "release: got msgs record for mail_id=%s: %s",
21755                             $p_mail_id, join(', ',@$a_ref));
21756        }
21757        $conn_h->finish($sel_msg)  if defined $a_ref;  # only if not all read
21758        $cnt <= 1 or die "Multiple ($cnt) records with same mail_id exist, ".
21759                         "specify a partition_tag in the AM.PDP request";
21760        if ($cnt < 1) {
21761          do_log(0, "release: no records with msgs.mail_id=%s in a database, ".
21762                    "trying to read from a quar. anyway", $p_mail_id);
21763        }
21764        $msginfo->partition_tag($partition_tag);  # could still be undef/NULL !
21765      }
21766      ll(5) && do_log(5, "release: opening mail_id=%s, partition_tag=%s",
21767                         $p_mail_id, $msginfo->partition_tag);
21768      $conn_h->begin_work_nontransaction;  # (re)connect if not connected
21769      $fh = Amavis::IO::SQL->new;
21770      $fh->open($conn_h, $sel_quar, $p_mail_id,
21771                'r', untaint($msginfo->partition_tag))
21772        or die "Can't open sql obj for reading: $!";  1;
21773    } else {  # mail checking or releasing from a file
21774      do_log(5, "preprocess_policy_query: opening mail '%s'", $fname);
21775      # set new amavis message id
21776      new_am_id( ($fname =~ m{amavis-(milter-)?([^/ \t]+)}s ? $2 : undef),
21777                 $Amavis::child_invocation_count )  if !$releasing;
21778      # file created by amavis helper program or other client, just open it
21779      my(@stat_list) = lstat($fname); my $errn = @stat_list ? 0 : 0+$!;
21780      if ($errn == ENOENT) { die "File $fname does not exist" }
21781      elsif ($errn) { die "File $fname inaccessible: $!" }
21782      elsif (!-f _) { die "File $fname is not a plain file" }
21783      add_entropy(@stat_list);
21784      if ($fname =~ /\.gz\z/) {
21785        $fh = Amavis::IO::Zlib->new;
21786        $fh->open($fname,'rb') or die "Can't open gzipped file $fname: $!";
21787      } else {
21788      # $msginfo->msg_size(0 + (-s _));  # underestimates the RFC 1870 size
21789        $fh = IO::File->new;
21790        $fh->open($fname,'<') or die "Can't open file $fname: $!";
21791        binmode($fh,':bytes') or die "Can't cancel :utf8 mode: $!";
21792        my $file_size = $stat_list[7];
21793        if ($file_size < 100*1024) {  # 100 KiB 'small mail', read into memory
21794          do_log(5, 'preprocess_policy_query: reading from %s to memory, '.
21795                    'file size %d bytes', $fname, $file_size);
21796          my $str = ''; read_file($fh,\$str);
21797          $fh->seek(0,0) or die "Can't rewind file $fname: $!";
21798          $msginfo->mail_text_str(\$str);  # save mail as a string
21799        }
21800      }
21801    }
21802    $msginfo->mail_text($fh);  # save file handle to object
21803    $msginfo->log_id(am_id());
21804  }
21805  if ($ampdp && ll(3)) {
21806    do_log(3, "Request: %s %s %s: %s -> %s", $attr_ref->{'request'},
21807              $attr_ref->{'mail_id'}, $msginfo->mail_tempdir,
21808              $msginfo->sender_smtp,
21809              join(',', map($_->recip_addr_smtp, @recips)) );
21810  } else {
21811    do_log(3, "Request: %s(%s): %s %s %s: %s[%s] <%s> -> <%s>",
21812              @$attr_ref{qw(request protocol_state mail_id protocol_name
21813              queue_id client_name client_address sender recipient)});
21814  }
21815  ($msginfo, \@bank_names);
21816}
21817
21818sub check_ampdp_policy($$$$) {
21819  my($msginfo,$check_mail,$old_amcl,$bank_names_ref) = @_;
21820  my($smtp_resp, $exit_code, $preserve_evidence);
21821  my(%baseline_policy_bank) = %current_policy_bank;
21822  # do some sanity checks before deciding to call check_mail()
21823  if (!ref($msginfo->per_recip_data) || !defined($msginfo->mail_text)) {
21824    $smtp_resp = '450 4.5.0 Incomplete request'; $exit_code = EX_TEMPFAIL;
21825  } else {
21826    # loading a policy bank can affect subsequent c(), cr() and ca() results,
21827    # so it is necessary to load each policy bank in the right order and soon
21828    # after information becomes available; general principle is that policy
21829    # banks are loaded in order in which information becomes available:
21830    # interface/socket, client IP, SMTP session info, sender, ...
21831    my $cl_ip  = $msginfo->client_addr;
21832    my $cl_src = $msginfo->client_source;
21833    my(@bank_names_cl);
21834    { my $cl_ip_tmp = $cl_ip;
21835      # treat unknown client IP addr as 0.0.0.0, from "This" Network, RFC 1700
21836      $cl_ip_tmp = '0.0.0.0'  if !defined($cl_ip) || $cl_ip eq '';
21837      my(@cp) = @{ca('client_ipaddr_policy')};
21838      do_log(-1,'@client_ipaddr_policy must contain pairs, '.
21839                'number of elements is not even')  if @cp % 2 != 0;
21840      my $labeler = Amavis::Lookup::Label->new('client_ipaddr_policy');
21841      while (@cp > 1) {
21842        my $lookup_table = shift(@cp);
21843        my $policy_names = shift(@cp);  # comma-separated string of names
21844        next if !defined $policy_names;
21845        if (lookup_ip_acl($cl_ip_tmp, $labeler, $lookup_table)) {
21846          local $1;
21847          push(@bank_names_cl,
21848               map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $policy_names)));
21849          last;  # should we stop here or not?
21850        }
21851      }
21852    }
21853    # load policy banks from the 'client_ipaddr_policy' lookup
21854    Amavis::load_policy_bank($_,$msginfo) for @bank_names_cl;
21855    # additional banks from the request
21856    Amavis::load_policy_bank(untaint($_),$msginfo) for @$bank_names_ref;
21857    my $sender = $msginfo->sender;
21858    if (defined $policy_bank{'MYUSERS'} &&
21859        $sender ne '' && $msginfo->originating &&
21860        lookup2(0,$sender, ca('local_domains_maps'))) {
21861      Amavis::load_policy_bank('MYUSERS',$msginfo);
21862    }
21863    my $debrecipm = ca('debug_recipient_maps');
21864    if (lookup2(0, $sender, ca('debug_sender_maps')) ||
21865        @$debrecipm && grep(lookup2(0, $_->recip_addr, $debrecipm),
21866                                    @{$msginfo->per_recip_data})) {
21867      debug_oneshot(1);
21868    }
21869    # check_mail() expects open file on $fh, need not be rewound
21870    Amavis::check_mail_begin_task();
21871    ($smtp_resp, $exit_code, $preserve_evidence) = &$check_mail($msginfo,0);
21872    my $fh = $msginfo->mail_text;  my $tempdir = $msginfo->mail_tempdir;
21873    $fh->close or die "Error closing temp file: $!"   if $fh;
21874    undef $fh; $msginfo->mail_text(undef);
21875    $msginfo->mail_text_str(undef); $msginfo->body_start_pos(undef);
21876    my $errn = $tempdir eq '' ? ENOENT : (stat($tempdir) ? 0 : 0+$!);
21877    if ($tempdir eq '' || $errn == ENOENT) {
21878      # do nothing
21879    } elsif ($msginfo->client_delete) {
21880      do_log(4, "AM.PDP: deletion of %s is client's responsibility", $tempdir);
21881    } elsif ($preserve_evidence) {
21882      do_log(-1,'AM.PDP: tempdir is to be PRESERVED: %s', $tempdir);
21883    } else {
21884      my $fname = $msginfo->mail_text_fn;
21885      do_log(4, 'AM.PDP: tempdir and file being removed: %s, %s',
21886                $tempdir,$fname);
21887      unlink($fname) or die "Can't remove file $fname: $!"  if $fname ne '';
21888      # must step out of the directory which is about to be deleted,
21889      # otherwise rmdir can fail (e.g. on Solaris)
21890      chdir($TEMPBASE) or die "Can't chdir to $TEMPBASE: $!";
21891      rmdir_recursively($tempdir);
21892    }
21893  }
21894  # amavisd -> amavis-helper protocol response consists of any number of
21895  # the following lines, the response is terminated by an empty line:
21896  #   version_server=2
21897  #   log_id=xxx
21898  #   delrcpt=<recipient>
21899  #   addrcpt=<recipient>
21900  #   delheader=hdridx hdr_head
21901  #   chgheader=hdridx hdr_head hdr_body
21902  #   insheader=hdridx hdr_head hdr_body
21903  #   addheader=hdr_head hdr_body
21904  #   replacebody=new_body  (not implemented)
21905  #   quarantine=reason  (currently never used, supposed to call
21906  #                       smfi_quarantine, placing message on hold)
21907  #   return_value=continue|reject|discard|accept|tempfail
21908  #   setreply=rcode xcode message
21909  #   exit_code=n
21910
21911  my(@response); my($rcpt_deletes,$rcpt_count)=(0,0);
21912  push(@response, proto_encode('version_server', '2'));
21913  push(@response, proto_encode('log_id', $msginfo->log_id));
21914  for my $r (@{$msginfo->per_recip_data}) {
21915    $rcpt_count++;
21916    $rcpt_deletes++  if $r->recip_done;
21917  }
21918  local($1,$2,$3);
21919  if ($smtp_resp=~/^([1-5]\d\d) ([245]\.\d{1,3}\.\d{1,3})(?: |\z)(.*)\z/s)
21920    { push(@response, proto_encode('setreply', $1,$2,$3)) }
21921  if (     $exit_code == EX_TEMPFAIL) {
21922    push(@response, proto_encode('return_value','tempfail'));
21923  } elsif ($exit_code == EX_NOUSER) {          # reject the whole message
21924    push(@response, proto_encode('return_value','reject'));
21925  } elsif ($exit_code == EX_UNAVAILABLE) {     # reject the whole message
21926    push(@response, proto_encode('return_value','reject'));
21927  } elsif ($exit_code == 99 || $rcpt_deletes >= $rcpt_count) {
21928    $exit_code = 99; # let MTA discard the message, it was already handled here
21929    push(@response, proto_encode('return_value','discard'));
21930  } elsif (grep($_->delivery_method ne '', @{$msginfo->per_recip_data})) {
21931    # explicit forwarding by us
21932    die "Not all recips done, but explicit forwarding";  # just in case
21933  } else {  # EX_OK
21934    for my $r (@{$msginfo->per_recip_data}) {  # modified recipient addresses?
21935      my $newaddr = $r->recip_final_addr;
21936      if ($r->recip_done) {           # delete
21937        push(@response, proto_encode('delrcpt', $r->recip_addr_smtp))
21938          if defined $r->recip_addr;  # if in the original list, not always_bcc
21939      } elsif ($newaddr ne $r->recip_addr) {   # modify, e.g. adding extension
21940        push(@response, proto_encode('delrcpt', $r->recip_addr_smtp))
21941          if defined $r->recip_addr;  # if in the original list, not always_bcc
21942        push(@response, proto_encode('addrcpt',
21943                                     qquote_rfc2821_local($newaddr)));
21944      }
21945    }
21946    my $hdr_edits = $msginfo->header_edits;
21947    if ($hdr_edits) {  # any added or modified header fields?
21948      local($1,$2); my($field_name,$edit,$field_body);
21949      while ( ($field_name,$edit) = each %{$hdr_edits->{edit}} ) {
21950        $field_body = $msginfo->get_header_field_body($field_name,0);  # first
21951        if (!defined($field_body)) {
21952          # such header field does not exist or is not available, do nothing
21953        } else {                 # edit the first occurrence
21954          chomp($field_body);
21955          my $orig_field_body = $field_body;
21956          for my $e (@$edit) {   # possibly multiple (iterative) edits
21957            if (!defined($e)) { $field_body = undef; last }  # delete existing
21958            my($new_fbody,$verbatim) = &$e($field_name,$field_body);
21959            if (!defined($new_fbody)) { $field_body = undef; last }  # delete
21960            my $curr_head = $verbatim ? ($field_name . ':' . $new_fbody)
21961                                      : hdr($field_name, $new_fbody, 0,
21962                                            $msginfo->smtputf8);
21963            chomp($curr_head); $curr_head .= "\n";
21964            $curr_head =~ /^([^:]*?)[ \t]*:(.*)\z/s;
21965            $field_body = $2; chomp($field_body);  # carry to next iteration
21966          }
21967          if (!defined($field_body)) {
21968            push(@response, proto_encode('delheader','1',$field_name));
21969          } elsif ($field_body ne $orig_field_body) {
21970            # sendmail inserts a space after a colon, remove ours
21971            $field_body =~ s/^[ \t]//;
21972            push(@response, proto_encode('chgheader','1',
21973                                         $field_name,$field_body));
21974          }
21975        }
21976      }
21977      my $hdridx = c('prepend_header_fields_hdridx');  # milter insertion index
21978      $hdridx = 0  if !defined($hdridx) || $hdridx < 0;
21979      $hdridx = sprintf("%d",$hdridx);  # convert to string
21980      # prepend header fields one at a time, topmost field last
21981      for my $hf (map(ref $hdr_edits->{$_} ? reverse @{$hdr_edits->{$_}} : (),
21982                      qw(addrcvd prepend)) ) {
21983        if ($hf =~ /^([^:]*?)[ \t]*:[ \t]*(.*?)$/s)
21984          { push(@response, proto_encode('insheader',$hdridx,$1,$2)) }
21985      }
21986      # append header fields
21987      for my $hf (map(ref $hdr_edits->{$_} ? @{$hdr_edits->{$_}} : (),
21988                      qw(append)) ) {
21989        if ($hf =~ /^([^:]*?)[ \t]*:[ \t]*(.*?)$/s)
21990          { push(@response, proto_encode('addheader',$1,$2)) }
21991      }
21992    }
21993    if ($old_amcl) {   # milter via old amavis helper program
21994      # warn if there is anything that should be done but MTA is not capable of
21995      # (or a helper program cannot pass the request)
21996      for (grep(/^(delrcpt|addrcpt)=/, @response))
21997        { do_log(-1, "WARN: MTA can't do: %s", $_) }
21998      if ($rcpt_deletes && $rcpt_count-$rcpt_deletes > 0) {
21999        do_log(-1, "WARN: ACCEPT THE WHOLE MESSAGE, ".
22000                   "MTA-in can't do selective recips deletion");
22001      }
22002    }
22003    push(@response, proto_encode('return_value','continue'));
22004  }
22005  push(@response, proto_encode('exit_code',sprintf("%d",$exit_code)));
22006  ll(3) && do_log(3, 'mail checking ended: %s', join("\n",@response));
22007  dump_captured_log(1, c('enable_log_capture_dump'));
22008  %current_policy_bank = %baseline_policy_bank;  # restore bank settings
22009  @response;
22010}
22011
22012# just a proof-of-concept, experimental
22013#
22014sub postfix_policy($$) {
22015  my($msginfo,$attr_ref) = @_;
22016  my(@response);
22017  if ($attr_ref->{'request'} ne 'smtpd_access_policy') {
22018    die("unknown 'request' value: " . $attr_ref->{'request'});
22019  } else {
22020    @response = 'action=DUNNO';
22021  }
22022  @response;
22023}
22024
22025sub dispatch_from_quarantine($$$) {
22026  my($msginfo,$request_type,$feedback_type) = @_;
22027  my $err;
22028  eval {
22029    # feed information to a msginfo object, possibly replacing it
22030    $msginfo = msg_from_quarantine($msginfo,$request_type,$feedback_type);
22031    mail_dispatch($msginfo,0,1);  # re-send the original mail or report
22032    1;
22033  } or do {
22034    $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
22035    do_log(0, "WARN: dispatch_from_quarantine failed: %s",$err);
22036    die $err  if $err =~ /^timed out\b/;  # resignal timeout
22037  };
22038  my(@response);
22039  my $per_recip_data = $msginfo->per_recip_data;
22040  if (!defined($per_recip_data) || !@$per_recip_data) {
22041    push(@response, proto_encode('setreply','250','2.5.0',
22042                                 "No recipients, nothing to do"));
22043  } else {
22044    Amavis::build_and_save_structured_report($msginfo,'SEND');
22045    for my $r (@$per_recip_data) {
22046      local($1,$2,$3); my($smtp_s,$smtp_es,$msg);
22047      my $resp = $r->recip_smtp_response;
22048      if ($err ne '')
22049        { ($smtp_s,$smtp_es,$msg) = ('450', '4.5.0', "ERROR: $err") }
22050      elsif ($resp =~ /^([1-5]\d\d) ([245]\.\d{1,3}\.\d{1,3})(?: |\z)(.*)\z/s)
22051        { ($smtp_s,$smtp_es,$msg) = ($1,$2,$3) }
22052      elsif ($resp =~ /^(([1-5])\d\d)(?: |\z)(.*)\z/s)
22053        { ($smtp_s,$smtp_es,$msg) = ($1, "$2.0.0" ,$3) }
22054      else
22055        { ($smtp_s,$smtp_es,$msg) = ('450', '4.5.0', "Unexpected: $resp") }
22056      push(@response, proto_encode('setreply',$smtp_s,$smtp_es,$msg));
22057    }
22058  }
22059  @response;
22060}
22061
220621;
22063
22064__DATA__
22065#
22066package Amavis::In::SMTP;
22067use strict;
22068use re 'taint';
22069use warnings;
22070use warnings FATAL => qw(utf8 void);
22071no warnings 'uninitialized';
22072# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
22073
22074BEGIN {
22075  require Exporter;
22076  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
22077  $VERSION = '2.412';
22078  @ISA = qw(Exporter);
22079  import Amavis::Conf qw(:platform :confvars c cr ca);
22080  import Amavis::Util qw(ll do_log do_log_safe untaint
22081                         dump_captured_log log_capture_enabled
22082                         am_id new_am_id snmp_counters_init
22083                         orcpt_decode xtext_decode safe_encode_utf8_inplace
22084                         idn_to_ascii sanitize_str add_entropy
22085                         debug_oneshot waiting_for_client prolong_timer
22086                         switch_to_my_time switch_to_client_time
22087                         setting_by_given_contents_category);
22088  import Amavis::Lookup qw(lookup lookup2);
22089  import Amavis::Lookup::IP qw(lookup_ip_acl normalize_ip_addr);
22090  import Amavis::Timing qw(section_time);
22091  import Amavis::rfc2821_2822_Tools;
22092  import Amavis::TempDir;
22093  import Amavis::In::Message;
22094  import Amavis::In::Connection;
22095}
22096
22097use Errno qw(ENOENT EACCES EINTR EAGAIN);
22098use MIME::Base64;
22099use Time::HiRes ();
22100#use IO::Socket::SSL;
22101
22102BEGIN {  # due to dynamic loading runs only after config files have been read
22103
22104  # for compatibility with 2.10 or earlier:
22105  $smtpd_tls_server_options{SSL_key_file} = $smtpd_tls_key_file
22106    if !exists $smtpd_tls_server_options{SSL_key_file} &&
22107       defined $smtpd_tls_key_file;
22108  $smtpd_tls_server_options{SSL_cert_file} = $smtpd_tls_cert_file
22109    if !exists $smtpd_tls_server_options{SSL_cert_file} &&
22110       defined $smtpd_tls_cert_file;
22111
22112  my $tls_security_level = c('tls_security_level_in');
22113  $tls_security_level = 0  if !defined($tls_security_level) ||
22114                              lc($tls_security_level) eq 'none';
22115  if ($tls_security_level) {
22116    ( defined $smtpd_tls_server_options{SSL_cert_file} &&
22117      $smtpd_tls_server_options{SSL_cert_file} ne ''
22118    ) or die '$tls_security_level is enabled '.
22119          'but $smtpd_tls_server_options{SSL_cert_file} is not provided'."\n";
22120    ( defined $smtpd_tls_server_options{SSL_key_file} &&
22121      $smtpd_tls_server_options{SSL_key_file} ne ''
22122    ) or die '$tls_security_level is enabled '.
22123          'but $smtpd_tls_server_options{SSL_key_file} is not provided'."\n";
22124  }
22125  1;
22126}
22127
22128sub new($) {
22129  my $class = $_[0];
22130  my $self = bless {}, $class;
22131  undef $self->{sock};              # SMTP socket
22132  $self->{proto} = undef;           # SMTP / ((ESMTP / LMTP) (A | S | SA)? )
22133  $self->{smtp_outbuf} = undef;     # SMTP responses buffer for PIPELINING
22134  undef $self->{pipelining};        # may we buffer responses?
22135  undef $self->{session_closed_normally};  # closed properly with QUIT
22136  $self->{within_data_transfer} = 0;
22137  $self->{smtp_inpbuf} = '';        # SMTP input buffer
22138  $self->{tempdir} = Amavis::TempDir->new;  # TempDir object
22139  $self;
22140}
22141
22142sub DESTROY {
22143  my $self = $_[0];
22144  local($@,$!,$_); my $myactualpid = $$;
22145  eval {
22146    if (defined($my_pid) && $myactualpid != $my_pid) {
22147      do_log(5,"Skip closing SMTP session in a clone [%s] (born as [%s])",
22148                $myactualpid, $my_pid);
22149    } elsif (ref($self->{sock}) && ! $self->{session_closed_normally}) {
22150      my $msg = "421 4.3.2 Service shutting down, closing channel";
22151      $msg .= ", during waiting for input from client" if waiting_for_client();
22152      $msg .= ", sig: " .
22153              join(',', keys %Amavisd::got_signals)  if %Amavisd::got_signals;
22154      $self->smtp_resp(1,$msg);
22155    }
22156    1;
22157  } or do {
22158    my $eval_stat = $@ ne '' ? $@ : "errno=$!";
22159    do_log_safe(1,"SMTP shutdown: %s", $eval_stat);
22160  };
22161}
22162
22163sub readline {
22164  my($self, $timeout) = @_;
22165  my($rout,$eout,$rin,$ein);
22166  my $ifh = $self->{sock};
22167  for (;;) {
22168    local($1);
22169    return $1  if $self->{smtp_inpbuf} =~ s/^(.*?\015\012)//s;
22170#   if (defined $timeout) {
22171#     if (!defined $rin) {
22172#       $rin = $ein = ''; vec($rin, fileno $self->{sock}, 1) = 1; $ein = $rin;
22173#     }
22174#     my($nfound,$timeleft) =
22175#       select($rout=$rin, undef, $eout=$ein, $timeout);
22176#     defined $nfound && $nfound >= 0
22177#       or die "Select failed: ".
22178#              (!$self->{ssl_active} ? $! : $ifh->errstr.", $!");
22179#     if (!$nfound) {
22180#       do_log(2, 'smtp readline: timed out, %s s', $timeout);
22181#       $timeout = undef; next;  # carry on as usual
22182#     }
22183#   }
22184    my $nbytes = $ifh->sysread($self->{smtp_inpbuf}, 16384,
22185                               length($self->{smtp_inpbuf}));
22186    if ($nbytes) {
22187      ll(5) && do_log(5, 'smtp readline: read %d bytes, new size: %d',
22188                         $nbytes, length($self->{smtp_inpbuf}));
22189    } elsif (defined $nbytes) {  # defined but zero
22190      do_log(5, 'smtp readline: EOF');
22191      $! = 0;  # eof, no error
22192      last;
22193    } elsif ($! == EAGAIN || $! == EINTR) {
22194      do_log(5, 'smtp readline: interrupted: %s',
22195                !$self->{ssl_active} ? $! : $ifh->errstr.", $!");
22196      # retry
22197    } else {
22198      do_log(5, 'smtp readline: error: %s',
22199                !$self->{ssl_active} ? $! : $ifh->errstr.", $!");
22200      last;
22201    }
22202  }
22203  undef;
22204}
22205
22206# Efficiently copy mail text from an SMTP socket to a file, converting
22207# CRLF to a local filesystem newlines \n, and handling dot-destuffing.
22208# Should be called just after the DATA command has been responded to,
22209# stops reading at a CRLF DOT CRLF or eof. Does not report stuffing errors.
22210#
22211# Our current statistics (Q4 2011) shows that 80 % of messages are below
22212# 30.000 bytes, and 90 % of messages are below 100.000 bytes in size.
22213#
22214sub copy_smtp_data {
22215  my($self, $ofh, $out_str_ref, $size_limit) = @_;
22216  my $ifh = $self->{sock};
22217  my $buff = $self->{smtp_inpbuf};  # work with a local copy
22218  $$out_str_ref = ''  if ref $out_str_ref;
22219  # assumes to be called right after a DATA<CR><LF>
22220  my $eof = 0; my $at_the_beginning = 1;
22221  my $size = 0; my $oversized = 0;
22222  my($errno,$nreads,$j);
22223  my $smtpd_t_o = c('smtpd_timeout');
22224  while (!$eof) {
22225    # alarm should apply per-line, but we are dealing with whole chunks here
22226    alarm($smtpd_t_o);
22227    $nreads = $ifh->sysread($buff, 65536, length $buff);
22228    if ($nreads) {
22229      ll(5) && do_log(5, "smtp copy: read %d bytes into buffer, new size: %d",
22230                         $nreads, length($buff));
22231    } elsif (defined $nreads) {
22232      $eof = 1;
22233      do_log(5, "smtp copy: EOF");
22234    } else {
22235      $eof = 1;
22236      $errno = !$self->{ssl_active} ? $! : $ifh->errstr.", $!";
22237      do_log(5, "smtp copy: error: %s", $errno);
22238    }
22239    if ($at_the_beginning && substr($buff,0,3) eq ".\015\012") {
22240      # a preceding \015\012 is implied, although no longer in the buffer
22241      substr($buff,0,3) = '';
22242      $self->{within_data_transfer} = 0;
22243      last;
22244    } elsif ( ($j=index($buff,"\015\012.\015\012")) >= 0 ) {  # last chunk
22245      my $carry = substr($buff,$j+5);  # often empty
22246      substr($buff,$j+2) = '';  # ditch the dot and the rest
22247      $size += length($buff);
22248      if (!$oversized) {
22249        $buff =~ s/\015\012\.?/\n/gs;
22250        # the last chunk is allowed to overshoot the 'small mail' limit
22251        $$out_str_ref .= $buff  if $out_str_ref;
22252        if ($ofh) {
22253          my $nwrites;
22254          for (my $ofs = 0; $ofs < length($buff); $ofs += $nwrites) {
22255            $nwrites = syswrite($ofh, $buff, length($buff)-$ofs, $ofs);
22256            defined $nwrites  or die "Error writing to mail file: $!";
22257          }
22258        }
22259        if ($size_limit && $size > $size_limit) {
22260          do_log(1,"Message size exceeded %d B", $size_limit);
22261          $oversized = 1;
22262        }
22263      }
22264      $buff = $carry;
22265      $self->{within_data_transfer} = 0;
22266      last;
22267    }
22268    my $carry = '';
22269    if ($eof) {
22270      # flush whatever is in the buffer, no more data coming
22271    } elsif ($at_the_beginning &&
22272             ($buff eq ".\015" || $buff eq '.' || $buff eq '')) {
22273      $carry = $buff; $buff = '';
22274    } elsif (substr($buff,-4,4) eq "\015\012.\015") {
22275      substr($buff,-4,4) = ''; $carry = "\015\012.\015";
22276    } elsif (substr($buff,-3,3) eq "\015\012.") {
22277      substr($buff,-3,3) = ''; $carry = "\015\012.";
22278    } elsif (substr($buff,-2,2) eq "\015\012") {
22279      substr($buff,-2,2) = ''; $carry = "\015\012";
22280    } elsif (substr($buff,-1,1) eq "\015") {
22281      substr($buff,-1,1) = ''; $carry = "\015";
22282    }
22283    if ($buff ne '') {
22284      $at_the_beginning = 0;
22285      # message size is defined in RFC 1870, includes CRLF but no stuffed dots
22286      # NOTE: we overshoot here by the number of stuffed dots, for performance;
22287      # the message size will be finely adjusted in get_body_digest()
22288      $size += length($buff);
22289      if (!$oversized) {
22290        # The RFC 5321 is quite clear, leading "." characters in
22291        # SMTP are stripped regardless of the following character.
22292        # Some MTAs only trim "." when the next character is also
22293        # a ".", but this violates the RFC.
22294        $buff =~ s/\015\012\.?/\n/gs;  # quite fast, but still a bottleneck
22295        if (!$out_str_ref) {
22296          # not writing to memory
22297        } elsif (length($$out_str_ref) < 100*1024) {  # 100 KiB 'small mail'
22298          $$out_str_ref .= $buff;
22299        } else {  # large mail, hand over writing to a file
22300#         my $nwrites;
22301#         for (my $ofs = 0; $ofs < length($$out_str_ref); $ofs += $nwrites) {
22302#           $nwrites = syswrite($ofh, $$out_str_ref,
22303#                               length($$out_str_ref)-$ofs, $ofs);
22304#           defined $nwrites  or die "Error writing to mail file: $!";
22305#         }
22306          $$out_str_ref = '';
22307          $out_str_ref = undef;
22308        }
22309        if ($ofh) {
22310          my $nwrites;
22311          for (my $ofs = 0; $ofs < length($buff); $ofs += $nwrites) {
22312            $nwrites = syswrite($ofh, $buff, length($buff)-$ofs, $ofs);
22313            defined $nwrites  or die "Error writing to mail file: $!";
22314          }
22315        }
22316        if ($size_limit && $size > $size_limit) {
22317          do_log(1,"Message size exceeded %d B, ".
22318                   "skipping further input", $size_limit);
22319          my $trunc_str = "\n***TRUNCATED***\n";
22320          $$out_str_ref .= $trunc_str  if $out_str_ref;
22321          if ($ofh) {
22322            my $nwrites = syswrite($ofh, $trunc_str);
22323            defined $nwrites  or die "Error writing to mail file: $!";
22324          }
22325          $oversized = 1;
22326        }
22327      }
22328    }
22329    $buff = $carry;
22330  }
22331  do_log(5, "smtp copy: %d bytes still buffered at end", length($buff));
22332  $self->{smtp_inpbuf} = $buff;  # put a local copy back into object
22333  !$self->{within_data_transfer}  or die "Connection broken during DATA: ".
22334                         (!$self->{ssl_active} ? $! : $ifh->errstr.", $!");
22335  # return a message size and an indication of exceeded size limit
22336  ($size,$oversized);
22337}
22338
22339sub preserve_evidence {  # preserve temporary files etc in case of trouble
22340  my $self = shift;
22341  !$self->{tempdir} ? undef : $self->{tempdir}->preserve(@_);
22342}
22343
22344sub authenticate($$$) {
22345  my($state,$auth_mech,$auth_resp) = @_;
22346  my($result,$newchallenge);
22347  if ($auth_mech eq 'ANONYMOUS') {   # RFC 2245
22348    $result = [$auth_resp,undef];
22349  } elsif ($auth_mech eq 'PLAIN') {  # RFC 2595, "user\0authname\0pass"
22350    if (!defined($auth_resp)) { $newchallenge = '' }
22351    else { $result = [ (split(/\000/,$auth_resp,-1))[0,2] ] }
22352  } elsif ($auth_mech eq 'LOGIN' && !defined $state) {
22353    $newchallenge = 'Username:'; $state = [];
22354  } elsif ($auth_mech eq 'LOGIN' && @$state==0) {
22355    push(@$state, $auth_resp); $newchallenge = 'Password:';
22356  } elsif ($auth_mech eq 'LOGIN' && @$state==1) {
22357    push(@$state, $auth_resp); $result = $state;
22358  } # CRAM-MD5:RFC 2195,  DIGEST-MD5:RFC 2831
22359  ($state,$result,$newchallenge);
22360}
22361
22362# Parse the "PROXY protocol header", which is a block of connection info
22363# the connection initiator prepends at the beginning of a connection.
22364# Recognizes the PROXY protocol Version 1  (V 2 is not supported here).
22365# http://www.haproxy.org/download/1.5/doc/proxy-protocol.txt
22366#
22367sub haproxy_protocol_parse($) {
22368  local($_) = $_[0];  # a "PROXY protocol header"
22369  my($proto, $src_addr, $dst_addr, $src_port, $dst_port);
22370  local($1,$2,$3,$4,$5);
22371  if (/^PROXY\ (UNKNOWN)/) {
22372    $proto = $1;  # receiver must ignore anything presented before the CRLF
22373  } elsif (/^PROXY\ ((?-i)TCP4)\ ((?:\d{1,3}\.){3}\d{1,3})
22374                               \ ((?:\d{1,3}\.){3}\d{1,3})
22375                               \ (\d{1,5})\ (\d{1,5})\x0D\x0A\z/xs) {
22376    ($proto, $src_addr, $dst_addr, $src_port, $dst_port) = ($1,$2,$3,$4,$5);
22377  } elsif (/^PROXY\ ((?-i)TCP6)\ ([0-9a-f]{0,4} (?: : [0-9a-f]{0,4}){2,7})
22378                               \ ([0-9a-f]{0,4} (?: : [0-9a-f]{0,4}){2,7})
22379                               \ (\d{1,5})\ (\d{1,5})\x0D\x0A\z/xsi) {
22380    ($proto, $src_addr, $dst_addr, $src_port, $dst_port) = ($1,$2,$3,$4,$5);
22381  }
22382  return ($proto)  if $proto !~ /^TCP[46]\z/;
22383  return if $src_port && $src_port =~ /^0/;  # leading zeroes not allowed
22384  return if $dst_port && $dst_port =~ /^0/;
22385  $src_port = 0+$src_port; $dst_port = 0+$dst_port;  # turn to numeric
22386  return if $src_port > 65535 || $dst_port > 65535;
22387  ($proto, $src_addr, $dst_addr, $src_port, $dst_port);
22388}
22389
22390# process the "PROXY protocol header" and pretend the claimed connection
22391#
22392sub haproxy_apply($$) {
22393  my($conn, $line) = @_;
22394  if (defined $line) {
22395    ll(4) && do_log(4, 'HAProxy: < %s', $line);
22396    my($proto, $src_addr, $dst_addr, $src_port, $dst_port) =
22397      haproxy_protocol_parse($line);
22398    if (!defined $src_addr || !defined $dst_addr ||
22399        !$src_port || !$dst_port) {
22400      do_log(0, "HAProxy: PROXY protocol header expected, got: %s", $line);
22401      die "HAProxy: a PROXY protocol header expected";
22402    } elsif (!Amavis::access_is_allowed(undef, $src_addr, $src_port,
22403                                               $dst_addr, $dst_port)) {
22404      do_log(0, "HAProxy, access denied: %s [%s]:%d -> [%s]:%d",
22405                $proto, $src_addr, $src_port, $dst_addr, $dst_port);
22406      die "HAProxy: access from client $src_addr denied\n";
22407    } else {
22408      if (ll(3)) {
22409        do_log(3,
22410          "HAProxy: accepted:   (client) [%s]:%d -> [%s]:%d (HA Proxy/server)",
22411          $src_addr, $src_port, $dst_addr, $dst_port);
22412        do_log(3,
22413          "HAProxy: (HA Proxy/initiator) [%s]:%d -> [%s]:%d (me/target)",
22414          $conn->client_ip||'x', $conn->client_port||0,
22415          $conn->socket_ip||'x', $conn->socket_port||0);
22416      };
22417      $conn->client_ip(untaint(normalize_ip_addr($src_addr)));
22418      $conn->socket_ip(untaint(normalize_ip_addr($dst_addr)));
22419      $conn->client_port(untaint($src_port));
22420      $conn->socket_port(untaint($dst_port));
22421    }
22422  }
22423}
22424
22425# Accept an SMTP or LMTP connect (which can do any number of transactions)
22426# and call content checking for each message received
22427#
22428sub process_smtp_request($$$$) {
22429  my($self, $sock, $lmtp, $conn, $check_mail) = @_;
22430  # $sock:       connected socket from Net::Server
22431  # $lmtp:       greet as an LMTP server instead of (E)SMTP
22432  # $conn:       information about client connection
22433  # $check_mail: subroutine ref to be called with file handle
22434
22435  my($msginfo, $authenticated, $auth_user, $auth_pass);
22436  my(%announced_ehlo_keywords);
22437  $self->{sock} = $sock;
22438  $self->{pipelining} = 0;    # may we buffer responses?
22439  $self->{smtp_outbuf} = [];  # SMTP responses buffer for PIPELINING
22440  $self->{session_closed_normally} = 0;  # closed properly with QUIT?
22441  $self->{ssl_active} = 0;    # session upgraded to SSL
22442  my $tls_security_level = c('tls_security_level_in');
22443  $tls_security_level = 0  if !defined($tls_security_level) ||
22444                              lc($tls_security_level) eq 'none';
22445  my $myheloname;
22446# $myheloname = idn_to_ascii(c('myhostname'));
22447# $myheloname = 'localhost';
22448# $myheloname = '[127.0.0.1]';
22449  my $sock_ip = $conn->socket_ip;
22450  $myheloname = defined $sock_ip && $sock_ip ne '' ? "[$sock_ip]"
22451                                                   : '[localhost]';
22452  new_am_id(undef, $Amavis::child_invocation_count, undef);
22453  my $initial_am_id = 1;
22454  my($sender_unq, $sender_quo, @recips, $got_rcpt);
22455  my $max_recip_size_limit;  # maximum of per-recipient message size limits
22456  my($terminating,$aborting,$eof,$voluntary_exit); my(%xforward_args);
22457  my $seq = 0;
22458  my(%baseline_policy_bank) = %current_policy_bank;
22459  $conn->appl_proto($self->{proto} = $lmtp ? 'LMTP' : 'SMTP');
22460
22461  my $final_oversized_destiny_all_pass = 1;
22462  my $oversized_fd_map_ref =
22463    setting_by_given_contents_category(CC_OVERSIZED,
22464                                       cr('final_destiny_maps_by_ccat'));
22465  my $oversized_lovers_map_ref =
22466    setting_by_given_contents_category(CC_OVERSIZED,
22467                                       cr('lovers_maps_by_ccat'));
22468  # system-wide message size limit, if any
22469  my $message_size_limit = c('smtpd_message_size_limit');
22470  if ($enforce_smtpd_message_size_limit_64kb_min &&
22471      $message_size_limit && $message_size_limit < 65536) {
22472    $message_size_limit = 65536;  # RFC 5321 requires at least 64k
22473  }
22474
22475  if (c('haproxy_target_enabled')) {
22476    Amavis::Timing::go_idle(4);
22477    my $line; { local($/) = "\012"; $line = $self->readline }
22478    Amavis::Timing::go_busy(5);
22479    defined $line  or die "Error reading, expected a PROXY header: $!";
22480    haproxy_apply($conn, $line);
22481  }
22482
22483  my $smtpd_greeting_banner_tmp = c('smtpd_greeting_banner');
22484  $smtpd_greeting_banner_tmp =~
22485    s{ \$ (?: \{ ([^\}]+) \} |
22486              ([a-zA-Z](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?\b) ) }
22487     { { 'helo-name'    => $myheloname,
22488         'myhostname'   => idn_to_ascii(c('myhostname')),
22489         'version'      => $myversion,
22490         'version-id'   => $myversion_id,
22491         'version-date' => $myversion_date,
22492         'product'      => $myproduct_name,
22493         'protocol'     => $lmtp?'LMTP':'ESMTP' }->{lc($1.$2)}
22494     }xgse;
22495  $self->smtp_resp(1,"220 $smtpd_greeting_banner_tmp");
22496  section_time('SMTP greeting');
22497  # each call to smtp_resp starts a $smtpd_timeout timeout to tame slow clients
22498
22499  $0 = sprintf("%s (ch%d-idle)",
22500               c('myprogram_name'), $Amavis::child_invocation_count);
22501  Amavis::Timing::go_idle(4);
22502  local($_);  local($/) = "\012";  # input line terminator set to LF
22503  for ($! = 0; defined($_ = $self->readline); $! = 0) {
22504    $0 = sprintf("%s (ch%d-%s)",
22505                c('myprogram_name'), $Amavis::child_invocation_count, am_id());
22506    Amavis::Timing::go_busy(5);
22507    # the ball is now in our courtyard, (re)start our timer;
22508    # each of our smtp responses will switch back to a $smtpd_timeout timer
22509    { # a block is used as a 'switch' statement - 'last' will exit from it
22510      my $cmd = $_;
22511      ll(4) && do_log(4, '%s< %s', $self->{proto},$cmd);
22512      if (!/^ [ \t]* ( [A-Za-z] [A-Za-z0-9]* ) (?: [ \t]+ (.*?) )? [ \t]*
22513              \015 \012 \z /xs) {
22514        $self->smtp_resp(1,"500 5.5.2 Error: bad syntax", 1, $cmd); last;
22515      };
22516      $_ = uc($1); my $args = $2;
22517      switch_to_my_time("rx SMTP $_");
22518
22519# (causes holdups in Postfix, it doesn't retry immediately; better set max_use)
22520#     $Amavis::child_task_count >= $max_requests    # exceeded max_requests
22521#     && /^(?:HELO|EHLO|LHLO|DATA|NOOP|QUIT|VRFY|EXPN|TURN)\z/ && do {
22522#       # pipelining checkpoints;
22523#       # in case of multiple-transaction protocols (e.g. SMTP, LMTP)
22524#       # we do not like to keep running indefinitely at the MTA's mercy
22525#       my $msg = "Closing transmission channel ".
22526#                  "after $Amavis::child_task_count transactions, $_";
22527#       do_log(2,"%s",$msg); $self->smtp_resp(1,"421 4.3.0 ".$msg);  #flush!
22528#       $terminating=1; last;
22529#     };
22530
22531      $tls_security_level && lc($tls_security_level) ne 'may' &&
22532      !$self->{ssl_active} && !/^(?:NOOP|EHLO|STARTTLS|QUIT)\z/ && do {
22533        $self->smtp_resp(1,"530 5.7.0 Must issue a STARTTLS command first",
22534                         1,$cmd);
22535        last;
22536      };
22537
22538#     lc($tls_security_level) eq 'verify' && !/^QUIT\z/ && do {
22539#       $self->smtp_resp(1,"554 5.7.0 Command refused due to lack of security",
22540#                        1,$cmd);
22541#       last;
22542#     };
22543
22544      /^NOOP\z/ && do { $self->smtp_resp(1,"250 2.0.0 Ok $_"); last };  #flush!
22545
22546      /^QUIT\z/ && do {
22547        if ($args ne '') {
22548          $self->smtp_resp(1,"501 5.5.4 Error: QUIT does not accept arguments",
22549                           1,$cmd);  #flush
22550        } else {
22551          my $smtpd_quit_banner_tmp = c('smtpd_quit_banner');
22552          $smtpd_quit_banner_tmp =~
22553            s{ \$ (?: \{ ([^\}]+) \} |
22554                      ([a-zA-Z](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?\b) ) }
22555             { { 'helo-name'    => $myheloname,
22556                 'myhostname'   => idn_to_ascii(c('myhostname')),
22557                 'version'      => $myversion,
22558                 'version-id'   => $myversion_id,
22559                 'version-date' => $myversion_date,
22560                 'product'      => $myproduct_name,
22561                 'protocol'     => $lmtp?'LMTP':'ESMTP' }->{lc($1.$2)}
22562             }xgse;
22563          $self->smtp_resp(1,"221 2.0.0 $smtpd_quit_banner_tmp");  #flush!
22564          $terminating = 1;
22565        }
22566        last;
22567      };
22568
22569      /^(?:RSET|HELO|EHLO|LHLO|STARTTLS)\z/ && do {
22570        # explicit or implicit session reset
22571        $sender_unq = $sender_quo = undef; @recips = (); $got_rcpt = 0;
22572        undef $max_recip_size_limit; undef $msginfo;  # forget previous
22573        $final_oversized_destiny_all_pass = 1;
22574        %current_policy_bank = %baseline_policy_bank;  # restore bank settings
22575        %xforward_args = ();
22576        if (/^(?:RSET|STARTTLS)\z/ && $args ne '') {
22577          $self->smtp_resp(1,"501 5.5.4 Error: $_ does not accept arguments",
22578                           1,$cmd);
22579        } elsif (/^RSET\z/) {
22580          $self->smtp_resp(0,"250 2.0.0 Ok $_");
22581        } elsif (/^STARTTLS\z/) {  # RFC 3207 (ex RFC 2487)
22582          if ($self->{ssl_active}) {
22583            $self->smtp_resp(1,"554 5.5.1 Error: TLS already active");
22584          } elsif (!$tls_security_level) {
22585            $self->smtp_resp(1,"502 5.5.1 Error: command not available");
22586        # } elsif (!$announced_ehlo_keywords{'STARTTLS'}) {
22587        #   $self->smtp_resp(1,"502 5.5.1 Error: ".
22588        #                      "service extension STARTTLS was not announced");
22589          } else {
22590            $self->smtp_resp(1,"220 2.0.0 Ready to start TLS");  #flush!
22591            %announced_ehlo_keywords = ();
22592            IO::Socket::SSL->start_SSL($sock,
22593              SSL_server => 1,
22594              SSL_hostname => idn_to_ascii(c('myhostname')),
22595              SSL_error_trap => sub {
22596                my($sock,$msg) = @_;
22597                do_log(-2,"STARTTLS, upgrading socket to TLS failed: %s",$msg);
22598              },
22599              %smtpd_tls_server_options,
22600            ) or die "Error upgrading input socket to TLS: ".
22601                     IO::Socket::SSL::errstr();
22602            if ($self->{smtp_inpbuf} ne '') {
22603              do_log(-1, "STARTTLS pipelining violation attempt, sanitized");
22604              $self->{smtp_inpbuf} = '';  # ditch any buffered data
22605            }
22606            $self->{ssl_active} = 1;
22607            ll(3) && do_log(3,"smtpd TLS cipher: %s", $sock->get_cipher);
22608            section_time('SMTP starttls');
22609          }
22610        } elsif (/^HELO\z/) {
22611          $self->{pipelining} = 0; $lmtp = 0;
22612          $conn->appl_proto($self->{proto} = 'SMTP');
22613          $self->smtp_resp(0,"250 $myheloname");
22614          $conn->smtp_helo($args); section_time('SMTP HELO');
22615        } elsif (/^(?:EHLO|LHLO)\z/) {
22616          $self->{pipelining} = 1; $lmtp = $_ eq 'LHLO' ? 1 : 0;
22617          $conn->appl_proto($self->{proto} = $lmtp ? 'LMTP' : 'ESMTP');
22618          my(@ehlo_keywords) = (
22619            'VRFY',
22620            'PIPELINING',           # RFC 2920
22621            !defined($message_size_limit) ? 'SIZE'  # RFC 1870
22622              : sprintf('SIZE %d',$message_size_limit),
22623            'ENHANCEDSTATUSCODES',  # RFC 2034, RFC 3463, RFC 5248
22624            '8BITMIME',             # RFC 6152
22625            'SMTPUTF8',             # RFC 6531
22626            'DSN',                  # RFC 3461
22627            !$tls_security_level || $self->{ssl_active} ? ()
22628              : 'STARTTLS',         # RFC 3207 (ex RFC 2487)
22629            !@{ca('auth_mech_avail')} ? ()   # RFC 4954 (ex RFC 2554)
22630              : join(' ','AUTH',@{ca('auth_mech_avail')}),
22631            'XFORWARD NAME ADDR PORT PROTO HELO IDENT SOURCE',
22632          # 'XCLIENT NAME ADDR PORT PROTO HELO LOGIN',
22633          );
22634          my(%smtpd_discard_ehlo_keywords) =
22635            map((uc($_),1), @{ca('smtpd_discard_ehlo_keywords')});
22636          # RFC 6531: Servers offering this extension MUST provide
22637          #   support for, and announce, the 8BITMIME extension
22638          $smtpd_discard_ehlo_keywords{'SMTPUTF8'} = 1
22639            if $smtpd_discard_ehlo_keywords{'8BITMIME'};
22640          @ehlo_keywords =
22641            grep(/^([A-Za-z0-9]+)/ &&
22642                 !$smtpd_discard_ehlo_keywords{uc $1}, @ehlo_keywords);
22643          $self->smtp_resp(1,"250 $myheloname\n" .
22644                             join("\n",@ehlo_keywords));  #flush!
22645          %announced_ehlo_keywords =
22646            map( (/^([A-Za-z0-9]+)/ && uc $1, 1), @ehlo_keywords);
22647          $conn->smtp_helo($args); section_time("SMTP $_");
22648        };
22649        last;
22650      };
22651
22652      /^XFORWARD\z/ && do {  # Postfix extension
22653        my $xcmd = $_;
22654        if (defined $sender_unq) {
22655          $self->smtp_resp(1,"503 5.5.1 Error: $xcmd not allowed ".
22656                             "within transaction",1,$cmd);
22657          last;
22658        }
22659        my $bad;
22660        for (split(' ',$args)) {
22661          if (!/^ ( [A-Za-z0-9] [A-Za-z0-9-]* ) =
22662                  ( [\x21-\x7E\x80-\xFF]{0,255} )\z/xs) {
22663            $self->smtp_resp(1,"501 5.5.4 Syntax error in $xcmd parameters",
22664                             1, $cmd);
22665            $bad = 1; last;
22666          } else {
22667            my($name,$val) = (uc($1), $2);
22668            if ($name=~/^(?:NAME|ADDR|PORT|PROTO|HELO|IDENT|SOURCE|LOGIN)\z/) {
22669              $val = undef  if uc($val) eq '[UNAVAILABLE]';
22670              # Postfix since vers 2.3 (20060610) uses xtext-encoded (RFC 3461)
22671              # strings in XCLIENT and XFORWARD attribute values, previous
22672              # versions sent plain text with neutered special characters.
22673              # The IDENT option is available since postfix 2.8.0 .
22674              $val = xtext_decode($val)  if defined $val &&
22675                                            $val =~ /\+([0-9a-fA-F]{2})/;
22676              $xforward_args{$name} = $val;
22677            } else {
22678              $self->smtp_resp(1,"501 5.5.4 $xcmd command parameter ".
22679                                 "error: $name=$val",1,$cmd);
22680              $bad = 1; last;
22681            }
22682          }
22683        }
22684        $self->smtp_resp(1,"250 2.5.0 Ok $_")  if !$bad;
22685        last;
22686      };
22687
22688      /^HELP\z/ && do {
22689        $self->smtp_resp(0,"214 2.0.0 See $myproduct_name home page at:\n".
22690                           "http://www.ijs.si/software/amavisd/");
22691        last;
22692      };
22693
22694      /^AUTH\z/ && @{ca('auth_mech_avail')} && do {  # RFC 4954 (ex RFC 2554)
22695      # if (!$announced_ehlo_keywords{'AUTH'}) {
22696      #   $self->smtp_resp(1,"502 5.5.1 Error: ".
22697      #                      "service extension AUTH was not announced");
22698      #   last;
22699      # } elsif
22700        if ($args !~ /^([^ ]+)(?: ([^ ]*))?\z/is) {
22701          $self->smtp_resp(1,"501 5.5.2 Syntax: AUTH mech [initresp]",1,$cmd);
22702          last;
22703        }
22704        # enhanced status codes: RFC 4954, RFC 5248
22705        my($auth_mech,$auth_resp) = (uc($1), $2);
22706        if ($authenticated) {
22707          $self->smtp_resp(1,"503 5.5.1 Error: session already authenticated",
22708                             1,$cmd);
22709        } elsif (defined $sender_unq) {
22710          $self->smtp_resp(1,"503 5.5.1 Error: AUTH not allowed within ".
22711                             "transaction",1,$cmd);
22712        } elsif (!grep(uc($_) eq $auth_mech, @{ca('auth_mech_avail')})) {
22713          $self->smtp_resp(1,"504 5.5.4 Error: requested authentication ".
22714                             "mechanism not supported",1,$cmd);
22715        } else {
22716          my($state,$result,$challenge);
22717          if   ($auth_resp eq '=') { $auth_resp = '' }  # zero length
22718          elsif ($auth_resp eq '') { $auth_resp = undef }
22719          for (;;) {
22720            if ($auth_resp !~ m{^[A-Za-z0-9+/]*=*\z}) {
22721              $self->smtp_resp(1,"501 5.5.2 Authentication failed: ".
22722                                 "malformed authentication response",1,$cmd);
22723              last;
22724            } else {
22725              $auth_resp = decode_base64($auth_resp)  if $auth_resp ne '';
22726              ($state,$result,$challenge) =
22727                authenticate($state, $auth_mech, $auth_resp);
22728              if (ref($result) eq 'ARRAY') {
22729                $self->smtp_resp(0,"235 2.7.0 Authentication succeeded");
22730                $authenticated = 1; ($auth_user,$auth_pass) = @$result;
22731                do_log(2,"AUTH %s, user=%s", $auth_mech,$auth_user); #auth_resp
22732                last;
22733              } elsif (defined $result && !$result) {
22734                $self->smtp_resp(0,"535 5.7.8 Authentication credentials ".
22735                                   "invalid", 1, $cmd);
22736                last;
22737              }
22738            }
22739            # server challenge or ready prompt
22740            $self->smtp_resp(1,"334 ".encode_base64($challenge,''));
22741            $! = 0; $auth_resp = $self->readline;
22742            defined $auth_resp  or die "Error reading auth resp: ".
22743                            (!$self->{ssl_active} ? $! : $sock->errstr.", $!");
22744            switch_to_my_time('rx AUTH challenge reply');
22745            do_log(5, "%s< %s", $self->{proto},$auth_resp);
22746            $auth_resp =~ s/\015?\012\z//;
22747            if (length($auth_resp) > 12288) {  # RFC 4954
22748              $self->smtp_resp(1,"500 5.5.6 Authentication exchange ".
22749                                 "line is too long");
22750              last;
22751            } elsif ($auth_resp eq '*') {
22752              $self->smtp_resp(1,"501 5.7.1 Authentication aborted");
22753              last;
22754            }
22755          }
22756        }
22757        last;
22758      };
22759
22760      /^VRFY\z/ && do {
22761        if ($args eq '') {
22762          $self->smtp_resp(1,"501 5.5.2 Syntax: VRFY address", 1,$cmd); #flush!
22763        } else {  # RFC 2505
22764          $self->smtp_resp(1,"252 2.0.0 Argument not checked", 0,$cmd); #flush!
22765        }
22766        last;
22767      };
22768
22769      /^MAIL\z/ && do {  # begin new SMTP transaction
22770        if (defined $sender_unq) {
22771          $self->smtp_resp(1,"503 5.5.1 Error: nested MAIL command", 1, $cmd);
22772          last;
22773        }
22774        if (!$authenticated &&
22775            c('auth_required_inp') && @{ca('auth_mech_avail')} ) {
22776          $self->smtp_resp(1,"530 5.7.0 Authentication required", 1, $cmd);
22777          last;
22778        }
22779        # begin SMTP transaction
22780        my $now = Time::HiRes::time;
22781        if (!$seq) { # the first connect
22782          section_time('SMTP pre-MAIL');
22783        } else {     # establish a new time reference for each transaction
22784          Amavis::Timing::init(); snmp_counters_init();
22785        }
22786        $seq++;
22787        new_am_id(undef, $Amavis::child_invocation_count, $seq)
22788          if !$initial_am_id;
22789        $initial_am_id = 0;
22790        # enter 'in transaction' state
22791        $Amavis::zmq_obj->register_proc(1,1,'m',am_id()) if $Amavis::zmq_obj;
22792        $Amavis::snmp_db->register_proc(1,1,'m',am_id()) if $Amavis::snmp_db;
22793        Amavis::check_mail_begin_task();
22794        $self->{tempdir}->prepare_dir;
22795        $self->{tempdir}->prepare_file;
22796        $msginfo = Amavis::In::Message->new;
22797        $msginfo->rx_time($now);
22798        $msginfo->log_id(am_id());
22799        $msginfo->conn_obj($conn);
22800
22801        my $cl_ip = normalize_ip_addr($xforward_args{'ADDR'});
22802        my $cl_port = $xforward_args{'PORT'};
22803        my $cl_src  = $xforward_args{'SOURCE'};  # local_header_rewrite_clients
22804        my $cl_login= $xforward_args{'LOGIN'};   # XCLIENT
22805        $cl_port = undef  if $cl_port !~ /^\d{1,9}\z/ || $cl_port > 65535;
22806        my(@bank_names_cl);
22807        { my $cl_ip_tmp = $cl_ip;
22808          # treat unknown client IP address as 0.0.0.0,
22809          # from "This" Network, RFC 1700
22810          $cl_ip_tmp = '0.0.0.0'  if !defined($cl_ip) || $cl_ip eq '';
22811          my(@cp) = @{ca('client_ipaddr_policy')};
22812          do_log(-1,'@client_ipaddr_policy must contain pairs, '.
22813                    'number of elements is not even')  if @cp % 2 != 0;
22814          my $labeler = Amavis::Lookup::Label->new('client_ipaddr_policy');
22815          while (@cp > 1) {
22816            my $lookup_table = shift(@cp);
22817            my $policy_names = shift(@cp);  # comma-separated string of names
22818            next if !defined $policy_names;
22819            if (lookup_ip_acl($cl_ip_tmp, $labeler, $lookup_table)) {
22820              local $1;
22821              push(@bank_names_cl,
22822                map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $policy_names)));
22823              last;  # should we stop here or not?
22824            }
22825          }
22826        }
22827        # load policy banks from the 'client_ipaddr_policy' lookup
22828        Amavis::load_policy_bank($_,$msginfo) for @bank_names_cl;
22829        $msginfo->originating(c('originating'));
22830
22831        $msginfo->client_addr($cl_ip);      # ADDR
22832        $msginfo->client_port($cl_port);    # PORT
22833        $msginfo->client_source($cl_src);   # SOURCE
22834        $msginfo->client_name($xforward_args{'NAME'});
22835        $msginfo->client_helo($xforward_args{'HELO'});
22836        $msginfo->client_proto($xforward_args{'PROTO'});
22837        $msginfo->queue_id($xforward_args{'IDENT'});
22838      # $msginfo->body_type('7BIT');  # presumed, unless explicitly declared
22839        %xforward_args = ();  # reset values for the next transaction
22840        if ($self->{ssl_active}) {
22841          $msginfo->tls_cipher($sock->get_cipher);
22842          if ($self->{proto} =~ /^(LMTP|ESMTP)\z/i) {
22843            $self->{proto} .= 'S';  # RFC 3848
22844            $conn->appl_proto($self->{proto});
22845          }
22846        }
22847        my $submitter;
22848        if ($authenticated) {
22849          $msginfo->auth_user($auth_user); $msginfo->auth_pass($auth_pass);
22850          if ($self->{proto} =~ /^(LMTP|ESMTP)S?\z/i) {
22851            $self->{proto} .= 'A';  # RFC 3848
22852            $conn->appl_proto($self->{proto});
22853          }
22854        } elsif (c('auth_reauthenticate_forwarded') &&
22855                 c('amavis_auth_user') ne '') {
22856          $msginfo->auth_user(c('amavis_auth_user'));
22857          $msginfo->auth_pass(c('amavis_auth_pass'));
22858        # $submitter = quote_rfc2821_local(c('mailfrom_notify_recip'));
22859        # safe_encode_utf8_inplace($submitter)  # to octets (if not already)
22860        # $submitter = expand_variables($submitter) if defined $submitter;
22861        }
22862        local($1,$2);
22863        if ($args !~ /^FROM: [ \t]*
22864                      ( < (?:  " (?: \\. | [^\\"] ){0,999} " | [^"\@ \t] )*
22865                          (?: \@ (?: \[ (?: \\. | [^\]\\] ){0,999} \] |
22866                                     [^\[\]\\> \t] )* )? > )
22867                      (?: [ \t]+ (.+) )? \z/isx ) {
22868          $self->smtp_resp(0,"501 5.5.2 Syntax: MAIL FROM:<address>",1,$cmd);
22869          last;
22870        }
22871        my($addr,$opt) = ($1,$2);
22872        my($size,$dsn_ret,$dsn_envid,$smtputf8);
22873        my $msg; my $msg_nopenalize = 0;
22874        for (split(' ',$opt)) {
22875          if (!/^ ( [A-Za-z0-9] [A-Za-z0-9-]* )
22876                  (?: = ( [^=\000-\040\177]+ ) )? \z/xs) {
22877                  # any CHAR excluding "=", SP, and control characters
22878            $msg = "501 5.5.4 Syntax error in MAIL FROM parameters";
22879          } else {
22880            my($name,$val) = (uc($1),$2);
22881            if (!defined($val) && $name =~ /^(?:BODY|RET|ENVID|AUTH)\z/) {
22882              $msg = "501 5.5.4 Syntax error in MAIL parameter, ".
22883                     "value is required: $name";
22884            } elsif ($name eq 'SIZE') {  # RFC 1870
22885              if (!$announced_ehlo_keywords{'SIZE'}) {
22886                do_log(5,'service extension SIZE was not announced');
22887                # "555 5.5.4 Service extension SIZE was not announced: $name"
22888              }
22889              if (!defined $val) {
22890                # value not provided, ignore
22891              } elsif ($val !~ /^\d{1,20}\z/) {
22892                $msg = "501 5.5.4 Syntax error in MAIL parameter: $name";
22893              } else {
22894                $size = untaint($val)  if !defined $size;
22895              }
22896            } elsif ($name eq 'SMTPUTF8') {  # RFC 6531
22897              if (!$announced_ehlo_keywords{'SMTPUTF8'}) {
22898                do_log(5,'service extension SMTPUTF8 was not announced');
22899                # "555 5.5.4 Service extension SMTPUTF8 not announced: $name"
22900              }
22901              if (defined $val) {
22902                # RFC 6531: The parameter does not accept a value.
22903                $msg = "501 5.5.4 Syntax error in MAIL parameter: $name";
22904              } else {
22905                $msginfo->smtputf8(1);
22906                if ($self->{proto} =~ /^(LMTP|ESMTP)S?A?\z/si) {
22907                  $self->{proto} = 'UTF8' . $self->{proto};  # RFC 6531
22908                  $self->{proto} =~ s/^UTF8ESMTP/UTF8SMTP/s;
22909                  $conn->appl_proto($self->{proto});
22910                }
22911              }
22912            } elsif ($name eq 'BODY') {  # RFC 6152: 8bit-MIMEtransport
22913              if (!$announced_ehlo_keywords{'8BITMIME'}) {
22914                do_log(5,'service extension 8BITMIME was not announced: BODY');
22915                # "555 5.5.4 Service extension 8BITMIME not announced: $name"
22916              }
22917              if (defined $val && $val =~ /^(?:7BIT|8BITMIME)\z/i) {
22918                $msginfo->body_type(uc $val);
22919              } else {
22920                $msg = "501 5.5.4 Syntax error in MAIL parameter: $name";
22921              }
22922            } elsif ($name eq 'RET') {    # RFC 3461
22923              if (!$announced_ehlo_keywords{'DSN'}) {
22924                do_log(5,'service extension DSN was not announced: RET');
22925                # "555 5.5.4 Service extension DSN not announced: $name"
22926              }
22927              if (!defined($dsn_ret)) {
22928                $dsn_ret = uc $val;
22929              } else {
22930                $msg = "501 5.5.4 Syntax error in MAIL parameter: $name";
22931              }
22932            } elsif ($name eq 'ENVID') {  # RFC 3461, value encoded as xtext
22933              if (!$announced_ehlo_keywords{'DSN'}) {
22934                do_log(5,'service extension DSN was not announced: ENVID');
22935                # "555 5.5.4 Service extension DSN not announced: $name"
22936              }
22937              if (!defined($dsn_envid)) {
22938                $dsn_envid = $val;
22939              } else {
22940                $msg = "501 5.5.4 Syntax error in MAIL parameter: $name";
22941              }
22942            } elsif ($name eq 'AUTH') {   # RFC 4954 (ex RFC 2554)
22943              if (!$announced_ehlo_keywords{'AUTH'}) {
22944                do_log(5,'service extension AUTH was not announced');
22945                # "555 5.5.4 Service extension AUTH not announced: $name"
22946              }
22947              my $s = xtext_decode($val); # encoded as xtext: RFC 3461
22948              do_log(5,"MAIL command, %s, submitter: %s", $authenticated,$s);
22949              if (defined $submitter) {   # authorized identity
22950                $msg = "504 5.5.4 MAIL command duplicate param.: $name=$val";
22951              } elsif (!@{ca('auth_mech_avail')}) {
22952                do_log(3,"MAIL command parameter AUTH supplied, but ".
22953                         "authentication capability not announced, ignored");
22954                $submitter = '<>';
22955                # mercifully ignore invalid parameter for the benefit of
22956                # running amavisd as a Postfix pre-queue smtp proxy filter
22957              # $msg = "503 5.7.4 Error: authentication disabled";
22958              } else {
22959                $submitter = $s;
22960              }
22961            } else {
22962              $msg = "504 5.5.4 MAIL command parameter error: $name=$val";
22963            }
22964          }
22965          last  if defined $msg;
22966        }
22967        if (!defined($msg) && defined $dsn_ret && $dsn_ret!~/^(FULL|HDRS)\z/) {
22968          $msg = "501 5.5.4 Syntax error in MAIL parameter RET: $dsn_ret";
22969        }
22970        if (!defined $msg) {
22971          $sender_quo = $addr; $sender_unq = unquote_rfc2821_local($addr);
22972          $addr = $1  if $addr =~ /^<(.*)>\z/s;
22973          my $requoted = qquote_rfc2821_local($sender_unq);
22974          do_log(2, "address modified (sender): %s -> %s",
22975                    $sender_quo, $requoted)  if $requoted ne $sender_quo;
22976          if (defined $policy_bank{'MYUSERS'} &&
22977              $sender_unq ne '' && $msginfo->originating &&
22978              lookup2(0,$sender_unq, ca('local_domains_maps'))) {
22979            Amavis::load_policy_bank('MYUSERS',$msginfo);
22980          }
22981          debug_oneshot(
22982            lookup2(0,$sender_unq, ca('debug_sender_maps')) ? 1 : 0,
22983            $self->{proto} . "< $cmd");
22984        # $submitter = $addr  if !defined($submitter);  # RFC 4954: MAY
22985          $submitter = '<>'   if !defined($msginfo->auth_user);
22986          $msginfo->auth_submitter($submitter);
22987          if (defined $size) {
22988            do_log(5, "mesage size set to a declared size %s", $size);
22989            $msginfo->msg_size(0+$size);
22990          }
22991          if (defined $dsn_ret || defined $dsn_envid) {
22992            # keep ENVID in xtext-encoded form
22993            $msginfo->dsn_ret($dsn_ret)      if defined $dsn_ret;
22994            $msginfo->dsn_envid($dsn_envid)  if defined $dsn_envid;
22995          }
22996          $msg = "250 2.1.0 Sender $sender_quo OK";
22997        };
22998        $self->smtp_resp(0,$msg, !$msg_nopenalize && $msg=~/^5/ ? 1 : 0, $cmd);
22999        section_time('SMTP MAIL');
23000        last;
23001      };
23002
23003      /^RCPT\z/ && do {
23004        if (!defined($sender_unq)) {
23005          $self->smtp_resp(1,"503 5.5.1 Need MAIL command before RCPT",1,$cmd);
23006          @recips = (); $got_rcpt = 0;
23007          last;
23008        }
23009        $got_rcpt++;
23010        local($1,$2);
23011        if ($args !~ /^TO: [ \t]*
23012                      ( < (?:  " (?: \\. | [^\\"] ){0,999} " | [^"\@ \t] )*
23013                          (?: \@ (?: \[ (?: \\. | [^\]\\] ){0,999} \] |
23014                                     [^\[\]\\> \t] )* )? > )
23015                      (?: [ \t]+ (.+) )? \z/isx ) {
23016          $self->smtp_resp(0,"501 5.5.2 Syntax: RCPT TO:<address>",1,$cmd);
23017          last;
23018        }
23019        my($addr_smtp,$opt) = ($1,$2);
23020        my($notify,$orcpt);
23021        my $msg; my $msg_nopenalize = 0;
23022        for (split(' ',$opt)) {
23023          if (!/^ ( [A-Za-z0-9] [A-Za-z0-9-]*  )
23024                  (?: = ( [^=\000-\040\177]+ ) )? \z/xs) {
23025                  # any CHAR excluding "=", SP, and control characters
23026            $msg = "501 5.5.4 Syntax error in RCPT parameters";
23027          } else {
23028            my($name,$val) = (uc($1),$2);
23029            if (!defined($val) && $name =~ /^(?:NOTIFY|ORCPT)\z/) {
23030              $msg = "501 5.5.4 Syntax error in RCPT parameter, ".
23031                     "value is required: $name";
23032            } elsif ($name eq 'NOTIFY') {  # RFC 3461
23033              if (!$announced_ehlo_keywords{'DSN'}) {
23034                do_log(5,'service extension DSN was not announced: NOTIFY');
23035                # "555 5.5.4 Service extension DSN not announced: $name"
23036              }
23037              if (!defined($notify)) {
23038                $notify = $val;
23039              } else {
23040                $msg = "501 5.5.4 Syntax error in RCPT parameter $name";
23041              }
23042            } elsif ($name eq 'ORCPT') {
23043              # RFC 3461: value encoded as xtext
23044              # RFC 6533: utf-8-addr-xtext, utf-8-addr-unitext, utf-8-address
23045              if (!$announced_ehlo_keywords{'DSN'}) {
23046                do_log(5,'service extension DSN was not announced: ORCPT');
23047                # "555 5.5.4 Service extension DSN not announced: $name"
23048              }
23049              if (defined $orcpt) {  # duplicate
23050                $msg = "501 5.5.4 Syntax error in RCPT parameter $name";
23051              } else {
23052                my($addr_type, $orcpt_dec) =
23053                  orcpt_decode($val, $msginfo->smtputf8);
23054                $orcpt = $addr_type . ';' . $orcpt_dec;
23055              }
23056            } else {
23057              $msg = "555 5.5.4 RCPT command parameter unrecognized: $name";
23058              # 504 5.5.4 RCPT command parameter not implemented:
23059              # 504 5.5.4 RCPT command parameter error:
23060              # 555 5.5.4 RCPT command parameter unrecognized:
23061            }
23062          }
23063          last  if defined $msg;
23064        }
23065        my $addr = unquote_rfc2821_local($addr_smtp);
23066        my $requoted = qquote_rfc2821_local($addr);
23067        if ($requoted ne $addr_smtp) {  # check for valid canonical quoting
23068          # RFC 3461: If no ORCPT parameter was present in the RCPT command
23069          # when the message was received, an ORCPT parameter MAY be added
23070          # to the RCPT command when the message is relayed. If an ORCPT
23071          # parameter is added by the relaying MTA, it MUST contain the
23072          # recipient address from the RCPT command used when the message
23073          # was received by that MTA
23074          if (defined $orcpt) {
23075            do_log(2, "address modified (recip): %s -> %s, orcpt retained: %s",
23076                      $addr_smtp, $requoted, $orcpt);
23077          } else {
23078            do_log(2, "address modified (recip): %s -> %s, setting orcpt",
23079                      $addr_smtp, $requoted);
23080            $orcpt = ';' . $addr_smtp;
23081          }
23082        }
23083        if (lookup2(0,$addr, ca('debug_recipient_maps'))) {
23084          debug_oneshot(1, $self->{proto} . "< $cmd");
23085        }
23086        my $mslm = ca('message_size_limit_maps');
23087        my $recip_size_limit;
23088        $recip_size_limit = lookup2(0,$addr,$mslm)  if @$mslm;
23089        if ($recip_size_limit) {
23090          # RFC 5321 requires at least 64k
23091          $recip_size_limit = 65536
23092            if $recip_size_limit < 65536 &&
23093               $enforce_smtpd_message_size_limit_64kb_min;
23094          $max_recip_size_limit = $recip_size_limit
23095            if $recip_size_limit > $max_recip_size_limit;
23096        }
23097        my $mail_size = $msginfo->msg_size;
23098        if (!defined($msg) && defined($notify)) {
23099          my(@v) = split(/,/,uc($notify),-1);
23100          if (grep(!/^(?:NEVER|SUCCESS|FAILURE|DELAY)\z/, @v)) {
23101            $msg = "501 5.5.4 Error in RCPT parameter NOTIFY, ".
23102                   "illegal value: $notify";
23103          } elsif (grep($_ eq 'NEVER', @v) && grep($_ ne 'NEVER', @v)) {
23104            $msg = "501 5.5.4 Error in RCPT parameter NOTIFY, ".
23105                   "illegal combination of values: $notify";
23106          } elsif (!@v) {
23107            $msg = "501 5.5.4 Error in RCPT parameter NOTIFY, ".
23108                   "missing value: $notify";
23109          }
23110          $notify = \@v;  # replace a string with a listref of items
23111        }
23112        if (!defined($msg) && $recip_size_limit) {
23113          # check mail size if known, update $final_oversized_destiny_all_pass
23114          my $fd = !ref $oversized_fd_map_ref ? $oversized_fd_map_ref # compat
23115               : lookup2(0, $addr, $oversized_fd_map_ref, Label => 'Destiny4');
23116          if (!defined $fd || $fd == D_PASS) {
23117            $fd = D_PASS;  # keep D_PASS
23118          } elsif (defined($oversized_lovers_map_ref) &&
23119                   lookup2(0, $addr, $oversized_lovers_map_ref,
23120                           Label => 'Lovers4')) {
23121            $fd = D_PASS;  # D_PASS for oversized lovers
23122          } else {  # $fd != D_PASS, blocked if oversized
23123            if ($final_oversized_destiny_all_pass) {
23124              $final_oversized_destiny_all_pass = 0;  # not PASS for all recips
23125              do_log(5, 'Not a D_PASS on oversized for all recips: %s', $addr);
23126            }
23127          }
23128          # check declared mail size here if known, otherwise we'll check
23129          # the actual mail size after the message is received
23130          if (defined $mail_size && $mail_size > $recip_size_limit) {
23131            $msg = $fd == D_TEMPFAIL ? '452 4.3.4' :
23132                   $fd == D_PASS     ? '250 2.3.4' : '552 5.3.4';
23133            $msg .= " Declared message size ($mail_size B) ".
23134                    "exceeds size limit for recipient $addr_smtp";
23135            $msg_nopenalize = 1;
23136            do_log(0, "%s %s 'RCPT TO': %s", $self->{proto},
23137                   $fd == D_TEMPFAIL ? 'TEMPFAIL' :
23138                   $fd == D_PASS ? 'PASS' : 'REJECT',
23139                   $msg);
23140          }
23141        }
23142        if (!defined($msg) && $got_rcpt > $smtpd_recipient_limit) {
23143          $msg = "452 4.5.3 Too many recipients";
23144        }
23145        if (!defined $msg) {
23146          $msg = "250 2.1.5 Recipient $addr_smtp OK";
23147        }
23148        if ($msg =~ /^2/) {
23149          my $recip_obj = Amavis::In::Message::PerRecip->new;
23150          $recip_obj->recip_addr($addr);
23151          $recip_obj->recip_addr_smtp($addr_smtp);
23152          $recip_obj->recip_destiny(D_PASS);  # default is Pass
23153          $recip_obj->dsn_notify($notify)  if defined $notify;
23154          $recip_obj->dsn_orcpt($orcpt)    if defined $orcpt;
23155          push(@recips,$recip_obj);
23156        }
23157        $self->smtp_resp(0,$msg, !$msg_nopenalize && $msg=~/^5/ ? 1 : 0, $cmd);
23158        last;
23159      };
23160
23161      /^DATA\z/ && $args ne '' && do {
23162        $self->smtp_resp(1,"501 5.5.4 Error: DATA does not accept arguments",
23163                         1,$cmd);  #flush
23164        last;
23165      };
23166
23167      /^DATA\z/ && !@recips && do {
23168        if (!defined($sender_unq)) {
23169          $self->smtp_resp(1,"503 5.5.1 Need MAIL command before DATA",1,$cmd);
23170        } elsif (!$got_rcpt) {
23171          $self->smtp_resp(1,"503 5.5.1 Need RCPT command before DATA",1,$cmd);
23172        } elsif ($lmtp) {  # RFC 2033 requires 503 code!
23173          $self->smtp_resp(1,"503 5.1.1 Error (DATA): no valid recipients",
23174                           0,$cmd);  #flush!
23175        } else {
23176          $self->smtp_resp(1,"554 5.1.1 Error (DATA): no valid recipients",
23177                           0,$cmd);  #flush!
23178        }
23179        last;
23180      };
23181
23182#     /^DATA\z/ && uc($msginfo->body_type) eq "BINARYMIME" && do {  # RFC 3030
23183#       $self->smtp_resp(1,"503 5.5.1 DATA is incompatible with BINARYMIME",
23184#                          0,$cmd);  #flush!
23185#       last;
23186#     };
23187
23188      /^DATA\z/ && do {
23189        # set timer to the initial value, MTA timer starts here
23190        if ($message_size_limit) {  # enforce system-wide size limit
23191          if (!$max_recip_size_limit ||
23192              $max_recip_size_limit > $message_size_limit) {
23193            $max_recip_size_limit = $message_size_limit;
23194          }
23195        }
23196        my $size = 0; my $oversized = 0; my $eval_stat; my $complete;
23197        # preallocate some storage
23198        my $out_str = ''; vec($out_str,65536,8) = 0; $out_str = '';
23199        eval {
23200          $msginfo->sender($sender_unq); $msginfo->sender_smtp($sender_quo);
23201          $msginfo->per_recip_data(\@recips);
23202          ll(1) && do_log(1, "%s %s:%s %s: %s -> %s%s Received: %s",
23203            $conn->appl_proto,
23204            !ref $inet_socket_bind && $conn->socket_ip eq $inet_socket_bind
23205              ? '' : '['.$conn->socket_ip.']',
23206            $conn->socket_port, $self->{tempdir}->path,
23207            $sender_quo,
23208            join(',', map($_->recip_addr_smtp, @{$msginfo->per_recip_data})),
23209            join('',
23210              !defined $msginfo->msg_size  ? () :  # RFC 1870
23211                                   ' SIZE='.$msginfo->msg_size,
23212              !defined $msginfo->body_type ? () : ' BODY='.$msginfo->body_type,
23213              !$msginfo->smtputf8          ? () : ' SMTPUTF8',
23214              !defined $msginfo->dsn_ret   ? () : ' RET='.$msginfo->dsn_ret,
23215              !defined $msginfo->dsn_envid ? () :
23216                                   ' ENVID='.xtext_decode($msginfo->dsn_envid),
23217              !defined $msginfo->auth_submitter ||
23218                       $msginfo->auth_submitter eq '<>' ? () :
23219                                   ' AUTH='.$msginfo->auth_submitter,
23220            ),
23221            make_received_header_field($msginfo,0) );
23222          # pipelining checkpoint
23223          $self->smtp_resp(1,"354 End data with <CR><LF>.<CR><LF>");  #flush!
23224          $self->{within_data_transfer} = 1;
23225          # data transferring state
23226          $Amavis::zmq_obj->register_proc(2,0,'d',am_id()) if $Amavis::zmq_obj;
23227          $Amavis::snmp_db->register_proc(2,0,'d',am_id()) if $Amavis::snmp_db;
23228          section_time('SMTP pre-DATA-flush')  if $self->{pipelining};
23229          $self->{tempdir}->empty(0);  # mark the mail file as non-empty
23230          switch_to_client_time('receiving data');
23231          my $fh = $self->{tempdir}->fh;
23232          # the copy_smtp_data() will use syswrite, flush buffer just in case
23233          if ($fh) { $fh->flush or die "Can't flush mail file: $!" }
23234          if (!$max_recip_size_limit || $final_oversized_destiny_all_pass) {
23235            # no message size limit enforced, faster
23236            ($size,$oversized) = $self->copy_smtp_data($fh, \$out_str, undef);
23237          } else {  # enforce size limit
23238            do_log(5,"enforcing size limit %s during DATA",
23239                     $max_recip_size_limit);
23240            ($size,$oversized) = $self->copy_smtp_data($fh, \$out_str,
23241                                                       $max_recip_size_limit);
23242          };
23243          switch_to_my_time('rx data-end');
23244          $complete = !$self->{within_data_transfer};
23245          $eof = 1  if !$complete;
23246          # normal data termination, eof on socket, timeout, fatal error
23247          do_log(4, "%s< .<CR><LF>", $self->{proto})  if $complete;
23248          if ($fh) {
23249            $fh->flush or die "Can't flush mail file: $!";
23250            # On some systems you have to do a seek whenever you
23251            # switch between reading and writing. Among other things,
23252            # this may have the effect of calling stdio's clearerr(3).
23253            $fh->seek(0,1) or die "Can't seek on file: $!";
23254          }
23255          section_time('SMTP DATA');
23256          1;
23257        } or do {  # end eval
23258          $eval_stat = $@ ne '' ? $@ : "errno=$!";
23259        };
23260        if ( defined $eval_stat || !$complete ||  # err or connection broken
23261             ($oversized && !$final_oversized_destiny_all_pass) ) {
23262          chomp $eval_stat  if defined $eval_stat;
23263          # on error, either send: '421 Shutting down',
23264          # or: '451 Aborted, error in processing' and NOT shut down!
23265          if ($oversized && !defined $eval_stat &&
23266              !$self->{within_data_transfer}) {
23267            my $msg = "552 5.3.4 Message size ($size B) exceeds size limit";
23268            do_log(0, "%s REJECT: %s", $self->{proto},$msg);
23269            $self->smtp_resp(1,$msg, 0,$cmd);
23270          } elsif (!$self->{within_data_transfer}) {
23271            my $msg = 'Error in processing: ' .
23272                      (defined $eval_stat ? $eval_stat
23273                       : !$complete ? 'incomplete' : '(no error?)');
23274            do_log(-2, "%s TROUBLE: 451 4.5.0 %s", $self->{proto},$msg);
23275            $self->smtp_resp(1,"451 4.5.0 $msg");
23276        ### $aborting = $msg;
23277          } else {
23278            $aborting = "Connection broken during data transfer"  if $eof;
23279            $aborting .= ', '  if $aborting ne '' && defined $eval_stat;
23280            $aborting .= $eval_stat  if defined $eval_stat;
23281            $aborting .= " during waiting for input from client"
23282              if defined $eval_stat && $eval_stat =~ /^timed out\b/
23283                 && waiting_for_client();
23284            $aborting = '???'  if $aborting eq '';
23285            do_log(defined $eval_stat ? -1 : 3,
23286                   "%s ABORTING: %s", $self->{proto}, $aborting);
23287          }
23288        } else {  # all OK
23289          # According to RFC 1047 it is not a good idea to do lengthy
23290          # processing here, but we do not have much choice, amavis has no
23291          # queuing mechanism and cannot accept responsibility for delivery.
23292          #
23293          # check contents before responding
23294          # check_mail() expects an open file handle in $msginfo->mail_text,
23295          # need not be rewound
23296          $msginfo->mail_tempdir($self->{tempdir}->path);
23297          $msginfo->mail_text_fn($self->{tempdir}->path . '/email.txt');
23298          $msginfo->mail_text($self->{tempdir}->fh);
23299          $msginfo->mail_text_str(\$out_str)  if defined $out_str &&
23300                                                 $out_str ne '';
23301          #
23302          # RFC 1870: The message size is defined as the number of octets,
23303          # including CR-LF pairs, but not counting the SMTP DATA command's
23304          # terminating dot or doubled (stuffing) dots
23305          my $declared_size = $msginfo->msg_size;  # RFC 1870
23306          if (!defined($declared_size)) {
23307            do_log(5, "message size set to %s", $size);
23308          } elsif ($size > $declared_size) { # shouldn't happen with decent MTA
23309            do_log(4,"Actual message size %s B greater than the ".
23310                     "declared %s B", $size,$declared_size);
23311          } elsif ($size < $declared_size) { # not unusual, but permitted
23312            do_log(4,"Actual message size %d B less than the declared %d B",
23313                     $size,$declared_size);
23314          }
23315          $msginfo->msg_size(untaint($size)); # store actual RFC 1870 mail size
23316
23317          # some fatal errors are not catchable by eval (like exceeding virtual
23318          # memory), but may still allow processing to continue in a DESTROY or
23319          # END method; turn on trouble flag here to allow DESTROY to deal with
23320          # such a case correctly, then clear the flag after content checking
23321          # if everything turned out well
23322          $self->{tempdir}->preserve(1);
23323          my($smtp_resp, $exit_code, $preserve_evidence) =
23324            &$check_mail($msginfo,$lmtp);  # do all the contents checking
23325          $self->{tempdir}->preserve(0)  if !$preserve_evidence;  # clear if ok
23326          prolong_timer('check done');
23327
23328          if ($smtp_resp =~ /^4/) {
23329            # ok, not-done recipients are to be expected, do not check
23330          } elsif (grep(!$_->recip_done && $_->delivery_method ne '',
23331                        @{$msginfo->per_recip_data})) {
23332            die "TROUBLE: (MISCONFIG?) not all recipients done";
23333          } elsif (grep(!$_->recip_done && $_->delivery_method eq '',
23334                        @{$msginfo->per_recip_data})) {
23335            die "NOT ALL RECIPIENTS DONE, EMPTY DELIVERY_METHOD!";
23336          # do_log(0, "NOT ALL RECIPIENTS DONE, EMPTY DELIVERY_METHOD!");
23337          }
23338          section_time('SMTP pre-response');
23339          if (!$lmtp) {  # smtp
23340            do_log(3, 'sending SMTP response: "%s"', $smtp_resp);
23341            $self->smtp_resp(0, $smtp_resp);
23342          } else {       # lmtp
23343            my $bounced = $msginfo->dsn_sent;  # 1=bounced, 2=suppressed
23344            for my $r (@{$msginfo->per_recip_data}) {
23345              my $resp = $r->recip_smtp_response;
23346              my $recip_quoted = $r->recip_addr_smtp;
23347              if ($resp=~/^[24]/) {
23348                # success or tempfail, no need to change status
23349              } elsif ($bounced && $bounced == 1) {  # genuine bounce
23350                # a non-delivery notifications was already sent by us, so
23351                # MTA must not bounce it again; turn status into a success
23352                $resp = sprintf("250 2.5.0 Ok %s, DSN was sent (%s)",
23353                                $recip_quoted, $resp);
23354              } elsif ($bounced) {  # fake bounce - bounce was suppressed
23355                $resp = sprintf("250 2.5.0 Ok %s, DSN suppressed (%s)",
23356                                $recip_quoted, $resp);
23357              } elsif ($resp=~/^5/ && $r->recip_destiny != D_REJECT) {
23358                # just in case, if the bounce suppression scheme did not work
23359                $resp = sprintf("250 2.5.0 Ok %s, DSN suppressed_2 (%s)",
23360                                $recip_quoted, $resp);
23361              }
23362              do_log(3, 'LMTP response for %s: "%s"', $recip_quoted, $resp);
23363              $self->smtp_resp(0, $resp);
23364            }
23365          }
23366          $self->smtp_resp_flush;  # optional, but nice to report timing right
23367          section_time('SMTP response');
23368        };  # end all OK
23369        $self->{tempdir}->clean;
23370        my $msg_size = $msginfo->msg_size;
23371        my $sa_rusage = $msginfo->supplementary_info('RUSAGE-SA');
23372        $sender_unq = $sender_quo = undef; @recips = (); $got_rcpt = 0;
23373        undef $max_recip_size_limit; undef $msginfo;  # forget previous
23374        $final_oversized_destiny_all_pass = 1;
23375        %xforward_args = ();
23376        section_time('dump_captured_log')  if log_capture_enabled();
23377        dump_captured_log(1, c('enable_log_capture_dump'));
23378        %current_policy_bank = %baseline_policy_bank;  # restore bank settings
23379        # report elapsed times by section for each transaction
23380        # (the time for a QUIT remains unaccounted for)
23381        if (ll(2)) {
23382          my $am_rusage_report = Amavis::Timing::rusage_report();
23383          my $am_timing_report = Amavis::Timing::report();
23384          if ($sa_rusage && @$sa_rusage) {
23385            local $1; my $sa_cpu_sum = 0; $sa_cpu_sum += $_ for @$sa_rusage;
23386            $am_timing_report =~  # ugly hack
23387              s{\bcpu ([0-9.]+) ms\]}
23388               {sprintf("cpu %s ms, AM-cpu %.0f ms, SA-cpu %.0f ms]",
23389                        $1, $1 - $sa_cpu_sum*1000, $sa_cpu_sum*1000) }se;
23390          }
23391          do_log(2,"size: %d, %s", $msg_size, $am_timing_report);
23392          do_log(2,"size: %d, RUSAGE %s", $msg_size, $am_rusage_report)
23393            if defined $am_rusage_report;
23394        }
23395        Amavis::Timing::init(); snmp_counters_init();
23396        $Amavis::last_task_completed_at = Time::HiRes::time;
23397        last;
23398      };  # DATA
23399
23400      /^(?:EXPN|TURN|ETRN|SEND|SOML|SAML)\z/ && do {
23401        $self->smtp_resp(1,"502 5.5.1 Error: command $_ not implemented",
23402                           0,$cmd);
23403        last;
23404      };
23405      # catchall (unknown commands):  #flush!
23406      $self->smtp_resp(1,"500 5.5.2 Error: command $_ not recognized", 1,$cmd);
23407    };  # end of 'switch' block
23408    if ($terminating || defined $aborting) {  # exit SMTP-session loop
23409      $voluntary_exit = 1; last;
23410    }
23411
23412    # don't bother, just flush any responses regardless of pending input;
23413    # this also keeps us on the safe side when a Postfix pre-queue setup
23414    # turns HELO into EHLO sessions and smtpd_proxy_options=speed_adjust
23415    # is not in use
23416    $self->smtp_resp_flush;
23417#
23418#   if ($self->{smtp_outbuf} && @{$self->{smtp_outbuf}} &&
23419#       $self->{pipelining}) {
23420#     # RFC 2920 requires a flush whenever a local TCP input buffer is emptied
23421#     my $fd_sock = fileno($sock);
23422#     my $rout; my $rin = ''; vec($rin,$fd_sock,1) = 1;
23423#     my($nfound, $timeleft) = select($rout=$rin, undef, undef, 0);
23424#     if (defined $nfound && $nfound > 0 && vec($rout, $fd_sock, 1)) {
23425#       # input is available, do not bother flushing output yet
23426#       do_log(2,"pipelining in effect, input available, flush delayed");
23427#     } else {
23428#       $self->smtp_resp_flush;
23429#     }
23430#   }
23431
23432    $0 = sprintf("%s (ch%d-%s-idle)",
23433                c('myprogram_name'), $Amavis::child_invocation_count, am_id());
23434    Amavis::Timing::go_idle(6);
23435  } # end of loop
23436  my($errn,$errs);
23437  if (!$voluntary_exit) {
23438    $eof = 1;
23439    if (!defined($_)) {
23440      $errn = 0+$!;
23441      $errs = !$self->{ssl_active} ? "$!" : $sock->errstr.", $!";
23442    }
23443  }
23444  # come here when: QUIT is received, eof or err on socket, or we need to abort
23445  $0 = sprintf("%s (ch%d)",
23446               c('myprogram_name'), $Amavis::child_invocation_count);
23447  alarm(0); do_log(4,"SMTP session over, timer stopped");
23448  Amavis::Timing::go_busy(7);
23449  # flush just in case, session might have been disconnected
23450  eval {
23451    $self->smtp_resp_flush;  1;
23452  } or do {
23453    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
23454    do_log(1, "flush failed: %s", $eval_stat);
23455  };
23456  my $msg =
23457    defined $aborting && !$eof ? "ABORTING the session: $aborting" :
23458    defined $aborting ? $aborting :
23459    !$terminating ? "client broke the connection without a QUIT ($errs)" : '';
23460  if ($msg eq '') {
23461    # ok
23462  } elsif ($aborting) {
23463    do_log(-1, "%s: NOTICE: %s", $self->{proto},$msg);
23464  } else {
23465    do_log( 3, "%s: notice: %s", $self->{proto},$msg);
23466  }
23467  if (defined $aborting && !$eof)
23468    { $self->smtp_resp(1,"421 4.3.2 Service shutting down, ".$aborting) }
23469  $self->{session_closed_normally} = 1;
23470  # Net::Server closes connection after child_finish_hook
23471}
23472
23473# sends an SMTP response consisting of a 3-digit code and an optional message;
23474# slow down evil clients by delaying response on permanent errors
23475#
23476sub smtp_resp($$$;$$) {
23477  my($self, $flush,$resp, $penalize,$line) = @_;
23478  if ($penalize) {  # PENALIZE syntax errors?
23479    do_log(0, "%s: %s; smtp_resp: %s", $self->{proto},$resp,$line);
23480#   sleep 1;
23481#   section_time('SMTP penalty wait');
23482  }
23483  push(@{$self->{smtp_outbuf}}, @{wrap_smtp_resp(sanitize_str($resp,1))});
23484  $self->smtp_resp_flush   if $flush || !$self->{pipelining} ||
23485                              @{$self->{smtp_outbuf}} > 200;
23486}
23487
23488sub smtp_resp_flush($) {
23489  my $self = $_[0];
23490  my $outbuf_ref = $self->{smtp_outbuf};
23491  if ($outbuf_ref && @$outbuf_ref) {
23492    if (ll(4)) { do_log(4, "%s> %s", $self->{proto}, $_) for @$outbuf_ref }
23493    my $sock = $self->{sock};
23494    my $stat = $sock->print(join('', map($_."\015\012", @$outbuf_ref)));
23495    @$outbuf_ref = ();  # prevent printing again even if error
23496    $stat or die "Error writing an SMTP response to the socket: ".
23497                            (!$self->{ssl_active} ? $! : $sock->errstr.", $!");
23498    $sock->flush or die "Error flushing an SMTP response to the socket: ".
23499                            (!$self->{ssl_active} ? $! : $sock->errstr.", $!");
23500    # put a ball in client's courtyard, start his timer
23501    switch_to_client_time('smtp response sent');
23502  }
23503}
23504
235051;
23506
23507__DATA__
23508#
23509package Amavis::In::Courier;
23510use strict;
23511use re 'taint';
23512use warnings;
23513use warnings FATAL => qw(utf8 void);
23514no warnings 'uninitialized';
23515# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
23516
23517BEGIN { die "Code not available for module Amavis::In::Courier" }
23518
235191;
23520
23521__DATA__
23522#
23523package Amavis::Out::SMTP::Protocol;
23524use strict;
23525use re 'taint';
23526use warnings;
23527use warnings FATAL => qw(utf8 void);
23528no warnings 'uninitialized';
23529# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
23530
23531BEGIN {
23532  require Exporter;
23533  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
23534  $VERSION = '2.412';
23535  @ISA = qw(Exporter);
23536  import Amavis::Conf qw(:platform);
23537  import Amavis::Util qw(ll do_log min max minmax);
23538}
23539
23540use Errno qw(EIO EINTR EAGAIN ECONNRESET);
23541use Encode ();
23542use Time::HiRes ();
23543
23544sub init {
23545  my $self = $_[0];
23546  delete $self->{domain};  delete $self->{supports};
23547  $self->{pipelining} = 0;
23548}
23549
23550sub new {
23551  my($class,$socket_specs,%arg) = @_;
23552  my $self = bless {}, $class;
23553  $self->{at_line_boundary} = 1;
23554  $self->{dotstuffing}  = 1;  # defaults to on
23555  $self->{dotstuffing}  = 0 if defined $arg{DotStuffing} && !$arg{DotStuffing};
23556  $self->{strip_cr}     = 1;  # sanitizing bare CR enabled by default
23557  $self->{strip_cr}     = 0 if defined $arg{StripCR} && !$arg{StripCR};
23558  $self->{sanitize_nul} = 1;  # sanitizing NUL bytes enabled by default
23559  $self->{sanitize_nul} = 0 if defined $arg{SanitizeNUL} && !$arg{SanitizeNUL};
23560  $self->{null_cnt} = 0;
23561  $self->{io} = Amavis::IO::RW->new($socket_specs, Eol => "\015\012", %arg);
23562  $self->init;
23563  $self;
23564}
23565
23566sub close {
23567  my $self = $_[0];
23568  $self->{io}->close;
23569}
23570
23571sub DESTROY {
23572  my $self = $_[0]; local($@,$!,$_);
23573  eval { $self->close } or 1;  # ignore failure, make perlcritic happy
23574}
23575
23576sub ehlo_response_parse {
23577  my($self,$smtp_resp) = @_;
23578  delete $self->{domain};  delete $self->{supports};
23579  my(@ehlo_lines) = split(/\n/,$smtp_resp,-1);
23580  my $bad; my $first = 1; local($1,$2);
23581  for my $el (@ehlo_lines) {
23582    if ($first) {
23583      if ($el =~ /^(\d{3})(?:[ \t]+(.*))?\z/s) { $self->{domain} = $2 }
23584      elsif (!defined($bad)) { $bad = $el }
23585      $first = 0;
23586    } elsif ($el =~ /^([A-Z0-9][A-Z0-9-]*)(?:[ =](.*))?\z/si) {
23587      $self->{supports}{uc($1)} = defined $2 ? $2 : '';
23588    } elsif ($el =~ /^[ \t]*\z/s) {
23589      # don't bother (e.g. smtp-sink)
23590    } elsif (!defined($bad)) {
23591      $bad = $el;
23592    }
23593  }
23594  $self->{pipelining} = defined $self->{supports}{'PIPELINING'} ? 1 : 0;
23595  do_log(0, "Bad EHLO kw %s ignored in %s, socket %s",
23596            $bad, $smtp_resp, $self->socketname)  if defined $bad;
23597  1;
23598}
23599
23600sub domain
23601  { my $self = $_[0]; $self->{domain} }
23602
23603sub supports
23604  { my($self,$keyword) = @_; $self->{supports}{uc($keyword)} }
23605
23606*print = \&datasend;  # alias name for datasend
23607sub datasend {
23608  my $self = shift;
23609  my $buff = @_ == 1 ? $_[0] : join('',@_);
23610  do_log(-1,"WARN: Unicode string passed to datasend: %s", $buff)
23611    if utf8::is_utf8($buff);  # always false on tainted, Perl 5.8 bug #32687
23612# ll(5) && do_log(5, 'smtp print %d bytes>', length($buff));
23613  $buff =~ tr/\015//d  if $self->{strip_cr};  # sanitize bare CR if necessary
23614  if ($self->{sanitize_nul}) {
23615    my $cnt = $buff =~ tr/\x00//;  # quick triage
23616    if ($cnt) {
23617      # this will break DKIM signatures, but IMAP (cyrus) hates NULs in mail
23618      $self->{null_cnt} += $cnt;
23619      $buff =~ s{\x00}{\xC0\x80}gs;  # turn to "Modified UTF-8" encoding of NUL
23620    }
23621  }
23622  # CR/LF are never split across a buffer boundary
23623  $buff =~ s{\n}{\015\012}gs;  # quite fast, but still a bottleneck
23624  if ($self->{dotstuffing}) {
23625    $buff =~ s{\015\012\.}{\015\012..}gs;  # dot stuffing
23626    $self->{io}->print('.')  if substr($buff,0,1) eq '.' &&
23627                             $self->{at_line_boundary};
23628  }
23629  $self->{io}->print($buff);
23630  $self->{at_line_boundary} = $self->{io}->at_line_boundary;
23631  $self->{io}->out_buff_large ? $self->flush : 1;
23632}
23633
23634sub socketname
23635  { my $self = shift; $self->{io}->socketname(@_) }
23636
23637sub protocol
23638  { my $self = shift; $self->{io}->protocol(@_) }
23639
23640sub timeout
23641  { my $self = shift; $self->{io}->timeout(@_) }
23642
23643sub ssl_active
23644  { my $self = shift; $self->{io}->ssl_active(@_) }
23645
23646sub ssl_upgrade
23647  { my $self = shift; $self->{io}->ssl_upgrade(@_) }
23648
23649sub last_io_event_timestamp
23650  { my $self = shift; $self->{io}->last_io_event_timestamp(@_) }
23651
23652sub last_io_event_tx_timestamp
23653  { my $self = shift; $self->{io}->last_io_event_tx_timestamp(@_) }
23654
23655sub eof
23656  { my $self = shift; $self->{io}->eof(@_) }
23657
23658sub flush
23659  { my $self = shift; $self->{io}->flush(@_) }
23660
23661sub dataend {
23662  my $self = $_[0];
23663  if (!$self->{at_line_boundary}) {
23664    $self->datasend("\n");
23665  }
23666  if ($self->{dotstuffing}) {
23667    $self->{dotstuffing} = 0;
23668    $self->datasend(".\n");
23669    $self->{dotstuffing} = 1;
23670  }
23671  if ($self->{null_cnt}) {
23672    do_log(0, 'smtp forwarding: SANITIZED %d NULL byte(s)', $self->{null_cnt});
23673    $self->{null_cnt} = 0;
23674  }
23675  $self->{io}->out_buff_large ? $self->flush : 1;
23676}
23677
23678sub command {
23679  my($self,$command,@args) = @_;
23680  my $line = $command =~ /:\z/ ? $command.join(' ',@args)
23681                               : join(' ',$command,@args);
23682  ll(3) && do_log(3, 'smtp cmd> %s', $line);
23683  $self->datasend($line."\n"); $self->{at_line_boundary} = 1;
23684  # RFC 2920: commands that can appear anywhere in a pipelined command group
23685  #   RSET, MAIL FROM, SEND FROM, SOML FROM, SAML FROM, RCPT TO, (data)
23686  if (!$self->{pipelining} || $self->{io}->out_buff_large ||
23687      $command !~ /^(?:RSET|MAIL|SEND|SOML|SAML|RCPT)\b/is) {
23688    return $self->flush;
23689  }
23690  1;
23691}
23692
23693sub smtp_response {
23694  my $self = $_[0];
23695  my $resp = ''; my($line,$code,$enh); my $first = 1;
23696  for (;;) {
23697    $line = $self->{io}->get_response_line;
23698    last  if !defined $line;  # eof, error, timeout
23699    my $line_complete = $line =~ s/\015\012\z//s;
23700    $line .= ' INCOMPLETE'  if !$line_complete;
23701    my $more; local($1,$2,$3);
23702    $line =~ s/^(\d{3}) (-|\ |\z)
23703                (?: ([245] \. \d{1,3} \. \d{1,3}) (\ |\z) )?//xs;
23704    if ($first) { $code = $1; $enh = $3; $first = 0 } else { $resp .= "\n" }
23705    $resp .= $line; $more = $2 eq '-';
23706    last  if !$more || !$line_complete;
23707  }
23708  !defined $code ? undef : $code . (defined $enh ? " $enh" : '') . ' '. $resp;
23709}
23710
23711sub helo { my $self = shift; $self->init; $self->command("HELO",@_) }
23712sub ehlo { my $self = shift; $self->init; $self->command("EHLO",@_) }
23713sub lhlo { my $self = shift; $self->init; $self->command("LHLO",@_) }
23714sub noop { my $self = shift; $self->command("NOOP",@_) }
23715sub rset { my $self = shift; $self->command("RSET",@_) }
23716sub auth { my $self = shift; $self->command("AUTH",@_) }
23717sub data { my $self = shift; $self->command("DATA",@_) }
23718sub quit { my $self = shift; $self->command("QUIT",@_) }
23719
23720sub mail {
23721  my($self,$reverse_path,%params) = @_;
23722  my(@mail_parameters) =
23723    map { my $v = $params{$_}; defined($v) ? "$_=$v" : "$_" } (keys %params);
23724  $self->command("MAIL FROM:", $reverse_path, @mail_parameters);
23725}
23726
23727sub recipient {
23728  my($self,$forward_path,%params) = @_;
23729  my(@rcpt_parameters) =
23730    map { my $v = $params{$_}; defined($v) ? "$_=$v" : "$_" } (keys %params);
23731  $self->command("RCPT TO:", $forward_path, @rcpt_parameters);
23732}
23733
237341;
23735
23736package Amavis::Out::SMTP::Session;
23737
23738# provides a mechanism for SMTP session caching
23739
23740use strict;
23741use re 'taint';
23742use warnings;
23743use warnings FATAL => qw(utf8 void);
23744no warnings 'uninitialized';
23745# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
23746
23747BEGIN {
23748  require Exporter;
23749  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
23750  $VERSION = '2.412';
23751  @ISA = qw(Exporter);
23752  @EXPORT_OK = qw(&rundown_stale_sessions);
23753  import Amavis::Conf qw(:platform c cr ca $smtp_connection_cache_enable
23754                         %smtp_tls_client_options);
23755  import Amavis::Util qw(min max minmax ll do_log snmp_count idn_to_ascii);
23756}
23757use subs @EXPORT_OK;
23758use vars qw(%sessions_cache);
23759
23760use Time::HiRes qw(time);
23761
23762sub new {
23763  my($class, $socket_specs, $deadline,
23764     $wildcard_implied_host, $wildcard_implied_port) = @_;
23765  my $self; my $cache_key; my $found_cached = 0;
23766  for my $proto_sockname (ref $socket_specs ? @$socket_specs : $socket_specs) {
23767    $cache_key = $proto_sockname;
23768    local($1,$2,$3,$4);
23769    if ($proto_sockname =~   # deal with dynamic destinations (wildcards)
23770        /^([a-z][a-z0-9.+-]*) : (?: \[ ([^\]]*) \] | ([^:]*) ) : ([^:]*)/xsi) {
23771      my $peeraddress = defined $2 ? $2 : $3;  my $peerport = $4;
23772      $peeraddress = $wildcard_implied_host  if $peeraddress eq '*';
23773      $peerport    = $wildcard_implied_port  if $peerport    eq '*';
23774      $cache_key = sprintf("%s:[%s]:%s", $1, $peeraddress, $peerport);
23775    }
23776    if (exists $sessions_cache{$cache_key}) { $found_cached = 1; last }
23777  }
23778  if ($found_cached) {
23779    $self = $sessions_cache{$cache_key};
23780    $self->{deadline} = $deadline;
23781    do_log(3, "smtp session reuse (%s), %d transactions so far",
23782              $cache_key, $self->{transaction_count});
23783  } else {
23784    do_log(3, "smtp session: setting up a new session");
23785    $cache_key = undef;
23786    $self = bless {
23787      socket_specs => $socket_specs,
23788      socketname => undef, protocol => undef, smtp_handle => undef,
23789      deadline => $deadline, timeout => undef, in_xactn => 0,
23790      transaction_count => 0, state => 'down', established_at_time => undef,
23791      wildcard_implied_host => $wildcard_implied_host,
23792      wildcard_implied_port => $wildcard_implied_port,
23793    }, $class;
23794  }
23795  $self->establish_or_refresh;
23796  if (!defined $cache_key) {  # newly established session
23797    $cache_key = sprintf("%s:%s", $self->protocol, $self->socketname);
23798    $sessions_cache{$cache_key} = $self;
23799  }
23800  $self;
23801}
23802
23803sub smtp_handle
23804  { @_<2 ? $_[0]->{handle}     : ($_[0]->{handle} = $_[1]) }
23805sub socketname
23806  { @_<2 ? shift->{socketname} : ($_[0]->{socketname} = $_[1]) }
23807sub protocol
23808  { @_<2 ? shift->{protocol}   : ($_[0]->{protocol} = $_[1]) }
23809sub session_state
23810  { @_<2 ? shift->{state}      : ($_[0]->{state} = $_[1]) }
23811sub in_smtp_transaction
23812  { @_<2 ? shift->{in_xactn}   : ($_[0]->{in_xactn} = $_[1]) }
23813sub established_at_time
23814  { @_<2 ? shift->{established_at_time} : ($_[0]->{established_at_time}=$_[1])}
23815
23816sub transaction_begins {
23817  my $self = $_[0];
23818  !$self->in_smtp_transaction
23819    or die "smtp session: transaction_begins, but already active";
23820  $self->in_smtp_transaction(1);
23821}
23822
23823sub transaction_begins_unconfirmed {
23824  my $self = $_[0];
23825  snmp_count('OutConnTransact'); $self->{transaction_count}++;
23826  !$self->in_smtp_transaction
23827    or die "smtp session: transaction_begins_unconfirmed, but already active";
23828  $self->in_smtp_transaction(undef);
23829}
23830
23831sub transaction_ends {
23832  my $self = $_[0];
23833  $self->in_smtp_transaction(0);
23834}
23835
23836sub transaction_ends_unconfirmed {
23837  my $self = $_[0];
23838  # if already 0 then keep it, otherwise undefine
23839  $self->in_smtp_transaction(undef)  if $self->in_smtp_transaction;
23840}
23841
23842sub timeout {
23843  my $self = shift;
23844  if (@_) {
23845    my $timeout = shift;
23846    $self->{timeout} = $timeout;
23847    $self->{handle}->timeout($timeout)  if defined $self->{handle};
23848  # do_log(5, "smtp session, timeout set to %s", $timeout);
23849  }
23850  $self->{timeout};
23851}
23852
23853sub supports {
23854  my($self,$keyword) = @_;
23855  $self->{handle} ? $self->{handle}->supports($keyword) : undef;
23856}
23857
23858sub smtp_response {
23859  my $self = $_[0];
23860  $self->{handle} ? $self->{handle}->smtp_response : undef;
23861}
23862
23863sub quit {
23864  my $self = $_[0];
23865  my $smtp_handle = $self->smtp_handle;
23866  if (defined $smtp_handle) {
23867    $self->session_state('quitsent');
23868    snmp_count('OutConnQuit');
23869    $smtp_handle->quit;  #flush!   QUIT
23870  }
23871}
23872
23873sub close {
23874  my($self,$keep_connected) = @_;
23875  my $msg;  my $smtp_handle = $self->smtp_handle;
23876  if (defined($smtp_handle) && $smtp_handle->eof) {
23877    $msg = 'already disconnected'; $keep_connected = 0;
23878  } else {
23879    $msg = $keep_connected ? 'keeping connection' : 'disconnecting';
23880  }
23881  do_log(3, "Amavis::Out::SMTP::Session close, %s", $msg);
23882  if (!$keep_connected) {
23883    if (defined $smtp_handle) {
23884      $smtp_handle->close
23885        or do_log(1, "Error closing Amavis::Out::SMTP::Protocol obj");
23886      $self->in_smtp_transaction(0); $self->established_at_time(undef);
23887      $self->smtp_handle(undef); $self->session_state('down');
23888    }
23889    if (defined $self->socketname) {
23890      my $cache_key = sprintf("%s:%s", $self->protocol, $self->socketname);
23891      delete $sessions_cache{$cache_key} if exists $sessions_cache{$cache_key};
23892    }
23893  }
23894  1;
23895}
23896
23897sub rundown_stale_sessions($) {
23898  my $close_all = $_[0];
23899  my $num_sessions_closed = 0;
23900  for my $cache_key (keys %sessions_cache) {
23901    my $smtp_session = $sessions_cache{$cache_key};
23902    my $smtp_handle = $smtp_session->smtp_handle;
23903    my $established_at_time = $smtp_session->established_at_time;
23904    my $last_event_time;
23905    $last_event_time = $smtp_handle->last_io_event_timestamp  if $smtp_handle;
23906    my $now = Time::HiRes::time;
23907    if ($close_all || !$smtp_connection_cache_enable ||
23908        !defined($last_event_time)     || $now - $last_event_time     >= 30 ||
23909        !defined($established_at_time) || $now - $established_at_time >= 60) {
23910      ll(3) && do_log(3,"smtp session rundown%s%s%s, %s, state %s",
23911                        $close_all ? ' all sessions'
23912                        : $smtp_connection_cache_enable ? ' stale sessions'
23913                        : ', cache off',
23914                        !defined($last_event_time) ? ''
23915                          : sprintf(", idle %.1f s", $now - $last_event_time),
23916                        !defined($established_at_time) ? ''
23917                          : sprintf(", since %.1f s ago",
23918                                    $now - $established_at_time),
23919                        $cache_key, $smtp_session->session_state);
23920      if ($smtp_session->session_state ne 'down' &&
23921          $smtp_session->session_state ne 'quitsent' &&
23922          (!defined($last_event_time) || $now - $last_event_time <= 55)) {
23923        do_log(3,"smtp session rundown, sending QUIT");
23924        eval { $smtp_session->quit } or 1;  #flush!   QUIT  (ignoring failures)
23925      }
23926      if ($smtp_session->session_state eq 'quitsent') {  # collect response
23927        $smtp_session->timeout(5);
23928        my $smtp_resp = eval { $smtp_session->smtp_response };
23929        if (!defined $smtp_resp) {
23930          do_log(3,"No SMTP resp. to QUIT");
23931        } elsif ($smtp_resp eq '') {
23932          do_log(3,"Empty SMTP resp. to QUIT");
23933        } elsif ($smtp_resp !~ /^2/) {
23934          do_log(3,"Negative SMTP resp. to QUIT: %s", $smtp_resp);
23935        } else {  # success, $smtp_resp =~ /^2/
23936          do_log(3,"smtp resp to QUIT: %s", $smtp_resp);
23937        }
23938      }
23939      if ($smtp_session->session_state ne 'down') {
23940        do_log(3,"smtp session rundown, closing session %s", $cache_key);
23941        $smtp_session->close(0)
23942          or do_log(-2, "Error closing smtp session %s", $cache_key);
23943        $num_sessions_closed++;
23944      }
23945    }
23946  }
23947  $num_sessions_closed;
23948}
23949
23950sub establish_or_refresh {
23951  my $self = $_[0];
23952  # Timeout should be more than MTA normally takes to check DNS and RBL,
23953  # which may take a minute or more in case of unreachable DNS server.
23954  # Specifying shorter timeout will cause alarm to terminate the wait
23955  # for SMTP status line prematurely, resulting in status code 000.
23956  # RFC 5321 (ex RFC 2821) section 4.5.3.2 requires timeout to be
23957  # at least 5 minutes
23958  my $smtp_connect_timeout  =  35;  # seconds
23959  my $smtp_helo_timeout     = 300;
23960  my $smtp_starttls_timeout = 300;
23961  my $smtp_handle = $self->smtp_handle;
23962  my $smtp_resp; my $last_event_time;
23963  $last_event_time = $smtp_handle->last_io_event_timestamp  if $smtp_handle;
23964  my $now = Time::HiRes::time;
23965  do_log(5,"establish_or_refresh, state: %s", $self->session_state);
23966  die "panic, still in SMTP transaction"  if $self->in_smtp_transaction;
23967  if (defined($smtp_handle) &&
23968      $self->session_state ne 'down' && $self->session_state ne 'quitsent') {
23969    # if session has been idling for some time, check with a low-cost NOOP
23970    # whether the session is still alive - reconnecting at this time is cheap;
23971    # note that NOOP is non-pipelinable, MTA must respond immediately
23972    if (defined($last_event_time) && $now - $last_event_time <= 18) {
23973      snmp_count('OutConnReuseRecent');
23974      do_log(3,"smtp session most likely still valid (short idle %.1f s)",
23975                $now - $last_event_time);
23976    } else {  # Postfix default smtpd idle timeout is 60 s
23977      eval {
23978        $self->timeout(15);
23979        $smtp_handle->noop;  #flush!
23980        $smtp_resp = $self->smtp_response;  # fetch response to NOOP
23981        do_log(3,"smtp resp to NOOP (idle %.1f s): %s",
23982                 $now - $last_event_time, $smtp_resp);
23983        1;
23984      } or do {
23985        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
23986        do_log(3,"smtp NOOP failed (idle %.1f s): %s",
23987                 $now - $last_event_time, $eval_stat);
23988        $smtp_resp = '';
23989      };
23990      if ($smtp_resp =~ /^2/) {
23991        snmp_count('OutConnReuseRefreshed');
23992      } else {
23993        snmp_count('OutConnReuseFail');
23994        $self->close(0) or do_log(-1, "Error closing smtp session");
23995      }
23996    }
23997  }
23998  if ($self->session_state eq 'down' || $self->session_state eq 'quitsent') {
23999    if (defined $smtp_handle) {
24000      $smtp_handle->close
24001        or do_log(-2, "Error closing Amavis::Out::SMTP::Protocol obj");
24002      undef $smtp_handle;
24003    }
24004    my $localaddr = c('local_client_bind_address');  # IP assigned to socket
24005    snmp_count('OutConnNew');
24006    $smtp_handle = Amavis::Out::SMTP::Protocol->new(
24007      $self->{socket_specs}, LocalAddr => $localaddr, Timeout => 35,
24008      WildcardImpliedHost => $self->{wildcard_implied_host},
24009      WildcardImpliedPort => $self->{wildcard_implied_port});
24010    $self->smtp_handle($smtp_handle);
24011    defined $smtp_handle  # don't change die text, it is referred to elsewhere
24012      or die sprintf("Can't connect to %s",
24013                     !ref $self->{socket_specs} ? $self->{socket_specs}
24014                                     : join(", ",@$self->{socket_specs}) );
24015    $self->socketname($smtp_handle->socketname);
24016    $self->protocol($smtp_handle->protocol);
24017    $self->session_state('connected');
24018    $self->established_at_time(time);
24019    $self->timeout($smtp_connect_timeout);
24020    $smtp_resp = $self->smtp_response;  # fetch greeting
24021    if (!defined $smtp_resp || $smtp_resp eq '') {
24022      die sprintf("%s greeting, dt: %.3f s\n",
24023                  !defined $smtp_resp ? 'No' : 'Empty',
24024                  time - $smtp_handle->last_io_event_tx_timestamp);
24025    } elsif ($smtp_resp !~ /^2/) {
24026      die "Negative greeting: $smtp_resp\n";
24027    } else {  # success, $smtp_resp =~ /^2/
24028      do_log(3,"smtp greeting: %s, dt: %.1f ms", $smtp_resp,
24029               1000*(time-$smtp_handle->last_io_event_tx_timestamp));
24030    }
24031  }
24032  if ($self->session_state eq 'connected') {
24033    my $lmtp = lc($self->protocol) eq 'lmtp' ? 1 : 0;  # RFC 2033
24034    my $deadline = $self->{deadline};
24035    my $tls_security_level = c('tls_security_level_out');
24036    $tls_security_level = 0  if !defined($tls_security_level) ||
24037                                lc($tls_security_level) eq 'none';
24038    my $myheloname = c('localhost_name');  # host name used in EHLO/HELO/LHLO
24039    $myheloname = 'localhost'  if $myheloname eq '';
24040    $myheloname = idn_to_ascii($myheloname);
24041    for (1..2) {
24042      # send EHLO/LHLO/HELO
24043      $self->timeout(max(60,min($smtp_helo_timeout,
24044                                $deadline - time)));
24045      if ($lmtp) { $smtp_handle->lhlo($myheloname) }  #flush!
24046      else       { $smtp_handle->ehlo($myheloname) }  #flush!
24047      $smtp_resp = $self->smtp_response;  # fetch response to EHLO/LHLO
24048      if (!defined $smtp_resp || $smtp_resp eq '') {
24049        die sprintf("%s response to %s, dt: %.3f s\n",
24050                    !defined $smtp_resp ? 'No' : 'Empty',
24051                    $lmtp ? 'LHLO' : 'EHLO',
24052                    time - $smtp_handle->last_io_event_tx_timestamp);
24053      } elsif ($smtp_resp =~ /^2/) {  # success
24054        do_log(3,"smtp resp to %s: %s", $lmtp?'LHLO':'EHLO', $smtp_resp);
24055      } elsif ($lmtp) {  # failure, no fallback possible
24056        die "Negative SMTP resp. to LHLO: $smtp_resp\n";
24057      } else {  # failure, SMTP fallback to HELO
24058        do_log(3,"Negative SMTP resp. to EHLO, will try HELO: %s", $smtp_resp);
24059        $smtp_handle->helo($myheloname);  #flush!
24060        $smtp_resp = $self->smtp_response;  # fetch response to HELO
24061        if (!defined $smtp_resp || $smtp_resp eq '') {
24062          die sprintf("%s response to HELO, dt: %.3f s\n",
24063                      !defined $smtp_resp ? 'No' : 'Empty',
24064                      time - $smtp_handle->last_io_event_tx_timestamp);
24065        } elsif ($smtp_resp !~ /^2/) {
24066          die "Negative response to HELO: $smtp_resp\n";
24067        } else {  # success, $smtp_resp =~ /^2/
24068          do_log(3,"smtp resp to HELO: %s", $smtp_resp);
24069        }
24070      }
24071      $self->session_state('ehlo');
24072      $smtp_handle->ehlo_response_parse($smtp_resp);
24073      my $tls_capable = defined $self->supports('STARTTLS');  # RFC 3207
24074      ll(5) && do_log(5, "tls active=%d, capable=%s, sec_level=%s",
24075                 $smtp_handle->ssl_active, $tls_capable, $tls_security_level);
24076      if ($smtp_handle->ssl_active) {
24077        last;  # done
24078      } elsif (!$tls_capable &&
24079               $tls_security_level && lc($tls_security_level) ne 'may') {
24080        die "MTA does not offer STARTTLS, ".
24081            "but TLS is required: \"$tls_security_level\"";
24082      } elsif (!$tls_capable || !$tls_security_level) {
24083        last;  # not offered and not mandated
24084      } else {
24085        $self->timeout(max(60,min($smtp_starttls_timeout,
24086                                  $deadline - time)));
24087        $smtp_handle->command('STARTTLS');  #flush!
24088        $smtp_resp = $self->smtp_response;  # fetch response to STARTTLS
24089        $smtp_resp = ''  if !defined $smtp_resp;
24090        do_log(3,"smtp resp to STARTTLS: %s", $smtp_resp);
24091        if ($smtp_resp !~ /^2/) {
24092          (!$tls_security_level || lc($tls_security_level) eq 'may')
24093            or die "Negative SMTP resp. to STARTTLS: $smtp_resp\n";
24094        } else {
24095          $smtp_handle->ssl_upgrade(%smtp_tls_client_options)
24096            or die "Error upgrading socket to SSL";
24097          $self->session_state('connected');
24098        }
24099      }
24100    }
24101  }
24102  $self;
24103}
24104
241051;
24106
24107package Amavis::Out::SMTP;
24108use strict;
24109use re 'taint';
24110use warnings;
24111use warnings FATAL => qw(utf8 void);
24112no warnings 'uninitialized';
24113# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
24114
24115BEGIN {
24116  require Exporter;
24117  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
24118  $VERSION = '2.412';
24119  @ISA = qw(Exporter);
24120  @EXPORT = qw(&mail_via_smtp);
24121  import Amavis::Conf qw(:platform c cr ca $smtp_connection_cache_enable);
24122  import Amavis::Util qw(untaint min max minmax ll do_log snmp_count
24123                         xtext_encode xtext_decode orcpt_encode orcpt_decode
24124                         idn_to_ascii mail_addr_idn_to_ascii
24125                         prolong_timer get_deadline
24126                         collect_equal_delivery_recips);
24127  import Amavis::Timing qw(section_time);
24128  import Amavis::rfc2821_2822_Tools;
24129  import Amavis::Lookup qw(lookup lookup2);
24130  import Amavis::Out::EditHeader;
24131}
24132
24133use Time::HiRes qw(time);
24134use Encode ();
24135# use Authen::SASL;
24136
24137# simple OO wrapper around Mail::DKIM::Signer to provide a method 'print'
24138# and to convert \n to CRLF
24139#
24140sub new_dkim_wrapper {
24141  my($class, $handle,$strip_cr) = @_;
24142  bless { handle => $handle, strip_cr => $strip_cr }, $class;
24143}
24144
24145sub close { 1 }
24146
24147sub print {
24148  my $self = shift;
24149  my $buff = @_ == 1 ? $_[0] : join('',@_);
24150  do_log(-1,"WARN: Unicode string passed to Amavis::Out::SMTP::print : %s",
24151      $buff)  if utf8::is_utf8($buff);  # false on tainted, Perl 5.8 bug #32687
24152  $buff =~ tr/\015//d  if $self->{strip_cr};  # sanitize bare CR
24153  $buff =~ s{\n}{\015\012}gs;
24154  $self->{handle}->PRINT($buff);
24155}
24156
24157# Add a log_id to the SMTP status text, insert a fabricated RFC 3463 enhanced
24158# status code if missing in an MTA response, see also RFC 5248
24159#
24160sub enhance_smtp_response($$$$$) {
24161  my($smtp_resp,$am_id,$mta_id,$dflt_enhcode,$cmd_name) = @_;
24162  local($1,$2,$3,$4); my $resp_msg;
24163  my($resp_code,$resp_more,$resp_enhcode) = ('451', ' ', '4.5.0');
24164  if (!defined($smtp_resp) || $smtp_resp eq '') {
24165    $smtp_resp = sprintf('No resp. to %s', $cmd_name);
24166  } elsif ($smtp_resp !~ /^[245]\d{2}/) {
24167    $smtp_resp = sprintf('Bad resp. to %s: %s', $cmd_name,$smtp_resp);
24168  } elsif ($smtp_resp =~ /^ (\d{3}) (\ |-|\z) [ \t]*
24169                            ([245] \. \d{1,3} \. \d{1,3})?
24170                            \s* (.*) \z/xs) {
24171    ($resp_code, $resp_more, $resp_enhcode, $resp_msg) = ($1, $2, $3, $4);
24172    if (!defined $resp_enhcode && $resp_code =~ /^[245]/) {
24173      my $c = substr($resp_code,0,1);
24174      $resp_enhcode = $dflt_enhcode; $resp_enhcode =~ s/^\d*/$c/;
24175    }
24176  }
24177  sprintf("%s%s%s from MTA(%s): %s",
24178          $resp_code, $resp_more, $resp_enhcode, $mta_id, $smtp_resp);
24179}
24180
24181# Send mail using SMTP - single transaction
24182# (e.g. forwarding original mail or sending notification)
24183# May throw exception (die) if temporary failure (4xx) or other problem
24184#
24185# Multiple transactions may be necessary, either due to different delivery
24186# methods (IP address, port, SMTP vs. LMTP) or due to '452 Too many recipients'
24187#
24188sub mail_via_smtp(@) {
24189  my($msginfo, $initial_submission, $dsn_per_recip_capable, $filter) = @_;
24190  #
24191  # RFC 2033: LMTP protocol MUST NOT be used on the TCP port 25
24192  #
24193  # $initial_submission can be treated as a boolean, but for more detailed
24194  # needs it can be any of:  false: 0
24195  #                       or true: 'Quar', 'Dsn', 'Notif', 'AV', 'Arf'
24196  my $which_section = 'fwd_init';
24197  my $id = $msginfo->parent_mail_id;
24198  $id = $msginfo->mail_id . (defined $id ? "($id)" : "");
24199  my $sender_smtp = $msginfo->sender_smtp;
24200  my $logmsg = sprintf("%s %s", $id, $initial_submission?'SEND':'FWD');
24201  my($per_recip_data_ref, $proto_sockname) =
24202    collect_equal_delivery_recips($msginfo, $filter, qr/^(?:smtp|lmtp):/i);
24203  if (!$per_recip_data_ref || !@$per_recip_data_ref) {
24204    do_log(5, "%s from %s, nothing to do", $logmsg, $sender_smtp);
24205    return 1;
24206  }
24207  my $proto_sockname_displ = !ref $proto_sockname ? $proto_sockname
24208                               : '(' . join(', ',@$proto_sockname) . ')';
24209  my(@per_recip_data) = @$per_recip_data_ref;  undef $per_recip_data_ref;
24210  ll(4) && do_log(4, "about to connect to %s, %s from %s -> %s",
24211                     $proto_sockname_displ, $logmsg, $sender_smtp,
24212                     join(',', qquote_rfc2821_local(
24213                                map($_->recip_final_addr, @per_recip_data)) ));
24214  my $am_id = $msginfo->log_id;
24215  my $dsn_envid = $msginfo->dsn_envid;
24216  my $dsn_ret = $msginfo->dsn_ret;
24217  my $smtputf8 = $msginfo->smtputf8;  # SMTPUTF8 requested
24218  my $smtputf8_capable;               # SMTPUTF8 offered by MTA, RFC 6531
24219  my($relayhost, $protocol, $lmtp, $mta_id, @snmp_vars);
24220  my($smtp_session, $smtp_handle, $smtp_resp, $smtp_response);
24221  my($any_valid_recips, $any_tempfail_recips, $pipelining,
24222     $any_valid_recips_and_data_sent, $recips_done_by_early_fail,
24223     $in_datasend_mode, $dsn_capable, $auth_capable) = (0) x 8;
24224  my $mimetransport8bit_capable = 0;  # RFC 6152
24225  my(%from_options);
24226  # RFC 5321 (ex RFC 2821), section 4.5.3.2. Timeouts
24227  my $smtp_connect_timeout   =  35;
24228  my $smtp_helo_timeout      = 300;
24229  my $smtp_starttls_timeout  = 300;
24230  my $smtp_xforward_timeout  = 300;
24231  my $smtp_mail_timeout      = 300;
24232  my $smtp_rcpt_timeout      = 300;
24233  my $smtp_data_init_timeout = 120;
24234  my $smtp_data_xfer_timeout = 180;
24235  my $smtp_data_done_timeout = 600;
24236  my $smtp_quit_timeout      =  10;  # 300
24237  my $smtp_rset_timeout      =  20;
24238  # can appear anywhere in a pipelined command group:
24239  #   RSET, MAIL FROM, SEND FROM, SOML FROM, SAML FROM, RCPT TO, data
24240  # can only appear as the last command in a pipelined group:  --> flush
24241  #   EHLO, DATA, VRFY, EXPN, TURN, QUIT, NOOP,
24242  #   AUTH(RFC 4954), STARTTLS(RFC 3207), and all unknown commands
24243
24244  # needed to implement dynamic_destination: a '*' in place of a host or port
24245  my($wildcard_implied_host, $wildcard_implied_port);
24246  my $conn = $msginfo->conn_obj;
24247  if ($conn) {
24248    my $host = $conn->client_ip;
24249    $wildcard_implied_host = $host  if defined($host) && $host ne '';
24250    my $port = $conn->socket_port;
24251    $wildcard_implied_port = $port+1  if defined($port) && $port =~ /^\d+\z/;
24252  }
24253  my($remaining_time, $deadline) = get_deadline($which_section, 1, 0);
24254  alarm(0);  # stop the timer
24255  my $err;
24256  eval {
24257    $which_section = 'fwd-connect';
24258    $smtp_session = Amavis::Out::SMTP::Session->new($proto_sockname, $deadline,
24259                               $wildcard_implied_host, $wildcard_implied_port)
24260      or die "Can't establish an SMTP/LMTP session with $proto_sockname_displ";
24261    $smtp_handle = $smtp_session->smtp_handle;
24262    if ($smtp_handle) {
24263      $relayhost = $smtp_handle->socketname;
24264      $protocol = $smtp_handle->protocol;
24265      $lmtp = lc($protocol) eq 'lmtp' ? 1 : 0;  # RFC 2033
24266      $mta_id = sprintf("%s:%s", $protocol, $relayhost);
24267      @snmp_vars = !$initial_submission ?
24268        ('', 'Relay',  'Proto'.uc($protocol), 'Proto'.uc($protocol).'Relay')
24269      : ('', 'Submit', 'Proto'.uc($protocol), 'Proto'.uc($protocol).'Submit',
24270         'Submit'.$initial_submission);
24271      snmp_count('OutMsgs'.$_)  for @snmp_vars;
24272    }
24273    $dsn_capable = c('propagate_dsn_if_possible') &&
24274                   defined($smtp_session->supports('DSN'));         # RFC 3461
24275    $mimetransport8bit_capable =  # 8bit-MIMEtransport service extension
24276                   defined($smtp_session->supports('8BITMIME'));    # RFC 6152
24277    $smtputf8_capable =           # "Internationalized Email" service extension
24278                   $mimetransport8bit_capable &&
24279                   defined($smtp_session->supports('SMTPUTF8'));    # RFC 6531
24280    $pipelining =  defined($smtp_session->supports('PIPELINING'));  # RFC 2920
24281    do_log(3,"No announced PIPELINING support by MTA?")  if !$pipelining;
24282    ll(5) && do_log(5,"Remote host presents itself as: %s, handles %s",
24283                      $smtp_handle->domain,
24284                      join(', ', $dsn_capable ? 'DSN' : (),
24285                                 $pipelining  ? 'PIPELINING' : (),
24286                                 $mimetransport8bit_capable ? '8BITMIME' : (),
24287                                 $smtputf8_capable ? 'SMTPUTF8' : () ) );
24288    if ($lmtp && !$pipelining) {  # RFC 2033 requirements
24289      die "An LMTP server implementation MUST implement PIPELINING";
24290    }
24291    if ($lmtp && !defined($smtp_session->supports('ENHANCEDSTATUSCODES'))) {
24292      die "An LMTP server implementation MUST implement ENHANCEDSTATUSCODES";
24293    }
24294
24295    if (!$smtputf8_capable || !$smtputf8) {
24296      # if SMTPUTF8 is not requested or if MTA is unable to handle
24297      # IDN with U-labels, and local part is all-ASCII, then we may
24298      # still get this delivered by converting a domain name
24299      # to ASCII-compatible encoding (ACE)
24300      if ($sender_smtp =~ /^ [\x00-\x7F]* \@ [^\@]* [^\x00-\x7F] [^\@]*\z/xs) {
24301        # localpart all-ASCII, domain is non-ASCII
24302        my $idn_ascii = mail_addr_idn_to_ascii($sender_smtp);
24303        do_log(2,'sender IDN encoded to ACE: %s -> %s',
24304                 $sender_smtp, $idn_ascii);
24305        $sender_smtp = $idn_ascii;
24306      }
24307      for my $r (@per_recip_data) {
24308        next  if $r->recip_done;
24309        my $rcpt_addr = $r->recip_final_addr;
24310        if ($rcpt_addr =~ /^ [\x00-\x7F]* \@ [^\@]* [^\x00-\x7F] [^\@]*\z/xs) {
24311          my $idn_ascii = mail_addr_idn_to_ascii($rcpt_addr);
24312          do_log(2,'recipient IDN encoded to ACE: %s -> %s',
24313                   $rcpt_addr, $idn_ascii);
24314          $rcpt_addr = $idn_ascii;
24315          $r->dsn_orcpt(join(';', orcpt_decode(';'.$r->recip_addr_smtp)))
24316            if !defined $r->dsn_orcpt;
24317          # N.B.: change recip_addr_modified(), not recip_final_addr() !
24318          $r->recip_addr_modified($rcpt_addr);
24319        }
24320      }
24321    }
24322
24323    if ($smtputf8) {  # SMTPUTF8 handling was requested, RFC 6531
24324      #
24325      # RFC 6531 section 3.4: If the SMTPUTF8-aware SMTP client is aware
24326      # that neither the envelope nor the message being sent requires any
24327      # of the SMTPUTF8 extension capabilities, it SHOULD NOT supply the
24328      # SMTPUTF8 parameter with the MAIL command.
24329      #
24330      my($sender_8bit, $recips_8bit);
24331      $sender_8bit = 1  if $msginfo->sender_smtp =~ tr/\x00-\x7F//c;
24332      for my $r (@per_recip_data) {
24333        next  if $r->recip_done;
24334        $recips_8bit = 1  if $r->recip_final_addr =~ tr/\x00-\x7F//c;
24335      }
24336
24337      if (!ll(5)) {
24338        # don't bother, just logging
24339      } elsif ($sender_8bit || $recips_8bit || $msginfo->header_8bit) {
24340        do_log(5,'SMTPUTF8 option requested and is needed, %s is non-ASCII',
24341                 join(' & ', $sender_8bit  ? 'sender' : (),
24342                             $recips_8bit  ? 'recip'  : (),
24343                             $msginfo->header_8bit ? 'header' : () ));
24344      } else {
24345        do_log(5,'SMTPUTF8 option requested but not needed');
24346      }
24347
24348      if (!$smtputf8_capable) {
24349        # RFC 6531 sect 3.5: An SMTPUTF8-aware SMTP client MUST NOT send
24350        # an internationalized message to an SMTP server that does not
24351        # support SMTPUTF8.
24352        # 550 5.6.7 Non-ASCII addresses not permitted for that sender
24353        # 553 5.6.7 Non-ASCII addresses not permitted for that recipient
24354        # after DATA-dot:
24355        # 554 5.6.9 UTF-8 header message cannot be transmitted to one or more
24356        #   recipients, so the message must be rejected
24357        #
24358        if (!$sender_8bit && !$recips_8bit) {
24359          # mail addresses are all-ASCII, don't care for an 8bit header
24360          do_log(3,'SMTPUTF8 option requested but not offered, turning it off');
24361          $smtputf8 = 0;  # turn off if not needed
24362        }
24363      }
24364    }
24365    section_time($which_section);
24366
24367    $which_section = 'fwd-xforward';
24368    my $cl_ip = $msginfo->client_addr;
24369    if (defined $cl_ip && $cl_ip ne '' &&
24370        defined($smtp_session->supports('XFORWARD'))) {
24371      $cl_ip = 'IPv6:'.$cl_ip  if $cl_ip =~ /:[0-9a-f]*:/i &&
24372                                  $cl_ip !~ /^IPv6:/i;
24373      my(%xfwd_supp_opt) = map((uc($_),1),
24374                               split(' ',$smtp_session->supports('XFORWARD')));
24375      my(@params) = map
24376        { my($n,$v) = @$_;
24377          # Postfix since version 20060610 uses xtext-encoded (RFC 3461)
24378          # strings in XCLIENT and XFORWARD attribute values, previous
24379          # versions expected plain text with neutered special characters;
24380          # see README_FILES/XFORWARD_README
24381          if (defined $v && $v ne '') {
24382            $v =~ s/[^\041-\176]/?/gs;  # isprint
24383            $v =~ s/[<>()\\";\@]/?/gs;  # other chars that are special in hdrs
24384                     # postfix/src/smtpd/smtpd.c NEUTER_CHARACTERS
24385            $v = xtext_encode($v);
24386            substr($v,255) = ''  if length($v) > 255;  # chop xtext, not nice
24387          }
24388          !defined $v || $v eq '' || !$xfwd_supp_opt{$n} ? () : ("$n=$v") }
24389        ( ['ADDR',$cl_ip],                ['NAME',$msginfo->client_name],
24390          ['PORT',$msginfo->client_port], ['PROTO',$msginfo->client_proto],
24391          ['HELO',$msginfo->client_helo], ['SOURCE',$msginfo->client_source],
24392          ['IDENT',$msginfo->queue_id] );
24393      $smtp_session->timeout(
24394        max(60,min($smtp_xforward_timeout,$deadline-time())));
24395      $smtp_handle->command('XFORWARD',@params);  #flush!
24396      $smtp_resp = $smtp_session->smtp_response;  # fetch response to XFORWARD
24397      if (!defined $smtp_resp || $smtp_resp eq '') {
24398        do_log(-1,"%s SMTP resp. to XFORWARD, dt: %.3f s",
24399                  !defined $smtp_resp ? 'No' : 'Empty',
24400                  time - $smtp_handle->last_io_event_tx_timestamp);
24401      } elsif ($smtp_resp !~ /^2/) {
24402        do_log(0,"Negative SMTP resp. to XFORWARD: %s", $smtp_resp);
24403      } else {  # success, $smtp_resp =~ /^2/
24404        do_log(3,"smtp resp to XFORWARD: %s", $smtp_resp);
24405      }
24406      section_time($which_section);
24407    }
24408
24409    $which_section = 'fwd-auth';
24410    my $auth_user = $msginfo->auth_user;
24411    my $mechanisms = $smtp_session->supports('AUTH');
24412    if (!c('auth_required_out')) {
24413      do_log(3,"AUTH not needed, user='%s', MTA offers '%s'",
24414               $auth_user,$mechanisms);
24415    } elsif ($mechanisms eq '') {
24416      do_log(3,"INFO: MTA does not offer AUTH capability, user='%s'",
24417               $auth_user);
24418    } elsif (!defined $auth_user) {
24419      do_log(0,"INFO: AUTH needed for submission but AUTH data not available");
24420    } else {
24421      do_log(3,"INFO: authenticating %s, server supports AUTH %s",
24422               $auth_user,$mechanisms);
24423      $auth_capable = 1;
24424#     my $sasl = Authen::SASL->new(
24425#       'callback' => { 'user' => $auth_user, 'authname' => $auth_user,
24426#                       'pass' => $msginfo->auth_pass });
24427#     $smtp_handle->auth($sasl) or die "sending AUTH, user=$auth_user\n";#flush
24428      do_log(0,"Sorry, AUTH not supported in this version of amavisd!");
24429      section_time($which_section);
24430    }
24431
24432    $which_section = 'fwd-pre-mail-from';
24433    $smtp_session->timeout(max(60,min($smtp_mail_timeout,$deadline-time())));
24434    my $fetched_mail_resp = 0;  my $fetched_rcpt_resp = 0;
24435    my $data_command_accepted = 0;
24436    if ($initial_submission && $dsn_capable && !defined($dsn_envid)) {
24437      # ENVID identifies transaction, not a message
24438      $dsn_envid = xtext_encode(sprintf("AM.%s.%s\@%s",
24439                     $msginfo->mail_id || $msginfo->log_id,
24440                     iso8601_utc_timestamp(time),
24441                     idn_to_ascii(c('myhostname')) ));
24442    }
24443    $from_options{'RET'} = $dsn_ret  if $dsn_capable && defined $dsn_ret;
24444    if ($dsn_capable && defined $dsn_envid) {
24445      # check for proper encoding (RFC 3461), just in case
24446      if ($dsn_envid =~ tr/ =\x00-\x1F//) {
24447        do_log(-1, "Prohibited character in ENVID: %s", $dsn_envid);
24448      } else {
24449        $from_options{'ENVID'} = $dsn_envid;
24450      }
24451    }
24452
24453    my $submitter = $msginfo->auth_submitter;
24454    $from_options{'AUTH'} = xtext_encode($submitter)  # RFC 4954 (ex RFC 2554)
24455      if $auth_capable &&
24456         defined($submitter) && $submitter ne '' && $submitter ne '<>';
24457
24458    if ($smtputf8 && $smtputf8_capable) {
24459      $from_options{'SMTPUTF8'} = undef;  # turn option *on*, no value
24460    }
24461
24462    my $btype = $msginfo->body_type;
24463    if (defined $btype && $btype ne '') {
24464      $btype = uc $btype;
24465      if ($btype ne '7BIT' && $btype ne '8BITMIME') {
24466        do_log(-1,'requested BODY type %s is unknown/unsupported', $btype);
24467      } elsif ($mimetransport8bit_capable) {
24468        $from_options{'BODY'} = $btype;
24469      }
24470    }
24471    if (!$mimetransport8bit_capable &&
24472        defined $btype && $btype ne '' && uc $btype ne '7BIT') {
24473      do_log(-1,'requested BODY type is %s, but MTA does not announce '.
24474                '8bit-MIMEtransport capability', $btype);  # RFC 6152
24475      for my $r (@per_recip_data) {
24476        next  if $r->recip_done;
24477        $r->recip_smtp_response('550 5.6.3 Conversion to 7BIT required '.
24478                                'but not supported');
24479        $r->recip_remote_mta($relayhost); $r->recip_done(2);
24480      }
24481      $recips_done_by_early_fail = 1;
24482    } elsif ($smtputf8 &&
24483             !$smtputf8_capable && $sender_smtp =~ tr/\x00-\x7F//c) {
24484      do_log(1,'SMTPUTF8 option requested, not offered by MTA, '.
24485               'sender is non-ASCII: %s', $sender_smtp);
24486      for my $r (@per_recip_data) {
24487        next  if $r->recip_done;
24488        $r->recip_smtp_response('550 5.6.7 Non-ASCII addresses not permitted '.
24489                                'for sender');
24490        $r->recip_remote_mta($relayhost); $r->recip_done(2);
24491      }
24492      $recips_done_by_early_fail = 1;
24493    } else {
24494      $which_section = 'fwd-mail-from';
24495      $smtp_handle->mail($sender_smtp, %from_options);  # MAIL FROM
24496      # consider the transaction state unknown until we see a response
24497      $smtp_session->transaction_begins_unconfirmed; # also counts transactions
24498      if (!$pipelining) {
24499        $smtp_resp = $smtp_session->smtp_response;  $fetched_mail_resp = 1;
24500        if (!defined $smtp_resp || $smtp_resp eq '') {
24501          die sprintf("%s response to MAIL, dt: %.3f s\n",
24502                      !defined $smtp_resp ? 'No' : 'Empty',
24503                      time - $smtp_handle->last_io_event_tx_timestamp);
24504        } elsif ($smtp_resp =~ /^2/) {
24505          do_log(3, "smtp resp to MAIL: %s", $smtp_resp);
24506          $smtp_session->transaction_begins;  # transaction is active
24507        } else {  # failure
24508          do_log(1, "smtp resp to MAIL: %s", $smtp_resp);
24509          # transaction state unchanged, consider it unknown
24510          my $smtp_resp_ext = enhance_smtp_response($smtp_resp,$am_id,$mta_id,
24511                                                    '.1.0','MAIL FROM');
24512          for my $r (@per_recip_data) {
24513            next  if $r->recip_done;
24514            $r->recip_remote_mta($relayhost);
24515            $r->recip_remote_mta_smtp_response($smtp_resp);
24516            $r->recip_smtp_response($smtp_resp_ext); $r->recip_done(2);
24517          }
24518          $recips_done_by_early_fail = 1;
24519        }
24520        section_time($which_section);
24521      }
24522    }
24523
24524    $which_section = 'fwd-rcpt-to';
24525    $smtp_session->timeout(max(60,min($smtp_rcpt_timeout,$deadline-time())));
24526    my($skipping_resp, @per_recip_data_rcpt_sent);
24527    for my $r (@per_recip_data) {  # send recipient addresses
24528      next  if $r->recip_done;
24529      if (defined $skipping_resp) {
24530        $r->recip_smtp_response($skipping_resp); $r->recip_done(2);
24531        next;
24532      }
24533      # prepare to send a RCPT TO command
24534      my $raddr = qquote_rfc2821_local($r->recip_final_addr);
24535      if ($smtputf8 && !$smtputf8_capable && $raddr =~ tr/\x00-\x7F//c) {
24536        do_log(1,'SMTPUTF8 option requested, not offered by MTA, '.
24537                 'recipient is non-ASCII: %s', $raddr);
24538        $r->recip_smtp_response('553 5.6.7 Non-ASCII addresses '.
24539                                'not permitted for recipient');
24540        $r->recip_remote_mta($relayhost); $r->recip_done(2);
24541      } elsif (!$dsn_capable) {
24542        $smtp_handle->recipient($raddr);  # a barebones RCPT TO command
24543        push(@per_recip_data_rcpt_sent, $r);  # remember which recips were sent
24544      } else {  # include dsn options with a RCPT TO command
24545        my(@dsn_notify);  # implies a default when the list is empty
24546        my $dn = $r->dsn_notify;
24547        @dsn_notify = @$dn  if $dn && $msginfo->sender ne '';  # if nondefault
24548        if (c('terminate_dsn_on_notify_success')) {
24549          # we want to handle option SUCCESS locally
24550          if (grep($_ eq 'SUCCESS', @dsn_notify)) {  # strip out SUCCESS
24551            @dsn_notify = grep($_ ne 'SUCCESS', @dsn_notify);
24552            @dsn_notify = ('NEVER')  if !@dsn_notify;
24553            do_log(3,"stripped out SUCCESS, result: NOTIFY=%s",
24554                     join(',',@dsn_notify));
24555          }
24556        }
24557        my(%rcpt_options);
24558        $rcpt_options{'NOTIFY'} =
24559          join(',', map(uc($_),@dsn_notify))  if @dsn_notify;
24560        my($addr_type, $addr) =
24561          orcpt_encode($r->dsn_orcpt, $smtputf8 && $smtputf8_capable, 1);
24562        $rcpt_options{'ORCPT'} = $addr_type.';'.$addr  if defined $addr;
24563        $smtp_handle->recipient($raddr, %rcpt_options);  # RCPT TO
24564        push(@per_recip_data_rcpt_sent, $r);  # remember which recips were sent
24565      }
24566      if (!$pipelining) {  # must fetch responses to RCPT TO right away
24567        $smtp_resp = $smtp_session->smtp_response;  $fetched_rcpt_resp = 1;
24568        if (defined $smtp_resp && $smtp_resp ne '') {
24569          $r->recip_remote_mta($relayhost);
24570          $r->recip_remote_mta_smtp_response($smtp_resp);
24571          my $smtp_resp_ext = enhance_smtp_response($smtp_resp,$am_id,$mta_id,
24572                                                    '.1.0','RCPT TO');
24573          $r->recip_smtp_response($smtp_resp_ext);  # preliminary response
24574        }
24575        if (!defined $smtp_resp || $smtp_resp eq '') {
24576          die sprintf("%s response to RCPT (%s), dt: %.3f s\n",
24577                      !defined $smtp_resp ? 'No' : 'Empty',  $raddr,
24578                      time - $smtp_handle->last_io_event_tx_timestamp);
24579        } elsif ($smtp_resp =~ /^2/) {
24580          do_log(3, "smtp resp to RCPT (%s): %s", $raddr,$smtp_resp);
24581          $any_valid_recips++;
24582        } else {  # failure
24583          do_log(1, "smtp resp to RCPT (%s): %s", $raddr,$smtp_resp);
24584          if ($smtp_resp =~ /^452/) {  # too many recipients - see RFC 5321
24585            do_log(-1, 'Only %d recips sent in one go: "%s"',
24586                       $any_valid_recips, $smtp_resp)
24587                       if !defined $skipping_resp;
24588            $skipping_resp = enhance_smtp_response($smtp_resp,$am_id,$mta_id,
24589                                                   '.5.3','RCPT TO');
24590          } elsif ($smtp_resp =~ /^4/) {
24591            $any_tempfail_recips++;
24592          }
24593          $r->recip_done(2);  # got a negative response to RCPT TO
24594        }
24595      }
24596    }
24597    section_time($which_section)  if !$pipelining;  # otherwise it just shows 0
24598
24599    my $what_cmd;
24600    if (!@per_recip_data_rcpt_sent ||  # no recipients were sent
24601        $fetched_rcpt_resp && !$any_valid_recips) {  # no recipients accepted
24602      # it is known there are no valid recipients, don't go into DATA section
24603      do_log(0,"no valid recipients, skip data transfer");
24604      $smtp_session->timeout($smtp_rset_timeout);
24605      $what_cmd = 'RSET';  $smtp_handle->rset;  # send a RSET
24606      $smtp_session->transaction_ends_unconfirmed;
24607    } elsif ($fetched_rcpt_resp &&              # no pipelining
24608             $any_tempfail_recips && !$dsn_per_recip_capable) {
24609      # we must not proceed if mail did not came in as LMTP,
24610      # or we would generate mail duplicates on each delivery attempt
24611      do_log(-1,"mail_via_smtp: DATA skipped, tempfailed recips: %s",
24612                $any_tempfail_recips);
24613      $smtp_session->timeout($smtp_rset_timeout);
24614      $what_cmd = 'RSET';  $smtp_handle->rset;  # send a RSET
24615      $smtp_session->transaction_ends_unconfirmed;
24616    } else {  # pipelining, or we know we got a clearance to proceed
24617      $which_section = 'fwd-data-cmd';
24618      # pipelining in effect, or we have at least one valid recipient, go DATA
24619      $smtp_session->timeout(
24620        max(60,min($smtp_data_init_timeout,$deadline-time())));
24621      $smtp_handle->data;  #flush!  DATA
24622      $in_datasend_mode = 1;  # DATA command was sent (but not yet confirmed)
24623      if (!$fetched_mail_resp) {  # pipelining in effect, late response to MAIL
24624        $which_section = 'fwd-mail-pip';
24625        $smtp_session->timeout(
24626          max(60,min($smtp_mail_timeout,$deadline-time())));
24627        $smtp_resp = $smtp_session->smtp_response;  $fetched_mail_resp = 1;
24628        if (!defined $smtp_resp || $smtp_resp eq '') {
24629          die sprintf("%s response to MAIL (pip), dt: %.3f s\n",
24630                      !defined $smtp_resp ? 'No' : 'Empty',
24631                      time - $smtp_handle->last_io_event_tx_timestamp);
24632        } elsif ($smtp_resp =~ /^2/) {
24633          do_log(3, "smtp resp to MAIL (pip): %s", $smtp_resp);
24634          $smtp_session->transaction_begins;  # transaction is active
24635        } else {  # failure
24636          do_log(1, "smtp resp to MAIL (pip): %s", $smtp_resp);
24637          # transaction state unchanged, consider it unknown
24638          my $smtp_resp_ext = enhance_smtp_response($smtp_resp,
24639                                         $am_id, $mta_id, '.1.0', 'MAIL FROM');
24640          for my $r (@per_recip_data) {
24641            next  if $r->recip_done;
24642            $r->recip_remote_mta($relayhost);
24643            $r->recip_remote_mta_smtp_response($smtp_resp);
24644            $r->recip_smtp_response($smtp_resp_ext); $r->recip_done(2);
24645          }
24646          $recips_done_by_early_fail = 1;
24647        }
24648        section_time($which_section);
24649      }
24650      if (!$fetched_rcpt_resp) {  # pipelining in effect, late response to RCPT
24651        $which_section = 'fwd-rcpt-pip';
24652        $smtp_session->timeout(
24653          max(60,min($smtp_rcpt_timeout,$deadline-time())));
24654        for my $r (@per_recip_data_rcpt_sent) {  # only for those actually sent
24655          my $raddr = qquote_rfc2821_local($r->recip_final_addr);
24656          $smtp_resp = $smtp_session->smtp_response;  $fetched_rcpt_resp = 1;
24657          if (defined $smtp_resp && $smtp_resp ne '') {
24658            if ($r->recip_done) {  # shouldn't happen, unless MAIL FROM failed
24659              do_log(-1,"panic: recipient done, but got an ".
24660                        "smtp resp to RCPT (pip) (%s): %s",
24661                        $raddr,$smtp_resp)  if !$recips_done_by_early_fail;
24662              next;  # do not overwrite previous diagnostics
24663            }
24664            $r->recip_remote_mta($relayhost);
24665            $r->recip_remote_mta_smtp_response($smtp_resp);
24666            my $smtp_resp_ext = enhance_smtp_response($smtp_resp,
24667                                           $am_id, $mta_id, '.1.0', 'RCPT TO');
24668            $r->recip_smtp_response($smtp_resp_ext);  # preliminary response
24669          }
24670          if (!defined $smtp_resp || $smtp_resp eq '') {
24671            die sprintf("%s response to RCPT (pip) (%s), dt: %.3f s\n",
24672                        !defined $smtp_resp ? 'No' : 'Empty',  $raddr,
24673                        time - $smtp_handle->last_io_event_tx_timestamp);
24674          } elsif ($smtp_resp =~ /^2/) {
24675            do_log(3, "smtp resp to RCPT (pip) (%s): %s", $raddr,$smtp_resp);
24676            $any_valid_recips++;
24677          } else {  # failure
24678            do_log(1, "smtp resp to RCPT (pip) (%s): %s", $raddr,$smtp_resp);
24679            if ($smtp_resp =~ /^452/) {  # too many recipients - see RFC 5321
24680              do_log(-1, 'Only %d recips sent in one go: "%s"',
24681                         $any_valid_recips, $smtp_resp)
24682                         if !defined $skipping_resp;
24683              $skipping_resp = enhance_smtp_response($smtp_resp,$am_id,$mta_id,
24684                                                     '.5.3','RCPT TO');
24685            } elsif ($smtp_resp =~ /^4/) {
24686              $any_tempfail_recips++;
24687            }
24688            $r->recip_done(2);  # got a negative response to RCPT TO
24689          }
24690        }
24691        section_time($which_section);
24692      }
24693      $which_section = 'fwd-data-chkpnt'  if $pipelining;
24694      $smtp_session->timeout(
24695        max(60,min($smtp_data_init_timeout,$deadline-time())));
24696      $smtp_resp = $smtp_session->smtp_response;  # fetch response to DATA
24697      section_time($which_section);
24698      $data_command_accepted = 0;
24699      if (!defined $smtp_resp || $smtp_resp eq '') {
24700        do_log(-1,"%s SMTP resp. to DATA, dt: %.3f s",
24701                  !defined $smtp_resp ? 'No' : 'Empty',
24702                  time - $smtp_handle->last_io_event_tx_timestamp);
24703        $smtp_resp = sprintf("450 4.5.0 %s response to DATA",
24704                             !defined $smtp_resp ? 'No' : 'Empty');
24705      } elsif ($smtp_resp !~ /^3/) {
24706        do_log(0,"Negative SMTP resp. to DATA: %s", $smtp_resp);
24707      } else {  # success, $smtp_resp =~ /^3/
24708        $data_command_accepted = 1;
24709        do_log(3,"smtp resp to DATA: %s", $smtp_resp);
24710      }
24711      if (!$data_command_accepted) {
24712        $in_datasend_mode = 0;
24713        $smtp_session->timeout($smtp_rset_timeout);
24714        $what_cmd = 'RSET';  $smtp_handle->rset;  # send a RSET
24715        $smtp_session->transaction_ends_unconfirmed;
24716        # replace success responses to RCPT TO commands with a response to DATA
24717        for my $r (@per_recip_data_rcpt_sent) {  # only for those actually sent
24718          next  if $r->recip_done;  # skip those that failed at earlier stages
24719          $r->recip_remote_mta($relayhost);
24720          $r->recip_remote_mta_smtp_response($smtp_resp);
24721          my $smtp_resp_ext = enhance_smtp_response($smtp_resp,
24722                                              $am_id, $mta_id, '.5.0', 'DATA');
24723          $smtp_response = $smtp_resp_ext  if !defined $smtp_response;
24724          $r->recip_smtp_response($smtp_resp_ext); $r->recip_done(2);
24725        }
24726      } elsif (!$any_valid_recips) {  # pipelining and no recipients, in DATA
24727        do_log(2,"Too late, DATA accepted but no valid recips, send dummy");
24728        $which_section = 'fwd-dummydata-end';
24729        $smtp_session->timeout(
24730          max(60,min($smtp_data_done_timeout,$deadline-time())));
24731        $what_cmd = 'data-dot';
24732        $smtp_handle->dataend;  # .<CR><LF>  as required by RFC 2920: if a DATA
24733               # command was accepted the SMTP client should send a single dot
24734        $in_datasend_mode = 0; $smtp_session->transaction_ends_unconfirmed;
24735      } elsif ($any_tempfail_recips && !$dsn_per_recip_capable) {  # pipelining
24736        # we must not proceed if mail did not came in as LMTP,
24737        # or we would generate mail duplicates on each delivery attempt
24738        do_log(2,"Too late, DATA accepted but tempfailed recips, bail out");
24739        die "Bail out, DATA accepted but tempfailed recips, not an LMTP input";
24740      } else {  # all ok so far, we are in a DATA state and must send contents
24741        $which_section = 'fwd-data-hdr';
24742        my $hdr_edits = $msginfo->header_edits;
24743        $hdr_edits = Amavis::Out::EditHeader->new  if !$hdr_edits;
24744        $smtp_session->timeout(
24745          max(60,min($smtp_data_xfer_timeout,$deadline-time())));
24746        my($received_cnt,$file_position) =
24747          $hdr_edits->write_header($msginfo,$smtp_handle,!$initial_submission);
24748        if ($received_cnt > 100) {
24749          # loop detection required by RFC 5321 (ex RFC 2821) section 6.3
24750          # Do not modify the signal text, it gets matched elsewhere!
24751          die "Too many hops: $received_cnt 'Received:' header fields\n";
24752        }
24753        $which_section = 'fwd-data-contents';
24754        # a file handle or a string ref or MIME::Entity object
24755        my $msg = $msginfo->mail_text;
24756        my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
24757        $msg = $msg_str_ref  if ref $msg_str_ref;
24758        if (!defined $msg) {
24759          # empty mail
24760        } elsif (ref $msg eq 'SCALAR') {
24761          # do it in chunks, saves memory, cache friendly
24762          while ($file_position < length($$msg)) {
24763            $smtp_handle->datasend(substr($$msg,$file_position,16384));
24764            $file_position += 16384;  # may overshoot, no problem
24765          }
24766        } elsif ($msg->isa('MIME::Entity')) {
24767          $msg->print_body($smtp_handle);
24768        } else {
24769          my($nbytes,$buff);
24770          while (($nbytes = $msg->read($buff,3*16384)) > 0) {
24771            $smtp_handle->datasend($buff);
24772          }
24773          defined $nbytes or die "Error reading: $!";
24774        }
24775        section_time($which_section);
24776
24777        $which_section = 'fwd-data-end';
24778        $smtp_session->timeout(
24779          max(60,min($smtp_data_done_timeout,$deadline-time())));
24780        $what_cmd = 'data-dot';
24781        $smtp_handle->dataend;  # .<CR><LF>
24782        $in_datasend_mode = 0; $smtp_session->transaction_ends_unconfirmed;
24783        $any_valid_recips_and_data_sent = 1;
24784        section_time($which_section)  if !$pipelining;  # otherwise it shows 0
24785      }
24786    }
24787    if ($pipelining && !$smtp_connection_cache_enable) {
24788      do_log(5,"smtp connection_cache disabled, sending QUIT");
24789      $smtp_session->quit;  #flush!   QUIT
24790      # can't be sure until we see a response, consider uncertain just in case
24791      $smtp_session->transaction_ends_unconfirmed;
24792    }
24793    $which_section = 'fwd-rundown-1';
24794    $smtp_resp = undef;
24795    if (!defined $what_cmd) {
24796      # not expecting a response?
24797    } elsif ($what_cmd ne 'data-dot') {  # must be a response to a RSET
24798      $smtp_resp = $smtp_session->smtp_response;  # fetch a response
24799      if (!defined $smtp_resp || $smtp_resp eq '') {
24800        die sprintf("%s SMTP response to %s, dt: %.3f s",
24801                    !defined $smtp_resp ? 'No' : 'Empty',  $what_cmd,
24802                    time - $smtp_handle->last_io_event_tx_timestamp);
24803      } elsif ($smtp_resp !~ /^2/) {
24804        die "Negative SMTP response to $what_cmd: $smtp_resp";
24805      } else {  # success, $smtp_resp =~ /^2/
24806        do_log(3,"smtp resp to %s: %s", $what_cmd,$smtp_resp);
24807        $smtp_session->transaction_ends  if $what_cmd eq 'RSET';
24808      }
24809    } else {  # get response(s) to data-dot
24810      # replace success responses to RCPT TO commands with a final response
24811      my $first = 1;  my $anyfail = 0;  my $anysucc = 0;
24812      for my $r (@per_recip_data_rcpt_sent) {  # only for those actually sent
24813        if ($lmtp || $first) {
24814          $first = 0;  my $raddr = qquote_rfc2821_local($r->recip_final_addr);
24815          $raddr .= ', etc.'  if !$lmtp && @per_recip_data > 1;
24816          $smtp_resp = $smtp_session->smtp_response;  # resp to data-dot
24817          if (!defined $smtp_resp || $smtp_resp eq '') {
24818            $anyfail = 1;
24819            do_log(0,"%s SMTP response to %s (%s), dt: %.3f s",
24820                     !defined $smtp_resp ? 'No' : 'Empty', $what_cmd, $raddr,
24821                     time - $smtp_handle->last_io_event_tx_timestamp);
24822          } elsif ($smtp_resp !~ /^2/) {
24823            $anyfail = 1;
24824            do_log(0,"Negative SMTP response to %s (%s): %s, dt: %.1f ms",
24825                     $what_cmd, $raddr, $smtp_resp,
24826                     1000*(time-$smtp_handle->last_io_event_tx_timestamp));
24827          } else {  # success, $smtp_resp =~ /^2/
24828            $anysucc = 1;
24829            ll(3) && do_log(3,"smtp resp to %s (%s): %s, dt: %.1f ms",
24830                       $what_cmd, $raddr, $smtp_resp,
24831                       1000*(time-$smtp_handle->last_io_event_tx_timestamp));
24832          }
24833        }
24834        next  if $r->recip_done;  # skip those that failed at earlier stages
24835        $r->recip_remote_mta($relayhost);
24836        $r->recip_remote_mta_smtp_response($smtp_resp);
24837        my $smtp_resp_ext = enhance_smtp_response($smtp_resp,$am_id,$mta_id,
24838                                                  '.6.0','data-dot');
24839        $smtp_response = $smtp_resp_ext  if !defined $smtp_response;
24840        $r->recip_smtp_response($smtp_resp_ext); $r->recip_done(2);
24841        $r->recip_mbxname($r->recip_final_addr)  if $smtp_resp =~ /^2/;
24842      }
24843      if ($first) {  # fetch an uncollected response
24844        # fetch unprocessed response if all recipients were rejected
24845        # but we nevertheless somehow entered a data transfer mode
24846        # (i.e. if an SMTP server failed to reject a DATA command).
24847        # RFC 2033: when there have been no successful RCPT commands in the
24848        # mail transaction, the DATA command MUST fail with a 503 reply code
24849        $smtp_resp = $smtp_session->smtp_response;  # resp to data-dot
24850        $smtp_resp = ''  if !defined $smtp_resp;
24851        if ($smtp_resp =~ /^2/) { $anysucc = 1 } else { $anyfail = 1 }
24852        do_log(3,"smtp resp to _dummy_ data %s: %s", $what_cmd,$smtp_resp);
24853      }
24854      if ($anysucc && !$anyfail) {
24855        # we are certain all went fine and a transaction is definitely over
24856        $smtp_session->transaction_ends;
24857      }
24858    }
24859#   if ($pipelining) {}     # QUIT was already sent
24860#   elsif (!$smtp_connection_cache_enable)  {
24861#     $smtp_session->quit;  #flush!   QUIT
24862#     # can't be sure until we see a response, consider uncertain just in case
24863#     $smtp_session->transaction_ends_unconfirmed;
24864#   }
24865#   if ($smtp_session->session_state eq 'quitsent') {
24866#     $smtp_session->timeout($smtp_quit_timeout);
24867#     $smtp_resp = $smtp_session->smtp_response;
24868#     $smtp_resp = ''  if !defined $smtp_resp;
24869#     do_log(3,"smtp resp to QUIT: %s", $smtp_resp);
24870#     if ($smtp_resp =~ /^2/) {
24871#       $smtp_session->transaction_ends;
24872#     } else {
24873#       $smtp_session->transaction_ends_unconfirmed;
24874#       do_log(0,"Negative SMTP resp. to QUIT: %s", $smtp_resp);
24875#     }
24876#   }
24877    my $keep_session = $smtp_session->session_state ne 'quitsent';
24878    if ($keep_session && !defined($smtp_session->in_smtp_transaction)) {
24879      do_log(2,"SMTP transaction state uncertain, closing just in case");
24880      $keep_session = 0;
24881    }
24882    $smtp_session->close($keep_session)
24883      or die "Error closing Amavis::Out::SMTP::Session";
24884    undef $smtp_handle; undef $smtp_session;
24885    1;
24886    # some unusual error conditions _are_ captured by eval, but fail to set $@
24887  } or do { $err = $@ ne '' ? $@ : "errno=$!"; chomp $err };
24888  my $saved_section_name = $which_section;
24889  $which_section = 'fwd-end-chkpnt';
24890  do_log(2,"mail_via_smtp: session failed: %s", $err)  if defined $err;
24891  prolong_timer($which_section);  # restart timer
24892  # terminate the SMTP session if still alive
24893  if (!defined($smtp_session)) {
24894    # already closed normally
24895  } elsif ($in_datasend_mode) {
24896    # We are aborting SMTP session. Data transfer mode must NOT be terminated
24897    # with a dataend (dot), otherwise recipient will receive a chopped-off mail
24898    # (and possibly be receiving it over and over again during each MTA retry.
24899    do_log(-1, "mail_via_smtp: NOTICE: aborting SMTP session, %s", $err);
24900    $smtp_session->close(0);  # abruptly terminate SMTP session, ignore status
24901  } else {
24902    do_log(5,"smtp session done, sending QUIT");
24903    eval {
24904      $smtp_session->timeout(1);    # don't wait for too long
24905      $smtp_session->quit;  #flush! # send a QUIT regardless of success so far
24906      $smtp_session->transaction_ends_unconfirmed;
24907      for (my $cnt=0; ; $cnt++) {  # curious if there are any pending responses
24908        my $smtp_resp = $smtp_session->smtp_response;
24909        last  if !defined $smtp_resp;
24910        do_log(0,"discarding unprocessed reply: %s", $smtp_resp);
24911        if ($cnt > 20) { do_log(-1,"aborting, discarding many replies"); last }
24912      }
24913    } or do {
24914      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
24915      do_log(-1, "mail_via_smtp: error during QUIT: %s", $eval_stat);
24916    };
24917    $smtp_session->close(0);  # terminate SMTP session, ignore status
24918  }
24919  undef $smtp_handle; undef $smtp_session;
24920  # prepare final smtp response and log abnormal events
24921  for my $r (@per_recip_data) {
24922    my $resp = $r->recip_smtp_response;
24923    $smtp_response = $resp  if !defined($smtp_response) ||
24924                               $resp =~ /^4/ && $smtp_response !~ /^4/ ||
24925                               $resp !~ /^2/ && $smtp_response !~ /^[45]/;
24926  }
24927  if (!defined $err) {
24928    # no errors
24929  } elsif ($err =~ /^timed out\b/ || $err =~ /: Timeout\z/) {
24930    $smtp_response = sprintf("450 4.4.2 Timed out during %s, MTA(%s), id=%s",
24931                             $saved_section_name, $mta_id, $am_id);
24932  } elsif ($err =~ /^Can't connect\b/) {
24933    $smtp_response = sprintf("450 4.4.1 %s, MTA(%s), id=%s",
24934                             $err, $mta_id, $am_id);
24935  } elsif ($err =~ /^Too many hops\b/) {
24936    $smtp_response = sprintf("554 5.4.6 Reject: %s, id=%s", $err, $am_id);
24937  } else {
24938    $smtp_response = sprintf("451 4.5.0 From MTA(%s) during %s (%s): id=%s",
24939                             $mta_id, $saved_section_name, $err, $am_id);
24940  }
24941  # NOTE: $initial_submission argument is typically treated as a boolean
24942  # but a value of 'AV' is supplied by av_smtp_client to allow a forwarding
24943  # method to distinguish it from ordinary submissions
24944  my $ll = ($smtp_response =~ /^2/ || $initial_submission eq 'AV') ? 1 : -1;
24945  ll($ll) && do_log($ll, "%s from %s -> %s, %s %s",
24946          $logmsg, $sender_smtp,
24947          join(',', qquote_rfc2821_local(
24948                      map($_->recip_final_addr, @per_recip_data))),
24949          join(' ', map { my $v=$from_options{$_}; defined($v)?"$_=$v":"$_" }
24950                        (keys %from_options)),
24951          $smtp_response);
24952  if (defined $smtp_response) {
24953    $msginfo->dsn_passed_on($dsn_capable && $smtp_response=~/^2/ &&
24954                            !c('terminate_dsn_on_notify_success') ? 1 : 0);
24955    for my $r (@per_recip_data) {
24956      # attach an SMTP response to each recip that was not already processed
24957      if (!$r->recip_done) {  # mark it as done
24958        $r->recip_smtp_response($smtp_response); $r->recip_done(2);
24959        $r->recip_mbxname($r->recip_final_addr)  if $smtp_response =~ /^2/;
24960      } elsif ($any_valid_recips_and_data_sent &&
24961               $r->recip_smtp_response =~ /^452/) {
24962        # 'undo' the RCPT TO '452 Too many recipients' situation,
24963        # mail needs to be transferred in more than one transaction
24964        $r->recip_smtp_response(undef); $r->recip_done(undef);
24965      }
24966    }
24967    if ($smtp_response =~ /^2/) {
24968      snmp_count('OutMsgsDelivers');
24969      my $size = $msginfo->msg_size;
24970      snmp_count( ['OutMsgsSize'.$_, $size, 'C64'] )  for @snmp_vars;
24971    } elsif ($smtp_response =~ /^4/) {
24972      snmp_count('OutMsgsAttemptFails');
24973    } elsif ($smtp_response =~ /^5/) {
24974      snmp_count('OutMsgsRejects');
24975    }
24976  }
24977  section_time($which_section);
24978  die $err  if defined($err) && $err =~ /^timed out\b/;  # resignal timeout
24979  1;
24980}
24981
249821;
24983
24984__DATA__
24985#
24986package Amavis::Out::Pipe;
24987use strict;
24988use re 'taint';
24989use warnings;
24990use warnings FATAL => qw(utf8 void);
24991no warnings 'uninitialized';
24992# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
24993
24994BEGIN {
24995  require Exporter;
24996  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
24997  $VERSION = '2.412';
24998  @ISA = qw(Exporter);
24999  @EXPORT = qw(&mail_via_pipe);
25000  import Amavis::Conf qw(:platform c cr ca);
25001  import Amavis::Util qw(untaint min max minmax ll do_log snmp_count
25002                         collect_equal_delivery_recips);
25003  import Amavis::ProcControl qw(exit_status_str proc_status_ok kill_proc
25004                         run_command_consumer);
25005  import Amavis::Timing qw(section_time);
25006  import Amavis::rfc2821_2822_Tools;
25007  import Amavis::Out::EditHeader;
25008}
25009
25010use Errno qw(ENOENT EACCES ESRCH);
25011use POSIX qw(WIFEXITED WIFSIGNALED WIFSTOPPED
25012             WEXITSTATUS WTERMSIG WSTOPSIG);
25013
25014# Send mail using external mail submission program 'sendmail' or its lookalike
25015# (also available with Postfix and Exim) - used for forwarding original mail
25016# or sending notifications or quarantining. May throw exception (die) on
25017# temporary failure (4xx) or other problem.
25018#
25019sub mail_via_pipe(@) {
25020  my($msginfo, $initial_submission, $dsn_per_recip_capable, $filter) = @_;
25021  my(@snmp_vars) = !$initial_submission ?
25022    ('', 'Relay',  'ProtoPipe', 'ProtoPipeRelay')
25023  : ('', 'Submit', 'ProtoPipe', 'ProtoPipeSubmit',
25024     'Submit'.$initial_submission);
25025  snmp_count('OutMsgs'.$_)  for @snmp_vars;
25026  my $id = $msginfo->parent_mail_id;
25027  $id = $msginfo->mail_id . (defined $id ? "($id)" : "");
25028  my $logmsg = sprintf("%s %s via PIPE from %s", $id,
25029                       ($initial_submission ? 'SEND' : 'FWD'),
25030                       $msginfo->sender_smtp);
25031  my($per_recip_data_ref, $proto_sockname) =
25032    collect_equal_delivery_recips($msginfo, $filter, qr/^pipe:/i);
25033  if (!$per_recip_data_ref || !@$per_recip_data_ref) {
25034    do_log(5, "%s, nothing to do", $logmsg);  return 1;
25035  }
25036  $proto_sockname = $proto_sockname->[0]  if ref $proto_sockname;
25037  ll(1) && do_log(1, "delivering to %s, %s -> %s",
25038                     $proto_sockname, $logmsg,
25039                     join(',', qquote_rfc2821_local(
25040                           map($_->recip_final_addr, @$per_recip_data_ref)) ));
25041  # just use the first one, ignoring failover alternatives
25042  local($1);
25043  $proto_sockname =~ /^pipe:(.*)\z/si
25044    or die "Bad fwd method syntax: ".$proto_sockname;
25045  my $pipe_args = $1;
25046  $pipe_args =~ s/^flags=\S*\s*//i;  # flags are currently ignored, q implied
25047  $pipe_args =~ s/^argv=//i;
25048  my(@pipe_args) = split(' ',$pipe_args);  my(@command) = shift(@pipe_args);
25049  my $dsn_capable = c('propagate_dsn_if_possible');  # assume, unless disabled
25050  $dsn_capable = 0  if $command[0] !~ /sendmail/;  # a hack, don't use -N or -V
25051  if ($dsn_capable) {    # DSN is supported since Postfix 2.3
25052    # notify options are per-recipient, yet a command option -N applies to all
25053    my $common_list; my $not_all_the_same = 0;
25054    for my $r (@{$msginfo->per_recip_data}) {
25055      my $dsn_notify = $r->dsn_notify;
25056      my $d;
25057      if ($msginfo->sender eq '') {
25058        $d = 'NEVER';
25059      } elsif (!$dsn_notify) {
25060        $d = 'DELAY,FAILURE';  # sorted
25061      } else {
25062        $d = uc(join(',', sort @$dsn_notify));  # normalize order
25063      }
25064      if (!defined($common_list)) { $common_list = $d }
25065      elsif ($d ne $common_list) { $not_all_the_same = 1 }
25066    }
25067    if ($common_list=~/\bSUCCESS\b/ && c('terminate_dsn_on_notify_success')) {
25068      # strip out option SUCCESS, we want to handle it locally
25069      my(@dsn_notify) = grep($_ ne 'SUCCESS', split(/,/,$common_list));
25070      @dsn_notify = ('NEVER')  if !@dsn_notify;
25071      $common_list = join(',',@dsn_notify);
25072      do_log(3,"stripped out SUCCESS, result: NOTIFY=%s", $common_list);
25073    }
25074    if ($not_all_the_same || $msginfo->sender eq '') {}  # leave at default
25075    elsif ($common_list eq 'DELAY,FAILURE') {}           # leave at default
25076    else { unshift(@pipe_args, '-N', $common_list) }
25077    unshift(@pipe_args,
25078            '-V', $msginfo->dsn_envid)  if defined $msginfo->dsn_envid;
25079    # but there is no mechanism to specify ORCPT or RET
25080  }
25081  for (@pipe_args) {
25082    # The sendmail command line expects addresses quoted as per RFC 822.
25083    #   "funny user"@some.domain
25084    # For compatibility with Sendmail, the Postfix sendmail command line also
25085    # accepts address formats that are legal in RFC 822 mail header section:
25086    #   Funny Dude <"funny user"@some.domain>
25087    # Although addresses passed as args to sendmail submission program
25088    # should not be <...> bracketed, for some reason original sendmail
25089    # issues a warning on null reverse-path, but gladly accepts <>.
25090    # As this is not strictly wrong, we comply to make it happy.
25091    # NOTE: the -fsender is not allowed, -f and sender must be separate args!
25092    my $null_ret_path = '<>';  # some sendmail lookalikes want '<>', others ''
25093    # Courier sendmail accepts '' but not '<>' for null reverse path
25094    $null_ret_path = ''  if $Amavis::extra_code_in_courier;
25095    if (/^\$\{sender\}\z/i) {
25096      push(@command, $msginfo->sender eq '' ? $null_ret_path
25097       : do { my $s = $msginfo->sender_smtp;
25098              $s =~ s/^<//; $s =~ s/>\z//; untaint($s) });
25099    } elsif (/^\$\{recipient\}\z/i) {
25100      push(@command,
25101           map { $_ eq '' ? $null_ret_path : untaint(quote_rfc2821_local($_)) }
25102           map($_->recip_final_addr, @$per_recip_data_ref));
25103    } else {
25104      push(@command, $_);
25105    }
25106  }
25107  ll(5) && do_log(5, "mail_via_pipe running command: %s", join(' ',@command));
25108  local $SIG{CHLD} = 'DEFAULT';
25109  local $SIG{PIPE} = 'IGNORE';  # don't signal on a write to a widowed pipe
25110  my($proc_fh,$pid) = run_command_consumer(undef,undef,@command);
25111  my $hdr_edits = $msginfo->header_edits;
25112  $hdr_edits = Amavis::Out::EditHeader->new  if !$hdr_edits;
25113  my($received_cnt,$file_position) =
25114    $hdr_edits->write_header($msginfo,$proc_fh,!$initial_submission);
25115  my $msg = $msginfo->mail_text;
25116  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
25117  $msg = $msg_str_ref  if ref $msg_str_ref;
25118  if ($received_cnt > 100) {  # loop detection required by RFC 5321 section 6.3
25119                              # deal with it later, for now just skip the body
25120  } elsif (!defined $msg) {
25121    # empty mail
25122  } elsif (ref $msg eq 'SCALAR') {
25123    # do it in chunks, saves memory, cache friendly
25124    while ($file_position < length($$msg)) {
25125      $proc_fh->print(substr($$msg,$file_position,16384))
25126        or die "writing mail text to a pipe failed: $!";
25127      $file_position += 16384;  # may overshoot, no problem
25128    }
25129  } elsif ($msg->isa('MIME::Entity')) {
25130    $msg->print_body($proc_fh);
25131  } else {
25132    my($nbytes,$buff);
25133    while (($nbytes = $msg->read($buff,32768)) > 0) {
25134      $proc_fh->print($buff)
25135        or die "writing mail text to a pipe failed: $!";
25136    }
25137    defined $nbytes or die "Error reading: $!";
25138  }
25139  $proc_fh->flush or die "Can't flush pipe to a mail submission program: $!";
25140  my $smtp_response;
25141  if ($received_cnt > 100) { # loop detection required by RFC 5321 section 6.3
25142    do_log(-2, "Too many hops: %d 'Received:' header fields", $received_cnt);
25143    kill_proc($pid,$command[0],10,$proc_fh,'too many hops')  if defined $pid;
25144    $proc_fh->close; undef $proc_fh; undef $pid;  # and ignore status
25145    $smtp_response = "554 5.4.6 Reject: " .
25146                     "Too many hops: $received_cnt 'Received:' header fields";
25147  } else {
25148    my $err = 0; $proc_fh->close or $err=$!;
25149    my $child_stat = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
25150    undef $proc_fh; undef $pid;
25151    # sendmail program (Postfix variant) can return the following exit codes:
25152    # EX_OK(0), EX_DATAERR, EX_SOFTWARE, EX_TEMPFAIL, EX_NOUSER, EX_UNAVAILABLE
25153    if (proc_status_ok($child_stat,$err, EX_OK)) {
25154      $smtp_response = "250 2.6.0 Ok";  # submitted to MTA
25155      snmp_count('OutMsgsDelivers');
25156      my $size = $msginfo->msg_size;
25157      snmp_count( ['OutMsgsSize'.$_, $size, 'C64'] )  for @snmp_vars;
25158    } elsif (proc_status_ok($child_stat,$err, EX_TEMPFAIL)) {
25159      $smtp_response = "450 4.5.0 Temporary failure submitting message";
25160      snmp_count('OutMsgsAttemptFails');
25161    } elsif (proc_status_ok($child_stat,$err, EX_NOUSER)) {
25162      $smtp_response = "554 5.1.1 Recipient unknown";
25163      snmp_count('OutMsgsRejects');
25164    } elsif (proc_status_ok($child_stat,$err, EX_UNAVAILABLE)) {
25165      $smtp_response = "554 5.5.0 Mail submission service unavailable";
25166      snmp_count('OutMsgsRejects');
25167    } else {
25168      $smtp_response = "451 4.5.0 Failed to submit a message: ".
25169                       exit_status_str($child_stat,$err);
25170      snmp_count('OutMsgsAttemptFails');
25171    }
25172    ll(3) && do_log(3,"mail_via_pipe %s, %s, %s", $command[0],
25173                      exit_status_str($child_stat,$err), $smtp_response);
25174  }
25175  $smtp_response .= ", id=" . $msginfo->log_id;
25176  for my $r (@$per_recip_data_ref) {
25177    next  if $r->recip_done;
25178    $r->recip_smtp_response($smtp_response); $r->recip_done(2);
25179    $r->recip_mbxname($r->recip_final_addr)  if $smtp_response =~ /^2/;
25180  }
25181  $msginfo->dsn_passed_on($dsn_capable && $smtp_response=~/^2/ &&
25182                          !c('terminate_dsn_on_notify_success') ? 1 : 0);
25183  section_time('fwd-pipe');
25184  1;
25185}
25186
251871;
25188
25189__DATA__
25190#
25191package Amavis::Out::BSMTP;
25192use strict;
25193use re 'taint';
25194use warnings;
25195use warnings FATAL => qw(utf8 void);
25196no warnings 'uninitialized';
25197# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
25198
25199BEGIN {
25200  require Exporter;
25201  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
25202  $VERSION = '2.412';
25203  @ISA = qw(Exporter);
25204  @EXPORT = qw(&mail_via_bsmtp);
25205  import Amavis::Conf qw(:platform $QUARANTINEDIR c cr ca);
25206  import Amavis::Util qw(untaint min max minmax ll do_log snmp_count
25207                         idn_to_ascii collect_equal_delivery_recips);
25208  import Amavis::Timing qw(section_time);
25209  import Amavis::rfc2821_2822_Tools;
25210  import Amavis::Out::EditHeader;
25211}
25212
25213use Errno qw(ENOENT EACCES);
25214use IO::File qw(O_CREAT O_EXCL O_WRONLY);
25215
25216# store message in a BSMTP format
25217#
25218# RFC 2442: Application/batch-SMTP material is generated by a specially
25219# modified SMTP client operating without a corresponding SMTP server.
25220# The client simply assumes a successful response to all commands it issues.
25221# The resulting content then consists of the collected output from the SMTP
25222# client.
25223#
25224sub mail_via_bsmtp(@) {
25225  my($msginfo, $initial_submission, $dsn_per_recip_capable, $filter) = @_;
25226  my(@snmp_vars) = !$initial_submission ?
25227    ('', 'Relay',  'ProtoBSMTP', 'ProtoBSMTPRelay')
25228  : ('', 'Submit', 'ProtoBSMTP', 'ProtoBSMTPSubmit',
25229     'Submit'.$initial_submission);
25230  snmp_count('OutMsgs'.$_)  for @snmp_vars;
25231  my $logmsg = sprintf("%s via BSMTP: %s", ($initial_submission?'SEND':'FWD'),
25232                       $msginfo->sender_smtp);
25233  my($per_recip_data_ref, $proto_sockname) =
25234    collect_equal_delivery_recips($msginfo, $filter, qr/^bsmtp:/i);
25235  if (!$per_recip_data_ref || !@$per_recip_data_ref) {
25236    do_log(5, "%s, nothing to do", $logmsg);  return 1;
25237  }
25238  $proto_sockname = $proto_sockname->[0]  if ref $proto_sockname;
25239  ll(1) && do_log(1, "delivering to %s, %s -> %s",
25240                     $proto_sockname, $logmsg,
25241                     join(',', qquote_rfc2821_local(
25242                           map($_->recip_final_addr, @$per_recip_data_ref)) ));
25243  # just use the first one, ignoring failover alternatives
25244  local($1);
25245  $proto_sockname =~ /^bsmtp:(.*)\z/si
25246    or die "Bad fwd method syntax: ".$proto_sockname;
25247  my $bsmtp_file_final = $1; my $mbxname;
25248  my $s = $msginfo->sender;  # sanitized sender name for use in a filename
25249  $s =~ tr/a-zA-Z0-9@._+-/=/c;
25250  substr($s,100) = '...'  if length($s) > 100+3;
25251  $s =~ s/\@/_at_/g; $s =~ s/^(\.{0,2})\z/_$1/;
25252  $bsmtp_file_final =~ s{%(.)}
25253    {  $1 eq 'b' ? $msginfo->body_digest
25254     : $1 eq 'P' ? $msginfo->partition_tag
25255     : $1 eq 'm' ? $msginfo->mail_id||''
25256     : $1 eq 'n' ? $msginfo->log_id
25257     : $1 eq 's' ? untaint($s)  # a hack, avoid using %s
25258     : $1 eq 'i' ? iso8601_timestamp($msginfo->rx_time,1)  #,'-')
25259     : $1 eq '%' ? '%' : '%'.$1 }gse;
25260  # prepend directory if not specified
25261  my $bsmtp_file_final_to_show = $bsmtp_file_final;
25262  $bsmtp_file_final = $QUARANTINEDIR."/".$bsmtp_file_final
25263    if $QUARANTINEDIR ne '' && $bsmtp_file_final !~ m{^/};
25264  my $bsmtp_file_tmp = $bsmtp_file_final . ".tmp";
25265  my $mp; my $err;
25266  eval {
25267    my $errn = lstat($bsmtp_file_tmp) ? 0 : 0+$!;
25268    if ($errn == ENOENT) {}   # good, no file, as expected
25269    elsif ($errn==0 && (-f _ || -l _))
25270      { die "File $bsmtp_file_tmp already exists, refuse to overwrite" }
25271    else
25272      { die "File $bsmtp_file_tmp exists??? Refuse to overwrite it, $!" }
25273    $mp = IO::File->new;
25274    # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
25275    $mp->open($bsmtp_file_tmp, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)
25276      or die "Can't create BSMTP file $bsmtp_file_tmp: $!";
25277    binmode($mp,':bytes') or die "Can't set :bytes, $!";
25278
25279#   RFC 2442: Since no SMTP server is present the client must be prepared
25280#   to make certain assumptions about which SMTP extensions can be used.
25281#   The generator MAY assume that ESMTP [RFC 1869 (obsoleted by RFC 5321)]
25282#   facilities are available, that is, it is acceptable to use the EHLO
25283#   command and additional parameters on MAIL FROM and RCPT TO.  If EHLO
25284#   is used MAY assume that the 8bitMIME [RFC 6152], SIZE [RFC 1870], and
25285#   NOTARY [RFC 1891] extensions are available. In particular, NOTARY
25286#   SHOULD be used. (nowadays called DSN)
25287
25288    my $myheloname = c('localhost_name');  # host name used in EHLO/HELO/LHLO
25289    $myheloname = 'localhost'  if $myheloname eq '';
25290    $myheloname = idn_to_ascii($myheloname);
25291    $mp->printf("EHLO %s\n", $myheloname)  or die "print failed (EHLO): $!";
25292    my $btype = $msginfo->body_type;  # RFC 6152: need "8bit Data"? (RFC 2045)
25293    $btype = ''  if !defined $btype;
25294    my $dsn_envid = $msginfo->dsn_envid; my $dsn_ret = $msginfo->dsn_ret;
25295    $mp->printf("MAIL FROM:%s\n", join(' ',
25296                          $msginfo->sender_smtp,
25297                          $btype ne ''       ? ('BODY='.uc($btype))  : (),
25298                          defined $dsn_ret   ? ('RET='.$dsn_ret)     : (),
25299                          defined $dsn_envid ? ('ENVID='.$dsn_envid) : () ),
25300                ) or die "print failed (MAIL FROM): $!";
25301    for my $r (@$per_recip_data_ref) {
25302      my(@dsn_notify);  # implies a default when the list is empty
25303      my $dn = $r->dsn_notify;
25304      @dsn_notify = @$dn  if $dn && $msginfo->sender ne '';  # if nondefault
25305      if (@dsn_notify && c('terminate_dsn_on_notify_success')) {
25306        # we want to handle option SUCCESS locally
25307        if (grep($_ eq 'SUCCESS', @dsn_notify)) {  # strip out SUCCESS
25308          @dsn_notify = grep($_ ne 'SUCCESS', @dsn_notify);
25309          @dsn_notify = ('NEVER')  if !@dsn_notify;
25310          do_log(3,"stripped out SUCCESS, result: NOTIFY=%s",
25311                   join(',',@dsn_notify));
25312        }
25313      }
25314      $mp->printf("RCPT TO:%s\n", join(' ',
25315                       qquote_rfc2821_local($r->recip_final_addr),
25316                       @dsn_notify ? ('NOTIFY='.join(',',@dsn_notify))  : (),
25317                       defined $r->dsn_orcpt ? ('ORCPT='.$r->dsn_orcpt) : () ),
25318                  ) or die "print failed (RCPT TO): $!";
25319    }
25320    $mp->print("DATA\n") or die "print failed (DATA): $!";
25321    my $hdr_edits = $msginfo->header_edits;
25322    $hdr_edits = Amavis::Out::EditHeader->new  if !$hdr_edits;
25323    my($received_cnt,$file_position) =
25324      $hdr_edits->write_header($msginfo,$mp,!$initial_submission);
25325    my $msg = $msginfo->mail_text;
25326    my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
25327    $msg = $msg_str_ref  if ref $msg_str_ref;
25328    if ($received_cnt > 100) {  # loop detection required by RFC 5321 sect. 6.3
25329      die "Too many hops: $received_cnt 'Received:' header fields";
25330    } elsif (!defined $msg) {
25331      # empty mail
25332    } elsif (ref $msg eq 'SCALAR') {
25333      my $buff = substr($$msg,$file_position);
25334      $buff =~ s/^\./../gm;
25335      $mp->print($buff)  or die "print failed - data: $!";
25336    } elsif ($msg->isa('MIME::Entity')) {
25337      $msg->print_body($mp);
25338    } else {
25339      my $ln;
25340      for ($! = 0; defined($ln=$msg->getline); $! = 0) {
25341        $mp->print($ln=~/^\./ ? (".",$ln) : $ln)
25342          or die "print failed - data: $!";
25343      }
25344      defined $ln || $! == 0  or die "Error reading: $!";
25345    }
25346    $mp->print(".\n")    or die "print failed (final dot): $!";
25347  # $mp->print("QUIT\n") or die "print failed (QUIT): $!";
25348    $mp->close or die "Error closing BSMTP file $bsmtp_file_tmp: $!";
25349    undef $mp;
25350    rename($bsmtp_file_tmp, $bsmtp_file_final)
25351      or die "Can't rename BSMTP file to $bsmtp_file_final: $!";
25352    $mbxname = $bsmtp_file_final;
25353    1;
25354  } or do { $err = $@ ne '' ? $@ : "errno=$!" };
25355  my $smtp_response;
25356  if ($err eq '') {
25357    $smtp_response = "250 2.6.0 Ok, queued as BSMTP $bsmtp_file_final_to_show";
25358    snmp_count('OutMsgsDelivers');
25359    my $size = $msginfo->msg_size;
25360    snmp_count( ['OutMsgsSize'.$_, $size, 'C64'] )  for @snmp_vars;
25361  } else {
25362    chomp $err;
25363    unlink($bsmtp_file_tmp)
25364      or do_log(-2,"Can't delete half-finished BSMTP file %s: %s",
25365                   $bsmtp_file_tmp, $!);
25366    $mp->close  if defined $mp;  # ignore status
25367    if ($err =~ /too many hops\b/i) {
25368      $smtp_response = "554 5.4.6 Reject: $err";
25369      snmp_count('OutMsgsRejects');
25370    } else {
25371      $smtp_response = "451 4.5.0 Writing $bsmtp_file_tmp failed: $err";
25372      snmp_count('OutMsgsAttemptFails');
25373    }
25374    die $err  if $err =~ /^timed out\b/;  # resignal timeout
25375  }
25376  $smtp_response .= ", id=" . $msginfo->log_id;
25377  $msginfo->dsn_passed_on($smtp_response=~/^2/ &&
25378                          !c('terminate_dsn_on_notify_success') ? 1 : 0);
25379  for my $r (@$per_recip_data_ref) {
25380    next  if $r->recip_done;
25381    $r->recip_smtp_response($smtp_response); $r->recip_done(2);
25382    $r->recip_mbxname($mbxname)  if $mbxname ne '' && $smtp_response =~ /^2/;
25383  }
25384  section_time('fwd-bsmtp');
25385  1;
25386}
25387
253881;
25389
25390__DATA__
25391#
25392package Amavis::Out::Local;
25393use strict;
25394use re 'taint';
25395use warnings;
25396use warnings FATAL => qw(utf8 void);
25397no warnings 'uninitialized';
25398# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
25399
25400BEGIN {
25401  require Exporter;
25402  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
25403  $VERSION = '2.412';
25404  @ISA = qw(Exporter);
25405  @EXPORT_OK = qw(&mail_to_local_mailbox);
25406  import Amavis::Conf qw(:platform c cr ca
25407                         $QUARANTINEDIR $quarantine_subdir_levels);
25408  import Amavis::Util qw(snmp_count ll do_log untaint unique_list
25409                         collect_equal_delivery_recips);
25410  import Amavis::Timing qw(section_time);
25411  import Amavis::rfc2821_2822_Tools;
25412  import Amavis::Out::EditHeader;
25413}
25414
25415use Errno qw(ENOENT EACCES);
25416use Fcntl qw(:flock);
25417#use File::Spec;
25418use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
25419
25420use subs @EXPORT_OK;
25421
25422# Deliver to local mailboxes only, ignore the rest: either to directory
25423# (maildir style), or file (Unix mbox).  (normally used as a quarantine method)
25424#
25425sub mail_to_local_mailbox(@) {
25426  my($msginfo, $initial_submission, $dsn_per_recip_capable, $filter) = @_;
25427
25428  # note that recipients of a message being delivered to a quarantine
25429  # are typically not the original envelope recipients, but are pseudo
25430  # address provided to do_quarantine() based on @quarantine_to_maps;
25431  # nevertheless, we do the usual collect_equal_delivery_recips() ritual
25432  # here too for consistency
25433  #
25434  my $logmsg = sprintf("%s via LOCAL: %s", ($initial_submission?'SEND':'FWD'),
25435                       $msginfo->sender_smtp);
25436  my($per_recip_data_ref, $proto_sockname) =
25437    collect_equal_delivery_recips($msginfo, $filter, qr/^local:/i);
25438  if (!$per_recip_data_ref || !@$per_recip_data_ref) {
25439    do_log(5, "%s, nothing to do", $logmsg);  return 1;
25440  }
25441  my(@per_recip_data) = @$per_recip_data_ref;  undef $per_recip_data_ref;
25442  $proto_sockname = $proto_sockname->[0]  if ref $proto_sockname;
25443  ll(4) && do_log(4, "delivering to %s, %s -> %s",
25444                     $proto_sockname, $logmsg,
25445                     join(',', qquote_rfc2821_local(
25446                           map($_->recip_final_addr, @per_recip_data)) ));
25447  # just use the first one, ignoring failover alternatives
25448  local($1);
25449  $proto_sockname =~ /^local:(.*)\z/si
25450    or die "Bad local method syntax: ".$proto_sockname;
25451  my $via_arg = $1;
25452  my(@snmp_vars) = !$initial_submission ?
25453    ('', 'Relay', 'ProtoLocal', 'ProtoLocalRelay')
25454  : ('', 'Submit','ProtoLocal', 'ProtoLocalSubmit',
25455     'Submit'.$initial_submission);
25456  snmp_count('OutMsgs'.$_)  for @snmp_vars;
25457  my $sender = $msginfo->sender;
25458  for my $r (@per_recip_data) {  # determine a mailbox file for each recipient
25459    # each recipient gets his own copy; these are not the original message's
25460    # recipients but are mailbox addresses, typically telling where a message
25461    # to be quarantined is to be stored
25462    my $recip = $r->recip_final_addr;
25463
25464    # %local_delivery_aliases emulates aliases map - this would otherwise
25465    # be done by MTA's local delivery agent if we gave the message to MTA.
25466    # This way we keep interface compatible with other mail delivery
25467    # methods. The hash value may be a ref to a pair of fixed strings,
25468    # or a subroutine ref (which must return such pair) to allow delayed
25469    # (lazy) evaluation when some part of the pair is not yet known
25470    # at initialization time.
25471    # If no matching entry is found quarantining is skipped.
25472
25473    my($mbxname, $suggested_filename);
25474    my($localpart,$domain) = split_address($recip);
25475    my $ldar = cr('local_delivery_aliases');  # a ref to a hash
25476    my $alias = $ldar->{$localpart};
25477    if (ref($alias) eq 'ARRAY') {
25478      ($mbxname, $suggested_filename) = @$alias;
25479    } elsif (ref($alias) eq 'CODE') {  # lazy (delayed) evaluation
25480      ($mbxname, $suggested_filename) = &$alias;
25481    } elsif ($alias ne '') {
25482      ($mbxname, $suggested_filename) = ($alias, undef);
25483    } elsif (!exists $ldar->{$localpart}) {
25484      do_log(3, "no key '%s' in %s, using a default",
25485                $localpart, '%local_delivery_aliases');
25486      ($mbxname, $suggested_filename) = ($QUARANTINEDIR, undef);
25487    }
25488    if (!defined($mbxname) || $mbxname eq '' || $recip eq '') {
25489      my $why = !exists $ldar->{$localpart} ? 1 : $alias eq '' ? 2 : 3;
25490      do_log(2, "skip local delivery(%s): <%s> -> <%s>", $why,$sender,$recip);
25491      my $smtp_response = "250 2.6.0 Ok, skip local delivery($why)";
25492      $smtp_response .= ", id=" . $msginfo->log_id;
25493      $r->recip_smtp_response($smtp_response); $r->recip_done(2);
25494      next;
25495    }
25496    my $ux;  # is it a UNIX-style mailbox?
25497    my $errn = stat($mbxname) ? 0 : 0+$!;
25498    if ($errn == ENOENT) {
25499      $ux = 1;           # $mbxname is a UNIX-style mailbox (new file)
25500    } elsif ($errn != 0) {
25501      die "Can't access a mailbox file or directory $mbxname: $!";
25502    } elsif (-f _) {
25503      $ux = 1;           # $mbxname is a UNIX-style mailbox (existing file)
25504    } elsif (!-d _) {
25505      die "Mailbox is neither a file nor a directory: $mbxname";
25506    } else {             # a directory
25507      $ux = 0;  # $mbxname is a directory (amavis/maildir style mailbox)
25508      my $explicitly_suggested_filename = $suggested_filename ne '';
25509      if ($suggested_filename eq '')
25510        { $suggested_filename = $via_arg ne '' ? $via_arg : '%m' }
25511      my $mail_id = $msginfo->mail_id;
25512      if (!defined($mail_id)) {
25513        do_log(-1, "mail_to_local_mailbox: mail_id still undefined!?");
25514        $mail_id = '';
25515      }
25516      $suggested_filename =~ s{%(.)}
25517        {  $1 eq 'b' ? $msginfo->body_digest
25518         : $1 eq 'P' ? $msginfo->partition_tag
25519         : $1 eq 'm' ? $mail_id
25520         : $1 eq 'n' ? $msginfo->log_id
25521         : $1 eq 'i' ? iso8601_timestamp($msginfo->rx_time,1)  #,'-')
25522         : $1 eq '%' ? '%' : '%'.$1 }gse;
25523    # $mbxname = File::Spec->catfile($mbxname, $suggested_filename);
25524      $mbxname = "$mbxname/$suggested_filename";
25525      if ($quarantine_subdir_levels>=1 && !$explicitly_suggested_filename) {
25526        # using a subdirectory structure to disperse quarantine files
25527        local($1,$2); my $subdir = substr($mail_id, 0, 1);
25528        $subdir=~/^[A-Z0-9]\z/i or die "Unexpected first char: $subdir";
25529        $mbxname =~ m{^ (.*/)? ([^/]+) \z}xs; my($path,$fname) = ($1,$2);
25530      # $mbxname = File::Spec->catfile($path, $subdir, $fname);
25531        $mbxname = "$path$subdir/$fname";  # resulting full filename
25532        my $errn = stat("$path$subdir") ? 0 : 0+$!;
25533        # only test for ENOENT, other errors will be detected later on access
25534        if ($errn == ENOENT) {  # check/prepare a set of subdirectories
25535          do_log(2, "checking/creating quarantine subdirs under %s", $path);
25536          for my $d ('A'..'Z','a'..'z','0'..'9') {
25537            $errn = stat("$path$d") ? 0 : 0+$!;
25538            if ($errn == ENOENT) {
25539              mkdir("$path$d", 0750) or die "Can't create dir $path$d: $!";
25540            }
25541          }
25542        }
25543      }
25544    }
25545    # save location where mail should be stored, prepend a mailbox style
25546    $r->recip_mbxname(($ux ? 'mbox' : 'maildir') . ':' . $mbxname);
25547  }
25548  #
25549  # now go ahead and store a message to predetermined files in recip_mbxname;
25550  # iterate by groups of recipients with the same mailbox name
25551  #
25552  @per_recip_data = grep(!$_->recip_done, @per_recip_data);
25553  while (@per_recip_data) {
25554    my $mbxname = $per_recip_data[0]->recip_mbxname;  # first mailbox name
25555    # collect all recipient which have the same mailbox file as the first one
25556    my(@recips_with_same_mbx) =
25557                      grep($_->recip_mbxname eq $mbxname, @per_recip_data);
25558    @per_recip_data = grep($_->recip_mbxname ne $mbxname, @per_recip_data);
25559
25560    # retrieve mailbox style and a filename
25561    local($1,$2);  $mbxname =~ /^([^:]*):(.*)\z/;
25562    my $ux = $1 eq 'mbox' ? 1 : 0;  $mbxname = $2;
25563
25564    my(@recips) = map($_->recip_final_addr, @recips_with_same_mbx);
25565    @recips = unique_list(\@recips);
25566    my $smtp_response;
25567    { # a block is used as a 'switch' statement - 'last' will exit from it
25568      do_log(1,"local delivery: %s -> %s, mbx=%s",
25569               $msginfo->sender_smtp, join(", ",@recips), $mbxname);
25570      my $eval_stat; my($mp,$pos);
25571      my $errn = stat($mbxname) ? 0 : 0+$!;
25572      section_time('stat-mbx');
25573      local $SIG{CHLD} = 'DEFAULT';
25574      local $SIG{PIPE} = 'IGNORE';  # don't signal on a write to a widowed pipe
25575      eval {                        # try to open the mailbox file for writing
25576        if (!$ux) {  # one mail per file, will create specified file
25577          if ($errn == ENOENT) {
25578            # good, no file, as expected
25579          } elsif ($errn != 0) {
25580            die "File $mbxname not accessible, refuse to write: $!";
25581          } elsif (!-f _) {
25582            die "File $mbxname is not a regular file, refuse to write";
25583          } else {
25584            die "File $mbxname already exists, refuse to overwrite";
25585          }
25586          if ($mbxname =~ /\.gz\z/) {
25587            $mp = Amavis::IO::Zlib->new; # ?how to request an exclusive access?
25588            $mp->open($mbxname,'wb')
25589              or die "Can't create gzip file $mbxname: $!";
25590          } else {
25591            $mp = IO::File->new;
25592            # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
25593            $mp->open($mbxname, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)
25594              or die "Can't create file $mbxname: $!";
25595            binmode($mp,':bytes') or die "Can't cancel :utf8 mode: $!";
25596          }
25597        } else {  # append to a UNIX-style mailbox
25598                  # deliver only to non-executable regular files
25599          if ($errn == ENOENT) {
25600            # if two processes try creating the same new UNIX-style mailbox
25601            # file at the same time, one will tempfail at this point, with
25602            # its mail delivery to be retried later by MTA
25603            $mp = IO::File->new;
25604            # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
25605            $mp->open($mbxname, untaint(O_CREAT|O_EXCL|O_APPEND|O_WRONLY),0640)
25606              or die "Can't create file $mbxname: $!";
25607          } elsif ($errn==0 && !-f _) {
25608            die "Mailbox $mbxname is not a regular file, refuse to deliver";
25609          } elsif (-x _ || -X _) {
25610            die "Mailbox file $mbxname is executable, refuse to deliver";
25611          } else {
25612            $mp = IO::File->new;
25613            # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
25614            $mp->open($mbxname, untaint(O_APPEND|O_WRONLY), 0640)
25615              or die "Can't append to $mbxname: $!";
25616          }
25617          binmode($mp,':bytes') or die "Can't cancel :utf8 mode: $!";
25618          flock($mp,LOCK_EX) or die "Can't lock mailbox file $mbxname: $!";
25619          $mp->seek(0,2) or die "Can't position mailbox file to its tail: $!";
25620          $pos = $mp->tell;  # remember where we started
25621        }
25622        section_time('open-mbx');
25623        1;
25624      } or do {
25625        $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
25626        $smtp_response =
25627          $eval_stat =~ /^timed out\b/ ? "450 4.4.2" : "451 4.5.0";
25628        $smtp_response .= " Local delivery(1) to $mbxname failed: $eval_stat";
25629        die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
25630      };
25631      last  if defined $eval_stat;  # exit block, not the loop
25632      my $failed = 0;  $eval_stat = undef;
25633      eval {  # if things fail from here on, try to restore mailbox state
25634        if ($ux) {
25635          # a null return path may not appear in the 'From ' delimiter line
25636          my $snd = $sender eq '' ? 'MAILER-DAEMON' # as in sendmail & Postfix
25637                                  : $msginfo->sender_smtp;
25638          # if the envelope sender contains spaces, tabs, or newlines,
25639          # the program (like qmail-local) replaces them with hyphens
25640          $snd =~ s/[ \t\n]/-/sg;
25641          # date/time in asctime (ctime) format, English month names!
25642          # RFC 4155 and qmail-local require UTC time, no timezone name
25643          $mp->printf("From %s %s\n", $snd, scalar gmtime($msginfo->rx_time) )
25644            or die "Can't write mbox separator line to $mbxname: $!";
25645        }
25646        my $hdr_edits = $msginfo->header_edits;
25647        if (!$hdr_edits) {
25648          $hdr_edits = Amavis::Out::EditHeader->new;
25649          $msginfo->header_edits($hdr_edits);
25650        }
25651        $hdr_edits->delete_header('Return-Path');
25652        $hdr_edits->prepend_header('Delivered-To', join(', ',@recips));
25653        $hdr_edits->prepend_header('Return-Path', $msginfo->sender_smtp);
25654        my($received_cnt,$file_position) =
25655          $hdr_edits->write_header($msginfo,$mp,!$initial_submission);
25656        if ($received_cnt > 110) {
25657          # loop detection required by RFC 5321 (ex RFC 2821) section 6.3
25658          # Do not modify the signal text, it gets matched elsewhere!
25659          die "Too many hops: $received_cnt 'Received:' header fields\n";
25660        }
25661        my $msg = $msginfo->mail_text;
25662        my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
25663        $msg = $msg_str_ref  if ref $msg_str_ref;
25664        if (!$ux) {  # do it in blocks for speed if we can
25665          if (!defined $msg) {
25666            # empty mail
25667          } elsif (ref $msg eq 'SCALAR') {
25668            $mp->print(substr($$msg,$file_position))
25669              or die "Can't write to $mbxname: $!";
25670          } elsif ($msg->isa('MIME::Entity')) {
25671            die "quarantining a MIME::Entity object is not implemented";
25672          } else {
25673            my($nbytes,$buff);
25674            while (($nbytes = $msg->read($buff,32768)) > 0) {
25675              $mp->print($buff) or die "Can't write to $mbxname: $!";
25676            }
25677            defined $nbytes or die "Error reading: $!";
25678          }
25679        } else {     # for UNIX-style mailbox file delivery: escape 'From '
25680          # mail(1) and elm(1) recognize /^From / as a message delimiter
25681          # only after a blank line, which is correct. Other MUAs like mutt,
25682          # thunderbird, kmail and pine need all /^From / lines escaped.
25683          # See also http://en.wikipedia.org/wiki/Mbox and RFC 4155.
25684          if (!defined $msg) {
25685            # empty mail
25686          } elsif (ref $msg eq 'SCALAR') {
25687            my $buff = substr($$msg,$file_position);
25688          # $buff =~ s/^From />From /gm;   # mboxo  format
25689            $buff =~ s/^(?=\>*From )/>/gm; # mboxrd format
25690            $mp->print($buff) or die "Can't write to $mbxname: $!";
25691          } elsif ($msg->isa('MIME::Entity')) {
25692            die "quarantining a MIME::Entity object is not implemented";
25693          } else {
25694            my $ln; my $blank_line = 1;
25695            # need to copy line-by-line, slow
25696            for ($! = 0; defined($ln=$msg->getline); $! = 0) {
25697              # see wikipedia and RFC 4155 for "From " escaping conventions
25698              $mp->print('>') or die "Can't write to $mbxname: $!"
25699                if $ln =~ /^(?:>*)From /;        # escape all "From " lines
25700              # if $blank_line && $ln =~ /^(?:>*)From /; # only after blankline
25701              $mp->print($ln) or die "Can't write to $mbxname: $!";
25702              $blank_line = $ln eq "\n";
25703            }
25704            defined $ln || $! == 0  or die "Error reading: $!";
25705          }
25706        }
25707        # must append an empty line for a Unix mailbox format
25708        $mp->print("\n") or die "Can't write to $mbxname: $!"  if $ux;
25709        1;
25710      } or do {  # trouble
25711        $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
25712        if ($ux && defined($pos)) {
25713          $mp->flush or die "Can't flush file $mbxname: $!";
25714          $can_truncate or
25715            do_log(-1, "Truncating a mailbox file will most likely fail");
25716          # try to restore UNIX-style mailbox to previous size;
25717          # Produces a fatal error if truncate isn't implemented on the system
25718          $mp->truncate($pos) or die "Can't truncate file $mbxname: $!";
25719        }
25720        $failed = 1;
25721        die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
25722      };
25723    # if ($ux) {
25724    #   # explicit unlocking is unnecessary, close will do a flush & unlock
25725    #   $mp->flush or die "Can't flush mailbox file $mbxname: $!";
25726    #   flock($mp,LOCK_UN) or die "Can't unlock mailbox $mbxname: $!";
25727    # }
25728      $mp->close or die "Error closing $mbxname: $!";
25729      undef $mp;
25730      if (!$failed) {
25731        $smtp_response = "250 2.6.0 Ok, delivered to $mbxname";
25732        snmp_count('OutMsgsDelivers');
25733        my $size = $msginfo->msg_size;
25734        snmp_count( ['OutMsgsSize'.$_, $size, 'C64'] )  for @snmp_vars;
25735      } elsif ($@ =~ /^timed out\b/) {
25736        $smtp_response = "450 4.4.2 Local delivery to $mbxname timed out";
25737        snmp_count('OutMsgsAttemptFails');
25738      } elsif ($@ =~ /too many hops\b/i) {
25739        $smtp_response = "554 5.4.6 Rejected delivery to mailbox $mbxname: $@";
25740        snmp_count('OutMsgsRejects');
25741      } else {
25742        $smtp_response = "451 4.5.0 Local delivery to mailbox $mbxname ".
25743                         "failed: $@";
25744        snmp_count('OutMsgsAttemptFails');
25745      }
25746    }  # end of block, 'last' within the block brings us here
25747    do_log(-1, "%s", $smtp_response)  if $smtp_response !~ /^2/;
25748    $smtp_response .= ", id=" . $msginfo->log_id;
25749    for my $r (@recips_with_same_mbx) {
25750      $r->recip_smtp_response($smtp_response); $r->recip_done(2);
25751      $r->recip_mbxname($smtp_response =~ /^2/ ? $mbxname : undef);
25752    }
25753  }
25754  section_time('save-to-local-mailbox');
25755}
25756
257571;
25758
25759__DATA__
25760#
25761package Amavis::OS_Fingerprint;
25762use strict;
25763use re 'taint';
25764use warnings;
25765use warnings FATAL => qw(utf8 void);
25766no warnings 'uninitialized';
25767# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
25768
25769BEGIN {
25770  require Exporter;
25771  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
25772  $VERSION = '2.412';
25773  @ISA = qw(Exporter);
25774  import Amavis::Conf qw(:platform);
25775  import Amavis::Util qw(ll do_log idn_to_ascii);
25776}
25777
25778use Errno qw(EINTR EAGAIN);
25779use Socket;
25780use IO::Socket::UNIX;
25781#use IO::Socket::INET;
25782use Time::HiRes ();
25783
25784sub new {
25785  my($class, $service_method,$timeout,
25786     $src_ip,$src_port, $dst_ip,$dst_port, $nonce) = @_;
25787  local($1,$2,$3); my($service_host, $service_port, $service_path);
25788  if ($service_method =~
25789      m{^p0f: (?: \[ ([^\]]*) \] | ([^:]*) ) : ([^:]*) }six) {
25790    ($service_host, $service_port) = ($1.$2, $3);
25791  } elsif ($service_method =~
25792      m{^p0f: ( / [^ ]+ ) \z}six) {  # looks like a unix socket
25793    $service_path = $1;
25794  } else {
25795    die "Bad p0f method syntax: $service_method";
25796  }
25797  $dst_ip = '0.0.0.0'  if !defined $dst_ip;         # our MTA's IP address
25798  $dst_port = defined $dst_port ? 0+$dst_port : 0;  # our MTA port, usually 25
25799  $src_port = defined $src_port ? 0+$src_port : 0;  # remote client's port no.
25800  do_log(4,"Fingerprint query: [%s]:%s %s %s",
25801           $src_ip, $src_port, $nonce, $service_method);
25802  my $sock; my $query; my $query_sent = 0;
25803  # send a UDP query to p0f-analyzer
25804  $query = '['.$src_ip.']' . ($src_port==0 ? '' : ':'.$src_port);
25805  if (defined $service_path) {
25806    $sock = IO::Socket::UNIX->new(Type => SOCK_DGRAM, Peer => $service_path);
25807    $sock or do_log(0,"Can't connect to a Unix socket %s: %s",
25808                       $service_path, $!);
25809  } else {  # assume an INET or INET6 protocol family
25810    $service_host = idn_to_ascii($service_host);
25811    $sock = $io_socket_module_name->new(
25812              Type => SOCK_DGRAM, Proto => 'udp',
25813              PeerAddr => $service_host, PeerPort => $service_port);
25814    $sock or do_log(0,"Can't create a socket [%s]:%s: %s",
25815                       $service_host, $service_port, $!);
25816  }
25817  if ($sock) {
25818    defined $sock->syswrite("$query $nonce")
25819      or do_log(0, "Fingerprint - error sending a query: %s", $!);
25820    $query_sent = 1;
25821  }
25822  return  if !$query_sent;
25823  bless { sock => $sock, wait_until => (Time::HiRes::time + $timeout),
25824          query => $query, nonce => $nonce }, $class;
25825}
25826
25827sub collect_response {
25828  my $self = $_[0];
25829  my $timeout = $self->{wait_until} - Time::HiRes::time;
25830  if ($timeout < 0) { $timeout = 0 };
25831  my $sock = $self->{sock};
25832  my($resp,$nfound,$inbuf);
25833  my($rin,$rout); $rin = ''; vec($rin,fileno($sock),1) = 1;
25834  for (;;) {
25835    $nfound = select($rout=$rin, undef, undef, $timeout);
25836    last  if !$nfound || $nfound < 0;
25837    my $rv = $sock->sysread($inbuf,1024);
25838    if (!defined $rv) {
25839      if ($! == EAGAIN || $! == EINTR) {
25840        Time::HiRes::sleep(0.1);  # slow down, just in case
25841      } else {
25842        do_log(2, "Fingerprint - error reading from socket: %s", $!);
25843      }
25844    } elsif (!$rv) {  # sysread returns 0 at eof
25845      last;
25846    } else {
25847      local($1,$2,$3);
25848      if ($inbuf =~ /^([^ ]*) ([^ ]*) (.*)\015\012\z/) {
25849        my($r_query,$r_nonce,$r_resp) = ($1,$2,$3);
25850        if ($r_query eq $self->{query} && $r_nonce eq $self->{nonce})
25851          { $resp = $r_resp };
25852      }
25853      do_log(4,"Fingerprint collect: max_wait=%.3f, %.35s... => %s",
25854               $timeout,$inbuf,$resp);
25855      $timeout = 0;
25856    }
25857  }
25858  defined $nfound && $nfound >= 0
25859    or die "Fingerprint - select on socket failed: $!";
25860  $sock->close  or die "Error closing socket: $!";
25861  $resp;
25862}
25863
258641;
25865
25866__DATA__
25867#^L
25868package Amavis::TinyRedis;
25869
25870use strict;
25871use re 'taint';
25872use warnings;
25873# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
25874
25875use Errno qw(EINTR EAGAIN EPIPE ENOTCONN ECONNRESET ECONNABORTED);
25876use IO::Socket::UNIX;
25877use Time::HiRes ();
25878
25879use vars qw($VERSION);
25880BEGIN {
25881  $VERSION = '1.000';
25882  import Amavis::Conf qw(:platform);  # $io_socket_module_name
25883}
25884
25885sub new {
25886  my($class, %args) = @_;
25887  my $self = bless { args => {%args} }, $class;
25888  my $outbuf = ''; $self->{outbuf} = \$outbuf;
25889  $self->{batch_size} = 0;
25890  $self->{server} = $args{server} || $args{sock} || '127.0.0.1:6379';
25891  $self->{on_connect} = $args{on_connect};
25892  return if !$self->connect;
25893  $self;
25894}
25895
25896sub DESTROY {
25897  my $self = $_[0];
25898  local($@, $!, $_);
25899  undef $self->{sock};
25900}
25901
25902sub disconnect {
25903  my $self = $_[0];
25904  local($@, $!);
25905  undef $self->{sock};
25906}
25907
25908sub connect {
25909  my $self = $_[0];
25910
25911  $self->disconnect;
25912  my $sock;
25913  my $server = $self->{server};
25914  if ($server =~ m{^/}) {
25915    $sock = IO::Socket::UNIX->new(
25916              Peer => $server, Type => SOCK_STREAM);
25917  } elsif ($server =~ /^(?: \[ ([^\]]+) \] | ([^:]+) ) : ([^:]+) \z/xs) {
25918    $server = defined $1 ? $1 : $2;  my $port = $3;
25919    $sock = $io_socket_module_name->new(
25920              PeerAddr => $server, PeerPort => $port, Proto => 'tcp');
25921  } else {
25922    die "Invalid 'server:port' specification: $server";
25923  }
25924  if ($sock) {
25925    $self->{sock} = $sock;
25926
25927    $self->{sock_fd} = $sock->fileno; $self->{fd_mask} = '';
25928    vec($self->{fd_mask}, $self->{sock_fd}, 1) = 1;
25929
25930    # an on_connect() callback must not use batched calls!
25931    $self->{on_connect}->($self)  if $self->{on_connect};
25932  }
25933  $sock;
25934}
25935
25936# Receive, parse and return $cnt consecutive redis replies as a list.
25937#
25938sub _response {
25939  my($self, $cnt) = @_;
25940
25941  my $sock = $self->{sock};
25942  if (!$sock) {
25943    $self->connect  or die "Connect failed: $!";
25944    $sock = $self->{sock};
25945  };
25946
25947  my @list;
25948
25949  for (1 .. $cnt) {
25950
25951    my $result = <$sock>;
25952    if (!defined $result) {
25953      $self->disconnect;
25954      die "Error reading from Redis server: $!";
25955    }
25956    chomp $result;
25957    my $resp_type = substr($result, 0, 1, '');
25958
25959    if ($resp_type eq '$') {  # bulk reply
25960      if ($result < 0) {
25961        push(@list, undef);  # null bulk reply
25962      } else {
25963        my $data = ''; my $ofs = 0; my $len = $result + 2;
25964        while ($len > 0) {
25965          my $nbytes = read($sock, $data, $len, $ofs);
25966          if (!$nbytes) {
25967            $self->disconnect;
25968            defined $nbytes  or die "Error reading from Redis server: $!";
25969            die "Redis server closed connection";
25970          }
25971          $ofs += $nbytes; $len -= $nbytes;
25972        }
25973        chomp $data;
25974        push(@list, $data);
25975      }
25976
25977    } elsif ($resp_type eq ':') {  # integer reply
25978      push(@list, 0+$result);
25979
25980    } elsif ($resp_type eq '+') {  # status reply
25981      push(@list, $result);
25982
25983    } elsif ($resp_type eq '*') {  # multi-bulk reply
25984      push(@list, $result < 0 ? undef : $self->_response(0+$result) );
25985
25986    } elsif ($resp_type eq '-') {  # error reply
25987      die "$result\n";
25988
25989    } else {
25990      die "Unknown Redis reply: $resp_type ($result)";
25991    }
25992  }
25993  \@list;
25994}
25995
25996sub _write_buff {
25997  my($self, $bufref) = @_;
25998
25999  if (!$self->{sock}) { $self->connect or die "Connect failed: $!" };
26000  my $nwrite;
26001  for (my $ofs = 0; $ofs < length($$bufref); $ofs += $nwrite) {
26002    # to reliably detect a disconnect we need to check for an input event
26003    # using a select; checking status of syswrite is not sufficient
26004    my($rout, $wout, $inbuff); my $fd_mask = $self->{fd_mask};
26005    my $nfound = select($rout=$fd_mask, $wout=$fd_mask, undef, undef);
26006    defined $nfound && $nfound >= 0 or die "Select failed: $!";
26007    if (vec($rout, $self->{sock_fd}, 1) &&
26008        !sysread($self->{sock}, $inbuff, 1024)) {
26009      # eof, try reconnecting
26010      $self->connect  or die "Connect failed: $!";
26011    }
26012    local $SIG{PIPE} = 'IGNORE';  # don't signal on a write to a widowed pipe
26013    $nwrite = syswrite($self->{sock}, $$bufref, length($$bufref)-$ofs, $ofs);
26014    next if defined $nwrite;
26015    $nwrite = 0;
26016    if ($! == EINTR || $! == EAGAIN) {  # no big deal, try again
26017      Time::HiRes::sleep(0.1);  # slow down, just in case
26018    } else {
26019      $self->disconnect;
26020      if ($! == ENOTCONN   || $! == EPIPE ||
26021          $! == ECONNRESET || $! == ECONNABORTED) {
26022        $self->connect  or die "Connect failed: $!";
26023      } else {
26024        die "Error writing to redis socket: $!";
26025      }
26026    }
26027  }
26028  1;
26029}
26030
26031# Send a redis command with arguments, returning a redis reply.
26032#
26033sub call {
26034  my $self = shift;
26035
26036  my $buff = '*' . scalar(@_) . "\015\012";
26037  $buff .= '$' . length($_) . "\015\012" . $_ . "\015\012"  for @_;
26038
26039  $self->_write_buff(\$buff);
26040  local($/) = "\015\012";
26041  my $arr_ref = $self->_response(1);
26042  $arr_ref && $arr_ref->[0];
26043}
26044
26045# Append a redis command with arguments to a batch.
26046#
26047sub b_call {
26048  my $self = shift;
26049
26050  my $bufref = $self->{outbuf};
26051  $$bufref .= '*' . scalar(@_) . "\015\012";
26052  $$bufref .= '$' . length($_) . "\015\012" . $_ . "\015\012"  for @_;
26053  ++ $self->{batch_size};
26054}
26055
26056# Send a batch of commands, returning an arrayref of redis replies,
26057# each array element corresponding to one command in a batch.
26058#
26059sub b_results {
26060  my $self = $_[0];
26061  my $batch_size = $self->{batch_size};
26062  return if !$batch_size;
26063  my $bufref = $self->{outbuf};
26064  $self->_write_buff($bufref);
26065  $$bufref = ''; $self->{batch_size} = 0;
26066  local($/) = "\015\012";
26067  $self->_response($batch_size);
26068}
26069
260701;
26071
26072
26073package Amavis::Redis;
26074use strict;
26075use re 'taint';
26076use warnings;
26077use warnings FATAL => qw(utf8 void);
26078no warnings 'uninitialized';
26079# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
26080
26081BEGIN {
26082  require Exporter;
26083  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
26084  $VERSION = '2.412';
26085  @ISA = qw(Exporter);
26086  import Amavis::Conf qw(:platform :confvars c cr ca);
26087  import Amavis::rfc2821_2822_Tools;
26088  import Amavis::Util qw(ll do_log do_log_safe min max minmax untaint
26089                         safe_encode safe_encode_utf8 idn_to_ascii
26090                         format_time_interval unique_list snmp_count);
26091  import Amavis::Lookup::IP qw(lookup_ip_acl normalize_ip_addr);
26092  import Amavis::Timing qw(section_time);
26093}
26094
26095sub new {
26096  my($class, @redis_dsn) = @_;
26097  bless { redis_dsn => \@redis_dsn }, $class;
26098}
26099
26100sub disconnect {
26101  my $self = $_[0];
26102# do_log(5, "redis: disconnect");
26103  $self->{connected} = 0; undef $self->{redis};
26104}
26105
26106sub on_connect {
26107  my($self, $r) = @_;
26108  my $db_id = $self->{db_id} || 0;
26109  do_log(5, "redis: on_connect, db_id %d", $db_id);
26110  eval {
26111    $r->call('SELECT', $db_id) eq 'OK' ? 1 : 0;
26112  } or do {
26113    if ($@ =~ /^NOAUTH\b/ || $@ =~ /^ERR operation not permitted/) {
26114      defined $self->{password}
26115        or die "Redis server requires authentication, no password provided";
26116      $r->call('AUTH', $self->{password});
26117      $r->call('SELECT', $db_id);
26118    } else {
26119      chomp $@; die "Command 'SELECT $db_id' failed: $@";
26120    }
26121  };
26122  eval {
26123    $r->call('CLIENT', 'SETNAME', 'amavis['.$$.']') eq 'OK' ? 1 : 0;
26124  } or do {  # no big deal, just log
26125    do_log(5, "redis: command 'CLIENT SETNAME' failed: %s", $@);
26126  };
26127  1;
26128}
26129
26130sub connect {
26131  my $self = $_[0];
26132# do_log(5, "redis: connect");
26133  $self->disconnect  if $self->{connected};
26134  $self->{redis} = $self->{db_id} = $self->{ttl} = undef;
26135
26136  my($r, $err, $dsn, %options);
26137  my $dsn_list_ref = $self->{redis_dsn};
26138  for my $j (1 .. @$dsn_list_ref) {
26139    $dsn = $dsn_list_ref->[0];
26140    %options = ref $dsn eq 'HASH' ? %$dsn : ();
26141    # expiration time (time-to-live) is 16 days by default
26142    $self->{ttl} = exists $options{ttl} ? $options{ttl} : $storage_redis_ttl;
26143    $self->{db_id} = $options{db_id};
26144    if (defined $options{password}) {
26145      $self->{password} = $options{password};
26146      $options{password} = '(hidden)';  # for logging purposes
26147    }
26148    undef $err;
26149    eval {
26150      my %opt = %options; delete @opt{qw(ttl db_id password)};
26151      $opt{server} = idn_to_ascii($opt{server})  if defined $opt{server};
26152      $r = Amavis::TinyRedis->new(on_connect => sub { $self->on_connect(@_) },
26153                                  %opt);
26154      $r or die "Error: $!";
26155    } or do {
26156      undef $r; $err = $@; chomp $err;
26157    };
26158    $self->{redis} = $r;
26159    last if $r;  # success, done
26160    if ($j < @$dsn_list_ref) {  # not all tried yet
26161      do_log(0, "Can't connect to a redis server, %s: %s; trying next",
26162                 join(' ',%options), $err);
26163      push(@$dsn_list_ref, shift @$dsn_list_ref);  # rotate left
26164    }
26165  }
26166  if (!$r) {
26167    $self->{redis} = $self->{db_id} = $self->{ttl} = undef;
26168    die sprintf("Can't connect to a redis server %s: %s\n",
26169                join(' ',%options), $err);
26170  }
26171  $self->{connected} = 1;
26172  ll(5) && do_log(5, "redis: connected to: %s, ttl %s s",
26173                  !defined $options{server} ? 'default server'
26174                                            : join(' ',%options),
26175                  $self->{ttl}||'x');
26176  section_time("redis-connect");
26177  $self->load_lua_programs;
26178  $r;
26179}
26180
26181sub DESTROY {
26182  my $self = $_[0]; local($@,$!,$_);
26183  do_log_safe(5,"Amavis::Redis DESTROY called");
26184  # ignore potential errors during DESTROY of a Redis object
26185  eval { $self->{connected} = 0; undef $self->{redis} };
26186}
26187
26188# find a penpals record which proves that a local user (sender) really sent a
26189# mail to a given recipient some time ago. Returns an interval time in seconds
26190# since the last such mail was sent by our local user to a specified recipient
26191# (or undef if information is not available).  If @$message_id_list is a
26192# nonempty list of Message-IDs as found in References header field, the query
26193# also finds previous outgoing messages with a matching Message-ID but
26194# possibly to recipients different from what the mail was originally sent to.
26195#
26196sub penpals_find {
26197  my($self, $msginfo, $message_id_list) = @_;
26198
26199  my $sender = $msginfo->sender;
26200  $message_id_list = []  if !$message_id_list;
26201  return if !@$message_id_list && $sender eq '';
26202
26203  # inbound or internal_to_internal, except self_to_self
26204  my(@per_recip_data) = grep(!$_->recip_done && $_->recip_is_local &&
26205                             lc($sender) ne lc($_->recip_addr),
26206                             @{$msginfo->per_recip_data});
26207  return if !@per_recip_data;
26208
26209# do_log(5, "redis: penpals_find");
26210  snmp_count('PenPalsAttempts');
26211
26212  my $sender_smtp = $msginfo->sender_smtp;
26213  local($1); $sender_smtp =~ s/^<(.*)>\z/$1/s;
26214  my(@recip_addresses) =
26215    map { my $a = $_->recip_addr_smtp; $a =~ s/^<(.*)>\z/$1/s; lc $a }
26216        @per_recip_data;
26217
26218  # NOTE: swap recipient and sender in a query here, as we are
26219  # now checking for a potential reply mail - whether the current
26220  # recipient has recently sent any mail to the sender of the
26221  # current mail:
26222
26223  # no need for cryptographical strength, just checking for protocol errors
26224  my $nonce = $msginfo->mail_id;
26225  my $result;
26226  my @args = (
26227    0, sprintf("%.0f",$msginfo->rx_time), $nonce, lc $sender_smtp,
26228    scalar @recip_addresses, @recip_addresses,
26229    scalar @$message_id_list, @$message_id_list,
26230  );
26231  eval {
26232    $self->connect  if !$self->{connected};
26233    $result =
26234      $self->{redis}->call('EVALSHA', $self->{lua_query_penpals}, @args);
26235    1;
26236  } or do {  # Lua function probably not cached, define again and re-try
26237    if ($@ !~ /^NOSCRIPT/) {
26238      $self->disconnect; undef $result; chomp $@;
26239      do_log(-1, 'penpals_find, Redis Lua error: %s', $@);
26240    } else {
26241      $self->load_lua_programs;
26242      $result =
26243        $self->{redis}->call('EVALSHA', $self->{lua_query_penpals}, @args);
26244    }
26245  };
26246
26247  my $ok = 1;
26248  if (!$result || !@$result) {
26249    $ok = 0; $self->disconnect;
26250    do_log(0, "redis: penpals_find - no results");
26251  } else {
26252    my $r_nonce = pop(@$result);
26253    if (!defined($r_nonce) || $r_nonce ne $nonce) {
26254      # redis protocol falling out of step?
26255      $ok = 0; $self->disconnect;
26256      do_log(-1,"redis: penpals_find - nonce mismatch, expected %s, got %s",
26257                 $nonce, defined $r_nonce ? $r_nonce : 'UNDEF');
26258    }
26259  }
26260  if ($ok && (@$result != @per_recip_data)) {
26261    $ok = 0; $self->disconnect;
26262    do_log(-1,"redis: penpals_find - number of results expected %d, got %d",
26263              scalar @per_recip_data, scalar @$result);
26264  }
26265  if ($ok) {
26266    for my $r (@per_recip_data) {
26267      my $result_entry = shift @$result;
26268      next if !$result_entry;
26269      my($sid, $rid, $send_time, $best_ref_mail_id, $report) = @$result_entry;
26270      if (!$send_time) {  # undef or empty (or zero)
26271        snmp_count('PenPalsMisses');
26272        ll(4) && do_log(4, "penpals: (redis) not found (%s,%s)%s%s",
26273                   $sid ? $sid : $r->recip_addr_smtp,
26274                   $rid ? $rid : $msginfo->sender_smtp,
26275                   !$report ? '' : ', refs: '.$report,
26276                   !@$message_id_list ? '' :
26277                                        '; '.join(', ',@$message_id_list) );
26278      } else {  # found a previous related correspondence
26279        snmp_count('PenPalsHits');
26280        my $age = max(0, $msginfo->rx_time - $send_time);
26281        $r->recip_penpals_age($age);
26282        $r->recip_penpals_related($best_ref_mail_id);
26283        ll(3) && do_log(3, "penpals: (redis) found (%s,%s) age %s%s",
26284                   $sid ? $sid : $r->recip_addr_smtp,
26285                   $rid ? $rid : $msginfo->sender_smtp,
26286                   format_time_interval($age),
26287                   !$report ? '' : ', refs: '.$report );
26288        # $age and $best_ref_mail_id are not logged explicitly,
26289        # as they can be seen in the first entry of a lua query report
26290        # (i.e. the last string)
26291      }
26292    }
26293  }
26294  $ok;
26295}
26296
26297sub save_info_preliminary {
26298  my($self, $msginfo) = @_;
26299
26300  my $mail_id = $msginfo->mail_id;
26301  defined $mail_id  or die "save_info_preliminary: mail_id still undefined";
26302
26303  $self->connect  if !$self->{connected};
26304  ll(5) && do_log(5, 'redis: save_info_preliminary: %s, %s, ttl %s s',
26305                  $mail_id, int $msginfo->rx_time, $self->{ttl}||'x');
26306
26307  # use Lua to do HSETNX *and* EXPIRE atomically, otherwise we risk inserting
26308  # a key with no expiration time if redis server goes down inbetween
26309  my $added;
26310  my $r = $self->{redis};
26311  my(@args) = (1, $mail_id,  int $msginfo->rx_time,
26312               $self->{ttl} ? int $self->{ttl} : 0);
26313  eval {
26314    $added = $r->call('EVALSHA', $self->{lua_save_info_preliminary}, @args);
26315    1;
26316  } or do {  # Lua function probably not cached, define again and re-try
26317    if ($@ !~ /^NOSCRIPT/) {
26318      $self->disconnect; chomp $@;
26319      do_log(-1, 'save_info_preliminary, Redis Lua error: %s', $@);
26320    } else {
26321      $self->load_lua_programs;
26322      $added = $r->call('EVALSHA', $self->{lua_save_info_preliminary}, @args);
26323    }
26324  };
26325  $self->disconnect  if !$database_sessions_persistent;
26326  $added;  # 1 if added successfully, false otherwise
26327}
26328
26329sub query_and_update_ip_reputation {
26330  my($self, $msginfo) = @_;
26331
26332  my $ip_trace_ref = $msginfo->ip_addr_trace_public;
26333  return if !$ip_trace_ref;
26334  my @ip_trace = unique_list($ip_trace_ref);
26335  return if !@ip_trace;
26336
26337  # Irwin-Hall distribution - approximates normal distribution
26338  # n = 4, mean = n/2, variance = n/12, sigma = sqrt(n/12) =~ 0.577
26339  my $normal_random = (rand() + rand() + rand() + rand() - 2) / 0.577;
26340
26341  my(@args) = (scalar @ip_trace, map("ip:$_",@ip_trace),
26342               sprintf("%.3f", $msginfo->rx_time),
26343               sprintf("%.6f", $normal_random) );
26344  my($r, $ip_stats);
26345  eval {
26346    $self->connect  if !$self->{connected};
26347    $r = $self->{redis};
26348    $ip_stats = $r->call('EVALSHA', $self->{lua_query_and_update_ip}, @args);
26349    1;
26350  } or do {  # Lua function probably not cached, define again and re-try
26351    if ($@ !~ /^NOSCRIPT/) {
26352      $self->disconnect; chomp $@;
26353      do_log(-1, "query_and_update_ip_reputation, Redis Lua error: %s", $@);
26354    } else {
26355      $self->load_lua_programs;
26356      $ip_stats = $r->call('EVALSHA', $self->{lua_query_and_update_ip}, @args);
26357    }
26358  };
26359  my($highest_score, $worst_ip);
26360  for my $entry (!$ip_stats ? () : @$ip_stats) {
26361    my($ip, $n_all, $s, $h, $b, $tfirst, $tlast, $ttl) = @$entry;
26362    $ip =~ s/^ip://s;  # strip key prefix
26363    # the current event is not yet counted nor classified
26364    if ($n_all <= 0) {
26365      do_log(5, "redis: IP %s ttl: %.1f h", $ip, $ttl/3600);
26366    } else {
26367      my $n_other = $n_all - ($s + $h + $b);
26368      if ($n_other < 0) { $n_all = $s + $h + $b; $n_other = 0 }  # just in case
26369      my $bad_content_ratio = ($s+$b) / $n_all;
26370      # gains strength by the number of samples, watered down by share of ham
26371      my $score = !($s+$b) ? 0 : 0.9 * ($n_all**0.36) * exp(-6 * $h/$n_all);
26372
26373      my $ip_ignore;
26374      if ($score >= 0.05) {
26375        # it is cheaper to do a redis/lookup unconditionally,
26376        # then ditch an ignored IP address later if necessary
26377        my($key, $err);
26378        ($ip_ignore, $key, $err) =
26379          lookup_ip_acl($ip, @{ca('ip_repu_ignore_maps')});
26380        undef $ip_ignore if $err;
26381      }
26382      my $ll = ($score <= 0 || $ip_ignore) ? 3 : 2;  # log level
26383      if (ll($ll)) {
26384        my $rxtime = $msginfo->rx_time;
26385        do_log($ll, "redis: IP %s age: %s%s, ttl: %.1f h, %s, %s%s",
26386          $ip, format_time_interval($rxtime-$tfirst),
26387          defined $tlast ? ', last: '.format_time_interval($rxtime-$tlast) :'',
26388          $ttl/3600,
26389          $n_other ?
26390            ($b ? "s/h/bv/?: $s/$h/$b/$n_other" : "s/h/?: $s/$h/$n_other")
26391          : ($b ? "s/h/bv: $s/$h/$b"            : "s/h: $s/$h"),
26392          $score <= 0 ? 'clean' : sprintf("%.0f%%, score: %.1f",
26393                                          100*$bad_content_ratio, $score),
26394          $ip_ignore ? ' =>0 ip_repu_ignore' : '');
26395      }
26396      $score = 0  if $ip_ignore || $score < 0.05;
26397      if (!defined $highest_score || $score > $highest_score) {
26398        $highest_score = $score; $worst_ip = $ip;
26399      }
26400    }
26401  }
26402  $self->disconnect  if !$database_sessions_persistent;
26403  ($highest_score, $worst_ip);
26404}
26405
26406sub save_structured_report {
26407  my($self, $report_ref, $log_key, $queue_size_limit) = @_;
26408  return if !$report_ref;
26409  $self->connect  if !$self->{connected};
26410  my $r = $self->{redis};
26411  my $report_json = Amavis::JSON::encode($report_ref);  # as string of chars
26412  # use safe_encode() instead of safe_encode_utf8() here, this way we ensure
26413  # the resulting string of octets is always a valid UTF-8, even in case
26414  # of a non-ASCII input string with utf8 flag off
26415  $report_json = safe_encode('UTF-8', $report_json);  # convert to octets
26416  do_log(5, "redis: structured_report: %s %s", $log_key, $report_json);
26417  $r->b_call("RPUSH", $log_key, $report_json);
26418  # keep most recent - queue size limit in case noone is pulling events
26419  $r->b_call("LTRIM", $log_key, -$queue_size_limit, -1) if $queue_size_limit;
26420  my $res = $r->b_results;  # errors will be signalled
26421  do_log(5, "redis: save_structured_report, %d bytes, q_lim=%s, q_size=%s",
26422            length $report_json, $queue_size_limit || 0,
26423            $res ? join(', ',@$res) : '?')  if ll(5);
26424  1;
26425}
26426
26427sub save_info_final {
26428  my($self, $msginfo, $report_ref) = @_;
26429
26430  $self->connect  if !$self->{connected};
26431  my $r = $self->{redis};
26432
26433  if (c('enable_ip_repu')) {
26434    my $rigm = ca('ip_repu_ignore_maps');
26435    my $ip_trace_ref = $msginfo->ip_addr_trace_public;
26436    my @ip_trace;
26437    @ip_trace = grep { my($ignore, $key, $err) = lookup_ip_acl($_, @$rigm);
26438                       !$ignore || $err;
26439                     } unique_list($ip_trace_ref)  if $ip_trace_ref;
26440    if (@ip_trace) {
26441      my $content =
26442        $msginfo->is_in_contents_category(CC_VIRUS)  ? 'b' :
26443        $msginfo->is_in_contents_category(CC_BANNED) ? 'b' : undef;
26444      if (!defined $content) {  # test for ham or spam
26445        my($min, $max);
26446        for my $r (@{$msginfo->per_recip_data}) {
26447          my $spam_level = $r->spam_level;
26448          next if !defined $spam_level;
26449          $max = $spam_level  if !defined $max || $spam_level > $max;
26450          $min = $spam_level  if !defined $min || $spam_level < $min;
26451        }
26452        if (defined $min) {
26453          my $ip_repu_score = $msginfo->ip_repu_score || 0;  # positive or 0
26454          # avoid self-reinforcing feedback in the IP reputation auto-learning,
26455          # use the score without the past IP reputation contribution
26456          if    ($max - $ip_repu_score <  0.5) { $content = 'h' }
26457          elsif ($min - $ip_repu_score >= 5)   { $content = 's' }
26458        }
26459      }
26460      if (!defined $content) {
26461        # just increment the total counter
26462        $r->b_call("HINCRBY", "ip:$_", 'n', 1) for @ip_trace;
26463        $r->b_results;
26464        if (ll(5)) { do_log(5,"redis: IP INCR %s", $_) for @ip_trace }
26465      } else {
26466        # content type is known
26467        for (@ip_trace) {
26468          $r->b_call("HINCRBY", "ip:$_", 'n', 1);
26469          $r->b_call("HINCRBY", "ip:$_", $content, 1);
26470        }
26471        my $counts = $r->b_results;
26472        if (ll(5) && $counts) {
26473          do_log(5,"redis: IP INCR %s n=%d, %s=%d",
26474                 $_, shift @$counts, $content, shift @$counts) for @ip_trace;
26475        }
26476      }
26477    }
26478  }
26479
26480  if (!$msginfo->originating) {
26481    # don't bother saving info on incoming messages, saves Redis storage
26482    # while still offering necessary data for a pen pals function
26483    $self->disconnect  if !$database_sessions_persistent;
26484    return;
26485  }
26486
26487  my $mail_id = $msginfo->mail_id;
26488  defined $mail_id  or die "save_info_preliminary: mail_id still undefined";
26489
26490  my $sender_smtp = $msginfo->sender_smtp;
26491  local($1); $sender_smtp =~ s/^<(.*)>\z/$1/s;
26492
26493  my(@recips);  # only recipients which did receive a message
26494  for my $r (@{$msginfo->per_recip_data}) {
26495    my($dest, $resp) = ($r->recip_destiny, $r->recip_smtp_response);
26496    next if $dest != D_PASS || ($r->recip_done && $resp !~ /^2/);
26497    my $addr_smtp = $r->recip_addr_smtp;
26498    next if !defined $addr_smtp;
26499    local($1); $addr_smtp =~ s/^<(.*)>\z/$1/s;
26500    # don't remember messages sent to self
26501    next if lc($sender_smtp) eq lc($addr_smtp);
26502    # don't remember problematic outgoing messages, even if delivered
26503    next if $r->is_in_contents_category(CC_VIRUS)  ||
26504            $r->is_in_contents_category(CC_BANNED) ||
26505            $r->is_in_contents_category(CC_SPAM)   ||  # kill_level
26506            $r->is_in_contents_category(CC_SPAMMY);    # tag2_level
26507    push(@recips, lc $addr_smtp);
26508  }
26509
26510  my $m_id = $msginfo->get_header_field_body('message-id');
26511  $m_id = join(' ',parse_message_id($m_id))
26512    if defined $m_id && $m_id ne '';  # strip CFWS
26513  my(@args) = map(defined $_ ? $_ : '',  # avoid nil in a Lua table
26514                   ($self->{ttl}, $msginfo->log_id,
26515                    $m_id, $msginfo->client_addr, lc $sender_smtp, @recips) );
26516  if (!@recips) {
26517    do_log(5,"redis: save_info_final: %s deleted", $mail_id);
26518  } elsif (ll(5)) {
26519    do_log(5,"redis: save_info_final: %s, passed %d of %d recips, %s",
26520             $mail_id, scalar @recips, scalar @{$msginfo->per_recip_data},
26521             join(', ',@args));
26522  }
26523  my $result;
26524  eval {
26525    $result = $r->call('EVALSHA', $self->{lua_save_final},
26526                       1, $mail_id, @args);
26527    1;
26528  } or do {  # Lua function probably not cached, define again and re-try
26529    if ($@ !~ /^NOSCRIPT/) {
26530      $self->disconnect; undef $result; chomp $@;
26531      do_log(-1, "save_info_final, Redis Lua error: %s", $@);
26532    } else {
26533      $self->load_lua_programs;
26534      $result = $r->call('EVALSHA', $self->{lua_save_final},
26535                         1, $mail_id, @args);
26536    }
26537  };
26538
26539  my $ok = 1;
26540  my $r_nonce = $result;
26541  if (!defined($r_nonce) || $r_nonce ne $mail_id) {
26542    # redis protocol falling out of step?
26543    $ok = 0; $self->disconnect;
26544    do_log(-1,"redis: save_info_final - nonce mismatch, expected %s, got %s",
26545               $mail_id, defined $r_nonce ? $r_nonce : 'UNDEF');
26546  }
26547  # $r->call("EVAL", 'collectgarbage()', 0);
26548  $self->disconnect  if !$database_sessions_persistent;
26549  $ok;
26550}
26551
26552sub load_lua_programs($) {
26553  my $self = $_[0];
26554  do_log(5, "redis: load_lua_programs");
26555  my $r = $self->{redis};
26556
26557  eval {
26558    $self->{lua_save_info_preliminary} = $r->call('SCRIPT', 'LOAD', <<'END');
26559--LUA_SAVE_INFO_PRELIMINARY
26560    local rcall, tonumber = redis.call, tonumber
26561    local mail_id, rx_time, ttl = KEYS[1], ARGV[1], ARGV[2]
26562
26563    -- ensure the mail_id is unique, report false otherwise
26564    local added = rcall("HSETNX", mail_id, "time", rx_time)
26565    if added == 1 and ttl and tonumber(ttl) > 0 then
26566      if rcall("EXPIRE", mail_id, ttl) ~= 1 then
26567        return { err = "Failed to set EXPIRE on key " .. mail_id }
26568      end
26569    end
26570    return added  -- 1:yes, 0:no,failed
26571END
26572  } or do {
26573    $self->disconnect; die "Redis LUA error - lua_save_info_preliminary: $@\n"
26574  };
26575
26576  eval {
26577    $self->{lua_save_final} = $r->call('SCRIPT', 'LOAD', <<'END');
26578--LUA_SAVE_FINAL
26579    local mail_id = KEYS[1]
26580    local rcall = redis.call
26581    local ARGV = ARGV
26582
26583    -- not delivered to any recipient, just delete the useless record
26584    if #ARGV < 6 then
26585      rcall("DEL", mail_id)
26586
26587    else
26588      local ttl, log_id, msgid, client_addr, sender = unpack(ARGV,1,5)
26589      local tonumber, unpack = tonumber, unpack
26590      if not tonumber(ttl) or tonumber(ttl) <= 0 then ttl = nil end
26591
26592      local addresses = { [sender] = true }
26593      -- remaining arguments 6 to #ARGV are recipient addresses
26594      for r = 6, #ARGV do addresses[ARGV[r]] = true end
26595
26596      -- create mail address -> id mapping
26597      for addr in pairs(addresses) do
26598        local addr_key = "a:" .. addr
26599        local addr_id
26600        if not ttl then
26601          addr_id = rcall("GET", addr_key)
26602        else
26603          -- to avoid potential race between GET and EXPIRE, set EXPIRE first
26604          local refreshed = rcall("EXPIRE", addr_key, ttl)
26605          if refreshed == 1 then addr_id = rcall("GET", addr_key) end
26606        end
26607        if not addr_id then
26608          -- not found, assign a new id and store the email address
26609          addr_id = rcall("INCR", "last.id.addr")  -- get next id, starts at 1
26610          addr_id = tostring(addr_id)
26611          local ok
26612          if ttl then
26613            ok = rcall("SET", addr_key, addr_id, "EX", ttl, "NX")
26614          else
26615            ok = rcall("SET", addr_key, addr_id,            "NX")
26616          end
26617          if not ok then
26618            -- shouldn't happen, Lua program runs atomically, but anyway...
26619            addr_id = rcall("GET", addr_key)  -- collision, retry
26620          end
26621        end
26622        addresses[addr] = addr_id
26623      end
26624
26625      -- create a Message-ID -> id mapping
26626      local msgid_key = "m:" .. msgid
26627      local msgid_id = rcall("GET", msgid_key)
26628      if msgid_id then  -- unlikely duplicate Message-ID, but anyway...
26629        if ttl then rcall("EXPIRE", msgid_key, ttl) end -- extend its lifetime
26630      else
26631        msgid_id = rcall("INCR", "last.id.msgid")  -- get next id, starts at 1
26632        msgid_id = tostring(msgid_id)
26633        local ok
26634        if ttl then
26635          ok = rcall("SET", msgid_key, msgid_id, "EX", ttl, "NX")
26636        else
26637          ok = rcall("SET", msgid_key, msgid_id,            "NX")
26638        end
26639        if not ok then
26640          -- shouldn't happen, Lua program runs atomically, but anyway...
26641          msgid_id = rcall("GET", msgid_key)  -- collision, retry
26642        end
26643      end
26644
26645      -- store additional information to an existing mail_id record
26646      local sender_id = addresses[sender]
26647      rcall("HSET",   mail_id,  "log", log_id)
26648   -- rcall("HMSET",  mail_id,  "log", log_id,
26649   --            "msgid", msgid_id,  "ip", client_addr,  "sender", sender_id)
26650
26651      -- store relations: sender/msgid and sender/recipient pairs
26652      local mapkeys = { "sm:" .. sender_id .. "::" .. msgid_id }
26653      for r = 6, #ARGV do
26654        local recip_id = addresses[ARGV[r]]
26655        -- only the most recent sr:* record is kept, older are overwritten
26656        mapkeys[#mapkeys+1] = "sr:"  .. sender_id .. ":" .. recip_id
26657   --   mapkeys[#mapkeys+1] = "srm:" .. sender_id .. ":" .. recip_id ..
26658   --                                                        ":" .. msgid_id
26659      end
26660      if not ttl then
26661        for _,k in ipairs(mapkeys) do rcall("SET", k, mail_id) end
26662      else
26663        for _,k in ipairs(mapkeys) do rcall("SET", k, mail_id, "EX", ttl) end
26664      end
26665    end
26666
26667    return mail_id
26668END
26669  } or do {
26670    $self->disconnect; die "Redis LUA error - lua_save_final: $@\n"
26671  };
26672
26673  eval {
26674    $self->{lua_query_and_update_ip} = $r->call('SCRIPT', 'LOAD', <<'END');
26675--LUA_QUERY_AND_UPDATE_IP
26676    local rcall, tonumber, unpack, floor, sprintf =
26677      redis.call, tonumber, unpack, math.floor, string.format
26678    local KEYS, ARGV = KEYS, ARGV
26679    local rx_time, normal_random = ARGV[1], tonumber(ARGV[2])
26680
26681    local results = {}
26682    for j = 1, #KEYS do
26683      local ipkey = KEYS[j]  -- an IP address, prefixed by "ip:"
26684      local tfirst, tlast  -- Unix times of creation and last access
26685      local n, s, h, b     -- counts: all, spam, ham, banned+virus
26686      local age, ttl       -- time since creation, time to live in seconds
26687      local ip_addr_data =
26688        rcall("HMGET", ipkey, 'tl', 'tf', 'n', 's', 'h', 'b')
26689      if ip_addr_data then
26690        tlast, tfirst, n, s, h, b = unpack(ip_addr_data,1,6)
26691      end
26692      if not tlast then  -- does not exist, a new entry is needed
26693        n = 0; tfirst = rx_time; ttl = 3*3600  -- 3 hours for new entries
26694        rcall("HMSET", ipkey, 'tf', rx_time, 'tl', rx_time, 'n', '0')
26695      else  -- a record for this IP address exists, collect its counts and age
26696        n = tonumber(n) or 0
26697        local rx_time_n, tfirst_n, tlast_n =
26698          tonumber(rx_time), tonumber(tfirst), tonumber(tlast)
26699        if rx_time_n and tfirst_n and tlast_n then  -- valid numbers
26700          age      = rx_time_n - tfirst_n  -- time since entry creation
26701          local dt = rx_time_n - tlast_n   -- time since last occurrence
26702          ttl = 3600 * (n >= 8 and 80 or (3 + n^2.2))  -- 4 to 80 hours
26703          if ttl < 1.5 * dt then ttl = 1.5 * dt end
26704        else  -- just in case - ditch a record with invalid fields
26705          n = 0; tfirst = rx_time; ttl = 3*3600
26706          rcall("DEL", ipkey);
26707          rcall("HMSET", ipkey, 'tf', rx_time, 'n', '0')
26708        end
26709        rcall("HMSET", ipkey, 'tl', rx_time)  -- update its last-seen time
26710      end
26711      -- the 's', 'h', 'b' and 'n' counts will be updated later
26712      if normal_random then
26713        -- introduce some randomness, don't let spammers depend on a fixed ttl
26714        ttl = ttl * (1 + normal_random * 0.2)
26715        if ttl < 4000 then ttl = 4000 end  -- no less than 1h 7min
26716      end
26717      -- set time-to-live in seconds, capped at 3 days, integer
26718      if age and (age + ttl > 3*24*3600) then ttl = 3*24*3600 - age end
26719      if ttl < 1 then
26720        rcall("DEL", ipkey); ttl = 0
26721      else
26722        rcall("EXPIRE", ipkey, floor(ttl))
26723      end
26724      results[#results+1] = { ipkey, n or 0, s or 0, h or 0, b or 0,
26725                              tfirst or "", tlast or "", ttl }
26726    end
26727    return results
26728END
26729  } or do {
26730    $self->disconnect; die "Redis LUA error - lua_query_and_update_ip: $@\n"
26731  };
26732
26733  eval {
26734    $self->{lua_query_penpals} = $r->call('SCRIPT', 'LOAD', <<'END');
26735--LUA_QUERY_PENPALS
26736    local tonumber, unpack, sprintf = tonumber, unpack, string.format
26737    local rcall = redis.call
26738    local ARGV = ARGV
26739
26740    local now, nonce, recipient = ARGV[1], ARGV[2], ARGV[3]
26741    local senders_count = tonumber(ARGV[4])
26742    local senders_argv_ofs = 5
26743    local messageid_argv_ofs = senders_argv_ofs + senders_count + 1
26744    local messageid_count = tonumber(ARGV[messageid_argv_ofs - 1])
26745
26746    local q_keys1 = {}
26747    -- current sender as a potential previous recipient
26748    if recipient == '' then recipient = nil end  -- nothing ever sent to "<>"
26749    if recipient then
26750      q_keys1[#q_keys1+1] = "a:" .. recipient
26751    end
26752    for j = 1, senders_count do
26753      q_keys1[#q_keys1+1] = "a:" .. ARGV[senders_argv_ofs + j - 1]
26754    end
26755    for j = 1, messageid_count do
26756      q_keys1[#q_keys1+1] = "m:" .. ARGV[messageid_argv_ofs + j - 1]
26757    end
26758
26759    -- map e-mail addresses and referenced Message-IDs to internal id numbers
26760    local q_result = rcall("MGET", unpack(q_keys1))
26761    q_keys1 = nil
26762
26763    local rid        -- internal id of a recipient's e-mail addresses
26764    local mids = {}  -- internal ids corresponding to referenced "Message-ID"s
26765    local senders = {}
26766    if q_result then
26767      local k = 0;
26768      if recipient then  -- nonempty e-mail address, i.e. not "<>"
26769        k = k+1; rid = q_result[k]
26770      end
26771      for j = 1, senders_count do
26772        k = k+1;
26773        if not q_result[k] then senders[j] = false  -- non-nil
26774        else senders[j] = { sid = q_result[k] } end
26775      end
26776      for j = 1, messageid_count do
26777        k = k+1;  if q_result[k] then mids[q_result[k]] = true end
26778      end
26779    end
26780    q_result = nil
26781
26782    -- prepare query keys to find a closest-matching previous e-mail message
26783    -- for each sender
26784    local q_keys2, belongs_to_sender, on_hit_txt = {}, {}, {}
26785    for _, s in ipairs(senders) do
26786      if s then
26787        -- try sender/Message-ID pairs without a recipient
26788        for m in pairs(mids) do
26789          local nxt = #q_keys2 + 1
26790          q_keys2[nxt] = "sm:" .. s.sid .. "::" .. m
26791          on_hit_txt[nxt] = "mid=" .. m
26792          belongs_to_sender[nxt] = s
26793        end
26794        -- try a sender/recipient pair without a Message-ID ref
26795        if rid then
26796          local nxt = #q_keys2 + 1
26797          q_keys2[nxt] = "sr:" .. s.sid .. ":" .. rid
26798          on_hit_txt[nxt] = "rid=" .. rid
26799          belongs_to_sender[nxt] = s
26800        end
26801      end
26802    end
26803
26804    -- get an internal id (or nil) of a matching mail_id for each query key
26805    local q_result2
26806    if #q_keys2 >= 1 then q_result2 = rcall("MGET", unpack(q_keys2)) end
26807
26808    local msginfo = {}  -- data about a message mail_id (e.g. its rx_time)
26809    if q_result2 then
26810      for j = 1, #q_keys2 do
26811        local rx_time_n
26812        local mail_id = q_result2[j]
26813
26814        if not mail_id then
26815          -- no matching mail_id
26816        elseif msginfo[mail_id] then  -- already looked-up
26817          rx_time_n = msginfo[mail_id].rx_time_n
26818        else  -- not yet looked-up
26819          msginfo[mail_id] = {}
26820          -- see if a record for this mail_id exists, find its timestamp
26821          rx_time_n = tonumber(rcall("HGET", mail_id, "time"))
26822          msginfo[mail_id].rx_time_n = rx_time_n
26823        end
26824
26825        if rx_time_n then  -- exists and is a valid number
26826          local s = belongs_to_sender[j]
26827          if not s.hits then s.hits = {} end
26828          if not s.hits[mail_id] then
26829            s.hits[mail_id] = on_hit_txt[j]
26830          else
26831            s.hits[mail_id] = s.hits[mail_id] .. " " .. on_hit_txt[j]
26832          end
26833
26834          -- for each sender manage a sorted list of mail_ids found
26835          if not s.mail_id_list then
26836            s.mail_id_list = { mail_id }
26837          else
26838            -- keep sender's mail_id_list sorted by rx_time, highest first
26839            local mail_id_list = s.mail_id_list
26840            local first_smaller_ind
26841            for j = 1, #mail_id_list do
26842              if msginfo[mail_id_list[j]].rx_time_n <= rx_time_n then
26843                first_smaller_ind = j; break
26844              end
26845            end
26846            table.insert(mail_id_list,
26847                         first_smaller_ind or #mail_id_list+1, mail_id)
26848          end
26849        end
26850      end
26851    end
26852
26853    local results = {}  -- one entry for each sender, followed by a nonce
26854    for _, s in ipairs(senders) do
26855      if not s or not s.mail_id_list then  -- no matching mail_id
26856        results[#results+1] = { s and s.sid or "", rid }
26857      else  -- some matches for this sender, compile a report
26858        local report = {}; local mail_id_list = s.mail_id_list
26859        for _, mail_id in ipairs(mail_id_list) do  -- first is best
26860          report[#report+1] = sprintf("%s (%.0f s) %s", mail_id,
26861                                tonumber(now) - msginfo[mail_id].rx_time_n,
26862                                s.hits and s.hits[mail_id] or "")
26863        end
26864        results[#results+1] =
26865          { s.sid or "", rid or "", msginfo[mail_id_list[1]].rx_time_n,
26866            mail_id_list[1], table.concat(report,", ") }
26867      end
26868    end
26869    results[#results+1] = nonce
26870    return results
26871END
26872    1;
26873  } or do {
26874    $self->disconnect; die "Redis LUA error - lua_query_penpals: $@\n"
26875  };
26876
26877  ll(5) && do_log(5, "redis: SHA fingerprints: final %s, query %s",
26878                  map(substr($_,0,10), @$self{qw(lua_save_final lua_query)}));
26879  section_time("redis-load");
26880  1;
26881}
26882
268831;
26884
26885__DATA__
26886#^L
26887package Amavis::Out::SQL::Connection;
26888use strict;
26889use re 'taint';
26890use warnings;
26891use warnings FATAL => qw(utf8 void);
26892no warnings 'uninitialized';
26893# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
26894
26895BEGIN {
26896  require Exporter;
26897  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
26898  $VERSION = '2.412';
26899  @ISA = qw(Exporter);
26900  import Amavis::Conf qw(:platform c cr ca);
26901  import Amavis::Util qw(ll do_log do_log_safe);
26902  import Amavis::Timing qw(section_time);
26903}
26904
26905use DBI qw(:sql_types);
26906
26907# one object per connection (normally exactly one) to a database server;
26908# connection need not exist at all times, stores info on how to connect;
26909# when connected it holds a database handle
26910#
26911sub new {
26912  my($class, @dsns) = @_;  # a list of DSNs to try connecting to sequentially
26913  bless { dbh=>undef, sth=>undef, incarnation=>1, in_transaction=>0,
26914          dsn_list=>\@dsns, dsn_current=>undef }, $class;
26915}
26916
26917sub dsn_current {  # get/set information on currently connected data set name
26918  my $self = shift; !@_ ? $self->{dsn_current} : ($self->{dsn_current}=shift);
26919}
26920
26921sub dbh {  # get/set database handle
26922  my $self = shift; !@_ ? $self->{dbh} : ($self->{dbh}=shift);
26923}
26924
26925sub sth {  # get/set statement handle
26926  my $self = shift; my $clause = shift;
26927  !@_ ? $self->{sth}{$clause} : ($self->{sth}{$clause}=shift);
26928}
26929
26930sub dbh_inactive {  # get/set dbh "InactiveDestroy" attribute
26931  my $self = shift;
26932  my $dbh = $self->dbh;
26933  return if !$dbh;
26934  !@_ ? $dbh->{'InactiveDestroy'} : ($dbh->{'InactiveDestroy'}=shift);
26935}
26936
26937sub DESTROY {
26938  my $self = $_[0]; local($@,$!,$_);
26939  do_log_safe(5,"Amavis::Out::SQL::Connection DESTROY called");
26940  # ignore failures, make perlcritic happy
26941  eval { $self->disconnect_from_sql } or 1;
26942}
26943
26944# returns current connection version; works like cache versioning/invalidation:
26945# SQL statement handles need to be rebuilt and caches cleared when SQL
26946# connection is re-established and a new database handle provided
26947#
26948sub incarnation { my $self = $_[0]; $self->{incarnation} }
26949
26950sub in_transaction {
26951  my $self = shift;
26952  !@_ ? $self->{in_transaction} : ($self->{in_transaction}=shift)
26953}
26954
26955# returns DBD driver name such as 'Pg', 'mysql';  or undef if unknown
26956#
26957sub driver_name {
26958  my $self = $_[0];  my $dbh = $self->dbh;
26959  $dbh or die "sql driver_name: dbh not available";
26960  !$dbh->{Driver} ? undef : $dbh->{Driver}->{Name};
26961}
26962
26963# DBI method wrappers:
26964#
26965sub begin_work {
26966  my $self = shift; do_log(5,"sql begin transaction");
26967  # DBD::mysql man page: if you detect an error while changing
26968  # the AutoCommit mode, you should no longer use the database handle.
26969  # In other words, you should disconnect and reconnect again
26970  $self->dbh or $self->connect_to_sql;
26971  my $stat; my $eval_stat;
26972  eval {
26973    $stat = $self->dbh->begin_work(@_);  1;
26974  } or do {
26975    $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
26976  };
26977  if (defined $eval_stat || !$stat) {
26978    do_log(-1,"sql begin transaction failed, ".
26979             "probably disconnected by server, reconnecting (%s)", $eval_stat);
26980    $self->disconnect_from_sql;
26981    die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
26982    $self->connect_to_sql;
26983    $stat = $self->dbh->begin_work(@_);
26984  }
26985  $self->in_transaction(1);
26986  $stat;
26987};
26988
26989sub begin_work_nontransaction {
26990  my $self = $_[0]; do_log(5,"sql begin, nontransaction");
26991  $self->dbh or $self->connect_to_sql;
26992};
26993
26994sub commit {
26995  my $self = shift; do_log(5,"sql commit");
26996  $self->in_transaction(0);
26997  my $dbh = $self->dbh;
26998  $dbh or die "commit: dbh not available";
26999  $dbh->commit(@_);  my($rv_err,$rv_str) = ($dbh->err, $dbh->errstr);
27000  do_log(2,"sql commit status: err=%s, errstr=%s",
27001           $rv_err,$rv_str)  if defined $rv_err;
27002  ($rv_err,$rv_str);  # potentially useful to see non-fatal errors
27003};
27004
27005sub rollback {
27006  my $self = shift; do_log(5,"sql rollback");
27007  $self->in_transaction(0);
27008  $self->dbh or die "rollback: dbh not available";
27009  eval {
27010    $self->dbh->rollback(@_);  1;
27011  } or do {
27012    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27013    do_log(-1,"sql rollback error, reconnecting (%s)", $eval_stat);
27014    $self->disconnect_from_sql;
27015    die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27016    $self->connect_to_sql;
27017#   $self->dbh->rollback(@_);  # too late now, hopefully implied in disconnect
27018  };
27019};
27020
27021sub fetchrow_arrayref {
27022  my($self,$clause,@args) = @_;
27023  $self->dbh or die "fetchrow_arrayref: dbh not available";
27024  my $sth = $self->sth($clause);
27025  $sth or die "fetchrow_arrayref: statement handle not available";
27026  $sth->fetchrow_arrayref(@args);
27027};
27028
27029sub finish {
27030  my($self,$clause,@args) = @_;
27031  $self->dbh or die "finish: dbh not available";
27032  my $sth = $self->sth($clause);
27033  $sth or die "finish: statement handle not available";
27034  $sth->finish(@args);
27035};
27036
27037sub execute {
27038  my($self,$clause,@args) = @_;
27039  $self->dbh or die "sql execute: dbh not available";
27040  my $sth = $self->sth($clause);  # fetch cached st. handle or prepare new
27041  if ($sth) {
27042    ll(5) && do_log(5, "sql: executing clause (%d args): %s",
27043                       scalar(@args), $clause);
27044  } else {
27045    ll(4) && do_log(4,"sql: preparing and executing (%d args): %s",
27046                       scalar(@args), $clause);
27047    $sth = $self->dbh->prepare($clause); $self->sth($clause,$sth);
27048    $sth or die "sql: prepare failed: ".$DBI::errstr;
27049  }
27050  my($rv_err,$rv_str);
27051  eval {
27052    for my $j (0..$#args) { # arg can be a scalar or [val,type] or [val,\%attr]
27053      my $arg = $args[$j];
27054      $sth->bind_param($j+1, !ref($arg) ? $arg : @$arg);
27055    # ll(5) && do_log(5, "sql: bind %d: %s",
27056    #                 $j+1, !ref($arg) ? $arg : '['.join(',',@$arg).']' );
27057    }
27058    $sth->execute; $rv_err = $sth->err; $rv_str = $sth->errstr;  1;
27059  } or do {
27060    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27061    # man DBI: ->err code is typically an integer but you should not assume so
27062    # $DBI::errstr is normally already contained in $eval_stat
27063    my $sqlerr   = $sth ? $sth->err   : $DBI::err;
27064    my $sqlstate = $sth ? $sth->state : $DBI::state;
27065    my $msg = sprintf("err=%s, %s, %s", $sqlerr, $sqlstate, $eval_stat);
27066    if (!$sth) {
27067      die "sql execute (no handle): ".$msg;
27068    } elsif (! ($sqlerr eq '2006' || $sqlerr eq '2013'   ||   # MySQL
27069                ($sqlerr == -1 && $sqlstate eq  'S1000') ||   # PostgreSQL 7
27070                ($sqlerr ==  7 && $sqlstate =~ /^(S8|08|57)...\z/i) )) { #PgSQL
27071                # libpq-fe.h: ExecStatusType PGRES_FATAL_ERROR=7
27072      # ignore failures, make perlcritic happy
27073      eval { $self->disconnect_from_sql } or 1;  # better safe than sorry
27074      die "sql exec: $msg\n";
27075    } else {  # Server has gone away; Lost connection to...
27076      # MySQL: 2006, 2013;  PostgreSQL: 7
27077      if ($self->in_transaction) {
27078        # ignore failures, make perlcritic happy
27079        eval { $self->disconnect_from_sql } or 1;
27080        die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27081        die "sql execute failed within transaction, $msg";
27082      } else {  # try one more time
27083        do_log(0,"NOTICE: reconnecting in response to: %s", $msg);
27084        # ignore failures, make perlcritic happy
27085        eval { $self->disconnect_from_sql } or 1;
27086        die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27087        $self->connect_to_sql;
27088        $self->dbh or die "sql execute: reconnect failed";
27089        do_log(4,"sql: preparing and executing (again): %s", $clause);
27090        $sth = $self->dbh->prepare($clause); $self->sth($clause,$sth);
27091        $sth or die "sql: prepare (reconnected) failed: ".$DBI::errstr;
27092        $rv_err = $rv_str = undef;
27093        eval {
27094          for my $j (0..$#args) {  # a scalar or [val,type] or [val,\%attr]
27095            $sth->bind_param($j+1, !ref($args[$j]) ? $args[$j] : @{$args[$j]});
27096          }
27097          $sth->execute; $rv_err = $sth->err; $rv_str = $sth->errstr;  1;
27098        } or do {
27099          $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27100          $msg = sprintf("err=%s, %s, %s", $DBI::err,$DBI::state,$eval_stat);
27101          $self->disconnect_from_sql;
27102          die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27103          die "sql execute failed again, $msg";
27104        };
27105      }
27106    }
27107  };
27108  # $rv_err: undef indicates success, "" indicates an 'information',
27109  #          "0" indicates a 'warning', true indicates an error
27110  do_log(2,"sql execute status: err=%s, errstr=%s",
27111           $rv_err,$rv_str)  if defined $rv_err;
27112  ($rv_err,$rv_str);  # potentially useful to see non-fatal errors
27113}
27114
27115# Connect to a database.  Take a list of database connection
27116# parameters and try each until one succeeds.
27117#  -- based on code from Ben Ransford <amavis@uce.ransford.org> 2002-09-22
27118#
27119sub connect_to_sql {
27120  my $self = shift;  # a list of DSNs to try connecting to sequentially
27121  my $dbh; my(@dsns) = @{$self->{dsn_list}};
27122  do_log(3,"Connecting to SQL database server");
27123  for my $tmpdsn (@dsns) {
27124    my($dsn, $username, $password) = @$tmpdsn;
27125    do_log(4,"connect_to_sql: trying '%s'", $dsn);
27126    $dbh = DBI->connect($dsn, $username, $password,
27127             {PrintError => 0, RaiseError => 0, Taint => 1, AutoCommit => 1} );
27128    if ($dbh) {
27129      $self->dsn_current($dsn);
27130      do_log(3,"connect_to_sql: '%s' succeeded", $dsn);
27131      last;
27132    }
27133    do_log(-1,"connect_to_sql: unable to connect to DSN '%s': %s",
27134              $dsn, $DBI::errstr);
27135  }
27136  $self->dbh($dbh); delete($self->{sth});
27137  $self->in_transaction(0); $self->{incarnation}++;
27138  $dbh or die "connect_to_sql: unable to connect to any dataset";
27139  $dbh->{'RaiseError'} = 1;
27140# $dbh->{mysql_auto_reconnect} = 1;  # questionable benefit
27141# $dbh->func(30000,'busy_timeout');  # milliseconds (SQLite)
27142
27143  # https://mathiasbynens.be/notes/mysql-utf8mb4
27144  #   Never use utf8 in MySQL — always use utf8mb4 instead.
27145  #   SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
27146  my $cmd = $self->driver_name eq 'mysql' ? "SET NAMES 'utf8mb4'"
27147                                          : "SET NAMES 'utf8'";
27148  eval {
27149    $dbh->do($cmd); 1;
27150  } or do {
27151    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27152    do_log(2,"connect_to_sql: %s failed: %s", $cmd, $eval_stat);
27153  };
27154  section_time('sql-connect');
27155  $self;
27156}
27157
27158sub disconnect_from_sql($) {
27159  my $self = $_[0];
27160  my $did_disconnect;
27161  $self->in_transaction(0);
27162  if ($self->dbh) {
27163    do_log(4,"disconnecting from SQL");
27164    $self->dbh->disconnect; $self->dbh(undef);
27165    $did_disconnect = 1;
27166  }
27167  delete $self->{sth}; $self->dsn_current(undef);
27168  $did_disconnect;
27169}
27170
271711;
27172
27173__DATA__
27174#^L
27175package Amavis::Out::SQL::Log;
27176use strict;
27177use re 'taint';
27178use warnings;
27179use warnings FATAL => qw(utf8 void);
27180no warnings 'uninitialized';
27181# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
27182
27183BEGIN {
27184  require Exporter;
27185  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
27186  $VERSION = '2.412';
27187  @ISA = qw(Exporter);
27188  import Amavis::Conf qw(:platform :confvars c cr ca);
27189  import Amavis::rfc2821_2822_Tools;
27190  import Amavis::Util qw(ll do_log do_log_safe min max minmax add_entropy
27191                         untaint untaint_inplace format_time_interval
27192                         truncate_utf_8 orcpt_encode
27193                         idn_to_utf8 idn_to_ascii mail_addr_idn_to_ascii
27194                         safe_encode safe_encode_utf8 safe_decode_mime
27195                         snmp_count ccat_split ccat_maj);
27196  import Amavis::Lookup qw(lookup lookup2);
27197  import Amavis::Out::SQL::Connection ();
27198}
27199
27200use DBI qw(:sql_types);
27201
27202sub new {
27203  my($class,$conn_h) = @_; bless { conn_h=>$conn_h, incarnation=>0 }, $class;
27204}
27205
27206sub DESTROY {
27207  my $self = $_[0]; local($@,$!,$_);
27208  do_log_safe(5,"Amavis::Out::SQL::Log DESTROY called");
27209}
27210
27211# find an existing e-mail address record or insert one, returning its id;
27212# may return undef if 'sel_adr' or 'ins_adr' SQL clauses are not defined;
27213#
27214sub find_or_save_addr {
27215  my($self,$addr,$partition_tag,$keep_localpart_case) = @_;
27216  my $id; my $existed = 0; my($localpart,$domain);
27217  my $naddr = untaint($addr);
27218  if ($naddr ne '') {    # normalize address (lowercase, 7-bit, max 255 ch...)
27219    ($localpart,$domain) = split_address($naddr);
27220    $domain = idn_to_ascii($domain);
27221    if (!$keep_localpart_case && !c('localpart_is_case_sensitive')) {
27222      $localpart = lc $localpart;
27223    }
27224    local($1);
27225    $domain = $1  if $domain=~/^\@?(.*?)\.*\z/s;  # chop leading @ and tr. dots
27226    $naddr = $localpart.'@'.$domain;
27227    substr($naddr,255) = ''  if length($naddr) > 255;
27228    # avoid UTF-8 SQL trouble, legitimate RFC 5321 addresses only need 7 bits
27229    $naddr =~ s/[^\040-\176]/?/gs  if !$sql_allow_8bit_address;
27230    # SQL character strings disallow zero octets, and also disallow any other
27231    # octet values and sequences of octet values that are invalid according to
27232    # the database's selected character set encoding
27233  }
27234  my $conn_h = $self->{conn_h}; my $sql_cl_r = cr('sql_clause');
27235  my $sel_adr = $sql_cl_r->{'sel_adr'};
27236  my $ins_adr = $sql_cl_r->{'ins_adr'};
27237  if (!defined($sel_adr) || $sel_adr eq '') {
27238    # no way to query a database, behave as if no record was found
27239    do_log(5,"find_or_save_addr: sel_adr query disabled, %s", $naddr);
27240  } else {
27241    $conn_h->begin_work_nontransaction;  #(re)connect if necessary, autocommit
27242    my $datatype = SQL_VARCHAR;
27243    if ($sql_allow_8bit_address) {
27244      my $driver = $conn_h->driver_name;  # only available when connected
27245      $datatype = $driver eq 'Pg' ? { pg_type => DBD::Pg::PG_BYTEA() }
27246                                  : SQL_VARBINARY;
27247    }
27248    $conn_h->execute($sel_adr, $partition_tag, [$naddr,$datatype]);
27249    my($a_ref,$a2_ref);
27250    if (defined($a_ref=$conn_h->fetchrow_arrayref($sel_adr))) {  # exists?
27251      $id = $a_ref->[0]; $conn_h->finish($sel_adr);
27252      $existed = 1;
27253    } elsif (!defined($ins_adr) || $ins_adr eq '') {
27254      # record does not exist, insertion is not allowed
27255      do_log(5,"find_or_save_addr: ins_adr insertion disabled, %s", $naddr);
27256    } else {  # does not exist, attempt to insert a new e-mail address record
27257      my $invdomain;  # domain with reversed fields, chopped to 255 characters
27258      $invdomain = join('.', reverse split(/\./,$domain,-1));
27259      substr($invdomain,255) = ''  if length($invdomain) > 255;
27260      $conn_h->begin_work_nontransaction;  # (re)connect if not connected
27261      my $eval_stat;
27262      eval { $conn_h->execute($ins_adr, $partition_tag,
27263                              [$naddr,$datatype], $invdomain); 1 }
27264        or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
27265      # INSERT may have failed because of race condition with other processes;
27266      # try the SELECT again, it will most likely succeed this time;
27267      # SELECT after INSERT also avoids the need for a working last_insert_id()
27268      $conn_h->begin_work_nontransaction;  # (re)connect if not connected
27269      # try select again, regardless of the success of INSERT
27270      $conn_h->execute($sel_adr, $partition_tag, [$naddr,$datatype]);
27271      if ( defined($a2_ref=$conn_h->fetchrow_arrayref($sel_adr)) ) {
27272        $id = $a2_ref->[0]; $conn_h->finish($sel_adr);
27273        add_entropy($id);
27274        if (!defined($eval_stat)) {  # status of the INSERT
27275          do_log(5,"find_or_save_addr: record inserted, id=%s, %s",
27276                   $id,$naddr);
27277        } else {
27278          $existed = 1; chomp $eval_stat;
27279          do_log(5,"find_or_save_addr: found on a second attempt, ".
27280                   "id=%s, %s, (first attempt: %s)", $id,$naddr,$eval_stat);
27281          die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27282        }
27283      } else {  # still does not exist
27284        $id = $existed = undef;
27285        if (defined $eval_stat) {  # status of the INSERT
27286          chomp $eval_stat;
27287          die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27288        };
27289        die "find_or_save_addr: failed to insert addr $naddr: $eval_stat";
27290      }
27291    }
27292  }
27293  ($id, $existed);
27294}
27295
27296# find a penpals record which proves that a local user (sid) really sent a
27297# mail to a recipient (rid) some time ago. Returns an interval time in seconds
27298# since the last such mail was sent by our local user to a specified recipient
27299# (or undef if information is not available).  If @$message_id_list is a
27300# nonempty list of Message-IDs as found in References header field, the query
27301# also finds previous outgoing messages with a matching Message-ID but
27302# possibly to recipients different from what the mail was originally sent to.
27303#
27304sub penpals_find {
27305  my($self, $sid,$rid,$message_id_list, $msginfo) = @_;
27306  my($a_ref,$found,$age,$send_time,$ref_mail_id,$ref_subj,$ref_mid,$ref_rid);
27307  my $conn_h = $self->{conn_h}; my $sql_cl_r = cr('sql_clause');
27308  my $sel_penpals = $sql_cl_r->{'sel_penpals'};
27309  my $sel_penpals_msgid = $sql_cl_r->{'sel_penpals_msgid'};
27310  $message_id_list = []  if !$message_id_list;
27311  if (defined($sel_penpals_msgid) && @$message_id_list && defined($sid)) {
27312    # list of refs to Message-ID is nonempty, try reference or recipient match
27313    my $n = scalar(@$message_id_list);  # number of keys
27314    my(@args) = ($sid,$rid);  my(@pos_args);  local($1);
27315    my $sel_taint = substr($sel_penpals_msgid,0,0);   # taintedness
27316    $sel_penpals_msgid =~
27317           s{ ( %m | \? ) }  # substitute %m for keys and ? for next arg
27318            { push(@pos_args,
27319                   $1 eq '%m' ? (map { my $s=$_; $s=~s/[^\040-\176]/?/gs; $s }
27320                                     @$message_id_list)
27321                              : shift @args),
27322              $1 eq '%m' ? join(',', ('?') x $n) : '?' }xgse;
27323    # keep original clause taintedness
27324    $sel_penpals_msgid = untaint($sel_penpals_msgid) . $sel_taint;
27325    untaint_inplace($_) for @pos_args;  # untaint arguments
27326    do_log(4, "penpals: query args: %s", join(', ',@pos_args));
27327    do_log(4, "penpals: %s", $sel_penpals_msgid);
27328    $conn_h->begin_work_nontransaction;  # (re)connect if not connected
27329    $conn_h->execute($sel_penpals_msgid,@pos_args);
27330    snmp_count('PenPalsAttempts'); snmp_count('PenPalsAttemptsMid');
27331    if (!defined($a_ref=$conn_h->fetchrow_arrayref($sel_penpals_msgid))) {
27332      snmp_count('PenPalsMisses');
27333    } else {
27334      ($send_time, $ref_mail_id, $ref_subj, $ref_mid, $ref_rid) = @$a_ref;
27335      $found = 1;  $conn_h->finish($sel_penpals_msgid);
27336      my $rid_match = defined $ref_rid && defined $rid && $rid eq $ref_rid;
27337      my $mid_match = grep($ref_mid eq $_, @$message_id_list);
27338      my $t = $mid_match && $rid_match     ? 'MidRid' :
27339            # $mid_match && !defined($rid) ? 'MidNullRPath' :
27340              $mid_match ? 'Mid' : $rid_match ? 'Rid' : 'none';
27341      snmp_count('PenPalsHits'.$t); snmp_count('PenPalsHits');
27342      ll(4) && do_log(4, "penpals: MATCH ON %s: %s",
27343                         $t, join(", ",@$a_ref));
27344    }
27345  }
27346  if (!$found && defined($sel_penpals) && defined($rid) && defined($sid)) {
27347    # list of Message-ID references not given, try matching on recipient only
27348    $conn_h->begin_work_nontransaction;  # (re)connect if not connected
27349    $conn_h->execute($sel_penpals, untaint($sid), untaint($rid));
27350    snmp_count('PenPalsAttempts'); snmp_count('PenPalsAttemptsRid');
27351    if (!defined($a_ref=$conn_h->fetchrow_arrayref($sel_penpals))) {  # exists?
27352      snmp_count('PenPalsMisses');
27353    } else {
27354      ($send_time, $ref_mail_id, $ref_subj) = @$a_ref;
27355      $found = 1;  $conn_h->finish($sel_penpals);
27356      snmp_count('PenPalsHitsRid'); snmp_count('PenPalsHits');
27357      ll(4) && do_log(4, "penpals: MATCH ON RID(%s): %s",
27358                         $rid, join(", ",@$a_ref));
27359    }
27360  }
27361  if (!$found) {
27362    ll(4) && do_log(4, "penpals: (sql) not found (%s,%s)%s", $sid,$rid,
27363             !@$message_id_list ? '' : ' refs: '.join(", ",@$message_id_list));
27364  } else {
27365    $age = max(0, $msginfo->rx_time - $send_time);
27366    ll(3) && do_log(3, "penpals: (sql) found (%s,%s) %s age %s (%.0f s)",
27367                    $sid, $rid, $ref_mail_id,
27368                    format_time_interval($age), $age);
27369  }
27370  ($age, $ref_mail_id, $ref_subj);
27371}
27372
27373sub save_info_preliminary {
27374  my($self, $msginfo) = @_;
27375  my $mail_id = $msginfo->mail_id;
27376  defined $mail_id  or die "save_info_preliminary: mail_id still undefined";
27377  my $partition_tag = $msginfo->partition_tag;
27378  my($sid,$existed,$sender_smtp); local($1);
27379  $sender_smtp = $msginfo->sender_smtp; $sender_smtp =~ s/^<(.*)>\z/$1/s;
27380  # find an existing e-mail address record for sender, or insert a new one
27381  ($sid,$existed) = $self->find_or_save_addr($sender_smtp,$partition_tag);
27382  if (defined $sid) {
27383    $msginfo->sender_maddr_id($sid);
27384    # there is perhaps 30-50% chance the sender address is already in the db
27385    snmp_count('SqlAddrSenderAttempts');
27386    snmp_count($existed ? 'SqlAddrSenderHits' : 'SqlAddrSenderMisses');
27387    do_log(4,"save_info_preliminary %s, sender id: %s, %s, %s",
27388             $mail_id, $sid, $sender_smtp, $existed ? 'exists' : 'new' );
27389  }
27390  # find existing address records for recipients, or insert them
27391  for my $r (@{$msginfo->per_recip_data}) {
27392    my $addr_smtp = $r->recip_addr_smtp;
27393    if (defined $addr_smtp) {
27394      $addr_smtp =~ s/^<(.*)>\z/$1/s;
27395      $addr_smtp = mail_addr_idn_to_ascii($addr_smtp);
27396    }
27397    my($rid, $o_rid, $existed);
27398    if ($addr_smtp ne '') {
27399      ($rid,$existed) = $self->find_or_save_addr($addr_smtp,$partition_tag);
27400      # there is perhaps 90-100% chance the recipient addr is already in the db
27401      if (defined $rid) {
27402        $r->recip_maddr_id($rid);
27403        snmp_count('SqlAddrRecipAttempts');
27404        snmp_count($existed ? 'SqlAddrRecipHits' : 'SqlAddrRecipMisses');
27405        my($addr_type, $addr) = orcpt_encode($r->dsn_orcpt, 1);
27406        ll(4) && do_log(4,"save_info_preliminary %s, recip id: %s, %s%s, %s",
27407                          $mail_id, $rid, $addr_smtp,
27408                          defined $addr ? " (ORCPT $addr_type;$addr)" : '',
27409                          $existed ? 'exists' : 'new');
27410      }
27411    }
27412  }
27413  my $conn_h = $self->{conn_h}; my $sql_cl_r = cr('sql_clause');
27414  my $ins_msg = $sql_cl_r->{'ins_msg'};
27415  if (!defined($ins_msg) || $ins_msg eq '') {
27416    do_log(4,"save_info_preliminary: ins_msg undef, not saving");
27417  } elsif (!defined($sid)) {
27418    do_log(4,"save_info_preliminary: sid undef, not saving");
27419  } else {
27420    $conn_h->begin_work;  # SQL transaction starts
27421    eval {
27422      # MySQL does not like a standard iso8601 delimiter 'T' or a timezone
27423      # when data type of msgs.time_iso is TIMESTAMP (instead of a string)
27424      my $time_iso = $timestamp_fmt_mysql && $conn_h->driver_name eq 'mysql'
27425                       ? iso8601_utc_timestamp($msginfo->rx_time,1,'')
27426                       : iso8601_utc_timestamp($msginfo->rx_time);
27427      # insert a placeholder msgs record with sender information
27428      $conn_h->execute($ins_msg,
27429        $partition_tag, $msginfo->mail_id, $msginfo->secret_id,
27430        $msginfo->log_id, int($msginfo->rx_time), $time_iso,
27431        untaint($sid), c('policy_bank_path'), untaint($msginfo->client_addr),
27432        0+untaint($msginfo->msg_size),
27433        untaint(substr(idn_to_utf8(c('myhostname')),0,255)));
27434      $conn_h->commit;  1;
27435    } or do {
27436      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27437      if ($conn_h->in_transaction) {
27438        eval {
27439          $conn_h->rollback;
27440          do_log(1,"save_info_preliminary: rollback done");  1;
27441        } or do {
27442          $@ = "errno=$!"  if $@ eq '';  chomp $@;
27443          do_log(1,"save_info_preliminary: rollback %s", $@);
27444          die $@  if $@ =~ /^timed out\b/;  # resignal timeout
27445        };
27446      }
27447      do_log(-1, "WARN save_info_preliminary: %s", $eval_stat);
27448      die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27449      return 0;
27450    };
27451  }
27452  1;
27453}
27454
27455sub save_info_final {
27456  my($self, $msginfo, $report_ref) = @_;
27457  my $mail_id = $msginfo->mail_id;
27458  defined $mail_id  or die "save_info_final: mail_id still undefined";
27459  my $dsn_sent = $msginfo->dsn_sent;
27460  $dsn_sent = !$dsn_sent ? 'N' : $dsn_sent==1 ? 'Y' : $dsn_sent==2 ? 'q' : '?';
27461  my $sid = $msginfo->sender_maddr_id;
27462  my $conn_h = $self->{conn_h}; my($sql_cl_r) = cr('sql_clause');
27463  my $ins_msg = $sql_cl_r->{'ins_msg'};
27464  my $upd_msg = $sql_cl_r->{'upd_msg'};
27465  my $ins_rcp = $sql_cl_r->{'ins_rcp'};
27466  if ($ins_msg eq '' || $upd_msg eq '' || $ins_rcp eq '') {
27467    # updates disabled
27468  } elsif (!defined($sid)) {
27469    # sender not in table maddr, msgs record was not inserted by preliminary
27470  } else {
27471    $conn_h->begin_work;  # SQL transaction starts
27472    eval {
27473      my(%ccat_short_name) = (  # as written to a SQL record
27474        CC_VIRUS,'V',  CC_BANNED,'B',  CC_UNCHECKED,'U',
27475        CC_SPAM,'S',   CC_SPAMMY,'Y',  CC_BADH.",2",'M',  CC_BADH,'H',
27476        CC_OVERSIZED,'O',  CC_MTA,'T',  CC_CLEAN,'C',  CC_CATCHALL,'?');
27477      my($min_spam_level, $max_spam_level) =
27478        minmax(map($_->spam_level, @{$msginfo->per_recip_data}));
27479      # insert per-recipient records into table msgrcpt
27480      my $r_seq_num = 0;  # can serve as a component of a primary key
27481      for my $r (@{$msginfo->per_recip_data}) {
27482        $r_seq_num++;
27483        my $rid = $r->recip_maddr_id;
27484        next  if !defined $rid; # e.g. always_bcc, or table 'maddr' is disabled
27485        my $o_rid = $r->recip_maddr_id_orig;  # may be undef
27486        my $spam_level = $r->spam_level;
27487        my($dest,$resp) = ($r->recip_destiny, $r->recip_smtp_response);
27488        my $d = $resp=~/^4/ ? 'TEMPFAIL'
27489             : ($dest==D_BOUNCE && $resp=~/^5/) ? 'BOUNCE'
27490             : ($dest!=D_BOUNCE && $resp=~/^5/) ? 'REJECT'
27491             : ($dest==D_PASS  && ($resp=~/^2/ || !$r->recip_done)) ? 'PASS'
27492             : ($dest==D_DISCARD) ? 'DISCARD' : '?';
27493        my $r_content_type =
27494          $r->setting_by_contents_category(\%ccat_short_name);
27495        for ($r_content_type) { $_ = ' '  if !defined $_ || /^ *\z/ }
27496        substr($resp,255) = ''  if length($resp) > 255;
27497        $resp =~ s/[^\040-\176]/?/gs;  # just in case, only need 7 bit printbl
27498        # avoid op '?:' on tainted operand in args list, see PR [perl #81028]
27499        my $recip_local_yn = $r->recip_is_local ? 'Y' : 'N';
27500        my $blacklisted_yn = $r->recip_blacklisted_sender ? 'Y' : 'N';
27501        my $whitelisted_yn = $r->recip_whitelisted_sender ? 'Y' : 'N';
27502        $conn_h->execute($ins_rcp,
27503          $msginfo->partition_tag, $mail_id,
27504          $sql_schema_version < 2.007000 ? untaint($rid)
27505            : ($r_seq_num, untaint($rid), $recip_local_yn, $r_content_type),
27506          substr($d,0,1), ' ',
27507          $blacklisted_yn, $whitelisted_yn, 0+untaint($spam_level),
27508          untaint($resp),
27509        );
27510        # untaint(defined $o_rid ? $o_rid : $rid),
27511        # int($msginfo->rx_time),
27512        # untaint($r->user_policy_id),
27513      }
27514      my $q_to = $msginfo->quarantined_to;  # ref to a list of quar. locations
27515      if (!defined($q_to) || !@$q_to) { $q_to = undef }
27516      else {
27517        $q_to = $q_to->[0];  # keep only the first quarantine location
27518        $q_to =~ s{^\Q$QUARANTINEDIR\E/}{};  # strip directory name
27519      }
27520      my $m_id = $msginfo->get_header_field_body('message-id');
27521      $m_id = join(' ',parse_message_id($m_id))  if $m_id ne '';  # strip CFWS
27522      my $subj = $msginfo->get_header_field_body('subject');
27523      my $from = $msginfo->get_header_field_body('from');  # raw full field
27524      my $rfc2822_from   = $msginfo->rfc2822_from;  # undef, scalar or listref
27525      my $rfc2822_sender = $msginfo->rfc2822_sender;  # undef or scalar
27526      $rfc2822_from = join(', ',@$rfc2822_from)  if ref $rfc2822_from;
27527      my $os_fp = $msginfo->client_os_fingerprint;
27528      $_ = !defined($_) ? '' :untaint($_) for ($subj,$from,$m_id,$q_to,$os_fp);
27529      for ($subj,$from) {  # character set decoding, sanitation
27530        chomp; s/\n(?=[ \t])//gs; s/^[ \t]+//s; s/[ \t]+\z//s;  # unfold, trim
27531        eval {  # convert to UTF-8 octets, truncate to 255 bytes
27532          my $chars  = safe_decode_mime($_);      # to logical characters
27533          my $octets = safe_encode_utf8($chars);  # to bytes, UTF-8 encoded
27534          $octets = truncate_utf_8($octets,255);
27535          # man DBI: Drivers should accept [unicode and non-unicode] strings
27536          # and, if required, convert them to the character set of the
27537          # database being used. Similarly, when fetching from the database
27538          # character data that isn't iso-8859-1 the driver should convert
27539          # it into UTF-8.
27540          $_ = $octets; 1;  # pass bytes to SQL, UTF-8, works better
27541        } or do {
27542          my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27543          do_log(1,"save_info_final INFO: header field ".
27544                   "not decodable, keeping raw bytes: %s", $eval_stat);
27545          substr($_,255) = ''  if length($_) > 255;
27546          die $eval_stat  if $eval_stat =~ /^timed out\b/; # resignal timeout
27547        };
27548      }
27549      for ($m_id,$q_to,$os_fp) {  # truncate to 255 ch, ensure 7-bit characters
27550        substr($_,255) = ''  if length($_) > 255;
27551        s/[^\040-\176]/?/gs;  # only use 7 bit printable, compatible with UTF-8
27552      }
27553      my $content_type =
27554        $msginfo->setting_by_contents_category(\%ccat_short_name);
27555      my $checks_performed = $msginfo->checks_performed;
27556      $checks_performed = !ref $checks_performed ? ''
27557                : join('', grep($checks_performed->{$_}, qw(V S H B F P D)));
27558      my $q_type = $msginfo->quar_type;
27559      # only keep the first quarantine type used (e.g. ignore archival quar.)
27560      $q_type = $q_type->[0]  if ref $q_type;
27561      for ($q_type,$content_type) { $_ = ' '  if !defined $_ || /^ *\z/ }
27562      $min_spam_level = 0  if !defined $min_spam_level;
27563      $max_spam_level = 0  if !defined $max_spam_level;
27564      my $orig = $msginfo->originating ? 'Y' : 'N';
27565      ll(4) && do_log(4,"save_info_final %s, orig=%s, chks=%s, cont.ty=%s, ".
27566                        "q.type=%s, q.to=%s, dsn=%s, score=%s, ".
27567                        "Message-ID: %s, From: '%s', Subject: '%s'",
27568                        $mail_id, $orig, $checks_performed, $content_type,
27569                        $q_type, $q_to, $dsn_sent, $min_spam_level,
27570                        $m_id, $from, $subj);
27571      # update message record with additional information
27572      $conn_h->execute($upd_msg,
27573               $content_type, $q_type, $q_to, $dsn_sent,
27574               0+untaint($min_spam_level), $m_id, $from, $subj,
27575               untaint($msginfo->client_addr), # we may have a better info now
27576               $sql_schema_version < 2.007000 ? () : $orig,
27577               $msginfo->partition_tag, $mail_id);
27578               # $os_fp, $rfc2822_sender, $rfc2822_from, $checks_performed, ...
27579      # SQL_CHAR, SQL_VARCHAR, SQL_VARBINARY, SQL_BLOB, SQL_INTEGER, SQL_FLOAT,
27580      # SQL_TIMESTAMP, SQL_TYPE_TIMESTAMP_WITH_TIMEZONE, ...
27581      $conn_h->commit;  1;
27582    } or do {
27583      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27584      if ($conn_h->in_transaction) {
27585        eval {
27586          $conn_h->rollback;
27587          do_log(1,"save_info_final: rollback done");  1;
27588        } or do {
27589          $@ = "errno=$!"  if $@ eq '';  chomp $@;
27590          do_log(1,"save_info_final: rollback %s", $@);
27591          die $@  if $@ =~ /^timed out\b/;  # resignal timeout
27592        };
27593      }
27594      do_log(-1, "WARN save_info_final: %s", $eval_stat);
27595      die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27596      return 0;
27597    };
27598  }
27599  1;
27600}
27601
276021;
27603
27604__DATA__
27605#
27606package Amavis::IO::SQL;
27607
27608# an IO wrapper around SQL for inserting/retrieving mail text
27609# to/from a database
27610
27611use strict;
27612use re 'taint';
27613use warnings;
27614use warnings FATAL => qw(utf8 void);
27615no warnings 'uninitialized';
27616# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
27617
27618BEGIN {
27619  require Exporter;
27620  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
27621  $VERSION = '2.412';
27622  @ISA = qw(Exporter);
27623  import Amavis::Util qw(ll do_log untaint min max minmax);
27624}
27625
27626use Errno qw(ENOENT EACCES EIO);
27627use DBI qw(:sql_types);
27628# use DBD::Pg;
27629
27630sub new {
27631  my $class = shift;
27632  my $self = bless {}, $class;
27633  if (@_) { $self->open(@_) or return }
27634  $self;
27635}
27636
27637sub open {
27638  my $self = shift;
27639  if (exists $self->{conn_h}) {
27640    eval { $self->close } or 1;  # ignore failure, make perlcritic happy
27641  }
27642  @$self{qw(conn_h clause dbkey mode partition_tag maxbuf rx_time)} = @_;
27643  my $conn_h = $self->{conn_h}; $self->{buf} = '';
27644  $self->{chunk_ind} = $self->{pos} = $self->{bufpos} = $self->{eof} = 0;
27645  my $driver; my $eval_stat;
27646  eval { $driver = $conn_h->driver_name;  1 }
27647    or do { $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat };
27648  die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
27649  if ($self->{mode} eq 'w') {  # open for write access
27650    ll(4) && do_log(4,"Amavis::IO::SQL::open %s drv=%s (%s); key=%s, p_tag=%s",
27651                    $self->{mode}, $driver, $self->{clause},
27652                    $self->{dbkey}, $self->{partition_tag});
27653  } else {  # open for read access
27654    $eval_stat = undef;
27655    eval {
27656      $conn_h->execute($self->{clause}, $self->{partition_tag},$self->{dbkey});
27657      1;
27658    } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat };
27659    my $ll = $eval_stat ne '' ? -1 : 4;
27660    do_log($ll,"Amavis::IO::SQL::open %s drv=%s (%s); key=%s, p_tag=%s, s: %s",
27661               $self->{mode}, $driver, $self->{clause},
27662               $self->{dbkey}, $self->{partition_tag}, $eval_stat)  if ll($ll);
27663    if ($eval_stat ne '') {
27664      if ($eval_stat =~ /^timed out\b/) { die $eval_stat }  # resignal timeout
27665      else { die "Amavis::IO::SQL::open $driver SELECT error: $eval_stat" }
27666      $! = EIO; return;  # not reached
27667    }
27668    $eval_stat = undef;
27669    eval {  # fetch the first chunk; if missing treat it as a file-not-found
27670      my $a_ref = $conn_h->fetchrow_arrayref($self->{clause});
27671      if (!defined($a_ref)) { $self->{eof} = 1 }
27672      else { $self->{buf} = $a_ref->[0]; $self->{chunk_ind}++ }
27673      1;
27674    } or do {
27675      $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27676      if ($eval_stat =~ /^timed out\b/) { die $eval_stat }  # resignal timeout
27677      else { die "Amavis::IO::SQL::open $driver read error: $eval_stat" }
27678      $! = EIO; return;  # not reached
27679    };
27680    if ($self->{eof}) {  # no records, make it look like a missing file
27681      do_log(0,"Amavis::IO::SQL::open key=%s, p_tag=%s: no such record",
27682               $self->{dbkey}, $self->{partition_tag});
27683      $! = ENOENT;  # No such file or directory
27684      return;
27685    }
27686  }
27687  $self;
27688}
27689
27690sub DESTROY {
27691  my $self = $_[0];
27692  local($@,$!,$_); my $myactualpid = $$;
27693  if ($self && $self->{conn_h}) {
27694    eval {
27695      $self->close or die "Error closing: $!";  1;
27696    } or do {
27697      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27698      warn "[$myactualpid] Amavis::IO::SQL::close error: $eval_stat";
27699    };
27700    delete $self->{conn_h};
27701  }
27702}
27703
27704sub close {
27705  my $self = $_[0];
27706  my $eval_stat;
27707  eval {
27708    if ($self->{mode} eq 'w') {
27709      $self->flush or die "Can't flush: $!";
27710    } elsif ($self->{conn_h} && $self->{clause} && !$self->{eof}) {
27711      # reading, closing before eof was reached
27712      $self->{conn_h}->finish($self->{clause}) or die "Can't finish: $!";
27713    };
27714    1;
27715  } or do {
27716    $eval_stat = $@ ne '' ? $@ : "errno=$!";
27717  };
27718  delete @$self{
27719    qw(conn_h clause dbkey mode maxbuf rx_time buf chunk_ind pos bufpos eof) };
27720  if (defined $eval_stat) {
27721    chomp $eval_stat;
27722    if ($eval_stat =~ /^timed out\b/) { die $eval_stat }  # resignal timeout
27723    else { die "Error closing, $eval_stat" }
27724    $! = EIO; return;  # not reached
27725  }
27726  1;
27727}
27728
27729sub seek {
27730  my($self,$pos,$whence) = @_;
27731  $whence == 0  or die "Only absolute seek is supported on sql i/o";
27732  $pos >= 0     or die "Can't seek to a negative absolute position on sql i/o";
27733  ll(5) && do_log(5, "Amavis::IO::SQL::seek mode=%s, pos=%s",
27734                     $self->{mode}, $pos);
27735  $self->{mode} ne 'w'
27736    or die "Seek to $whence,$pos on sql i/o only supported for read mode";
27737  if ($pos < $self->{pos}) {
27738    if (!$self->{eof} && $self->{chunk_ind} <= 1) {
27739      # still in the first chunk, just reset pos
27740      $self->{pos} = $self->{bufpos} = 0;  # reset
27741    } else {  # beyond the first chunk, restart the query from the beginning
27742      my($con,$clause,$key,$mode,$partition_tag,$maxb,$rx_time) =
27743        @$self{qw(conn_h clause dbkey mode partition_tag maxbuf rx_time)};
27744      $self->close or die "seek: error closing, $!";
27745      $self->open($con,$clause,$key,$mode,$partition_tag,$maxb,$rx_time)
27746        or die "seek: reopen failed: $!";
27747    }
27748  }
27749  my $skip = $pos - $self->{pos};
27750  if ($skip > 0) {
27751    my $s;  my $nbytes = $self->read($s,$skip);  # acceptable for small skips
27752    defined $nbytes or die "seek: error skipping $skip bytes on sql i/o: $!";
27753  }
27754  1;  # seek is supposed to return 1 upon success, 0 otherwise
27755}
27756
27757sub read {  # SCALAR,LENGTH,OFFSET
27758  my $self = shift; my $req_len = $_[1]; my $offset = $_[2];
27759  my $conn_h = $self->{conn_h}; my $a_ref;
27760  ll(5) && do_log(5, "Amavis::IO::SQL::read, %d, %d",
27761                     $self->{chunk_ind}, $self->{bufpos});
27762  eval {
27763    while (!$self->{eof} && length($self->{buf})-$self->{bufpos} < $req_len) {
27764      $a_ref = $conn_h->fetchrow_arrayref($self->{clause});
27765      if (!defined($a_ref)) { $self->{eof} = 1 }
27766      else { $self->{buf} .= $a_ref->[0]; $self->{chunk_ind}++ }
27767    }
27768    1;
27769  } or do {
27770    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27771    # we can't stash an arbitrary error message string into $!,
27772    # which forces us to use 'die' to properly report an error
27773    if ($eval_stat =~ /^timed out\b/) { die $eval_stat }  # resignal timeout
27774    else { die "read: sql select failed, $eval_stat" }
27775    $! = EIO; return;  # not reached
27776  };
27777  my $nbytes;
27778  if (!defined($offset) || $offset == 0) {
27779    $_[0] = substr($self->{buf}, $self->{bufpos}, $req_len);
27780    $nbytes = length($_[0]);
27781  } else {
27782    my $buff = substr($self->{buf}, $self->{bufpos}, $req_len);
27783    substr($_[0],$offset) = $buff; $nbytes = length($buff);
27784  }
27785  $self->{bufpos} += $nbytes; $self->{pos} += $nbytes;
27786  if ($self->{bufpos} > 0 && $self->{chunk_ind} > 1) {
27787    # discard used-up part of the buf unless at ch.1, which may still be useful
27788    ll(5) && do_log(5,"read: moving on by %d chars", $self->{bufpos});
27789    $self->{buf} = substr($self->{buf},$self->{bufpos}); $self->{bufpos} = 0;
27790  }
27791  $nbytes;   # eof: 0, error: undef
27792}
27793
27794sub getline {
27795  my $self = $_[0];  my $conn_h = $self->{conn_h};
27796  ll(5) && do_log(5, "Amavis::IO::SQL::getline, chunk %d, pos %d",
27797                     $self->{chunk_ind}, $self->{bufpos});
27798  my($a_ref,$line); my $ind = -1;
27799  eval {
27800    while (!$self->{eof} &&
27801           ($ind=index($self->{buf},"\n",$self->{bufpos})) < 0) {
27802      $a_ref = $conn_h->fetchrow_arrayref($self->{clause});
27803      if (!defined($a_ref)) { $self->{eof} = 1 }
27804      else { $self->{buf} .= $a_ref->[0]; $self->{chunk_ind}++ }
27805    }
27806    1;
27807  } or do {
27808    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27809    if ($eval_stat =~ /^timed out\b/) { die $eval_stat }  # resignal timeout
27810    else { die "getline: reading sql select results failed, $eval_stat" }
27811    $! = EIO; return;  # not reached
27812  };
27813  if ($ind < 0 && $self->{eof})  # imply a NL before eof if missing
27814    { $self->{buf} .= "\n"; $ind = index($self->{buf}, "\n", $self->{bufpos}) }
27815  $ind >= 0  or die "Programming error, NL not found";
27816  if (length($self->{buf}) > $self->{bufpos}) {  # nonempty buffer?
27817    $line = substr($self->{buf}, $self->{bufpos}, $ind+1-$self->{bufpos});
27818    my $nbytes = length($line);
27819    $self->{bufpos} += $nbytes; $self->{pos} += $nbytes;
27820    if ($self->{bufpos} > 0 && $self->{chunk_ind} > 1) {
27821      # discard used part of the buf unless at ch.1, which may still be useful
27822      ll(5) && do_log(5,"getline: moving on by %d chars", $self->{bufpos});
27823      $self->{buf} = substr($self->{buf},$self->{bufpos}); $self->{bufpos} = 0;
27824    }
27825  }
27826  # eof: undef, $! zero;  error: undef, $! nonzero
27827  $! = 0;  $line eq '' ? undef : $line;
27828}
27829
27830sub flush {
27831  my $self = $_[0];
27832  return  if $self->{mode} ne 'w';
27833  my $msg; my $conn_h = $self->{conn_h};
27834  while ($self->{buf} ne '') {
27835    my $ind = $self->{chunk_ind} + 1;
27836    ll(4) && do_log(4, "sql flush: key: (%s, %d), p_tag=%s, rx_t=%d, size=%d",
27837                $self->{dbkey}, $ind, $self->{partition_tag}, $self->{rx_time},
27838                min(length($self->{buf}),$self->{maxbuf}));
27839    eval {
27840      my $driver = $conn_h->driver_name;
27841      $conn_h->execute($self->{clause},
27842                       $self->{partition_tag}, $self->{dbkey}, $ind,
27843                     # int($self->{rx_time}),
27844                       [ untaint(substr($self->{buf},0,$self->{maxbuf})),
27845                         $driver eq 'Pg' ? { pg_type => DBD::Pg::PG_BYTEA() }
27846                                         : SQL_BLOB ] );
27847      1;
27848    } or do {
27849      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27850      $msg = $eval_stat;
27851    };
27852    last  if defined $msg;
27853    substr($self->{buf},0,$self->{maxbuf}) = ''; $self->{chunk_ind} = $ind;
27854  }
27855  if (defined $msg) {
27856    chomp $msg;
27857    if ($msg =~ /^timed out\b/) { die $msg }  # resignal timeout
27858    else {
27859      $msg = "flush: sql inserting text failed, $msg";
27860      die $msg;  # we can't stash an arbitrary error message string into $!,
27861                 # which forces us to use 'die' to properly report an error
27862    }
27863    $! = EIO; return;  # not reached
27864  }
27865  1;
27866}
27867
27868sub print {
27869  my $self = shift;
27870  $self->{mode} eq 'w' or die "Can't print, not opened for writing";
27871  my $buff_ref = @_ == 1 ? \$_[0] : \join('',@_);
27872  my $len = length($$buff_ref);
27873  my $nbytes; my $conn_h = $self->{conn_h};
27874  if ($len <= 0) { $nbytes = "0 but true" }
27875  else {
27876    $self->{buf} .= $$buff_ref; $self->{pos} += $len; $nbytes = $len;
27877    while (length($self->{buf}) >= $self->{maxbuf}) {
27878      my $ind = $self->{chunk_ind} + 1;
27879      ll(4) && do_log(4, "sql print: key: (%s, %d), p_tag=%s, size=%d",
27880                         $self->{dbkey}, $ind,
27881                         $self->{partition_tag}, $self->{maxbuf});
27882      eval {
27883        my $driver = $conn_h->driver_name;
27884        $conn_h->execute($self->{clause},
27885                         $self->{partition_tag}, $self->{dbkey}, $ind,
27886                       # int($self->{rx_time}),
27887                         [ untaint(substr($self->{buf},0,$self->{maxbuf})),
27888                           $driver eq 'Pg' ? { pg_type => DBD::Pg::PG_BYTEA() }
27889                                           : SQL_BLOB ] );
27890        1;
27891      } or do {
27892        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
27893        # we can't stash an arbitrary error message string into $!,
27894        # which forces us to use 'die' to properly report an error
27895        if ($eval_stat =~ /^timed out\b/) { die $eval_stat } # resignal timeout
27896        else { die "print: sql inserting mail text failed, $eval_stat" }
27897        $! = EIO; return;  # not reached
27898      };
27899      substr($self->{buf},0,$self->{maxbuf}) = ''; $self->{chunk_ind} = $ind;
27900    }
27901  }
27902  $nbytes;
27903}
27904
27905sub printf { shift->print(sprintf(shift,@_)) }
27906
279071;
27908
27909#^L
27910package Amavis::Out::SQL::Quarantine;
27911use strict;
27912use re 'taint';
27913use warnings;
27914use warnings FATAL => qw(utf8 void);
27915no warnings 'uninitialized';
27916# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
27917
27918BEGIN {
27919  require Exporter;
27920  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
27921  $VERSION = '2.412';
27922  @ISA = qw(Exporter);
27923  @EXPORT = qw(&mail_via_sql);
27924  import Amavis::Conf qw(:platform c cr ca $sql_quarantine_chunksize_max);
27925  import Amavis::rfc2821_2822_Tools qw(qquote_rfc2821_local);
27926  import Amavis::Util qw(ll do_log snmp_count collect_equal_delivery_recips);
27927  import Amavis::Timing qw(section_time);
27928  import Amavis::Out::SQL::Connection ();
27929}
27930use subs @EXPORT;
27931
27932use DBI qw(:sql_types);
27933
27934sub mail_via_sql {
27935  my($conn_h,
27936     $msginfo, $initial_submission, $dsn_per_recip_capable, $filter) = @_;
27937  my(@snmp_vars) = !$initial_submission ?
27938    ('', 'Relay',  'ProtoSQL', 'ProtoSQLRelay')
27939  : ('', 'Submit', 'ProtoSQL', 'ProtoSQLSubmit',
27940     'Submit'.$initial_submission);
27941  snmp_count('OutMsgs'.$_)  for @snmp_vars;
27942  my $logmsg =
27943    sprintf("%s via SQL (%s): %s", ($initial_submission?'SEND':'FWD'),
27944            $conn_h->dsn_current, $msginfo->sender_smtp);
27945  my($per_recip_data_ref, $proto_sockname) =
27946    collect_equal_delivery_recips($msginfo, $filter, qr/^sql:/i);
27947  if (!$per_recip_data_ref || !@$per_recip_data_ref) {
27948    do_log(5, "%s, nothing to do", $logmsg);  return 1;
27949  }
27950  my $mail_id = $msginfo->mail_id;
27951  defined $mail_id  or die "mail_via_sql: mail_id still undefined";
27952  $proto_sockname = $proto_sockname->[0]  if ref $proto_sockname;
27953  ll(1) && do_log(1, "delivering to %s, %s -> %s, mail_id %s",
27954                     $proto_sockname, $logmsg,
27955                     join(',', qquote_rfc2821_local(
27956                            map($_->recip_final_addr, @$per_recip_data_ref)) ),
27957                     $mail_id);
27958  my $msg = $msginfo->mail_text;  # a scalar reference, or a file handle
27959  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
27960  $msg = $msg_str_ref  if ref $msg_str_ref;
27961  my($err,$smtp_response);
27962  eval {
27963    my $sql_cl_r = cr('sql_clause');
27964    $conn_h->begin_work;  # SQL transaction starts
27965    eval {
27966      my $mp = Amavis::IO::SQL->new;
27967      $mp->open($conn_h, $sql_cl_r->{'ins_quar'}, $msginfo->mail_id, 'w',
27968                $msginfo->partition_tag, $sql_quarantine_chunksize_max,
27969                $msginfo->rx_time)
27970        or die "Can't open Amavis::IO::SQL object: $!";
27971      my $hdr_edits = $msginfo->header_edits;
27972      $hdr_edits = Amavis::Out::EditHeader->new  if !$hdr_edits;
27973      my($received_cnt,$file_position) =
27974        $hdr_edits->write_header($msginfo,$mp,!$initial_submission);
27975      if ($received_cnt > 100) { # loop detection required by RFC 5321 sect 6.2
27976        die "Too many hops: $received_cnt 'Received:' header fields";
27977      } elsif (!defined $msg) {
27978        # empty mail
27979      } elsif (ref $msg eq 'SCALAR') {
27980        $mp->print(substr($$msg,$file_position))
27981          or die "Can't write to SQL storage: $!";
27982      } elsif ($msg->isa('MIME::Entity')) {
27983        $msg->print_body($mp);
27984      } else {
27985        my($nbytes,$buff);
27986        while (($nbytes = $msg->read($buff,32768)) > 0) {
27987          $mp->print($buff) or die "Can't write to SQL storage: $!";
27988        }
27989        defined $nbytes or die "Error reading: $!";
27990      }
27991      $mp->close or die "Error closing Amavis::IO::SQL object: $!";
27992      $conn_h->commit;  1;
27993    } or do {
27994      my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;  my $msg = $err;
27995      $msg = "writing mail text to SQL failed: $msg"; do_log(0,"%s",$msg);
27996      if ($conn_h->in_transaction) {
27997        eval {
27998          $conn_h->rollback;
27999          do_log(1,"mail_via_sql: rollback done");  1;
28000        } or do {
28001          $@ = "errno=$!"  if $@ eq '';  chomp $@;
28002          do_log(1,"mail_via_sql: rollback %s", $@);
28003          die $@  if $@ =~ /^timed out\b/;  # resignal timeout
28004        };
28005      }
28006      die $err  if $err =~ /^timed out\b/;  # resignal timeout
28007      die $msg;
28008    };
28009    1;
28010  } or do { $err = $@ ne '' ? $@ : "errno=$!" };
28011  if ($err eq '') {
28012    $smtp_response = "250 2.6.0 Ok, Stored to sql db as mail_id $mail_id";
28013    snmp_count('OutMsgsDelivers');
28014    my $size = $msginfo->msg_size;
28015    snmp_count( ['OutMsgsSize'.$_, $size, 'C64'] )  for @snmp_vars;
28016  } else {
28017    chomp $err;
28018    if ($err =~ /too many hops\b/i) {
28019      $smtp_response = "554 5.4.6 Reject: $err";
28020      snmp_count('OutMsgsRejects');
28021    } else {
28022      $smtp_response =
28023        "451 4.5.0 Storing to sql db as mail_id $mail_id failed: $err";
28024      snmp_count('OutMsgsAttemptFails');
28025    }
28026    die $err  if $err =~ /^timed out\b/;  # resignal timeout
28027  }
28028  $smtp_response .= ", id=" . $msginfo->log_id;
28029  for my $r (@$per_recip_data_ref) {
28030    next  if $r->recip_done;
28031    $r->recip_smtp_response($smtp_response); $r->recip_done(2);
28032    if ($smtp_response =~ /^2/) {
28033      my $mbxname = $mail_id;
28034      my $p_tag = $msginfo->partition_tag;
28035      $mbxname .= '[' . $p_tag . ']'
28036        if defined($p_tag) && $p_tag ne '' && $p_tag ne '0';
28037      $r->recip_mbxname($mbxname);
28038    }
28039  }
28040  section_time('fwd-sql');
28041  1;
28042}
28043
280441;
28045
28046__DATA__
28047#
28048package Amavis::AV;
28049use strict;
28050use re 'taint';
28051use warnings;
28052use warnings FATAL => qw(utf8 void);
28053no warnings 'uninitialized';
28054# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
28055
28056BEGIN {
28057  require Exporter;
28058  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
28059  $VERSION = '2.412';
28060  @ISA = qw(Exporter);
28061  import Amavis::Conf qw(:platform :confvars c cr ca);
28062  import Amavis::Util qw(ll untaint min max minmax unique_list do_log
28063                         add_entropy proto_decode rmdir_recursively
28064                         prolong_timer get_deadline generate_mail_id);
28065  import Amavis::ProcControl qw(exit_status_str proc_status_ok
28066                         run_command run_as_subprocess
28067                         collect_results collect_results_structured);
28068  import Amavis::Lookup qw(lookup lookup2);
28069  import Amavis::Timing qw(section_time);
28070  import Amavis::Out qw(mail_dispatch);
28071  import Amavis::rfc2821_2822_Tools qw(one_response_for_all);
28072}
28073use subs @EXPORT_OK;
28074use vars @EXPORT;
28075
28076use POSIX qw(WIFEXITED WIFSIGNALED WIFSTOPPED
28077             WEXITSTATUS WTERMSIG WSTOPSIG);
28078use Errno qw(EPIPE ENOTCONN ENOENT EACCES EINTR EAGAIN ECONNRESET);
28079use Time::HiRes ();
28080
28081use vars qw(%st_socket_created %st_sock); # keep persistent state (per-socket)
28082
28083sub clamav_module_init($) {
28084  my $av_name = $_[0];
28085  # each child should reinitialize clamav module to reload databases
28086  my $clamav_version = Mail::ClamAV->VERSION;
28087  my $dbdir = Mail::ClamAV::retdbdir();
28088  my $clamav_obj = Mail::ClamAV->new($dbdir);
28089  ref $clamav_obj
28090    or die "$av_name: Can't load db from $dbdir: $Mail::ClamAV::Error";
28091  $clamav_obj->buildtrie;
28092  $clamav_obj->maxreclevel($MAXLEVELS)  if $MAXLEVELS > 0;
28093  $clamav_obj->maxfiles($MAXFILES)      if $MAXFILES  > 0;
28094  $clamav_obj->maxfilesize($MAX_EXPANSION_QUOTA || 50*1024*1024);
28095  if ($clamav_version >= 0.12) {
28096    $clamav_obj->maxratio($MAX_EXPANSION_FACTOR);
28097#   $clamav_obj->archivememlim(0);  # limit memory usage for bzip2 (0/1)
28098  }
28099  do_log(3,"clamav_module_init: %s init", $av_name);
28100  section_time('clamav_module_init');
28101  ($clamav_obj,$clamav_version);
28102}
28103
28104# called from sub ask_clamav or ask_daemon, should not run as a subprocess
28105#
28106use vars qw($clamav_obj $clamav_version);
28107sub clamav_module_internal_pre($) {
28108  my $av_name = $_[0];
28109  if (!defined $clamav_obj) {
28110    ($clamav_obj,$clamav_version) = clamav_module_init($av_name);  # first time
28111  } elsif ($clamav_obj->statchkdir) {     # db reload needed?
28112    do_log(2, "%s: reloading virus database", $av_name);
28113    ($clamav_obj,$clamav_version) = clamav_module_init($av_name);
28114  }
28115}
28116
28117# called from sub ask_clamav or ask_daemon, may be called directly
28118# or in a subprocess
28119#
28120sub clamav_module_internal($@) {
28121  my($query, $bare_fnames,$names_to_parts,$tempdir, $av_name) = @_;
28122  $query = join(' ',@$query)  if ref $query;
28123  my $fname = "$tempdir/parts/$query";   # file to be checked
28124  my $part = $names_to_parts->{$query};  # get corresponding parts object
28125  my $options = 0;  # bitfield of options to Mail::ClamAV::scan
28126  my($opt_archive,$opt_mail);
28127  if ($clamav_version < 0.12) {
28128    $opt_archive = &Mail::ClamAV::CL_ARCHIVE;
28129    $opt_mail    = &Mail::ClamAV::CL_MAIL;
28130  } else {         # >= 0.12, reflects renamed flags in libclamav 0.80
28131    $opt_archive = &Mail::ClamAV::CL_SCAN_ARCHIVE;
28132    $opt_mail    = &Mail::ClamAV::CL_SCAN_MAIL;
28133  }
28134  # see clamav.h for standard options enabled by CL_SCAN_STDOPT
28135  $options |= &Mail::ClamAV::CL_SCAN_STDOPT  if $clamav_version >= 0.13;
28136  $options |= $opt_archive;  # turn on ARCHIVE
28137  $options &= ~$opt_mail;    # turn off MAIL
28138  my $type_decl = $part->type_declared;
28139  if (ref $part &&
28140      ($part->type_short eq 'MAIL' ||
28141       defined $type_decl && $type_decl=~m{^message/(?:rfc822|global)\z}si)) {
28142    do_log(2, "%s: $query - enabling option CL_MAIL", $av_name);
28143    $options |= $opt_mail;   # turn on MAIL
28144  }
28145  my $ret = $clamav_obj->scan(untaint($fname), $options);
28146  my($output,$status);
28147  if    ($ret->virus) { $status = 1; $output = "INFECTED: $ret" }
28148  elsif ($ret->clean) { $status = 0; $output = "CLEAN" }
28149  else { $status = 2; $output = $ret->error.", errno=".$ret->errno }
28150  ($status,$output);  # return synthesised status and a result string
28151}
28152
28153# subroutine available for calling from @av_scanners list entries;
28154# it has the same args and returns as run_av() below
28155#
28156sub ask_clamav {
28157  my($bare_fnames,$names_to_parts,$tempdir, $av_name) = @_;
28158  clamav_module_internal_pre($av_name);  # must not run as a subprocess
28159# my(@results) = ask_av(\&clamav_module_internal, @_);  # invoke directly
28160  my($proc_fh,$pid) = run_as_subprocess(\&ask_av, \&clamav_module_internal,@_);
28161  my($results_ref,$child_stat) =
28162    collect_results_structured($proc_fh,$pid,$av_name,200*1024);
28163  !$results_ref ? () : @$results_ref;
28164}
28165
28166my $savi_obj;
28167sub sophos_savi_init {
28168  my($av_name, $command) = @_;
28169  my(@savi_bool_options) = qw(
28170         GrpArchiveUnpack GrpSelfExtract GrpExecutable GrpInternet GrpMSOffice
28171         GrpMisc !GrpDisinfect !GrpClean EnableAutoStop FullSweep FullPdf Xml
28172  );
28173  $savi_obj = SAVI->new;
28174  ref $savi_obj or die "$av_name: Can't create SAVI object, err=$savi_obj";
28175  my $status = $savi_obj->load_data;
28176  !defined($status) or die "$av_name: Failed to load SAVI virus data " .
28177                           $savi_obj->error_string($status) . " ($status)";
28178  my $version = $savi_obj->version;
28179  ref $version or die "$av_name: Can't get SAVI version, err=$version";
28180  do_log(2,"%s init: Version %s (engine %d.%d) recognizing %d viruses",
28181           $av_name, $version->string, $version->major, $version->minor,
28182           $version->count);
28183  my $error;
28184  if ($MAXLEVELS > 0) {
28185    $error = $savi_obj->set('MaxRecursionDepth', $MAXLEVELS);
28186    !defined $error
28187      or die "$av_name: error setting MaxRecursionDepth: err=$error";
28188  }
28189  $error = $savi_obj->set('NamespaceSupport', 3);  # new with Sophos 3.67
28190  !defined $error
28191    or do_log(-1,"%s: error setting NamespaceSupport: err=%s",$av_name,$error);
28192  for (@savi_bool_options) {
28193    my $value = /^!/ ? 0 : 1;  s/^!+//;
28194    $error = $savi_obj->set($_, $value);
28195    !defined $error or die "$av_name: Error setting $_: err=$error";
28196  }
28197  section_time('sophos_savi_init');
28198  1;
28199}
28200
28201sub sophos_savi_stale {
28202  defined $savi_obj && $savi_obj->stale;
28203}
28204
28205# run by a master(!) process, invoked from a hook run_n_children_hook
28206#
28207sub sophos_savi_reload {
28208  if (defined $savi_obj) {
28209    do_log(3,"sophos_savi_reload: about to reload SAVI data");
28210    eval {
28211      my $status = $savi_obj->load_data;
28212      do_log(-1,"sophos_savi_reload: failed to load SAVI virus data %s (%s)",
28213                 $savi_obj->error_string($status), $status) if defined $status;
28214      1;
28215    } or do {
28216      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
28217      do_log(-1,"sophos_savi_reload failed: %s", $eval_stat);
28218    };
28219    my $version = $savi_obj->version;
28220    if (!ref($version)) {
28221      do_log(-1,"sophos_savi_reload: Can't get SAVI version: %s", $version);
28222    } else {
28223      do_log(2,"Updated SAVI data: Version %s (engine %d.%d) ".
28224               "recognizing %d viruses", $version->string,
28225               $version->major, $version->minor, $version->count);
28226    }
28227  }
28228}
28229
28230# to be called from sub sophos_savi
28231#
28232sub sophos_savi_internal {
28233  my($query,
28234     $bare_fnames,$names_to_parts,$tempdir, $av_name,$command,$args) = @_;
28235  $query = join(' ',@$query)  if ref $query;
28236  my $fname = "$tempdir/parts/$query";  # file to be checked
28237  if (!c('bypass_decode_parts')) {
28238    my $part = $names_to_parts->{$query};  # get corresponding parts object
28239    my $mime_option_value = 0;
28240    my $type_decl = $part->type_declared;
28241    if (ref $part &&
28242        ($part->type_short eq 'MAIL' ||
28243         defined $type_decl && $type_decl=~m{^message/(?:rfc822|global)\z}si)){
28244      do_log(2, "%s: %s - enabling option Mime", $av_name, $query);
28245      $mime_option_value = 1;
28246    }
28247    my $error = $savi_obj->set('Mime', $mime_option_value);
28248    !defined $error or die sprintf("%s: Error %s option Mime: err=%s",
28249                $av_name, $mime_option_value ? 'setting' : 'clearing', $error);
28250  }
28251  my($output,$status); $!=0; my $result = $savi_obj->scan($fname);
28252  if (!ref($result)) {  # error
28253    my $msg = "error scanning file $fname, " .
28254              $savi_obj->error_string($result) . " ($result)";  # ignore $! ?
28255    if ( !grep($result == $_, (514,527,530,538,549)) ) {
28256      $status = 2; $output = "ERROR $query: $msg";
28257    } else { # don't panic on non-fatal (encrypted, corrupted, partial)
28258      $status = 0; $output = "CLEAN $query: $msg";
28259    }
28260    do_log(5,"%s: %s", $av_name,$output);
28261  } elsif ($result->infected) {
28262    $status = 1; $output = join(", ", $result->viruses) . " FOUND";
28263  } else {
28264    $status = 0; $output = "CLEAN $query";
28265  }
28266  ($status,$output);  # return synthesised status and a result string
28267}
28268
28269# implements client side of the Sophos SSSP protocol
28270#
28271sub sophos_sssp_internal {
28272  my($query,
28273     $bare_fnames,$names_to_parts,$tempdir, $av_name,$command,$args) = @_;
28274  my($query_template, $socket_specs) = !$args ? () : @$args;
28275
28276  # short timeout for connect and sending a request
28277  prolong_timer('sophos_sssp_connect', undef, undef, 10);
28278  my($remaining_time, $deadline) = get_deadline('sophos_sssp_internal');
28279  # section_time('sssp-pre');
28280
28281  my $sssp_handle =
28282    Amavis::IO::RW->new($socket_specs, Eol => "\015\012", Timeout => 10);
28283  defined $sssp_handle or die "Can't connect to savdid";
28284  # section_time('sssp-conn');
28285
28286  my $ln; local($1);
28287  $ln = $sssp_handle->get_response_line;  # greeting
28288  defined $ln && $ln ne ''  or die "sssp no greeting";
28289  do_log(5,"sssp greeting %s", $ln);
28290  $ln =~ m{^OK\s+SSSP/(\d+.*)\015\012\z}s  or die "sssp bad greeting '$ln'";
28291  # section_time('sssp-greet');
28292
28293# # Use the SSSP OPTIONS request only if necessary, it is cheaper to have the
28294# # options set in the configuration file. If a client has needs different
28295# # from other clients, create another channel tailored for that client.
28296# #
28297# $sssp_handle->print("SSSP/1.0 OPTIONS\015\012".
28298#                     "savists:zipdecompression 1\015\012".
28299#                     "output: brief\015\012\015\012")
28300#   or die "Error writing to sssp socket";
28301# $sssp_handle->flush or die "Error flushing sssp socket";
28302# $ln = $sssp_handle->get_response_line;
28303# defined $ln && $ln ne ''  or die "sssp no response to OPTIONS";
28304# do_log(5,"sssp response to OPTIONS: %s", $ln);
28305# $ln =~ /^ACC\s+(\S*)/  or die "sssp OPTIONS request not accepted";
28306# while (defined($ln = $sssp_handle->get_response_line)) {
28307#   last if $ln eq "\015\012";
28308#   do_log(5,"sssp result of OPTIONS: %s", $ln);
28309# }
28310# # section_time('sssp-opts');
28311
28312  my $output = '';
28313  # normal timeout for reading a response
28314  prolong_timer('sophos_sssp_scan');
28315  $sssp_handle->timeout(max(3, $deadline - Time::HiRes::time));
28316  for my $fname (!ref($query) ? $query : @$query) {
28317    my $fname_enc = $fname;
28318    $fname_enc =~ s/([%\000-\040\177\377])/sprintf("%%%02X",ord($1))/gse;
28319    $sssp_handle->print("SSSP/1.0 SCANDIRR $fname_enc\015\012")
28320      or die "Error writing to sssp socket";
28321    $sssp_handle->flush or die "Error flushing sssp socket";
28322    $ln = $sssp_handle->get_response_line;
28323    defined $ln && $ln ne ''  or die "sssp no response to SCANDIRR";
28324    do_log(5,"sssp response to SCANDIRR: %s", $ln);
28325    # section_time('sssp-scan-ack');
28326    $ln =~ /^ACC\s+(\S*)/  or die "sssp SCANDIRR request not accepted";
28327    while (defined($ln = $sssp_handle->get_response_line)) {
28328      last if $ln eq "\015\012";
28329      do_log(3,"sssp result: %s", $ln);
28330      $output .= $ln  if length($output) < 10000;
28331    }
28332  }
28333  $output = proto_decode($output);
28334  # section_time('sssp-scan-result');
28335
28336  $sssp_handle->print("BYE\015\012") or die "Error writing to sssp socket";
28337  $sssp_handle->flush or die "Error flushing sssp socket";
28338  $sssp_handle->timeout(max(3, $deadline - Time::HiRes::time));
28339  while (defined($ln = $sssp_handle->get_response_line)) {
28340    do_log(5,"sssp response to BYE: %s", $ln);
28341    last if $ln eq "\015\012" || $ln =~ /^BYE/;
28342  }
28343  # section_time('sssp-bye');
28344  $sssp_handle->close  or do_log(-1, "sssp - error closing session: $!");
28345  # section_time('sssp-close');
28346  (0,$output);  # return synthesised status and a result string
28347}
28348
28349# implements client side of the AVIRA SAVAPI3 protocol
28350#
28351sub avira_savapi_internal {
28352  my($query,
28353     $bare_fnames,$names_to_parts,$tempdir, $av_name,$command,$args) = @_;
28354  my($query_template, $socket_specs, $product_id) = !$args ? () : @$args;
28355
28356  # short timeout for connect and sending a request
28357  prolong_timer('avira_savapi_connect', undef, undef, 10);
28358  my($remaining_time, $deadline) = get_deadline('avira_savapi_internal');
28359  # section_time('savapi-pre');
28360
28361  my $savapi_handle =
28362    Amavis::IO::RW->new($socket_specs, Eol => "\012", Timeout => 10);
28363  defined $savapi_handle or die "Can't connect to savapi daemon";
28364  # section_time('savapi-conn');
28365
28366  my $ln; local($1);
28367  $ln = $savapi_handle->get_response_line;  # greeting
28368  defined $ln && $ln ne ''  or die "savapi no greeting";
28369  do_log(5,"savapi greeting %s", $ln);
28370  $ln =~ m{^100 SAVAPI:(\d+.*)\012\z}s  or die "savapi bad greeting '$ln'";
28371  # section_time('savapi-greet');
28372
28373  $remaining_time = int(max(3, $deadline - Time::HiRes::time + 0.5));
28374  for my $cmd ("SET PRODUCT $product_id",
28375               "SET SCAN_TIMEOUT $remaining_time",
28376               "SET CWD $tempdir/parts",
28377              ) {
28378    # consider: "SET MAILBOX_SCAN 1", "SET ARCHIVE_SCAN 1", "SET HEUR_LEVEL 2"
28379    $savapi_handle->print($cmd."\012") or die "Error writing '$cmd' to socket";
28380    $savapi_handle->flush or die "Error flushing socket";
28381    $ln = $savapi_handle->get_response_line;
28382    defined $ln && $ln ne ''  or die "savapi: no response to $cmd";
28383    do_log(5,"savapi response to '%s': %s", $cmd,$ln);
28384    $ln =~ /^100/  or die "savapi: $cmd request not accepted: $ln";
28385  }
28386  # section_time('savapi-settings');
28387
28388  # set a normal timeout for reading a response
28389  prolong_timer('avira_savapi_scan');
28390  $savapi_handle->timeout(max(3, $deadline - Time::HiRes::time));
28391  my $keep_one_success; my $output = '';
28392  for my $fname (!ref($query) ? $query : @$query) {
28393    my $cmd = "SCAN $fname";  # files only, no directories
28394    $savapi_handle->print($cmd."\012") or die "Error writing '$cmd' to socket";
28395    $savapi_handle->flush or die "Error flushing socket";
28396    while (defined($ln = $savapi_handle->get_response_line)) {
28397      do_log(5,"savapi response to '%s': %s", $cmd,$ln);
28398      if ($ln =~ /^200/) {  # clean
28399        $keep_one_success = $ln  if !defined $keep_one_success;
28400      } else {
28401        $output .= $ln  if length($output) < 10000;  # sanity limit
28402      }
28403      last if $ln =~ /^([0125-9]\d\d|300|319).*\012/;  # terminal status
28404    # last if $ln =~ !/^(310|420|421|422|430).*\012/;  # nonterminal status
28405    }
28406  }
28407  $output = $keep_one_success  if $output eq '' && defined $keep_one_success;
28408  do_log(5,"savapi result: %s", $output);
28409  # section_time('savapi-scan-result');
28410
28411  $savapi_handle->print("QUIT\012")
28412    or do_log(-1, "savapi - error writing QUIT to socket");
28413  $savapi_handle->flush
28414    or do_log(-1, "savapi - error flushing socket after QUIT");
28415  $savapi_handle->close
28416    or do_log(-1, "savapi - error closing session: $!");
28417  # section_time('savapi-close');
28418  (0,$output);  # return synthesised status and a result string
28419}
28420
28421# implements client side of the ClamAV clamd protocol
28422#
28423sub clamav_clamd_internal {
28424  my($query,
28425     $bare_fnames,$names_to_parts,$tempdir, $av_name,$command,$args) = @_;
28426  my($query_template, $socket_specs, $product_id) = !$args ? () : @$args;
28427
28428  # short timeout for connect
28429  prolong_timer('clamav_connect', undef, undef, 10);
28430  my($remaining_time, $deadline) = get_deadline('clamav_internal');
28431  my $clamav_handle =
28432    Amavis::IO::RW->new($socket_specs, Eol => "\000", Timeout => 10);
28433  $clamav_handle or die "Can't connect to a clamd daemon";
28434
28435  # set a normal timeout
28436  prolong_timer('clamav_scan');
28437  $clamav_handle->timeout(max(3, $deadline - Time::HiRes::time));
28438  $clamav_handle->print("zIDSESSION\0")
28439    or die "Error writing 'zIDSESSION' to a clamd socket: $!";
28440
28441  my(@requests, @requests_filename, @requests_timestamp, $end_sent);
28442  my($req_id, $requests_pending) = (0,0);
28443  my $requests_remaining = !ref $query ? 1 : scalar @$query;
28444  my($keep_one_success, $aborted_id, $found_infected);
28445  my $output = '';
28446  while ($requests_remaining > 0 || $requests_pending > 0) {
28447    my $throttling = $requests_pending >= 8;
28448    if ($throttling) {
28449      # wait first for some of the pending results before sending new requests
28450      $clamav_handle->flush or die "Error flushing socket: $!";
28451      do_log(5,'clamav: throttling: %d pending, %d remaining',
28452               $requests_pending, $requests_remaining);
28453    } elsif ($requests_remaining > 0) {
28454      my $fname = !ref $query ? $query : $query->[$req_id];
28455      $req_id++;
28456      $requests[$req_id] = 'INITIATING';
28457      $requests_filename[$req_id] = $fname;
28458      ll(5) && do_log(5,'clamav: sending contents of %s, req_id %d',
28459                      $fname, $req_id);
28460      $clamav_handle->print("zINSTREAM\0")
28461        or die "Error writing 'zINSTREAM' to a clamd socket: $!";
28462      $requests[$req_id] = 'OPEN';
28463      my $fh = IO::File->new;
28464      $fh->open($fname,'<') or die "Can't open file $fname: $!";
28465      binmode($fh,':bytes') or die "Can't cancel :utf8 mode: $!";
28466      eval {
28467        my($nbytes,$buff); $buff = pack('N',0);
28468        while (($nbytes=$fh->read($buff, 32768-4, 4)) > 0) {
28469          $requests[$req_id] = 'SENDING';
28470          substr($buff,0,4) = pack('N',$nbytes);  # 32 bits len -> 4 bytes
28471          $clamav_handle->print($buff)
28472            or die "Error writing $nbytes bytes to a clamd socket: $!";
28473        }
28474        defined $nbytes or die "Error reading from $fname: $!";
28475        my $eod = pack('N',0);  # length zero indicates end of data
28476        if ($requests_remaining <= 0) { $eod .= "zEND\0"; $end_sent = 1 }
28477        $clamav_handle->print($eod)
28478          or die "Error writing end-of-data to a clamd socket: $!";
28479        $clamav_handle->flush or die "Error flushing clamd socket: $!";
28480        $requests[$req_id] = 'SENT';
28481        1;
28482      } or do {
28483        my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
28484        $requests[$req_id] = 'ABORTED: '.$eval_stat;
28485        $aborted_id = $req_id;  # also boolean true, request IDs start with 1
28486        do_log(-1,'clamav: while feeding req_id %d: %s', $req_id, $eval_stat);
28487        my $disc_len = $clamav_handle->discard_pending_output;
28488        do_log(2,'clamav: discarding %d bytes', $disc_len)  if $disc_len;
28489      };
28490      $requests_timestamp[$req_id] = Time::HiRes::time;
28491      $requests_remaining--; $requests_pending++;
28492      $fh->close or die "Error closing file $fname: $!";
28493      do_log(5,'clamav: finished sending %s, req_id %d', $fname, $req_id);
28494    }
28495    while ( ($requests_pending > 0 && !$aborted_id) ||
28496            $clamav_handle->response_line_available ) {
28497      my $ln = $clamav_handle->get_response_line;
28498      last if !defined $ln;
28499      my $rx_time = Time::HiRes::time;
28500      do_log(5,'clamav: got response %s', $ln);
28501
28502      my($id, $id_n, $resp); local($1,$2);
28503      if ($ln =~ /^(\d+):\s*(.*?)\000\z/s) {
28504        ($id,$resp) = ($1,$2); $id_n = 0+$id;
28505      } elsif ($ln =~ / ERROR\000\z/) {
28506        if ($aborted_id) {
28507          $id = $aborted_id; $id_n = 0+$id;
28508          do_log(-1,'clamav: (possibly id=%d) error response: %s', $id,$ln);
28509        } else {
28510          do_log(-1,'clamav: error response: %s', $ln);
28511        }
28512      } else {
28513        do_log(-1,'clamav: unparseable response %s', $ln);
28514        next;
28515      }
28516      if (!defined $id) {
28517        # failure already reported
28518      } elsif (!defined $requests[$id_n]) {
28519        do_log(-1,'clamav: bogus id %s in response ignored: %s', $id,$ln);
28520      } elsif ($requests[$id_n] eq 'DONE') {
28521        do_log(-1,'clamav: duplicate result for id %s: %s', $id,$ln);
28522      } else {
28523        ll(5) && do_log(5,'clamav: request id %s on %s took %.1f ms',
28524                          $id, $requests_filename[$id_n],
28525                          1000 * ($rx_time - $requests_timestamp[$id_n]));
28526        if ($requests[$id_n] ne 'SENT') {
28527          do_log(2,'clamav: result based on incomplete data, state %s: %s',
28528                   $requests[$id_n], $ln);
28529        }
28530        $ln =~ s/\000\z/\n/s;
28531        $ln =~ s/^\Q$id\E:\s*stream:\s*/$requests_filename[$id_n]: /s;
28532        if (defined $resp && $resp =~ /\bOK\z/) {  # clean
28533          $keep_one_success = $ln  if !defined $keep_one_success;
28534        } else {
28535          $output .= $ln  if length($output) < 10000;  # sanity limit
28536        }
28537        $requests[$id_n] = 'DONE';
28538        $requests_pending--  if $requests_pending > 0;
28539        undef $requests_filename[$id_n];
28540        undef $requests_timestamp[$id_n];
28541        if ($resp =~ /\bFOUND\z/) {
28542          $found_infected = 1;
28543          if ($requests_remaining > 0 && c('first_infected_stops_scan')) {
28544            do_log(2,'clamav: first infected stops scan');
28545            $requests_remaining = 0;
28546          }
28547        }
28548      }
28549    }
28550    if ($aborted_id) {
28551      do_log(-1,'clamav: aborting: %d pending, %d remaining',
28552                $requests_pending, $requests_remaining);
28553      $clamav_handle->close
28554        or do_log(5,'clamav: error closing session: %s', $!);
28555      undef $clamav_handle;
28556      if ($found_infected) {
28557        # just normally return an infection report,
28558        # even though not all content has been scanned
28559        do_log(5,'clamav: result: %s', $output);
28560        return (0,$output);  # return synthesised status and a result string
28561      } else {
28562        die 'clamav: '.$requests[$aborted_id];
28563      }
28564    }
28565  }
28566  $output = $keep_one_success  if $output eq '' && defined $keep_one_success;
28567  do_log(5,'clamav: result: %s', $output);
28568  if ($clamav_handle) {
28569    if (!$end_sent) {
28570      $clamav_handle->print("zEND\0")
28571        or do_log(-1,"clamav: error writing 'zEND' to a clamd socket: %s", $!);
28572    }
28573    $clamav_handle->close
28574      or do_log(-1,'clamav: error closing session: %s', $!);
28575  }
28576  (0,$output);  # return synthesised status and a result string
28577}
28578
28579sub av_smtp_client($$$$) {
28580  my($msginfo,$av_name,$av_test_method,$av_test_recip) = @_;
28581  $av_test_recip = 'dummy@localhost'  if !defined $av_test_recip;
28582  my $test_msg = Amavis::In::Message->new;
28583  $test_msg->rx_time($msginfo->rx_time);      # copy the reception time
28584  $test_msg->log_id($msginfo->log_id);        # use the same log_id
28585  $test_msg->partition_tag($msginfo->partition_tag);  # same partition_tag
28586  $test_msg->parent_mail_id($msginfo->mail_id);
28587  $test_msg->mail_id(scalar generate_mail_id());
28588  $test_msg->conn_obj($msginfo->conn_obj);
28589  $test_msg->mail_id($msginfo->mail_id);      # use the same mail_id
28590  $test_msg->body_type($msginfo->body_type);  # use the same BODY= type
28591  $test_msg->header_8bit($msginfo->header_8bit);
28592  $test_msg->body_8bit($msginfo->body_8bit);
28593  $test_msg->body_digest($msginfo->body_digest);  # copy original digest
28594  $test_msg->dsn_ret($msginfo->dsn_ret);
28595  $test_msg->dsn_envid($msginfo->dsn_envid);
28596  $test_msg->smtputf8($msginfo->smtputf8);
28597  $test_msg->sender($msginfo->sender);        # original sender
28598  $test_msg->sender_smtp($msginfo->sender_smtp);
28599  $test_msg->auth_submitter($msginfo->sender_smtp);
28600  $test_msg->auth_user(c('amavis_auth_user'));
28601  $test_msg->auth_pass(c('amavis_auth_pass'));
28602  $test_msg->recips([$av_test_recip]);        # made-up recipient
28603  $_->delivery_method($av_test_method)  for @{$test_msg->per_recip_data};
28604  $test_msg->originating(0);                  # disables DKIM signing
28605  $test_msg->mail_text($msginfo->mail_text);  # the original mail contents
28606  $test_msg->mail_text_str($msginfo->mail_text_str);
28607  $test_msg->body_start_pos($msginfo->body_start_pos);
28608  $test_msg->skip_bytes($msginfo->skip_bytes);
28609  # NOTE: $initial_submission argument is typically treated as a boolean
28610  # but here a value of 2 is supplied to allow a forwarding method to
28611  # distinguish it from ordinary submissions
28612  mail_dispatch($test_msg, 'AV', 0);
28613  my($smtp_resp, $exit_code, $dsn_needed) =
28614    one_response_for_all($test_msg, 0);  # check status
28615  do_log(2, "av_smtp_client %s: %s, %s", $av_name,$av_test_method,$smtp_resp);
28616  (0, $smtp_resp);
28617}
28618
28619# same args and returns as run_av() below,
28620# but prepended by a $query, which is a string to be sent to the daemon.
28621# Handles UNIX, INET and INET6 domain sockets.
28622# More than one socket may be specified for redundancy, they will be tried
28623# one after the other until one succeeds.
28624#
28625sub ask_daemon_internal {
28626  my($query,  # expanded query template, often a command and a file or dir name
28627     $bare_fnames,$names_to_parts,$tempdir, $av_name,$command,$args,
28628     $sts_clean,$sts_infected,$how_to_get_names,  # regexps
28629  ) = @_;
28630  my($query_template_orig,$socket_specs) = @$args;
28631  my $output = '';
28632  $socket_specs = [ $socket_specs ]  if !ref($socket_specs);
28633  my($remaining_time, $deadline) =
28634    get_deadline('ask_daemon_internal_connect_pre');
28635  my $max_retries = 2 * @$socket_specs;  my $retries = 0;
28636  # Sophie, Trophie and fpscand can accept multiple requests per session
28637  # and return a single line response each time
28638  my $multisession = $av_name =~ /\b(Sophie|Trophie|fpscand)\b/i ? 1 : 0;
28639  for (;;) {  # gracefully handle cases when av process times out or restarts
28640    # short timeout for connect and sending a request
28641    prolong_timer('ask_daemon_internal_connect', undef, undef, 10);
28642    @$socket_specs  or die "panic, no sockets specified!?";  # sanity
28643    # try the first one in the current list
28644    my $socketname = $socket_specs->[0];
28645    my $sock = $st_sock{$socketname};
28646    my $eval_stat;
28647    eval {
28648      if (!$st_socket_created{$socketname}) {
28649        ll(3) && do_log(3, "%s: Connecting to socket %s %s%s",
28650                           $av_name, $daemon_chroot_dir, $socketname,
28651                           !$retries ? '' : ", retry #$retries" );
28652        $sock = Amavis::IO::RW->new($socketname, Timeout => 10);
28653        $st_sock{$socketname} = $sock;
28654        defined $sock or die "Can't connect to socket $socketname\n";
28655        $st_socket_created{$socketname} = 1;
28656      }
28657      $query = join(' ',@$query)  if ref $query;
28658      ll(3) && do_log(3,"%s: Sending %s to socket %s",
28659                        $av_name, $query, $socketname);
28660      $sock->print($query) or die "Error writing to socket $socketname\n";
28661      $sock->flush         or die "Error flushing socket $socketname\n";
28662
28663      # normal timeout for reading a response
28664      prolong_timer('ask_daemon_internal_scan');
28665      $sock->timeout(max(3, $deadline - Time::HiRes::time));
28666      if ($multisession) {
28667        # depends on TCP segment boundaries, unreliable
28668        my $nread = $sock->read($output,16384);
28669        defined $nread  or die "Error reading from $socketname: $!\n";
28670        # and keep the socket open
28671      } else {  # single request/response per connection
28672        my $buff = '';
28673        for (;;) {
28674          my $nread = $sock->read($buff,16384);
28675          if (!defined($nread)) {
28676            die "Error reading from $socketname: $!\n";
28677          } elsif ($nread < 1) {
28678            last;   # sysread returns 0 at eof
28679          } else {  # successful read
28680            $output .= $buff  if length($output) < 100000;  # sanity
28681          }
28682        }
28683        $sock->close  or die "Error closing socket $socketname\n";
28684        $st_sock{$socketname} = $sock = undef;
28685        $st_socket_created{$socketname} = 0;
28686      }
28687      $output ne '' or die "Empty result from $socketname\n";
28688      1;
28689    } or do {
28690      $eval_stat = $@ ne '' ? $@ : "errno=$!";
28691    };
28692    prolong_timer('ask_daemon_internal');
28693    last  if !defined $eval_stat;  # mission accomplished
28694
28695    # error handling (the most interesting error codes are EPIPE and ENOTCONN)
28696    chomp $eval_stat; my $err = "$!"; my $errn = 0+$!;
28697
28698    # close socket through its DESTROY method, ignoring status
28699    $st_sock{$socketname} = $sock = undef;
28700    $st_socket_created{$socketname} = 0;
28701
28702    if (Time::HiRes::time >= $deadline) {
28703      die "ask_daemon_internal: Exceeded allowed time";
28704    }
28705    ++$retries <= $max_retries
28706      or die "Too many retries to talk to $socketname ($eval_stat)";
28707    if ($retries <= 1 && $errn == EPIPE) {  # common, don't cause concern
28708      do_log(2,"%s broken pipe (don't worry), retrying (%d)",
28709               $av_name,$retries);
28710    } else {
28711      do_log( ($retries > 1 ? -1 : 1),
28712              "%s: %s, retrying (%d)", $av_name,$eval_stat,$retries);
28713      if ($retries % @$socket_specs == 0) {  # every time the list is exhausted
28714        my $dly = min(20, 1 + 5 * ($retries/@$socket_specs - 1));
28715        do_log(3,"%s: sleeping for %s s", $av_name,$dly);
28716        sleep($dly);   # slow down a possible runaway
28717      }
28718    }
28719    # leave good socket as the first entry in the list
28720    # so that it will be tried first when needed again
28721    if (@$socket_specs > 1) {
28722      push(@$socket_specs, shift @$socket_specs);  # circular shift left
28723    }
28724  }
28725  (0,$output);  # return synthesised status and a result string
28726}
28727
28728# subroutine is available for calling from @av_scanners list entries;
28729# it has the same args and returns as run_av() below.
28730# Based on an implied protocol, or on an explicitly specified protocol name
28731# in the second element of array @$args, it determines a subroutine needed
28732# to implement the required protocol (defaulting to &ask_daemon_internal)
28733# and replaces $command in the argument list by this subroutine reference,
28734# then calls run_av with adjusted arguments.  So, its main purpose is to map
28735# a protocol name (a string) into an internal code reference.
28736#
28737sub ask_daemon {
28738  my($bare_fnames,$names_to_parts,$tempdir, $av_name,$command,$args,
28739     $sts_clean,$sts_infected,$how_to_get_names) = @_;
28740  my($av_method,$av_protocol); local($1);
28741  # determine a protocol name from the second element of array @$args
28742  $av_method = $args->[1]  if $args && @$args >= 2;
28743  $av_method = $av_method->[0]  if ref $av_method;
28744  $av_protocol = lc($1)  if defined $av_method &&
28745                            $av_method =~ /^([a-z][a-z0-9.+-]*):/si;
28746  my $code; my $run_spawned = 0;
28747  if (!defined $av_protocol) {
28748    # for compatibility with old style socket specification with
28749    # no protocol (scheme) field, equivalent to a former call to ask_av()
28750    #   Sophie, Trophie, ClamAV-clamd, OpenAntiVirus, AVG,
28751    #   F-Prot fpscand, F-Prot f-protd, DrWebD, avast, ESET NOD32SS
28752    $code = \&ask_daemon_internal;
28753  } elsif ($av_protocol =~ /^(simple|sophie|trophie)\z/) {
28754    # same as default, but with an explicit protocol prefix
28755    $code = \&ask_daemon_internal;
28756  } elsif ($av_protocol eq 'sssp') {  # Sophos SSSP
28757    $code = \&sophos_sssp_internal;
28758  } elsif ($av_protocol eq 'savapi') {  # Avira SAVAPI3
28759    $code = \&avira_savapi_internal;
28760  } elsif ($av_protocol eq 'clamd') {  # ClamAV clamd protocol
28761    $code = \&clamav_clamd_internal;
28762  } elsif ($av_protocol eq 'smtp' || $av_protocol eq 'lmtp') {
28763    $code = sub { av_smtp_client($Amavis::MSGINFO, $av_name,
28764                                 $av_method, $args->[2]) };
28765  } elsif ($av_protocol eq 'savi-perl') {  # using SAVI-Perl perl module
28766    if (@_ < 3+6) {  # supply default arguments for backward compatibility
28767      $args = ['*']; $sts_clean = [0]; $sts_infected = [1];
28768      $how_to_get_names = qr/^(.*) FOUND$/m;
28769    }
28770    $code = \&sophos_savi_internal;
28771  } elsif ($av_protocol eq 'clamav-perl') {  # using Mail::ClamAV perl module
28772    clamav_module_internal_pre($av_name);  # must not run as a subprocess
28773    $code = \&clamav_module_internal; $run_spawned = 1;
28774  }
28775  ll(5) && do_log(5, "ask_daemon: proto=%s, spawn=%s, (%s) %s",
28776                     !defined $av_protocol ? 'DFLT' : $av_protocol,
28777                     $run_spawned, $av_name, $av_method);
28778  ref $code or die "Unsupported AV protocol name: $av_method";
28779  $command = $code;
28780  # reassemble arguments, after possibly being modified
28781  my(@run_av_args) = ($bare_fnames,$names_to_parts,$tempdir,
28782          $av_name,$command,$args, $sts_clean,$sts_infected,$how_to_get_names);
28783  my(@results);
28784  if (!$run_spawned) {
28785    @results = run_av(@run_av_args);  # invoke directly
28786  } else {
28787    my($proc_fh,$pid) = run_as_subprocess(\&ask_av, @run_av_args);
28788    my($results_ref,$child_stat) =
28789      collect_results_structured($proc_fh,$pid,$av_name,200*1024);
28790    @results = @$results_ref  if $results_ref;
28791  }
28792  @results;  # ($scan_status,$output,$virusnames)
28793}
28794
28795# for compatibility with pre-2.6.0 versions of amavisd-new and
28796# old @av_scanners entries;  use ask_daemon and/or run_av instead
28797sub ask_av(@) {
28798  my($code, @run_av_args) = @_;
28799  $run_av_args[4] = $code;  # replaces $command with a supplied $code
28800  run_av(@run_av_args);
28801}
28802
28803# Call a virus scanner and parse its output.
28804# Returns a triplet, or dies in case of failure.
28805# The first element of the triplet has the following semantics:
28806# - true if virus found,
28807# - 0 if no viruses found,
28808# - undef if it did not complete its job;
28809# the second element is a string, the text as provided by the virus scanner;
28810# the third element is ref to a list of virus names found (if any).
28811#   (it is guaranteed the list will be nonempty if virus was found)
28812#
28813# If there is at least one glob character '*' present in a query template, the
28814# subroutine will traverse supplied files (@$bare_fnames) and call a supplied
28815# subroutine or program for each file to be scanned, summarizing the final
28816# av scan result. If there are no glob characters in a template, the result
28817# is a single call to a supplied subroutine or program, which will presumably
28818# traverse a directory by itself.
28819#
28820sub run_av(@) {
28821  my($bare_fnames,  # a ref to a list of filenames to scan (basenames)
28822     $names_to_parts, # ref to a hash that maps base file names to parts object
28823     $tempdir,      # temporary directory
28824      # n-tuple from an @av_scanners list entry starts here
28825     $av_name, $command, $args,
28826     $sts_clean,    # a ref to a list of status values, or a regexp
28827     $sts_infected, # a ref to a list of status values, or a regexp
28828     $how_to_get_names, # ref to sub, or a regexp to get list of virus names
28829     $pre_code, $post_code,  # routines to be invoked before and after av
28830  ) = @_;
28831  my($scan_status,@virusnames,$error_str); my $output = '';
28832  return (0,$output,\@virusnames)  if !defined($bare_fnames) || !@$bare_fnames;
28833  my($query_template, $socket_specs); my $av_protocol = '';
28834  if (!ref $args) {
28835    $query_template = $args;
28836  } else {
28837    ($query_template, $socket_specs) = @$args;
28838    $socket_specs = $socket_specs->[0]  if ref $socket_specs;
28839    if (defined $socket_specs) {
28840      local($1);
28841      $av_protocol = lc($1)  if $socket_specs =~ /^([a-z][a-z0-9.+-]*):/si;
28842    }
28843  }
28844  my $one_at_a_time = 0;
28845  $one_at_a_time = 1  if ref $command &&
28846                         $av_protocol !~ /^(?:sssp|savapi|clamd)\z/;
28847  my(@query_template) = $one_at_a_time ? $query_template  # treat it as one arg
28848                                    : split(' ',$query_template);  # shell-like
28849  my $bare_fnames_last = $#{$bare_fnames};
28850  do_log(5,"run_av (%s): query template(%s,%d): %s",
28851           $av_name,$one_at_a_time,$bare_fnames_last,$query_template);
28852  my($remaining_time, $deadline) = prolong_timer('run_av_pre');
28853  my $cwd = "$tempdir/parts";
28854  chdir($cwd) or die "Can't chdir to $cwd: $!";
28855  &$pre_code(@_)  if defined $pre_code;
28856  # a '{}' will be replaced by a directory name, '{}/*' and '*' by file names
28857  local($1);
28858  my(@query_expanded) = map($_ eq '*' || $_ eq '{}/*' ? []
28859                          : m{^ \{ \} ( / .* )? \z}xs ? "$tempdir/parts$1"
28860                          : $_,  @query_template);
28861  my $eval_stat;
28862  eval {
28863    for (my $k = 0; $k <= $bare_fnames_last;  ) {  # traverse fnames in chunks
28864      my(@processed_filenames);
28865      my $arglist_size = 0;  # size of a command with its arguments so far
28866      for ($command,@query_expanded) { $arglist_size+=length($_)+1 if !ref $_ }
28867      for (@query_expanded) { @$_ = () if ref $_ }  # reset placeholder lists
28868      while ($k <= $bare_fnames_last) {  # traverse fnames individually
28869        my $f = $bare_fnames->[$k];  my $multi = 0;
28870        if ($one_at_a_time) {  # glob templates may be substrings anywhere
28871          local($1);  @query_expanded = @query_template;  # start afresh
28872          s{ ( \{\} (?: / \* )? | \* ) }
28873           { $1 eq '{}'   ? "$tempdir/parts"
28874           : $1 eq '{}/*' ? ($multi=1,"$tempdir/parts/$f")
28875           : $1 eq '*'    ? ($multi=1,$f)  : $1
28876           }xgse  for @query_expanded;
28877        } else {
28878          # collect as many filename arguments as suitable, but at least one
28879          my $arg_size = 0;
28880          for (@query_template) {
28881            if ($_ eq '{}/*') { $arg_size += length("$tempdir/parts/$f") + 1 }
28882            elsif ($_ eq '*') { $arg_size += length($f) + 1 }
28883          }
28884        # do_log(5,"run_av arglist size: %d + %d", $arglist_size,$arg_size);
28885          if (@processed_filenames && $arglist_size + $arg_size > 4000) {
28886            # POSIX requires 4 kB as a minimum buffer size for program args
28887            last;  # enough collected for now, the rest on the next iteration
28888          }
28889          # exact matching on command arguments, no substring matches
28890          for my $j (0..$#query_template) {
28891            if (ref $query_expanded[$j]) {  # placeholders collecting fnames
28892              my $arg = $query_template[$j];
28893              my $repl = $arg eq '{}/*' ? "$tempdir/parts/$f"
28894                       : $arg eq '*'    ? $f  : undef;
28895              $multi = 1;
28896              push(@{$query_expanded[$j]}, untaint($repl));
28897              $arglist_size += length($repl) + 1;
28898            }
28899          }
28900        }
28901        $k = $multi ? $k+1 : $bare_fnames_last+1;
28902        push(@processed_filenames, $multi ? $f : "$tempdir/parts");
28903        last  if $one_at_a_time;
28904      }
28905      # now that arguments have been expanded, invoke the scanner
28906      my($child_stat,$t_status,$t_output);
28907      prolong_timer('run_av_scan');  # restart timer
28908      if (ref $command) {
28909        my(@q) = map(ref $_ ? @$_ : $_, @query_expanded);
28910        ll(3) && do_log(3, "run_av Using (%s): (code) %s",
28911                           $av_name, join(' ',@q));
28912        # call subroutine directly, passing all our arguments to it
28913        ($t_status,$t_output) = &$command(!@q ? '' : @q==1 ? $q[0] : \@q, @_);
28914        prolong_timer('run_av_3');  # restart timer
28915        $child_stat = 0;  # no spawned process, just declare success
28916        do_log(4,"run_av (%s) result: %s", $av_name,$t_output);
28917      } else {
28918        my($proc_fh,$pid); my $results_ref;
28919        my $eval_stat2;
28920        eval {
28921          my(@q) = map(ref $_ ? @$_ : $_, @query_expanded);
28922          ll(3) && do_log(3,"run_av Using (%s): %s %s",
28923                            $av_name,$command,join(' ',@q));
28924          ($proc_fh,$pid) = run_command(undef, '&1', $command, @q);
28925          ($results_ref,$child_stat) =
28926            collect_results($proc_fh,$pid, $av_name,200*1024);
28927          1;
28928        } or do { $eval_stat2 = $@ ne '' ? $@ : "errno=$!" };
28929        undef $proc_fh; undef $pid;
28930        $error_str = exit_status_str($child_stat,0);
28931        $t_status = WEXITSTATUS($child_stat)  if defined $child_stat;
28932        prolong_timer('run_av_4');  # restart timer
28933        if (defined $eval_stat2) {
28934          chomp $eval_stat2; $error_str = $eval_stat2;
28935          do_log(-1, "run_av (%s): %s", $av_name,$eval_stat2);
28936        }
28937        if (defined $results_ref)
28938          { $t_output = $$results_ref; undef $results_ref }
28939        chomp($t_output); my $t_output_trimmed = $t_output;
28940        $t_output_trimmed =~ s/\r\n/\n/gs; local($1);
28941        $t_output_trimmed =~ s/([ \t\n\r])[ \t\n\r]{4,}/$1.../gs;
28942        $t_output_trimmed = "..." . substr($t_output_trimmed,-800)
28943          if length($t_output_trimmed) > 800;
28944        do_log(3, "run_av: %s %s, %s", $command,$error_str,$t_output_trimmed);
28945      }
28946      if (!defined($child_stat) || !WIFEXITED($child_stat)) {
28947        # leave $scan_status undefined, indicating an error
28948      # braindamaged Perl: empty string implies the last successfully
28949      # matched regular expression; we must avoid this
28950      } elsif (defined $sts_infected && (
28951          ref($sts_infected) eq 'ARRAY' ? (grep($_==$t_status, @$sts_infected))
28952                              : $sts_infected eq '' ? 1  # avoid m// stupidity
28953                              : $t_output=~/$sts_infected/m)) {  # is infected
28954        # test for infected first, in case both expressions match
28955        $scan_status = 1;  # 'true' indicates virus found
28956        my(@t_virusnames) = ref($how_to_get_names) eq 'CODE'
28957                              ? &$how_to_get_names($t_output)
28958                              : $how_to_get_names eq '' ? ()
28959                              : $t_output=~/$how_to_get_names/gm;
28960        @t_virusnames = grep(defined $_, @t_virusnames);
28961        push(@virusnames, @t_virusnames);
28962        $output .= $t_output . "\n";
28963        do_log(2,"run_av (%s): %s INFECTED: %s", $av_name,
28964                 join(' ',@processed_filenames), join(', ',@t_virusnames) );
28965      } elsif (!defined($sts_clean)) {  # clean, but inconclusive
28966        # by convention: undef $sts_clean means result is inconclusive,
28967        # file appears clean, but continue scanning with other av scanners,
28968        # the current scanner does not want to vouch for it; useful for a
28969        # scanner like jpeg checker which tests for one vulnerability only
28970        do_log(3,"run_av (%s): CLEAN, but inconclusive", $av_name);
28971      } elsif (ref($sts_clean) eq 'ARRAY'
28972                    ? (grep($_==$t_status, @$sts_clean))
28973                    : ""=~/x{0}/ && $t_output=~/$sts_clean/m) {  # is clean
28974        # 'false' (but defined) indicates no viruses
28975        $scan_status = 0  if !$scan_status;   # no viruses, no errors
28976        do_log(3,"run_av (%s): CLEAN", $av_name);
28977      } else {
28978      # $error_str = "unexpected $error_str, output=\"$t_output_trimmed\"";
28979        $error_str = "unexpected $error_str, output=\"$t_output\"";
28980        do_log(-1,"run_av (%s) FAILED - %s", $av_name,$error_str);
28981        last;  # error, bail out
28982      }
28983      die "Exceeded allowed time\n"  if time >= $deadline;
28984    }
28985    1;
28986  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
28987  &$post_code(@_)  if defined $post_code;
28988  @virusnames = ('')  if $scan_status && !@virusnames;  # ensure nonempty list
28989  do_log(3,"run_av (%s) result: clean", $av_name)
28990    if defined($scan_status) && !$scan_status;
28991  chdir($tempdir) or die "Can't chdir to $tempdir: $!";
28992  if (defined $eval_stat) {
28993    prolong_timer('run_av_5');  # restart timer
28994    die "run_av error: $eval_stat\n";
28995  }
28996  if (!defined($scan_status) && defined($error_str)) {
28997    die "$command $error_str";  # die is more informative than a return value
28998  }
28999  ($scan_status, $output, \@virusnames);
29000}
29001
29002# @av_scanners is a list of n-tuples, where fields semantics is:
29003#  1. name: an AV scanner plain name, to be used in log and reports;
29004#  2a. program: a scanner program name; this string will be submitted to
29005#     subroutine find_external_programs(), which will try to find the full
29006#     program path name during startup according to a search path in variable
29007#     $path; if program is not found, this scanner is disabled. Besides a
29008#     simple string (a full program path name or just the basename to be
29009#     looked for in PATH), this may be an array ref of alternative program
29010#     names or full paths - the first match in the list will be used;
29011#  2b. subroutine: alternatively, this second field may be a subroutine
29012#     reference, and the whole n-tuple entry is passed to it as args;
29013#     it should return a triple: ($scan_status,$output,$virusnames_ref),
29014#     where:
29015#     - $scan_status is: true if a virus was found, 0 if no viruses,
29016#       undef if scanner was unable to complete its job (failed);
29017#     - $output is an optional result string to appear in logging and macro %v;
29018#     - $virusnames_ref is a ref to a list of detected virus names (may be
29019#       undef or a ref to an empty list);
29020#  3. args: command arguments to be given to the scanner program;
29021#     a substring {} will be replaced by the directory name to be scanned, i.e.
29022#     "$tempdir/parts", a "*" will be replaced by base file names of parts;
29023#  4. clean: an array ref of av scanner exit status values, or a regexp
29024#     (to be matched against scanner output), indicating NO VIRUSES found;
29025#     a special case is a value undef, which does not claim file to be clean
29026#     (i.e. it never matches, similar to []), but suppresses a failure warning;
29027#     to be used when the result is inconclusive (useful for specialized and
29028#     quick partial scanners such as jpeg checker);
29029#  5. infected: an array ref of av scanner exit status values, or a regexp
29030#     (to be matched against scanner output), indicating VIRUSES WERE FOUND;
29031#     a value undef may be used and it never matches (for consistency with 4.);
29032#     Note: the virus match prevails over a 'not found' match, so it is safe
29033#     even if the no. 4. matches for viruses too;
29034#  6. virus name: a regexp (to be matched against scanner output), returning
29035#     a list of virus names found, or a sub ref, returning such a list when
29036#     given scanner output as argument;
29037#  7. and 8.: (optional) subroutines to be executed before and after scanner
29038#     (e.g. to set environment or current directory);
29039#     see examples for these at KasperskyLab AVP and NAI uvscan.
29040
29041sub virus_scan($$) {
29042  my($msginfo,$firsttime) = @_;
29043  my $tempdir = $msginfo->mail_tempdir;
29044  my($scan_status,$output,@virusname);
29045  my(@detecting_scanners,@av_scanners_results);
29046  my $anyone_done = 0; my $anyone_tried = 0;
29047  my($bare_fnames_ref,$names_to_parts);
29048  my $j; my $tier = 'primary';
29049  for my $av (@{ca('av_scanners')}, "\000", @{ca('av_scanners_backup')}) {
29050    next  if !defined $av;
29051    if ($av eq "\000") {  # 'magic' separator between lists
29052      last  if $anyone_done;
29053      do_log(-1,"WARN: all %s virus scanners failed, considering backups",
29054                $tier);
29055      $tier = 'secondary';  next;
29056    }
29057    next  if !ref $av || !defined $av->[1];
29058    if (!defined $bare_fnames_ref) {  # first time: collect file names to scan
29059      my $parts_root = $msginfo->parts_root;
29060      ($bare_fnames_ref,$names_to_parts) =
29061        files_to_scan("$tempdir/parts", $parts_root);
29062      if (!@$bare_fnames_ref) {
29063        do_log(2, "Not calling virus scanners, no files to scan in %s/parts",
29064                  $tempdir);
29065      } else {
29066        do_log(5, "Calling virus scanners, %d files to scan in %s/parts",
29067                  scalar(@$bare_fnames_ref), $tempdir);
29068      }
29069    }
29070    my($scanner_name,$command) = @$av;
29071    $anyone_tried = 1; my($this_status,$this_output,$this_vn);
29072    if (!@$bare_fnames_ref) {  # no files to scan?
29073      ($this_status,$this_output,$this_vn) = (0, '', undef);  # declare clean
29074    } else {  # call virus scanner
29075      do_log(5, "invoking av-scanner %s", $scanner_name);
29076      eval {
29077        ($this_status,$this_output,$this_vn) = ref $command eq 'CODE'
29078            ? &$command($bare_fnames_ref,$names_to_parts,$tempdir, @$av)
29079            :    run_av($bare_fnames_ref,$names_to_parts,$tempdir, @$av);
29080        1;
29081      } or do {
29082        my $err = $@ ne '' ? $@ : "errno=$!";  chomp $err;
29083        $err = sprintf("%s av-scanner FAILED: %s", $scanner_name, $err);
29084        do_log(-1, "%s", $err);
29085        $this_status = undef;
29086      };
29087    }
29088    $anyone_done = 1  if defined $this_status;
29089    $j++; section_time("AV-scan-$j");
29090    if ($this_status && $this_vn && @$this_vn) {
29091      @$this_vn = unique_list($this_vn);
29092      # virus is reported by this scanner; is it for real, or is it just spam?
29093      my(@spam_hits);  my $vnts = ca('virus_name_to_spam_score_maps');
29094      @spam_hits =  # map each reported virus name to spam score or to undef
29095        map(scalar(lookup2(0,$_,$vnts)), @$this_vn)  if ref $vnts;
29096      if (@spam_hits && !grep(!defined($_), @spam_hits)) {  # all defined
29097        # AV scanner did trigger, but all provided names are actually spam!
29098        my(%seen);
29099        for my $r (@{$msginfo->per_recip_data}) {
29100          my $spam_tests = $r->spam_tests;
29101          if ($spam_tests) {
29102            local($1,$2);
29103            for (split(/,/, join(',',map($$_,@$spam_tests)))) {
29104              $seen{$1} = $2  if /^AV\.([^=]*)=([0-9.+-]+)\z/;
29105            }
29106          }
29107        }
29108        my(@vnms,@hits);
29109        # remove already detected virus names and duplicates from the list
29110        for my $j (0..$#$this_vn) {
29111          my $vname = $this_vn->[$j];
29112          if (!exists($seen{$vname})) {
29113            push(@vnms,$vname); push(@hits,$spam_hits[$j]);
29114            $seen{$vname} = $spam_hits[$j];  # keep only one copy
29115          }
29116        }
29117        @$this_vn = @vnms; @spam_hits = @hits;
29118        if (!@spam_hits) {
29119          do_log(2,"Turning AV infection into a spam report, ".
29120                   "name already accounted for");
29121        } else {
29122          my $spam_level = max(@spam_hits);
29123          my $spam_tests = join(',',
29124                    map(sprintf("AV:%s=%s", $this_vn->[$_], $spam_hits[$_]),
29125                        (0..$#$this_vn) ));
29126          for my $r (@{$msginfo->per_recip_data}) {
29127            $r->spam_level( ($r->spam_level || 0) + $spam_level );
29128            if (!$r->spam_tests) {
29129              $r->spam_tests([ \$spam_tests ]);
29130            } else {
29131              push(@{$r->spam_tests}, \$spam_tests);
29132            }
29133          }
29134          my $spam_report = $spam_tests;
29135          my $spam_summary =
29136            sprintf("AV scanner %s reported spam (not infection):\n%s\n",
29137                    $scanner_name, join(',',@$this_vn));
29138          do_log(2,"Turning AV infection into a spam report: score=%s, %s",
29139                   $spam_level, $spam_tests);
29140          if (defined($msginfo->spam_report)||defined($msginfo->spam_summary)){
29141            $spam_report = $msginfo->spam_report . ', ' . $spam_report
29142              if $msginfo->spam_report ne '';
29143            $spam_summary = $msginfo->spam_summary . "\n\n" . $spam_summary
29144              if $msginfo->spam_summary ne '';
29145          }
29146          $msginfo->spam_report($spam_report);
29147          $msginfo->spam_summary($spam_summary);
29148        }
29149        $this_status = 0; @$this_vn = (); # TURN OFF ALERT for this AV scanner!
29150      }
29151    }
29152    push(@av_scanners_results,
29153         [$av, $this_status, !$this_vn ? () : @$this_vn]);
29154    if ($this_status) {  # a virus detected by this scanner, really! (not spam)
29155      push(@detecting_scanners, $scanner_name);
29156      if (!@virusname) {  # store results of the first scanner detecting
29157        @virusname = @$this_vn  if $this_vn;
29158        $scan_status = $this_status; $output = $this_output;
29159      }
29160      last  if c('first_infected_stops_scan');  # stop now if we found a virus?
29161    } elsif (!defined($scan_status)) {  # tentatively keep regardless of status
29162      $scan_status = $this_status; $output = $this_output;
29163    }
29164  }
29165  if (ll(2) && @virusname && @detecting_scanners) {
29166    my(@ds) = @detecting_scanners;  s/,/;/ for @ds;  # facilitates parsing
29167    do_log(2, "virus_scan: (%s), detected by %d scanners: %s",
29168              join(', ',@virusname), scalar(@ds), join(', ',@ds));
29169  }
29170  $output =~ s{\Q$tempdir\E/parts/?}{}gs  if defined $output;  # hide path info
29171  if (!$anyone_tried) { die "NO VIRUS SCANNERS AVAILABLE\n" }
29172  elsif (!$anyone_done) { die "ALL VIRUS SCANNERS FAILED\n" }
29173  ($scan_status, $output, \@virusname,
29174   \@detecting_scanners, \@av_scanners_results);  # return a 5-tuple
29175}
29176
29177# return a ref to a list of files to be scanned in a given directory
29178#
29179sub files_to_scan($$) {
29180  my($dir,$parts_root) = @_;
29181  my $names_to_parts = {};  # a hash that maps base file names
29182                            # to Amavis::Unpackers::Part object
29183  # traverse decomposed parts tree breadth-first, match it to actual files
29184  for (my $part, my(@unvisited)=($parts_root);
29185       @unvisited and $part=shift(@unvisited);
29186       push(@unvisited,@{$part->children}))
29187    { $names_to_parts->{$part->base_name} = $part  if $part ne $parts_root }
29188  my $bare_fnames_ref = []; my(%bare_fnames);
29189  # traverse parts directory and check for actual files
29190  local(*DIR); opendir(DIR,$dir) or die "Can't open directory $dir: $!";
29191  # modifying a directory while traversing it can cause surprises, avoid;
29192  # avoid slurping the whole directory contents into memory
29193  my($f, @rmfiles, @rmdirs);
29194  while (defined($f = readdir(DIR))) {
29195    next  if $f eq '.' || $f eq '..';
29196    my $fname = $dir . '/' . $f;
29197    my(@stat_list) = lstat($fname); my $errn = @stat_list ? 0 : 0+$!;
29198    next  if $errn == ENOENT;
29199    if ($errn) { die "files_to_scan: file $fname inaccessible: $!" }
29200    add_entropy(@stat_list);
29201    if (!-r _) {  # attempting to gain read access to the file
29202      do_log(3,"files_to_scan: attempting to gain read access to %s", $fname);
29203      chmod(0750, untaint($fname))
29204        or die "files_to_scan: Can't change protection on $fname: $!";
29205      $errn = lstat($fname) ? 0 : 0+$!;
29206      if ($errn) { die "files_to_scan: file $fname inaccessible: $!" }
29207      if (!-r _) { die "files_to_scan: file $fname not readable" }
29208    }
29209    if (!-f _ || !exists $names_to_parts->{$f}) {
29210      # not a regular file or unexpected
29211      my $what = -l _ ? 'symlink' : -d _ ? 'directory' : -f _ ? 'file'
29212                 : 'non-regular file';
29213      my $msg = "removing unexpected $what $fname";
29214      $msg .= ", it has no corresponding parts object"
29215        if !exists $names_to_parts->{$f};
29216      do_log(-1, "WARN: files_to_scan: %s", $msg);
29217      if (-d _) { push(@rmdirs, $f) } else { push(@rmfiles, $f) }
29218    } elsif (-z _) {
29219      # empty file
29220    } else {
29221      if ($f !~ /^[A-Za-z0-9_.-]+\z/s) {
29222        do_log(-1,"WARN: files_to_scan: unexpected/suspicious file name: %s",
29223                  $f);
29224      }
29225      push(@$bare_fnames_ref,$f); $bare_fnames{$f} = 1;
29226    }
29227  }
29228  closedir(DIR) or die "Error closing directory $dir: $!";
29229  for my $f (@rmfiles) {
29230    my $fname = $dir . '/' . untaint($f);
29231    do_log(5,"files_to_scan: deleting file %s", $fname);
29232    unlink($fname) or die "Can't delete $fname: $!";
29233  }
29234  undef @rmfiles;
29235  for my $d (@rmdirs) {
29236    my $dname = $dir . '/' . untaint($d);
29237    do_log(5,"files_to_scan: deleting directory %s", $dname);
29238    rmdir_recursively($dname);
29239  }
29240  undef @rmdirs;
29241  # remove entries from %$names_to_parts that have no corresponding files
29242  my($fname,$part);
29243  while ( ($fname,$part) = each %$names_to_parts ) {
29244    next  if exists $bare_fnames{$fname};
29245    if (ll(4) && $part->exists) {
29246      my $type_short = $part->type_short;
29247      do_log(4,"files_to_scan: info: part %s (%s) no longer present",
29248          $fname, (!ref $type_short ? $type_short : join(', ',@$type_short)) );
29249    }
29250    delete $names_to_parts->{$fname}; # delete is allowed for the current elem.
29251  }
29252  ($bare_fnames_ref, $names_to_parts);
29253}
29254
292551;
29256
29257__DATA__
29258#
29259package Amavis::SpamControl;
29260use strict;
29261use re 'taint';
29262use warnings;
29263use warnings FATAL => qw(utf8 void);
29264no warnings 'uninitialized';
29265# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
29266
29267use Fcntl qw(:flock);
29268use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
29269
29270BEGIN {
29271  require Exporter;
29272  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
29273  $VERSION = '2.412';
29274  @ISA = qw(Exporter);
29275  import Amavis::Conf qw(:platform c cr ca);
29276  import Amavis::Util qw(ll do_log min max minmax untaint untaint_inplace
29277                         unique_list);
29278  import Amavis::Lookup qw(lookup lookup2);
29279  import Amavis::rfc2821_2822_Tools qw(make_query_keys qquote_rfc2821_local);
29280}
29281
29282sub new {
29283  my $class = $_[0];
29284  my $self = bless { scanners_list => [] }, $class;
29285  for my $as (@{ca('spam_scanners')}) {
29286    if (ref $as && defined $as->[1] && $as->[1] ne '') {
29287      my($scanner_name,$module,@args) = @$as; my $scanner_obj;
29288      do_log(5, "SpamControl: attempting to load scanner %s, module %s",
29289                $scanner_name,$module);
29290      { no strict 'subs';
29291        $scanner_obj = $module->new($scanner_name,$module,@args);
29292      }
29293      if ($scanner_obj) {
29294        push(@{$self->{scanners_list}}, [$scanner_obj, @$as]);
29295        do_log(2, "SpamControl: scanner %s, module %s",
29296                  $scanner_name,$module);
29297      } else {
29298        do_log(5, "SpamControl: no scanner %s, module %s",
29299                  $scanner_name,$module);
29300      }
29301    }
29302  }
29303  $self;
29304}
29305
29306# called at startup, before chroot and before main fork
29307#
29308sub init_pre_chroot {
29309  my $self = $_[0];
29310  for my $as (@{$self->{scanners_list}}) {
29311    my($scanner_obj,$scanner_name) = @$as;
29312    if ($scanner_obj && $scanner_obj->UNIVERSAL::can('init_pre_chroot')) {
29313      $scanner_obj->init_pre_chroot;
29314      do_log(1, "SpamControl: init_pre_chroot on %s done", $scanner_name);
29315    }
29316  }
29317}
29318
29319# called at startup, after chroot and changing UID, but before main fork
29320#
29321sub init_pre_fork {
29322  my $self = $_[0];
29323  for my $as (@{$self->{scanners_list}}) {
29324    my($scanner_obj,$scanner_name) = @$as;
29325    if ($scanner_obj && $scanner_obj->UNIVERSAL::can('init_pre_fork')) {
29326      $scanner_obj->init_pre_fork;
29327      do_log(1, "SpamControl: init_pre_fork on %s done", $scanner_name);
29328    }
29329  }
29330}
29331
29332# called during child process initialization
29333#
29334sub init_child {
29335  my $self = $_[0];
29336  my $failure_msg;
29337  for my $as (@{$self->{scanners_list}}) {
29338    my($scanner_obj,$scanner_name) = @$as;
29339    if ($scanner_obj && $scanner_obj->UNIVERSAL::can('init_child')) {
29340      eval {
29341        $scanner_obj->init_child;
29342        do_log(5, "SpamControl: init_child on %s done", $scanner_name);
29343        1;
29344      } or do {
29345        my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
29346        do_log(-1, "init_child on spam scanner %s failed: %s",
29347                   $scanner_name, $eval_stat);
29348        $failure_msg = "init_child $scanner_name failed: $eval_stat"
29349          if !defined $failure_msg;
29350      };
29351    }
29352  }
29353  if (defined $failure_msg) { die $failure_msg }
29354}
29355
29356sub lock {
29357  my($self,$scanner_obj,$lock_type_name) = @_;
29358  my $lock_file = $scanner_obj->{options}->{'lock_file'};
29359  if (defined $lock_file && $lock_file ne '') {
29360    my $lock_type = $scanner_obj->{options}->{$lock_type_name};
29361    $lock_type = $scanner_obj->{options}->{'lock_type'} if !defined $lock_type;
29362    $lock_type = 'exclusive'  if !defined $lock_type;
29363    if ($lock_type ne '' && lc($lock_type) ne 'none') {
29364      my $lock_fh = IO::File->new;
29365      $lock_fh->open($lock_file, O_CREAT|O_RDWR, 0640)
29366        or die "Can't open a lock file $lock_file: $!";
29367      $scanner_obj->{lock_fh} = $lock_fh;
29368      my $lock_type_displ;
29369      if (defined $lock_type && lc($lock_type) eq 'shared') {
29370        $lock_type = LOCK_SH; $lock_type_displ = 'a shared';
29371      } else {
29372        $lock_type = LOCK_EX; $lock_type_displ = 'an exclusive';
29373      }
29374      do_log(5,"acquring %s lock on %s for %s",
29375                $lock_type_displ, $lock_file, $scanner_obj->{scanner_name});
29376      flock($lock_fh, $lock_type)
29377        or die "Can't acquire $lock_type_displ lock on $lock_file: $!";
29378    }
29379  }
29380}
29381
29382sub unlock {
29383  my($self,$scanner_obj) = @_;
29384  my $lock_fh = $scanner_obj->{lock_fh};
29385  if ($lock_fh) {
29386    my $scanner_name = $scanner_obj->{scanner_name};
29387    do_log(5, "releasing a lock for %s", $scanner_name);
29388    # close would unlock automatically, but let's check for locking mistakes
29389    flock($lock_fh, LOCK_UN)
29390      or die "Can't release a lock for $scanner_name: $!";
29391    $lock_fh->close or die "Can't close a lock file for $scanner_name: $!";
29392    undef $scanner_obj->{lock_fh};
29393  }
29394}
29395
29396# actual spam checking for every message
29397#
29398sub spam_scan {
29399  my($self,$msginfo) = @_;
29400  my $failure_msg;
29401  for my $as (@{$self->{scanners_list}}) {
29402    my($scanner_obj,$scanner_name) = @$as;
29403    next if !$scanner_obj && !$scanner_obj->UNIVERSAL::can('check');
29404    do_log(5, "SpamControl: calling spam scanner %s", $scanner_name);
29405    $self->lock($scanner_obj, 'classifier_lock_type');
29406    eval {
29407      $scanner_obj->check($msginfo); 1;
29408    } or do {
29409      my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
29410      do_log(-1, "checking with spam scanner %s failed: %s",
29411                 $scanner_name, $eval_stat);
29412      $failure_msg =
29413        "$scanner_name failed: $eval_stat"  if !defined $failure_msg;
29414    };
29415    $self->unlock($scanner_obj);
29416  }
29417  if (defined $failure_msg) { die $failure_msg }
29418  1;
29419}
29420
29421sub auto_learn {
29422  my($self,$msginfo) = @_;
29423  my $failure_msg;
29424  for my $as (@{$self->{scanners_list}}) {
29425    my($scanner_obj,$scanner_name) = @$as;
29426    next if !$scanner_obj || !$scanner_obj->UNIVERSAL::can('auto_learn');
29427    next if !$scanner_obj->UNIVERSAL::can('can_auto_learn') ||
29428            !$scanner_obj->can_auto_learn;
29429
29430    # learn-on-error logic: what was the final outcome
29431    my($min_spam_level, $max_spam_level) =
29432      minmax(map($_->spam_level, @{$msginfo->per_recip_data}));
29433    next if !defined $min_spam_level || !defined $max_spam_level;
29434
29435    # learn-on-error logic: what this scanner thinks
29436    my $my_verdict = $msginfo->supplementary_info('VERDICT-'.$scanner_name);
29437    $my_verdict = !defined $my_verdict ? '' : lc $my_verdict;
29438    my $my_score = $msginfo->supplementary_info('SCORE-'.$scanner_name);
29439    $my_score = 0  if !defined $my_score;
29440
29441    # learn-on-error logic: opinions differ?
29442    my $learn_as;  # leaving out a contribution by this spam scanner
29443    if ($my_verdict ne 'ham' && $max_spam_level-$my_score < 0.5) {
29444      $learn_as = 'ham';
29445    } elsif ($my_verdict ne 'spam' && $min_spam_level-$my_score >= 5) {
29446      $learn_as = 'spam';
29447    }
29448    next if !defined $learn_as;
29449
29450    ll(2) && do_log(2,
29451      "SpamControl: scanner %s, auto-learn as %s / %.3f (was: %s / %s)",
29452      $scanner_name, $learn_as,
29453      $my_verdict ne 'ham' ? $max_spam_level : $min_spam_level,
29454      $my_verdict, !$my_score ? '0' : sprintf("%.3f",$my_score));
29455
29456    $self->lock($scanner_obj, 'learner_lock_type');
29457    eval {
29458      $scanner_obj->auto_learn($msginfo,$learn_as); 1;
29459    } or do {
29460      my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
29461      do_log(-1, "auto-learning with spam scanner %s failed: %s",
29462                 $scanner_name, $eval_stat);
29463      $failure_msg =
29464        "$scanner_name failed: $eval_stat"  if !defined $failure_msg;
29465    };
29466    $self->unlock($scanner_obj);
29467  }
29468  if (defined $failure_msg) { die $failure_msg }
29469  1;
29470}
29471
29472# called during child process shutdown
29473#
29474sub rundown_child() {
29475  my $self = $_[0];
29476  for my $as (@{$self->{scanners_list}}) {
29477    my($scanner_obj,$scanner_name) = @$as;
29478    if ($scanner_obj && $scanner_obj->UNIVERSAL::can('rundown_child')) {
29479      eval {
29480        $scanner_obj->rundown_child;
29481        do_log(5, "SpamControl: rundown_child on %s done", $scanner_name);
29482        1;
29483      } or do {
29484        my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
29485        do_log(-1, "rundown_child on spam scanner %s failed: %s",
29486                   $scanner_name, $eval_stat);
29487      };
29488    }
29489  }
29490}
29491
29492# check envelope sender and author for white or blacklisting by each recipient;
29493# Saves the result in recip_blacklisted_sender and recip_whitelisted_sender
29494# properties of each recipient object, and updates spam score for each
29495# recipient according to soft-w/b-listing.
29496#
29497sub white_black_list($$$$) {
29498  my($msginfo,$sql_wblist,$user_id_sql,$ldap_lookups) = @_;
29499  my $fm = $msginfo->rfc2822_from;
29500  my(@rfc2822_from) = !defined($fm) ? () : ref $fm ? @$fm : $fm;
29501  my(@senders) = ($msginfo->sender, @rfc2822_from);
29502  @senders = unique_list(\@senders);  # remove possible duplicates
29503  ll(4) && do_log(4,"wbl: checking sender %s",
29504                    scalar(qquote_rfc2821_local(@senders)));
29505  my($any_w,$any_b,$all,$wr,$br);
29506  $any_w = 0; $any_b = 0; $all = 1;
29507  for my $r (@{$msginfo->per_recip_data}) {  # for each recipient
29508    next  if $r->recip_done;  # already dealt with
29509    my($wb,$boost); my $found = 0; my $recip = $r->recip_addr;
29510    my($user_id_ref,$mk_ref);
29511    $user_id_ref = $r->user_id;
29512    $user_id_ref = []  if !defined $user_id_ref;
29513    do_log(5,"wbl: (SQL) recip <%s>, %s matches",
29514             $recip, scalar(@$user_id_ref))  if $sql_wblist && ll(5);
29515    for my $sender (@senders) {
29516      for my $ind (0..$#{$user_id_ref}) { # for ALL SQL sets matching the recip
29517        my $user_id = $user_id_ref->[$ind];  my $mkey;
29518        ($wb,$mkey) = lookup(0,$sender,
29519                Amavis::Lookup::SQLfield->new($sql_wblist,'wb','S',$user_id) );
29520        do_log(4,'wbl: (SQL) recip <%s>, rid=%s, got: "%s"',
29521                 $recip,$user_id,$wb);
29522        if (!defined($wb)) {
29523          # NULL field or no match: remains undefined
29524        } elsif ($wb =~ /^ *([+-]?\d+(?:\.\d*)?) *\z/) {  # numeric
29525          my $val = 0+$1;  # penalty points to be added to the score
29526          $boost += $val;
29527          ll(2) && do_log(2,
29528                  'wbl: (SQL) soft-%slisted (%s) sender <%s> => <%s> (rid=%s)',
29529                  ($val<0?'white':'black'), $val, $sender, $recip, $user_id);
29530          $wb = undef;  # not hard- white or blacklisting, does not exit loop
29531        } elsif ($wb =~ /^[ \000]*\z/) {        # neutral, stops the search
29532          $found=1; $wb = 0;
29533          do_log(5, 'wbl: (SQL) recip <%s> is neutral to sender <%s>',
29534                    $recip,$sender);
29535        } elsif ($wb =~ /^([BbNnFf])[ ]*\z/) {  # blacklisted (B,N(o), F(alse))
29536          $found=1; $wb = -1; $any_b++; $br = $recip;
29537          $r->recip_blacklisted_sender(1);
29538          do_log(5, 'wbl: (SQL) recip <%s> blacklisted sender <%s>',
29539                    $recip,$sender);
29540        } else {            # whitelisted (W, Y(es), T(true), or anything else)
29541          if ($wb =~ /^([WwYyTt])[ ]*\z/) {
29542            do_log(5, 'wbl: (SQL) recip <%s> whitelisted sender <%s>',
29543                      $recip,$sender);
29544          } else {
29545            do_log(-1,'wbl: (SQL) recip <%s> whitelisted sender <%s>, '.
29546                      'unexpected wb field value: "%s"', $recip,$sender,$wb);
29547          }
29548          $found=1; $wb = +1; $any_w++; $wr = $recip;
29549          $r->recip_whitelisted_sender(1);
29550        }
29551        last  if $found;
29552      }
29553      if (!$found && $ldap_lookups && c('enable_ldap')) {  # LDAP queries
29554        my $wblist;
29555        my($keys_ref,$rhs_ref) = make_query_keys($sender,0,0);
29556        my(@keys) = @$keys_ref;
29557        unshift(@keys, '<>')  if $sender eq ''; # a hack for a null return path
29558        untaint_inplace($_) for @keys;  # untaint keys
29559        $_ = Net::LDAP::Util::escape_filter_value($_) for @keys;
29560        do_log(5,'wbl: (LDAP) query keys: %s', join(', ',map("\"$_\"",@keys)));
29561
29562        $wblist = lookup(0,$recip,Amavis::Lookup::LDAPattr->new(
29563                                $ldap_lookups, 'amavisBlacklistSender', 'L-'));
29564        for my $key (@keys) {
29565          if (grep(lc($_) eq lc($key), @$wblist)) {
29566            $found=1; $wb = -1; $br = $recip; $any_b++;
29567            $r->recip_blacklisted_sender(1);
29568            do_log(5,'wbl: (LDAP) recip <%s> blacklisted sender <%s>',
29569                     $recip,$sender);
29570          }
29571        }
29572        $wblist = lookup(0,$recip,Amavis::Lookup::LDAPattr->new(
29573                                $ldap_lookups, 'amavisWhitelistSender', 'L-'));
29574        for my $key (@keys) {
29575          if (grep(lc($_) eq lc($key), @$wblist)) {
29576            $found=1; $wb = +1; $wr = $recip; $any_w++;
29577            $r->recip_whitelisted_sender(1);
29578            do_log(5,'wbl: (LDAP) recip <%s> whitelisted sender <%s>',
29579                     $recip,$sender);
29580          }
29581        }
29582      }
29583      if (!$found) {  # fall back to static lookups if no match
29584        # sender can be both white- and blacklisted at the same time
29585        my($val, $r_ref, $mk_ref, @t);
29586
29587        # NOTE on the specifics of $per_recip_blacklist_sender_lookup_tables :
29588        # the $r_ref below is supposed to be a ref to a single lookup table
29589        # for compatibility with pre-2.0 versions of amavisd-new;
29590        # Note that this is different from @score_sender_maps, which is
29591        # supposed to contain a ref to a _list_ of lookup tables as a result
29592        # of the first-level lookup (on the recipient address as a key).
29593        #
29594        ($r_ref,$mk_ref) = lookup(0,$recip,
29595                         Amavis::Lookup::Label->new("blacklist_recip<$recip>"),
29596                         cr('per_recip_blacklist_sender_lookup_tables'));
29597        @t = ((defined $r_ref ? $r_ref : ()), @{ca('blacklist_sender_maps')});
29598        $val = lookup2(0,$sender,\@t,Label=>"blacklist_sender<$sender>") if @t;
29599        if ($val) {
29600          $found=1; $wb = -1; $br = $recip; $any_b++;
29601          $r->recip_blacklisted_sender(1);
29602          do_log(5,'wbl: recip <%s> blacklisted sender <%s>', $recip,$sender);
29603        }
29604        # similar for whitelists:
29605        ($r_ref,$mk_ref) = lookup(0,$recip,
29606                         Amavis::Lookup::Label->new("whitelist_recip<$recip>"),
29607                         cr('per_recip_whitelist_sender_lookup_tables'));
29608        @t = ((defined $r_ref ? $r_ref : ()), @{ca('whitelist_sender_maps')});
29609        $val = lookup2(0,$sender,\@t,Label=>"whitelist_sender<$sender>") if @t;
29610        if ($val) {
29611          $found=1; $wb = +1; $wr = $recip; $any_w++;
29612          $r->recip_whitelisted_sender(1);
29613          do_log(5,'wbl: recip <%s> whitelisted sender <%s>', $recip,$sender);
29614        }
29615      }
29616      if (!defined($boost)) {  # lookup @score_sender_maps if no match with SQL
29617        # note the first argument of lookup() is true, requesting ALL matches
29618        my($r_ref,$mk_ref) = lookup2(1,$recip, ca('score_sender_maps'),
29619                                     Label=>"score_recip<$recip>");
29620        for my $j (0..$#{$r_ref}) {  # for ALL tables matching the recipient
29621          my($val,$key) = lookup2(0,$sender,$r_ref->[$j],
29622                                  Label=>"score_sender<$sender>");
29623          if (defined $val && $val != 0) {
29624            $boost += $val;
29625            ll(2) && do_log(2,'wbl: soft-%slisted (%s) sender <%s> => <%s>, '.
29626                              'recip_key="%s"', ($val<0?'white':'black'),
29627                              $val, $sender, $recip, $mk_ref->[$j]);
29628          }
29629        }
29630      }
29631    } # endfor on @senders
29632    if ($boost) {  # defined and nonzero
29633      $r->spam_level( ($r->spam_level || 0) + $boost);
29634      my $spam_tests = 'AM.WBL=' . (0+sprintf("%.3f",$boost));
29635      if (!$r->spam_tests) {
29636        $r->spam_tests([ \$spam_tests ]);
29637      } else {
29638        unshift(@{$r->spam_tests}, \$spam_tests);
29639      }
29640    }
29641    $all = 0  if !$wb;
29642  } # endfor on recips
29643  if (!ll(2)) {
29644    # don't bother preparing a log report which will not be printed
29645  } else {
29646    my $msg = '';
29647    if    ($all && $any_w && !$any_b) { $msg = "whitelisted" }
29648    elsif ($all && $any_b && !$any_w) { $msg = "blacklisted" }
29649    elsif ($all) { $msg = "black or whitelisted by all recips" }
29650    elsif ($any_b || $any_w) {
29651      $msg .= "whitelisted by ".($any_w>1?"$any_w recips, ":"$wr, ") if $any_w;
29652      $msg .= "blacklisted by ".($any_b>1?"$any_b recips, ":"$br, ") if $any_b;
29653      $msg .= "but not by all,";
29654    }
29655    do_log(2,"wbl: %s sender %s",
29656             $msg, scalar(qquote_rfc2821_local(@senders)))  if $msg ne '';
29657  }
29658  ($any_w+$any_b, $all);
29659}
29660
296611;
29662
29663__DATA__
29664#
29665package Amavis::SpamControl::ExtProg;
29666use strict;
29667use re 'taint';
29668use warnings;
29669use warnings FATAL => qw(utf8 void);
29670no warnings 'uninitialized';
29671# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
29672
29673BEGIN {
29674  require Exporter;
29675  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
29676  $VERSION = '2.412';
29677  @ISA = qw(Exporter);
29678  import Amavis::Conf qw(:platform :confvars :sa c cr ca);
29679  import Amavis::Util qw(ll do_log sanitize_str min max minmax
29680                         prolong_timer get_deadline);
29681  import Amavis::ProcControl qw(exit_status_str proc_status_ok
29682                         kill_proc run_command run_command_consumer);
29683  import Amavis::rfc2821_2822_Tools qw(qquote_rfc2821_local);
29684  import Amavis::Timing qw(section_time);
29685}
29686use subs @EXPORT_OK;
29687
29688use Errno qw(EIO EINTR EAGAIN ECONNRESET EBADF);
29689use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
29690use Time::HiRes ();
29691
29692sub new {
29693  my($class, $scanner_name,$module,@args) = @_;
29694  my($cmd,$cmdargs,%options) = @args;
29695  return if !defined $cmd || $cmd eq '';
29696  bless {
29697    scanner_name => $scanner_name, command => $cmd, args => $cmdargs,
29698    options => \%options,
29699  }, $class;
29700}
29701
29702sub check {
29703  my($self,$msginfo) = @_;
29704  $self->check_or_learn($msginfo,undef);
29705};
29706
29707sub auto_learn {
29708  my($self,$msginfo,$learn_as) = @_;
29709  $self->check_or_learn($msginfo,$learn_as);
29710}
29711
29712sub can_auto_learn {
29713  my $self = $_[0];
29714  my $opt = $self->{options};
29715  $opt && defined $opt->{'learn_ham'} && defined $opt->{'learn_spam'};
29716}
29717
29718# pass a mail message to an external (spam checking) program,
29719# extract interesting header fields from the result
29720#
29721sub check_or_learn {
29722  my($self,$msginfo,$learn_as) = @_;
29723  my $scanner_name = $self->{scanner_name};
29724  my $cmd = $self->{command};
29725  my $cmdargs; my $auto_learning;
29726  if (!defined $learn_as) {
29727    $cmdargs = $self->{args};
29728  } elsif ($learn_as eq 'ham') {
29729    $cmdargs = $self->{options}->{'learn_ham'}; $auto_learning = 1;
29730  } elsif ($learn_as eq 'spam') {
29731    $cmdargs = $self->{options}->{'learn_spam'}; $auto_learning = 1;
29732  }
29733  my $size_limit;
29734  my $mbsl = $self->{options}->{'mail_body_size_limit'};
29735  $mbsl = c('sa_mail_body_size_limit')  if !defined $mbsl;
29736  if (defined $mbsl) {
29737    $size_limit = min(64*1024, $msginfo->orig_header_size) + 1 +
29738                  min($mbsl,   $msginfo->orig_body_size);
29739    # don't bother if slightly oversized, it's faster without size checks
29740    undef $size_limit  if $msginfo->msg_size < $size_limit + 5*1024;
29741  }
29742  my $prefix = '';
29743  # fake a local delivery agent by inserting a Return-Path
29744  $prefix .= sprintf("Return-Path: %s\n", $msginfo->sender_smtp);
29745  $prefix .= sprintf("X-Envelope-To: %s\n",
29746                     join(",\n ",qquote_rfc2821_local(@{$msginfo->recips})));
29747  my $os_fp = $msginfo->client_os_fingerprint;
29748  $prefix .= sprintf("X-Amavis-OS-Fingerprint: %s\n",
29749                     sanitize_str($os_fp))  if defined($os_fp) && $os_fp ne '';
29750  my(@av_tests);
29751  my $per_recip_data = $msginfo->per_recip_data;
29752  $per_recip_data = []  if !$per_recip_data;
29753  for my $r (@$per_recip_data) {
29754    my $spam_tests = $r->spam_tests;
29755    push(@av_tests, grep(/^AV\..+=/,
29756                 split(/,/, join(',',map($$_,@$spam_tests)))))  if $spam_tests;
29757  }
29758  $prefix .= sprintf("X-Amavis-AV-Status: %s\n",
29759                     sanitize_str(join(',',@av_tests)))  if @av_tests;
29760  $prefix .= sprintf("X-Amavis-PolicyBank: %s\n", c('policy_bank_path'));
29761  $prefix .= sprintf("X-Amavis-MessageSize: %d%s\n", $msginfo->msg_size,
29762                     !defined $size_limit ? '' : ", TRUNCATED to $size_limit");
29763  my $resp_stdout_fh  = IO::File->new;  # parent reading side of the pipe
29764  my $child_stdout_fh = IO::File->new;  # child stdout writing side of a pipe
29765  my $resp_stderr_fh  = IO::File->new;  # parent reading side of the pipe
29766  my $child_stderr_fh = IO::File->new;  # child stderr writing side of a pipe
29767  pipe($resp_stdout_fh, $child_stdout_fh)
29768    or die "$scanner_name: Can't create pipe1: $!";
29769  pipe($resp_stderr_fh, $child_stderr_fh)
29770    or die "$scanner_name: Can't create pipe2: $!";
29771  binmode($resp_stdout_fh)  or die "Can't set pipe1 to binmode: $!";
29772  binmode($resp_stderr_fh)  or die "Can't set pipe2 to binmode: $!";
29773
29774  my($proc_fh,$pid) = run_command_consumer('&='.fileno($child_stdout_fh),
29775                                           '&='.fileno($child_stderr_fh),
29776                                           $cmd, @$cmdargs);
29777  $child_stdout_fh->close
29778    or die "Parent failed to close child side of the pipe1: $!";
29779  $child_stderr_fh->close
29780    or die "Parent failed to close child side of the pipe2: $!";
29781  undef $child_stdout_fh; undef $child_stderr_fh;
29782
29783  my($remaining_time, $deadline) = get_deadline($scanner_name.'_scan', 0.8, 5);
29784  alarm(0);  # stop the timer
29785  my $proc_fd = fileno($proc_fh);
29786  my $resp_stdout_fd = fileno($resp_stdout_fh);
29787  my $resp_stderr_fd = fileno($resp_stderr_fh);
29788  my $response = ''; my $response_stderr = ''; my $response_chopped = 0;
29789  my $child_stat; my $bytes_sent = 0; my $err_on_child = 0;
29790  my $msg = $msginfo->mail_text;
29791  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
29792  $msg = $msg_str_ref  if ref $msg_str_ref;
29793  eval {
29794    if (!defined $msg) {
29795      # empty mail
29796    } elsif (ref $msg ne 'SCALAR' && $msg->isa('MIME::Entity')) {
29797    # $msg->print_body($proc_fh);  # flushing the pipe?
29798      die "$scanner_name: reading from MIME::Entity is not implemented";
29799    } else {  # handles a message in-memory or on a file
29800      my $file_position = $msginfo->skip_bytes;
29801      if (ref $msg ne 'SCALAR') {
29802        $msg->seek($file_position, 0) or die "Can't rewind mail file: $!";
29803      }
29804      my $data_source = $prefix;
29805      my $eof_on_response = 0;
29806      my $eof_on_msg = 0; my $force_eof_on_msg = 0;
29807      my($rout,$wout,$eout,$rin,$win,$ein); $rin=$win=$ein='';
29808      vec($rin,$resp_stdout_fd,1) = 1;
29809      vec($rin,$resp_stderr_fd,1) = 1;
29810      for (;;) {
29811        vec($win,$proc_fd,1) = 0;
29812        vec($win,$proc_fd,1) = 1  if defined $proc_fh &&
29813                                     (!$eof_on_msg || $data_source ne '');
29814        $ein = $rin | $win;
29815        my $timeout = max(3, $deadline - Time::HiRes::time);
29816        my($nfound,$timeleft) =
29817          select($rout=$rin, $wout=$win, $eout=$ein, $timeout);
29818        defined $nfound && $nfound >= 0
29819          or die "$scanner_name: select failed: $!";
29820        if (vec($rout,$resp_stderr_fd,1)) {
29821          my $inbuf = ''; $! = 0;
29822          my $nread = sysread($resp_stderr_fh, $inbuf, 16384);
29823          if ($nread) {  # successful read
29824            ll(5) && do_log(5, 'rx stderr: %d %s [...]',
29825                               $nread, substr($inbuf,0,1000));
29826            $response_stderr .= $inbuf  if length($response_stderr) < 10000;
29827          } elsif (defined $nread) {  # defined but zero: EOF
29828            # sysread returns 0 at eof
29829          } elsif ($! == EAGAIN || $! == EINTR) {
29830            Time::HiRes::sleep(0.1);  # slow down, just in case
29831          } else {  # read error
29832            do_log(0,"%s: error reading from pipe2: %s", $scanner_name,$!);
29833          }
29834        }
29835        if (vec($rout,$resp_stdout_fd,1)) {
29836          my $inbuf = ''; $! = 0;
29837          my $nread = sysread($resp_stdout_fh, $inbuf, 16384);
29838          if ($nread) {  # successful read
29839            ll(5) && do_log(5, 'rx: %d %s [...]',
29840                               $nread, substr($inbuf,0,30));
29841            my $response_l = length($response);
29842            if ($response_chopped || $response_l >= 65536) {
29843              # ignore the rest of input
29844            } else {
29845              $response .= $inbuf;
29846              my $j = $response_l <= 1 ? 0 : $response_l - 1;
29847              # we only need a mail header from the returned text
29848              $response_chopped = 1  if index($response,"\n\n",$j) >= 0;
29849            }
29850          } elsif (defined $nread) {  # defined but zero: EOF
29851            $eof_on_response = 1;  # sysread returns 0 at eof
29852          } elsif ($! == EAGAIN || $! == EINTR) {
29853            Time::HiRes::sleep(0.1);  # slow down, just in case
29854          } else {  # read error
29855            $eof_on_response = 1;
29856            die "$scanner_name: error reading from pipe1: $!";
29857          }
29858        }
29859        if (vec($wout,$proc_fd,1)) {  # subprocess is ready to receive more
29860          if ($data_source eq '' && !$eof_on_msg) {  # get more data
29861            my $nread = 0;
29862            if ($force_eof_on_msg) {
29863              # pretend to already be at eof
29864            } elsif (ref $msg ne 'SCALAR') {  # message is on a file
29865              $nread = $msg->read($data_source,32768);
29866            } elsif ($file_position < length($$msg)) {  # message in memory
29867              # do it in chunks, saves memory, cache friendly
29868              $data_source = substr($$msg,$file_position,32768);
29869              $nread = length($data_source);
29870            }
29871            if (!$nread) {
29872              $eof_on_msg = 1;
29873              defined $nread or die "$scanner_name: error reading message: $!";
29874              if (defined $proc_fh) { $proc_fh->close or $err_on_child=$! };
29875              undef $proc_fh;
29876              do_log(5,"tx: eof");
29877            }
29878            $file_position += $nread;
29879            if (defined $size_limit) {
29880              my $remaining_room = $size_limit - $bytes_sent;
29881              $remaining_room = 0  if $remaining_room < 0;
29882              if ($nread > $remaining_room) {
29883                substr($data_source, $remaining_room) = '';
29884                do_log(5,"tx: (size limit) %d -> %d", $nread,$remaining_room);
29885                $force_eof_on_msg = 1;
29886              }
29887            }
29888          }
29889          if ($data_source ne '' && defined $proc_fh) {
29890            ll(5) && do_log(5, "tx: %d %s [...]",
29891                            length($data_source), substr($data_source,0,30));
29892            # syswrite does a write(2), no need to call $proc_fh->flush
29893            my $nwrite = syswrite($proc_fh, $data_source);
29894            if (!defined($nwrite)) {
29895              if ($! == EAGAIN || $! == EINTR) {
29896                Time::HiRes::sleep(0.1);   # slow down, just in case
29897              } else {
29898                $data_source = ''; $eof_on_msg = 1;  # simulate an eof
29899                do_log(-1,"%s: error writing to pipe: %s", $scanner_name,$!);
29900                $proc_fh->close or $err_on_child=$!; undef $proc_fh;
29901                do_log(5,"tx: eof (wr err)");
29902              }
29903            } elsif ($nwrite > 0) {  # successful write
29904              $bytes_sent += $nwrite;
29905              if ($nwrite < length($data_source)) {
29906                substr($data_source,0,$nwrite) = '';
29907              } else {
29908                $data_source = '';
29909              }
29910            }
29911          }
29912        }
29913        last  if $eof_on_response;
29914        if (Time::HiRes::time >= $deadline) {
29915          die "$scanner_name: exceeded allowed time\n";
29916        }
29917      }
29918    }
29919    if (defined $proc_fh) { $proc_fh->close or $err_on_child=$! }
29920    $child_stat = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
29921    undef $proc_fh; undef $pid;
29922    1;
29923  } or do {
29924    my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
29925    do_log(-1,"%s failed: %s", $scanner_name,$eval_stat);
29926    kill_proc($pid,$scanner_name,1,$proc_fh,$eval_stat)  if defined $pid;
29927    undef $proc_fh; undef $pid;
29928  };
29929  prolong_timer($scanner_name);  # restart timer
29930
29931  substr($response_stderr,2000) = '[...]'  if length($response_stderr) > 2000;
29932  if (proc_status_ok($child_stat,$err_on_child)) {
29933    do_log(2, "%s stderr: %s",
29934              $scanner_name,$response_stderr)  if $response_stderr ne '';
29935  } else {
29936    do_log(-1,"%s stderr: %s",
29937              $scanner_name,$response_stderr)  if $response_stderr ne '';
29938    die "$scanner_name: error running program $cmd: " .
29939           exit_status_str($child_stat,$err_on_child) . "\n";
29940  }
29941  # keep just a header section in $response
29942  if ($response eq '') {
29943    # empty mail
29944  } elsif (substr($response, 0,1) eq "\n") {
29945    $response = '';  # empty header section
29946  } else {
29947    my $ind = index($response,"\n\n");  # find header/body separator
29948    substr($response, $ind+1) = ''  if $ind >= 0;
29949  }
29950  my $crm114_score;
29951  if ($cmd =~ /\bcrm/ && $response =~ /^\s*([+-]?\d*(?:\.\d*)?)\s*$/) {
29952    $crm114_score = $1;
29953    $response = '';  # skip the header parsing loop below
29954  }
29955  my(@response_lines) = split(/^/m, $response, -1);
29956  push(@response_lines, "\n", "\n");  # insure a trailing NL and a separator
29957  undef $response;
29958
29959  my(%header_field, @header_field_name, $curr_head);
29960  # scan mail header section retrieved from an external program on its stdout
29961  for my $ln (@response_lines) {  # guaranteed to contain header/body separator
29962    if ($ln =~ /^[ \t]/) {  # folded
29963      $curr_head .= $ln;
29964    } else {  # a new header field, process previous if any
29965      if (defined $curr_head) {
29966        local($1,$2);
29967        if ($curr_head =~ /^ ( (?: X-DSPAM | X-CRM114 | X-Bogosity) [^:]*? )
29968                           [ \t]* : [ \t]* (.*) $/xs) {
29969          my($hn,$hb) = ($1,$2); my $hnlc = lc $hn;
29970          push(@header_field_name, $hn)  if !exists($header_field{$hnlc});
29971          $header_field{$hnlc} = $hb;  # keep last
29972        }
29973      }
29974      $curr_head = $ln;
29975      last  if $ln eq "\n";
29976    }
29977  }
29978
29979  my($spam_score, $spam_tests);
29980  my $score_factor = $self->{options}->{'score_factor'};
29981
29982  my $dspam_result = $header_field{lc('X-DSPAM-Result')};
29983  if (defined $dspam_result) {
29984    if ($dspam_result =~ /\b(signature|result|probability|confidence)=.*;/) {
29985      # combined result, split
29986      my(%attribute);
29987      for my $attr (split(/;\s*/, $dspam_result)) {
29988        local($1,$2);
29989        my($n,$v) = ($attr =~ /^([^=]*)=(.*)\z/s) ? ($1,$2) : ('user',$attr);
29990        $v =~ s/^"//; $v =~ s/"\z//; $attribute{$n} = $v;
29991      }
29992      # simulate separate header fields
29993      @header_field_name = qw(X-DSPAM-Result X-DSPAM-Class X-DSPAM-Confidence
29994                              X-DSPAM-Probability X-DSPAM-Signature);
29995      for my $hn (@header_field_name) {
29996        my $hnlc = lc $hn; my $name = $hnlc; $name =~ s/^X-DSPAM-//i;
29997        $header_field{$hnlc} = $attribute{$name};
29998      }
29999    }
30000    $dspam_result =       $header_field{lc('X-DSPAM-Result')};
30001    my $dspam_signature = $header_field{lc('X-DSPAM-Signature')};
30002    $dspam_result    = ''  if !defined $dspam_result;
30003    $dspam_signature = ''  if !defined $dspam_signature;
30004    chomp($dspam_result); chomp($dspam_signature);
30005    $dspam_signature = ''  if $dspam_signature eq 'N/A';
30006    if (!$auto_learning) {
30007      $msginfo->supplementary_info('DSPAMRESULT',    $dspam_result);
30008      $msginfo->supplementary_info('DSPAMSIGNATURE', $dspam_signature);
30009      $msginfo->supplementary_info('VERDICT-'.$scanner_name, $dspam_result);
30010      $spam_score = $dspam_result eq 'Spam' ? 10 : -1;  # fabricated
30011      $score_factor = 1  if !defined $score_factor;
30012      $spam_score *= $score_factor;
30013      $spam_tests = sprintf("%s.%s=%.3f",
30014                            $scanner_name, $dspam_result, $spam_score);
30015      do_log(2,"%s result: %s, score=%.3f, sig=%s",
30016               $scanner_name, $dspam_result, $spam_score, $dspam_signature);
30017    }
30018  }
30019
30020  my $crm114_status = $header_field{lc('X-CRM114-Status')};
30021  if (defined $crm114_score || defined $crm114_status) {
30022    local($1,$2);
30023    if (!defined $crm114_status) {  # presumably using --stats_only
30024      # fabricate a Status from score
30025      $crm114_status = !defined $crm114_score ? 'unknown'
30026                     : $crm114_score <= -10 ? uc('spam')
30027                     : $crm114_score >= +10 ? 'GOOD' : 'UNSURE';
30028      $header_field{lc('X-CRM114-Status')} =
30029        sprintf("%s ( %s )", $crm114_status, $crm114_score);
30030      @header_field_name = qw(X-CRM114-Status);
30031    } elsif ($crm114_status =~ /^([A-Z]+)\s+\(\s+([-\d\.]+)\s+\)/) {
30032      $crm114_status = $1; $crm114_score = $2;
30033    }
30034    my $crm114_cacheid = $header_field{lc('X-CRM114-CacheID')};
30035    if (defined $crm114_cacheid && $crm114_cacheid =~ /^sfid-\s*$/i) {
30036      delete $header_field{lc('X-CRM114-CacheID')}; $crm114_cacheid = undef;
30037    }
30038    s/[ \t\r\n]+\z//  for ($crm114_status, $crm114_score, $crm114_cacheid);
30039    $score_factor = -0.10  if !defined $score_factor;
30040    $spam_score = $score_factor * $crm114_score;
30041    $spam_tests = sprintf("%s.%s(%s)=%.3f",
30042                    $scanner_name, $crm114_status, $crm114_score, $spam_score);
30043    if (!$auto_learning) {
30044      $msginfo->supplementary_info('VERDICT-'.$scanner_name,
30045                         uc $crm114_status eq 'GOOD' ? 'Ham' : $crm114_status);
30046      $msginfo->supplementary_info('CRM114STATUS',
30047                           sprintf("%s ( %s )", $crm114_status,$crm114_score));
30048      $msginfo->supplementary_info('CRM114SCORE',   $crm114_score);
30049      $msginfo->supplementary_info('CRM114CACHEID', $crm114_cacheid);
30050      do_log(2,"%s result: score=%s (%s), status=%s, cacheid=%s",
30051               $scanner_name, $spam_score,
30052               $crm114_score, $crm114_status, $crm114_cacheid);
30053    }
30054  }
30055
30056  my $bogo_line = $header_field{lc('X-Bogosity')};
30057  my($bogo_status, $bogo_score, $bogo_tests);
30058  if (defined $bogo_line) {
30059    ($bogo_status, $bogo_tests, $bogo_score) = split(/,\s*/,$bogo_line);
30060    local($1);
30061    $bogo_score =~ s/^spamicity=([0-9.+-]*).*\z/$1/s;
30062    $spam_score = $bogo_status eq 'Spam' ? 5 : $bogo_status eq 'Ham' ? -5 : 0;
30063    $score_factor = 1  if !defined $score_factor;
30064    $spam_score = $score_factor * $spam_score;
30065    # trim trailing fraction zeroes
30066    $spam_score = 0 + sprintf("%.3f",$spam_score);
30067    $spam_tests = sprintf("%s=%s", $scanner_name, $spam_score);
30068#   $spam_tests = sprintf("%s(%s/%s)=%s",
30069#                   $scanner_name, $bogo_status, $bogo_score, $spam_score);
30070    if (!$auto_learning) {
30071      $msginfo->supplementary_info('VERDICT-'.$scanner_name, $bogo_status);
30072      $msginfo->supplementary_info('BOGOSTATUS', sprintf("%s ( %s )",
30073                                                   $bogo_status, $bogo_score));
30074      $msginfo->supplementary_info('BOGOSCORE', $bogo_score);
30075      do_log(2,"%s result: score=%s (%s), status=%s",
30076               $scanner_name, $spam_score, $bogo_score, $bogo_status);
30077    }
30078  }
30079
30080  if (!$auto_learning) {
30081    my $hdr_edits = $msginfo->header_edits;
30082    my $use_our_hdrs = cr('prefer_our_added_header_fields');
30083    my $allowed_hdrs = cr('allowed_added_header_fields');
30084    my $all_local = !grep(!$_->recip_is_local, @$per_recip_data);
30085    for my $hn (@header_field_name) {
30086      my $hnlc = lc $hn; my $hb = $header_field{$hnlc};
30087      if (defined $hb) {
30088        $hb =~ s/[ \t\r\n]+\z//;  # trim trailing whitespace and eol
30089        do_log(5,"%s: suppl attr: %s = '%s'", $scanner_name,$hn,$hb);
30090        $msginfo->supplementary_info($hn,$hb);
30091        # add header fields to passed mail for all recipients
30092        if ($all_local && $allowed_hdrs && $allowed_hdrs->{$hnlc} &&
30093            !($use_our_hdrs && $use_our_hdrs->{$hnlc})) {
30094          $hdr_edits->add_header($hn,$hb,2);
30095        }
30096      }
30097    }
30098    if (defined $spam_score) {
30099      $msginfo->supplementary_info('SCORE-'.$scanner_name, $spam_score);
30100      for my $r (@$per_recip_data) {
30101        $r->spam_level( ($r->spam_level || 0) + $spam_score );
30102        if (!$r->spam_tests) {
30103          $r->spam_tests([ \$spam_tests ]);
30104        } else {
30105          push(@{$r->spam_tests}, \$spam_tests);
30106        }
30107      }
30108    }
30109  }
30110  section_time($scanner_name);
30111}
30112
301131;
30114
30115__DATA__
30116#
30117package Amavis::SpamControl::RspamdClient;
30118use strict;
30119use re 'taint';
30120use warnings;
30121use warnings FATAL => qw(utf8 void);
30122no warnings 'uninitialized';
30123
30124=pod
30125
30126=head1 Amavis extension module to use Rspamd as a spam checker
30127
30128Copyright (c) 2019 Ralph Seichter, partially based on the
30129SpamdClient extension. Released under GNU General Public
30130License; see Amavis LICENSE file for details.
30131
30132=head2 Example configuration #1 (local Rspamd)
30133
30134  # Rspamd running on the same machine as Amavis. Default URL
30135  # is http://127.0.0.1:11333/checkv2 , matching Rspamd's
30136  # "normal" worker defaults.
30137  @spam_scanners = ( [
30138      'Local Rspamd', 'Amavis::SpamControl::RspamdClient',
30139
30140      # Adjust scores according to Rspamd's "required score"
30141      # setting (defaults to 15). Scores reported by Rspamd
30142      # will be multiplied with this factor. The following
30143      # adjusts Rspamd scores to SpamAssassin scores. While
30144      # this setting is technically optional, not adjusting
30145      # scores is prone to cause headaches.
30146      score_factor => $sa_tag2_level_deflt / 15.0,
30147
30148      # MTA name is used to assess validity of existing
30149      # Authentication-Results headers, e.g. if DKIM/DMARC
30150      # validation has already happened.
30151      mta_name => 'mail.example.com',
30152  ] );
30153
30154=head2 Example configuration #2 (remote Rspamd)
30155
30156  # Rspamd running behind HTTPS-capable proxy using basic
30157  # authentication to control access.
30158  @spam_scanners = ( [
30159      'Remote Rspamd', 'Amavis::SpamControl::RspamdClient',
30160      url => 'https://rspamd-proxy.example.com/checkv2',
30161
30162      # Response timeout in seconds. Default is 60, matching
30163      # Rspamd's standard config for the "normal" worker.
30164      timeout => 42,
30165
30166      # SSL-options and -credentials passed to LWP::UserAgent,
30167      # see https://metacpan.org/pod/LWP::UserAgent . Default:
30168      # ssl_opts => { verify_hostname => 1 },
30169      credentials => {
30170          # The following <host>:<port> must match the 'url'
30171          # defined above or credentials won't be transmitted.
30172          netloc => 'rspamd-proxy.example.com:443',
30173          # Remote authentication realm
30174          realm => 'Rspamd restricted access',
30175          username => 'Marco',
30176          password => 'Polo',
30177      },
30178
30179      # Don't scan messages remotely if the body size extends
30180      # the following limit (optional setting).
30181      mail_body_size_limit => 32 * 1024,
30182
30183      score_factor => $sa_tag2_level_deflt / 15.0,
30184      mta_name => 'mail.example.com',
30185  ] );
30186
30187=head2 Requirements
30188
30189In addition to Amavis' core requirements, this extension needs
30190the following additional Perl modules:
30191
30192  JSON
30193  HTTP::Message
30194  LWP::UserAgent
30195  LWP::Protocol::https
30196  Net::SSLeay
30197
30198Should your host OS not provide the necessary packages, these
30199modules can be obtained via https://www.cpan.org .
30200
30201=cut
30202
30203BEGIN {
30204    require Exporter;
30205    use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
30206    $VERSION = '2.412';
30207    @ISA = qw(Exporter);
30208    import Amavis::Util qw(do_log min prolong_timer);
30209    import Amavis::rfc2821_2822_Tools qw(qquote_rfc2821_local);
30210    import Amavis::Timing qw(section_time);
30211}
30212
30213use JSON qw(decode_json);
30214use LWP::UserAgent;
30215
30216sub new {
30217    my ($class, $scanner_name, $module, @args) = @_;
30218    my (%options) = @args;
30219    bless { scanner_name => $scanner_name, options => \%options }, $class;
30220}
30221
30222# Pass meta information using Rspamd's non-standard HTTP headers.
30223sub pass_meta {
30224    my ($request, $name, $value) = @_;
30225    if (defined $value && $value ne '') {
30226        $request->header($name => $value);
30227    }
30228}
30229
30230# Invoked by Amavis to spam-check one message.
30231sub check {
30232    my ($self, $msginfo) = @_;
30233    my ($which_section, $spam_level, $rspamd_action, $rspamd_rscore,
30234        $rspamd_skipped, $rspamd_tests, $rspamd_verdict, $size_limit);
30235    my $scanner_name = $self->{scanner_name};
30236    my $mbsl = $self->{options}->{'mail_body_size_limit'};
30237    if (defined $mbsl) {
30238        $size_limit = min(32 * 1024, $msginfo->orig_header_size) +
30239            min($mbsl, $msginfo->orig_body_size);
30240        # Allow slightly oversized messages to pass in full.
30241        undef $size_limit if $msginfo->msg_size < $size_limit + 5 * 1024;
30242    }
30243    my $per_recip_data = $msginfo->per_recip_data;
30244    $per_recip_data = [] if !$per_recip_data;
30245
30246    my $msg = $msginfo->mail_text;
30247    my $msg_str_ref = $msginfo->mail_text_str; # In-memory copy available?
30248    $msg = $msg_str_ref if ref $msg_str_ref;
30249    eval {
30250        if (!defined $msg) {
30251            do_log(3, "Empty message");
30252        }
30253        elsif (ref $msg eq 'SCALAR') {
30254            $which_section = 'rspamd_connect';
30255            my $timeout = $self->{options}->{'timeout'};
30256            $timeout = 60 unless defined $timeout;
30257            my $url = $self->{options}->{'url'};
30258            $url = 'http://127.0.0.1:11333/checkv2' unless defined $url;
30259            do_log(3, "connecting to rspamd %s (timeout %s)", $url, $timeout);
30260
30261            my $request = HTTP::Request->new(POST => $url);
30262            $request->content_type('application/octet-stream');
30263            $request->content(defined $size_limit ? substr($$msg, 0, $size_limit) : $$msg);
30264            pass_meta($request, 'Helo', $msginfo->client_helo);
30265            pass_meta($request, 'Hostname', $msginfo->client_name);
30266            pass_meta($request, 'IP', $msginfo->client_addr);
30267            pass_meta($request, 'MTA-Name', $self->{options}->{'mta_name'});
30268            pass_meta($request, 'From', $msginfo->sender_smtp);
30269            pass_meta($request, 'Queue-Id', $msginfo->queue_id);
30270            for my $rcpt (qquote_rfc2821_local(@{$msginfo->recips})) {
30271                pass_meta($request, 'Rcpt', $rcpt);
30272            }
30273
30274            $which_section = 'rspamd_tx';
30275            my $ssl_opts = $self->{options}->{'ssl_opts'};
30276            $ssl_opts = { verify_hostname => 1 } unless defined $ssl_opts;
30277            my $user_agent = LWP::UserAgent->new(
30278                protocols_allowed => [ 'http', 'https' ],
30279                ssl_opts          => $ssl_opts
30280            );
30281            my $credentials = $self->{options}->{'credentials'};
30282            if (defined $credentials) {
30283                $user_agent->credentials(
30284                    $credentials->{'netloc'},
30285                    $credentials->{'realm'},
30286                    $credentials->{'username'},
30287                    $credentials->{'password'},
30288                )
30289            }
30290            $user_agent->agent('amavis/' . $VERSION);
30291            $user_agent->timeout($timeout);
30292
30293            prolong_timer($which_section, undef, undef, $timeout);
30294            my $response = $user_agent->request($request);
30295            $response->is_success or die "Error calling rspamd: " . $response->status_line . ", stopped";
30296            my $content = $response->content;
30297            defined $content or die "Missing rspamd response, stopped";
30298            do_log(5, "Rspamd response: " . $content);
30299            my $rspamd = decode_json $content;
30300            $rspamd_skipped = $rspamd->{is_skipped};
30301            $spam_level = $rspamd->{score};
30302            $rspamd_rscore = $rspamd->{required_score};
30303            $rspamd_action = $rspamd->{action};
30304            my $rspamd_symbols = $rspamd->{symbols};
30305            if (defined $rspamd_symbols) {
30306                my @tests;
30307                while (my ($ignored, $symbol) = each %$rspamd_symbols) {
30308                    my $symbol_name = $symbol->{name};
30309                    $symbol_name =~ tr/=,/__/;
30310                    my $t = sprintf("%s=%s", $symbol_name, $symbol->{score});
30311                    push(@tests, $t);
30312                }
30313                $rspamd_tests = join(',', @tests);
30314            }
30315            # Map Rspamd action to Amavis verdict
30316            my %action2verdict = (
30317                'add header'      => 'Spam',
30318                'no action'       => 'Ham',
30319                'reject'          => 'Spam',
30320                'rewrite subject' => 'Spam',
30321                # Rspamd 1.9 and later
30322                'discard'         => 'Spam',
30323                'quarantine'      => 'Spam',
30324            );
30325            $rspamd_verdict = exists $action2verdict{$rspamd_action} ?
30326                $action2verdict{$rspamd_action} : 'Unknown';
30327        }
30328        else {
30329            do_log(2, "%s skipping message type %s", $scanner_name, ref $msg);
30330            $rspamd_action = 'N/A';
30331            $rspamd_verdict = 'Unknown';
30332            $rspamd_skipped = 1;
30333            $rspamd_rscore = 0;
30334            $spam_level = 0;
30335        }
30336
30337        1;
30338
30339    } or do {
30340        my $eval_stat = $@ ne '' ? $@ : "errno=$!";
30341        chomp $eval_stat;
30342        do_log(-1, "%s client failed: %s", $scanner_name, $eval_stat);
30343    };
30344
30345    section_time($which_section);
30346    my $score_factor = $self->{options}->{'score_factor'};
30347    if (defined $spam_level && defined $score_factor) {
30348        $spam_level *= $score_factor;
30349        $rspamd_rscore *= $score_factor;
30350    }
30351    do_log(2, "%s rspamd %sscore %.2f/%.2f (%s) %s", $scanner_name,
30352        $rspamd_skipped ? 'skipped/' : '',
30353        $spam_level, $rspamd_rscore, $rspamd_action, $rspamd_tests);
30354    $msginfo->supplementary_info('SCORE-' . $scanner_name, $spam_level);
30355    $msginfo->supplementary_info('VERDICT-' . $scanner_name, $rspamd_verdict);
30356    for my $r (@$per_recip_data) {
30357        $r->spam_level(($r->spam_level || 0) + $spam_level);
30358        if (!$r->spam_tests) {
30359            $r->spam_tests([ \$rspamd_tests ]);
30360        }
30361        else {
30362            push(@{$r->spam_tests}, \$rspamd_tests);
30363        }
30364    }
30365}
30366
303671;
30368
30369__DATA__
30370#
30371package Amavis::SpamControl::SpamdClient;
30372use strict;
30373use re 'taint';
30374use warnings;
30375use warnings FATAL => qw(utf8 void);
30376no warnings 'uninitialized';
30377# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
30378
30379BEGIN {
30380  require Exporter;
30381  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
30382  $VERSION = '2.412';
30383  @ISA = qw(Exporter);
30384  import Amavis::Conf qw(:platform :confvars :sa c cr ca);
30385  import Amavis::Util qw(ll do_log sanitize_str min max minmax get_deadline);
30386  import Amavis::rfc2821_2822_Tools qw(qquote_rfc2821_local);
30387  import Amavis::Timing qw(section_time);
30388}
30389
30390use Errno qw(ENOENT EACCES);
30391
30392sub new {
30393  my($class, $scanner_name,$module,@args) = @_;
30394  my(%options) = @args;
30395  bless { scanner_name => $scanner_name, options => \%options }, $class;
30396}
30397
30398# needs spamd running, could be started like this:
30399#   spamd -H /var/amavis/home -r /var/amavis/home/spamd.pid -s stderr \
30400#     -u vscan -g vscan -x -P --allow-tell --min-children=2 --max-children=2
30401
30402sub check {
30403  my($self,$msginfo) = @_;
30404  my($which_section, $spam_level, $sa_tests, $size_limit, %attr);
30405  my $scanner_name = $self->{scanner_name};
30406  my $mbsl = $self->{options}->{'mail_body_size_limit'};
30407  $mbsl = c('sa_mail_body_size_limit')  if !defined $mbsl;
30408  if (defined $mbsl) {
30409    $size_limit = min(64*1024, $msginfo->orig_header_size) + 1 +
30410                  min($mbsl,   $msginfo->orig_body_size);
30411    # don't bother if slightly oversized, it's faster without size checks
30412    undef $size_limit  if $msginfo->msg_size < $size_limit + 5*1024;
30413  }
30414  my $hdr_edits = $msginfo->header_edits;
30415  # fake a local delivery agent by inserting Return-Path
30416  $which_section = 'prepare pseudo header section';
30417  my $hdr_prefix = '';
30418  $hdr_prefix .= sprintf("Return-Path: %s\n", $msginfo->sender_smtp);
30419  $hdr_prefix .= sprintf("X-Envelope-To: %s\n",
30420       join(",\n ",qquote_rfc2821_local(@{$msginfo->recips})));
30421  my $os_fp = $msginfo->client_os_fingerprint;
30422  $hdr_prefix .= sprintf("X-Amavis-OS-Fingerprint: %s\n",
30423       sanitize_str($os_fp))  if defined($os_fp) && $os_fp ne '';
30424  my(@av_tests);
30425  my $per_recip_data = $msginfo->per_recip_data;
30426  $per_recip_data = []  if !$per_recip_data;
30427  for my $r (@$per_recip_data) {
30428    my $spam_tests = $r->spam_tests;
30429    push(@av_tests, grep(/^AV\..+=/,
30430                 split(/,/, join(',',map($$_,@$spam_tests)))))  if $spam_tests;
30431  }
30432  $hdr_prefix .= sprintf("X-Amavis-AV-Status: %s\n",
30433                         sanitize_str(join(',',@av_tests)))  if @av_tests;
30434  $hdr_prefix .= sprintf("X-Amavis-PolicyBank: %s\n", c('policy_bank_path'));
30435  $hdr_prefix .= sprintf("X-Amavis-MessageSize: %d%s\n", $msginfo->msg_size,
30436                   !defined $size_limit ? '' : ", TRUNCATED to $size_limit");
30437  my($remaining_time, $deadline) = get_deadline('spamd check', 1, 5);
30438  my $msg = $msginfo->mail_text;
30439  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
30440  $msg = $msg_str_ref  if ref $msg_str_ref;
30441  eval {
30442    $which_section = 'spamd_connect';  do_log(3,"connecting to spamd");
30443    my $spamd_handle = Amavis::IO::RW->new(
30444      [ '127.0.0.1:783', '[::1]:783' ], Eol => "\015\012", Timeout => 10);
30445    defined $spamd_handle or die "Can't connect to spamd, $@ ($!)";
30446    $spamd_handle->timeout(max(3, $deadline - Time::HiRes::time));
30447    section_time($which_section);
30448
30449    $which_section = 'spamd_tx';  do_log(4,"sending to spamd");
30450    $hdr_prefix =~ s{\n}{\015\012}gs;
30451    my $file_position = $msginfo->skip_bytes;
30452    my $msgsize = length($hdr_prefix);  # prepended lines...
30453    $msgsize += $msginfo->msg_size;     # size as defined by RFC 1870
30454    $msgsize -= $file_position;   # TODO: adjust for CRLF (alright for 0)
30455    ll(5) && do_log(5, "spamc: message size: %d + %d - %d = %s",
30456               length($hdr_prefix), $msginfo->msg_size, $file_position,
30457               defined $size_limit && $msgsize > $size_limit
30458                 ? "LIM:$size_limit" : $msgsize);
30459    if (defined $size_limit && $msgsize > $size_limit) {
30460      # consider $size_limit in the RFC 1870 sense for simplicity
30461      $msgsize = $size_limit;
30462    }
30463    $spamd_handle->print("SYMBOLS SPAMC/1.3\015\012");  # HEADERS
30464    $spamd_handle->print("Content-length: " . $msgsize . "\015\012");
30465    $spamd_handle->print("\015\012");
30466    $spamd_handle->print($hdr_prefix);
30467    my $bytes_written = length($hdr_prefix);
30468    if (!defined $msg) {
30469      # empty mail
30470    } elsif (ref $msg eq 'SCALAR') {
30471      # do it in chunks, saves memory, cache friendly
30472      my $done;
30473      while ($file_position < length($$msg)) {
30474        my $buff = substr($$msg,$file_position,16384);
30475        $file_position += length($buff);
30476        $buff =~ s{\n}{\015\012}gs;
30477        if (defined $size_limit &&
30478            $bytes_written + length($buff) >= $size_limit) {
30479          substr($buff, $size_limit - $bytes_written) = '';  # truncate
30480          # spamd reads line-by-line and hangs if not terminated by a NL
30481          substr($buff,-1,1) = "\012";
30482          do_log(5,"spamc: reached size limit %d bytes, ".
30483                   "%d = %d (sent) + %d (still to go)",
30484                   $size_limit, $bytes_written+length($buff),
30485                   $bytes_written, length($buff));
30486          $done = 1;
30487        }
30488        $spamd_handle->print($buff);
30489        $bytes_written += length($buff);
30490        last if $done;
30491      }
30492    } elsif ($msg->isa('MIME::Entity')) {  # TODO - content length won't match!
30493      do_log(3,"spamc: message is MIME::Entity, size won't match");
30494      $msg->print_body($spamd_handle);
30495    } else {
30496      $msg->seek($file_position,0) or die "Can't rewind mail file: $!";
30497      my($nbytes,$buff,$done);
30498      while ( $nbytes=$msg->sysread($buff,16384) ) {
30499        $file_position += $nbytes;
30500        $buff =~ s{\n}{\015\012}gs;
30501        if (defined $size_limit &&
30502            $bytes_written + length($buff) >= $size_limit) {
30503          substr($buff, $size_limit - $bytes_written) = '';  # truncate
30504          # spamd reads line-by-line and hangs if not terminated by a NL
30505          substr($buff,-1,1) = "\012";
30506          do_log(5,"spamc: reached size limit %d bytes, ".
30507                   "%d = %d (sent) + %d (still to go)",
30508                   $size_limit, $bytes_written+length($buff),
30509                   $bytes_written, length($buff));
30510          $done = 1;
30511        }
30512        $spamd_handle->print($buff);
30513        $bytes_written += length($buff);
30514        last if $done;
30515      }
30516      defined $nbytes or die "Error reading: $!";
30517    }
30518    $spamd_handle->flush;
30519    $hdr_prefix = undef;
30520    section_time($which_section);
30521
30522    $which_section = 'spamd_rx';  do_log(4,"receiving from spamd");
30523    my($version, $resp_code, $resp_msg);
30524    local($1,$2,$3); my($ln,$error,$first); $first = 1;
30525    while (defined($ln = $spamd_handle->get_response_line)) {
30526      do_log(4,"from spamd - resp.hdr: %s", $ln);
30527      if ($ln eq "\015\012") {
30528        last;
30529      } elsif ($first) {
30530        $first = 0; $ln =~ s/\015\012\z//;
30531        ($version,$resp_code,$resp_msg) = split(/[ \t]+/,$ln,3);
30532      } elsif ($ln =~ /^([^:]*?)[ \t]*:[ \t]*(.*)\015\012\z/i) {
30533        $attr{lc($1)} = $2;
30534      } else { $error = $ln }
30535    }
30536    if ($first) { do_log(-1,"Empty spamd response") }
30537    elsif (defined $error) { do_log(-1,"Error in spamd resp: %s",$error) }
30538    elsif ($resp_code !~ /^\d+\z/ || $resp_code != 0) {
30539      do_log(-1,"Failure reported by spamd: %s %s %s",
30540                $version,$resp_code,$resp_msg);
30541    } else {
30542      my $reply_len = 0;
30543      while (defined($ln = $spamd_handle->get_response_line)) {
30544        do_log(5,"from spamd: %s", $ln);
30545        $reply_len += length($ln); $ln =~ s/\015\012\z//; $sa_tests = $ln;
30546      }
30547      do_log(-1,"Reply from spamd size mismatch: %d %s",
30548                $reply_len, $attr{'content-length'}
30549            )  if $reply_len != $attr{'content-length'};
30550    }
30551    $spamd_handle->close;  # terminate the session, ignoring status
30552    undef $spamd_handle;
30553    $spam_level = $2  if $attr{'spam'} =~ m{(\S+) ; (\S+) / (\S+)};
30554    1;
30555  } or do {
30556    my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
30557    do_log(-1,"%s client failed: %s", $scanner_name, $eval_stat);
30558  };
30559  section_time($which_section);
30560  my $score_factor = $self->{options}->{'score_factor'};
30561  if (defined $spam_level && defined $score_factor) {
30562    $spam_level *= $score_factor;
30563  }
30564  do_log(2,"%s spamd score=%s, tests=%s",
30565           $scanner_name, $spam_level, $sa_tests);
30566  $msginfo->supplementary_info('SCORE-'.$scanner_name, $spam_level);
30567  $msginfo->supplementary_info('VERDICT-'.$scanner_name,
30568                               $attr{'spam'} =~ /^True/  ? 'Spam'
30569                             : $attr{'spam'} =~ /^False/ ? 'Ham' : 'Unknown');
30570  for my $r (@$per_recip_data) {
30571    $r->spam_level( ($r->spam_level || 0) + $spam_level );
30572    if (!$r->spam_tests) {
30573      $r->spam_tests([ \$sa_tests ]);
30574    } else {
30575      push(@{$r->spam_tests}, \$sa_tests);
30576    }
30577  }
30578}
30579
305801;
30581
30582__DATA__
30583#
30584package Mail::SpamAssassin::Logger::Amavislog;
30585use strict;
30586use re 'taint';
30587use warnings;
30588use warnings FATAL => qw(utf8 void);
30589no warnings 'uninitialized';
30590# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
30591
30592BEGIN {
30593  require Exporter;
30594  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
30595  $VERSION = '2.412';
30596  @ISA = qw(Exporter);
30597  # let a 'require' understand that this module is already loaded:
30598  $INC{'Mail/SpamAssassin/Logger/Amavislog.pm'} = 'amavisd';
30599  import Amavis::Util qw(ll do_log);
30600}
30601
30602sub new {
30603  my($class,%args) = @_;
30604  my(%llmap) = (error => -1, warn => 0, info => 1, dbg => 3);
30605  # $args{debug} is a simple boolean, sets the log level floor to 1 when true
30606  if ($args{debug}) { for (keys %llmap) { $llmap{$_} = 1 if $llmap{$_} > 1 } }
30607  bless { llmap => \%llmap }, $class;
30608}
30609
30610sub close_log { 1 }
30611
30612sub log_message {
30613  my($self, $level,$msg) = @_;
30614  my $ll = $self->{llmap}->{$level};
30615  $ll = 1  if !defined $ll;
30616  ll($ll) && do_log($ll, "SA %s: %s", $level,$msg);
30617  1;
30618}
30619
306201;
30621
30622package Amavis::SpamControl::SpamAssassin;
30623use strict;
30624use re 'taint';
30625use warnings;
30626use warnings FATAL => qw(utf8 void);
30627no warnings 'uninitialized';
30628# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
30629
30630BEGIN {
30631  require Exporter;
30632  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
30633  $VERSION = '2.412';
30634  @ISA = qw(Exporter);
30635  import Amavis::Conf qw(:platform :confvars :sa $daemon_user c cr ca);
30636  import Amavis::Util qw(ll do_log do_log_safe sanitize_str prolong_timer
30637                         add_entropy min max minmax get_deadline
30638                         safe_encode_utf8_inplace);
30639  import Amavis::ProcControl qw(exit_status_str proc_status_ok
30640                         kill_proc run_command run_as_subprocess
30641                         collect_results collect_results_structured);
30642  import Amavis::rfc2821_2822_Tools;
30643  import Amavis::Timing qw(section_time get_rusage);
30644  import Amavis::Lookup qw(lookup lookup2);
30645  import Amavis::IO::FileHandle;
30646}
30647use subs @EXPORT_OK;
30648
30649use Errno qw(ENOENT EACCES EAGAIN EBADF);
30650use FileHandle;
30651use Mail::SpamAssassin;
30652
30653sub getCommonSAModules {
30654  my $self = $_[0];
30655  my(@modules) = qw(
30656    Mail::SpamAssassin::Locker
30657    Mail::SpamAssassin::Locker::Flock
30658    Mail::SpamAssassin::Locker::UnixNFSSafe
30659    Mail::SpamAssassin::PersistentAddrList
30660    Mail::SpamAssassin::DBBasedAddrList
30661    Mail::SpamAssassin::AutoWhitelist
30662    Mail::SpamAssassin::BayesStore
30663    Mail::SpamAssassin::BayesStore::DBM
30664    Mail::SpamAssassin::PerMsgLearner
30665    Net::DNS::RR::SOA Net::DNS::RR::NS Net::DNS::RR::MX
30666    Net::DNS::RR::A Net::DNS::RR::AAAA Net::DNS::RR::PTR
30667    Net::DNS::RR::CNAME Net::DNS::RR::DNAME Net::DNS::RR::OPT
30668    Net::DNS::RR::TXT Net::DNS::RR::SPF Net::DNS::RR::NAPTR
30669    Net::DNS::RR::RP Net::DNS::RR::HINFO Net::DNS::RR::AFSDB
30670    Net::CIDR::Lite
30671    URI URI::Escape URI::Heuristic URI::QueryParam URI::Split URI::URL
30672    URI::WithBase URI::_foreign URI::_generic URI::_ldap URI::_login
30673    URI::_query URI::_segment URI::_server URI::_userpass
30674    URI::_idna URI::_punycode URI::data URI::ftp
30675    URI::gopher URI::http URI::https URI::ldap URI::ldapi URI::ldaps
30676    URI::mailto URI::mms URI::news URI::nntp URI::pop URI::rlogin URI::rsync
30677    URI::rtsp URI::rtspu URI::sip URI::sips URI::snews URI::ssh URI::telnet
30678    URI::tn3270 URI::urn URI::urn::oid
30679    URI::file URI::file::Base URI::file::Unix URI::file::Win32
30680  );
30681  # DBD::mysql
30682  # DBI::Const::GetInfo::ANSI DBI::Const::GetInfo::ODBC DBI::Const::GetInfoType
30683  # Mail::SpamAssassin::BayesStore::SQL
30684  # Mail::SpamAssassin::SQLBasedAddrList
30685  # ??? ArchiveIterator Reporter Getopt::Long Sys::Syslog lib
30686  @modules;
30687}
30688
30689sub getSA2Modules {
30690  qw(Mail::SpamAssassin::UnixLocker Mail::SpamAssassin::BayesStoreDBM
30691  );
30692# Mail::SpamAssassin::SpamCopURI
30693}
30694
30695sub getSA31Modules {
30696  qw( );
30697# Mail::SpamAssassin::BayesStore::MySQL
30698# Mail::SpamAssassin::BayesStore::PgSQL
30699}
30700
30701sub getSA32Modules {
30702  qw(Mail::SpamAssassin::Bayes Mail::SpamAssassin::Bayes::CombineChi
30703     Mail::SpamAssassin::Locales Encode::Detect
30704  );
30705# Mail::SpamAssassin::BayesStore::MySQL
30706# Mail::SpamAssassin::BayesStore::PgSQL
30707# /var/db/spamassassin/compiled/.../Mail/SpamAssassin/CompiledRegexps/body_0.pm
30708}
30709
30710sub getSAPlugins {
30711  my $self = $_[0];
30712  my(@modules);
30713  my $sa_version_num = $self->{version_num};
30714  push(@modules, qw(Hashcash RelayCountry SPF URIDNSBL)) if $sa_version_num>=3;
30715  push(@modules, qw(DKIM))  if $sa_version_num >= 3.001002;
30716  if ($sa_version_num >= 3.001000) {
30717    push(@modules, qw(
30718      AWL AccessDB AntiVirus AutoLearnThreshold DCC MIMEHeader Pyzor Razor2
30719      ReplaceTags TextCat URIDetail WhiteListSubject));
30720      # 'DomainKeys' plugin fell out of fashion with SA 3.2.0, don't load it
30721      # 'SpamCop' loads Net::SMTP and Net::Cmd, not needed otherwise
30722  }
30723  if ($sa_version_num >= 3.002000) {
30724    push(@modules, qw(
30725      BodyEval DNSEval HTMLEval HeaderEval MIMEEval RelayEval URIEval WLBLEval
30726      ASN Bayes BodyRuleBaseExtractor Check HTTPSMismatch OneLineBodyRuleType
30727      ImageInfo Rule2XSBody Shortcircuit VBounce));
30728  }
30729  if ($sa_version_num >= 3.004000) {
30730    push(@modules, qw(AskDNS));
30731  }
30732  $_ = 'Mail::SpamAssassin::Plugin::'.$_  for @modules;
30733  my(%mod_names) = map(($_,1), @modules);
30734  # add supporting modules
30735  push(@modules, qw(Razor2::Client::Agent))
30736    if $mod_names{'Mail::SpamAssassin::Plugin::Razor2'};
30737# push(@modules, qw(IP::Country::Fast))
30738#   if $mod_names{'Mail::SpamAssassin::Plugin::RelayCountry'};
30739  push(@modules, qw(Mail::DKIM Mail::DKIM::Verifier Net::DNS::Resolver))
30740    if $mod_names{'Mail::SpamAssassin::Plugin::DKIM'};
30741  push(@modules, qw(Image::Info Image::Info::GIF Image::Info::JPEG
30742                    Image::Info::PNG Image::Info::BMP Image::Info::TIFF))
30743    if $mod_names{'Mail::SpamAssassin::Plugin::ImageInfo'};
30744  if ($mod_names{'Mail::SpamAssassin::Plugin::SPF'}) {
30745    if ($sa_version_num < 3.002000) {
30746      # only the old Mail::SPF::Query was supported
30747      push(@modules, qw(Mail::SPF::Query));
30748    } else {
30749      # SA 3.2.0 supports both the newer Mail::SPF and the old Mail::SPF::Query
30750      # but we won't be loading the Mail::SPF::Query
30751      push(@modules, qw(
30752        Mail::SPF Mail::SPF::Server Mail::SPF::Request
30753        Mail::SPF::Mech Mail::SPF::Mech::A Mail::SPF::Mech::PTR
30754        Mail::SPF::Mech::All Mail::SPF::Mech::Exists Mail::SPF::Mech::IP4
30755        Mail::SPF::Mech::IP6 Mail::SPF::Mech::Include Mail::SPF::Mech::MX
30756        Mail::SPF::Mod Mail::SPF::Mod::Exp Mail::SPF::Mod::Redirect
30757        Mail::SPF::SenderIPAddrMech
30758        Mail::SPF::v1::Record Mail::SPF::v2::Record
30759        NetAddr::IP NetAddr::IP::Util
30760        auto::NetAddr::IP::_compV6 auto::NetAddr::IP::short
30761        auto::NetAddr::IP::InetBase::inet_any2n
30762        auto::NetAddr::IP::InetBase::inet_n2ad
30763        auto::NetAddr::IP::InetBase::inet_n2dx
30764        auto::NetAddr::IP::InetBase::inet_ntoa
30765        auto::NetAddr::IP::InetBase::ipv6_aton
30766        auto::NetAddr::IP::InetBase::ipv6_ntoa
30767      ));
30768    }
30769  }
30770  if ($mod_names{'Mail::SpamAssassin::Plugin::DomainKeys'} ||
30771      $mod_names{'Mail::SpamAssassin::Plugin::DKIM'}) {
30772    push(@modules, qw(
30773      Crypt::OpenSSL::RSA
30774      auto::Crypt::OpenSSL::RSA::new_public_key
30775      auto::Crypt::OpenSSL::RSA::new_key_from_parameters
30776      auto::Crypt::OpenSSL::RSA::get_key_parameters
30777      auto::Crypt::OpenSSL::RSA::import_random_seed
30778      Digest::SHA Error));
30779  }
30780# HTML/HeadParser.pm
30781# do_log(5, "getSAPlugins %s: %s", $sa_version_num, join(', ',@modules));
30782  @modules;
30783}
30784
30785# invoked by a parent process before forking and chrooting
30786#
30787sub loadSpamAssassinModules {
30788  my $self = $_[0];
30789  my $sa_version_num = $self->{version_num};
30790  my @modules;  # modules to be loaded before chroot takes place
30791  push(@modules, $self->getCommonSAModules);
30792  if (!defined($sa_version_num)) {
30793    die "loadSpamAssassinModules: unknown version of Mail::SpamAssassin";
30794  } elsif ($sa_version_num < 3) {
30795    push(@modules, $self->getSA2Modules);
30796  } elsif ($sa_version_num >= 3.001 && $sa_version_num < 3.002) {
30797    push(@modules, $self->getSA31Modules);
30798  } elsif ($sa_version_num >= 3.002) {
30799    push(@modules, $self->getSA32Modules);
30800  }
30801  push(@modules, $self->getSAPlugins);
30802  my $missing;
30803  $missing = Amavis::Boot::fetch_modules('PRE-COMPILE OPTIONAL MODULES', 0,
30804                                         @modules)  if @modules;
30805  do_log(2, 'INFO: SA version: %s, %.6f, no optional modules: %s',
30806         $self->{version}, $sa_version_num, join(' ',@$missing))
30807         if ref $missing && @$missing;
30808}
30809
30810# invoked by a parent process before forking but after chrooting
30811#
30812sub initializeSpamAssassinLogger {
30813  my $self = $_[0];
30814  local($1,$2,$3,$4,$5,$6);  # just in case
30815  if (!Mail::SpamAssassin::Logger->UNIVERSAL::can('add')) {
30816    # old SA?
30817  } elsif (!Mail::SpamAssassin::Logger::add(method => 'Amavislog',
30818                                            debug  => $sa_debug )) {
30819    do_log(-1,"Mail::SpamAssassin::Logger::add failed");
30820  } else {  # successfully rigged SpamAssassin with our logger
30821    Mail::SpamAssassin::Logger::remove('stderr');  # remove a default SA logger
30822    if (defined $sa_debug && $sa_debug =~ /[A-Za-z_,-]/) {
30823      # looks like a list of SA debug facilities
30824      push(@sa_debug_fac, split(/[ \t]*,[ \t]*/, $sa_debug));
30825    } else {
30826      unshift(@sa_debug_fac, 'info', $sa_debug ? 'all' : () );
30827    }
30828  }
30829}
30830
30831# invoked by a parent process before forking but after chrooting
30832#
30833sub new_SpamAssassin_instance {
30834  my($self,$running_as_parent) = @_;
30835  # pick next available number as an instance name
30836  my $sa_instance_name = sprintf('%s', scalar @{$self->{instances}});
30837  do_log(1, "initializing Mail::SpamAssassin (%s)", $sa_instance_name);
30838  my $sa_version_num = $self->{version_num};
30839  my(@new_sa_debug_fac);
30840  for my $fac (@sa_debug_fac) { # handle duplicates and negation: foo,nofoo,x,x
30841    my $bfac = $fac;  $bfac =~ s/^none\z/noall/i;  $bfac =~ s/^no(?=.)//si;
30842    @new_sa_debug_fac = grep(!/^(no)?\Q$bfac\E\z/si, @new_sa_debug_fac);
30843    push(@new_sa_debug_fac, $fac);
30844  }
30845  do_log(2,"SpamAssassin debug facilities: %s", join(',',@sa_debug_fac));
30846  my $sa_args = {
30847    debug             => !@sa_debug_fac ? undef : \@sa_debug_fac,
30848    save_pattern_hits => grep(lc($_) eq 'all', @sa_debug_fac) ? 1 : 0,
30849    dont_copy_prefs   => 1,
30850    require_rules     => 1,
30851    stop_at_threshold => 0,
30852    need_tags         => 'TIMING,LANGUAGES,RELAYCOUNTRY,ASN,ASNCIDR',
30853    local_tests_only  => $sa_local_tests_only,
30854    home_dir_for_helpers => $helpers_home,
30855    rules_filename       => $sa_configpath,
30856    site_rules_filename  => $sa_siteconfigpath,
30857    userprefs_filename   => $sa_userprefs_file,
30858    skip_prng_reseeding  => 1,  # we'll do it ourselves (SA 3.4.0)
30859#   PREFIX            => '/usr/local',
30860#   DEF_RULES_DIR     => '/usr/local/share/spamassassin',
30861#   LOCAL_RULES_DIR   => '/etc/mail/spamassassin',
30862#   LOCAL_STATE_DIR   => '/var/lib/spamassassin',
30863#see Mail::SpamAssassin man page for other options
30864  };
30865  if ($sa_version_num < 3.001005 && !defined $sa_args->{LOCAL_STATE_DIR})
30866    { $sa_args->{LOCAL_STATE_DIR} = '/var/lib' } # don't ignore sa-update rules
30867  local($1,$2,$3,$4,$5,$6);  # avoid Perl bug, $1 gets tainted in compile_now
30868  my $spamassassin_obj = Mail::SpamAssassin->new($sa_args);
30869# $Mail::SpamAssassin::DEBUG->{rbl}=-3;
30870# $Mail::SpamAssassin::DEBUG->{rulesrun}=4+64;
30871  if ($running_as_parent) {
30872    # load SA config files and rules, try to preload most modules
30873    $spamassassin_obj->compile_now;
30874    $spamassassin_obj->call_plugins("prefork_init");  # since SA 3.4.0
30875  }
30876  if (ll(2) && !@{$self->{instances}}) {
30877    # created the first/main/only SA instance
30878    if ($spamassassin_obj->UNIVERSAL::can('get_loaded_plugins_list')) {
30879      my(@plugins) = $spamassassin_obj->get_loaded_plugins_list;
30880      do_log(2, "SpamAssassin loaded plugins: %s", join(', ', sort
30881        map { my $n = ref $_; $n =~ s/^Mail::SpamAssassin::Plugin:://; $n }
30882            @plugins));
30883#     printf STDOUT ("%s\n", join(", ",@plugins));
30884#       not in use: AccessDB AntiVirus TextCat; ASN BodyRuleBaseExtractor
30885#                   OneLineBodyRuleType Rule2XSBody Shortcircuit
30886    }
30887  }
30888  # provide a default username
30889  my $sa_uname = $spamassassin_obj->{username};
30890  if (!defined $sa_uname || $sa_uname eq '')
30891    { $spamassassin_obj->{username} = $sa_uname = $daemon_user }
30892  $self->{default_username} = $sa_uname  if !defined $self->{default_username};
30893
30894  my $sa_instance = {
30895    instance_name => $sa_instance_name,
30896    spamassassin_obj => $spamassassin_obj,
30897    loaded_user_name => $sa_uname, loaded_user_config => '',
30898    conf_backup => undef, conf_backup_additional => {},
30899  };
30900  # remember some initial settings, like %msa_backup in spamd
30901  for (qw(username user_dir userstate_dir learn_to_journal)) {
30902    if (exists $spamassassin_obj->{$_}) {
30903      $sa_instance->{conf_backup_additional}{$_} = $spamassassin_obj->{$_};
30904    }
30905  }
30906  push(@{$self->{instances}}, $sa_instance);
30907
30908  alarm(0);  # seems like SA forgets to clear alarm in some cases
30909  umask($self->{saved_umask});  # restore our umask, SA clobbered it
30910  section_time('SA new');
30911
30912  $sa_instance;
30913}
30914
30915sub new {
30916  my($class, $scanner_name,$module,@args) = @_;
30917  my(%options) = @args;
30918  my $self =
30919    bless { scanner_name => $scanner_name, options => \%options }, $class;
30920  $self->{initialized_stage} = 1;
30921  $self->{saved_umask} = umask;
30922  my $sa_version = Mail::SpamAssassin->Version;
30923  local($1,$2,$3);
30924  my $sa_version_num;  # turn '3.1.8-pre1' into 3.001008
30925  $sa_version_num = sprintf("%d.%03d%03d", $1,$2,$3)
30926    if $sa_version =~ /^(\d+)\.(\d+)(?:\.(\d+))/;  # ignore trailing non-digits
30927  $self->{version} = $sa_version;
30928  $self->{version_num} = $sa_version_num;
30929  $self->{default_username} = undef;
30930  $self->{instances} = [];
30931  $self;
30932}
30933
30934sub init_pre_chroot {
30935  my $self = $_[0];
30936  $self->{initialized_stage} == 1
30937    or die "Wrong initialization sequence: " . $self->{initialized_stage};
30938  $self->loadSpamAssassinModules;
30939  $self->{initialized_stage} = 2;
30940}
30941
30942sub init_pre_fork {
30943  my $self = $_[0];
30944  $self->{initialized_stage} == 2
30945    or die "Wrong initialization sequence: " . $self->{initialized_stage};
30946  $self->initializeSpamAssassinLogger;
30947  $self->new_SpamAssassin_instance(1)  for (1 .. max(1,$sa_num_instances));
30948  $self->{initialized_stage} = 3;
30949}
30950
30951sub init_child {
30952  my $self = $_[0];
30953  $self->{initialized_stage} == 3
30954    or die "Wrong initialization sequence: " . $self->{initialized_stage};
30955  for my $sa_instance (@{$self->{instances}}) {
30956    my $spamassassin_obj = $sa_instance->{spamassassin_obj};
30957    next if !$spamassassin_obj;
30958    $spamassassin_obj->call_plugins("spamd_child_init");
30959    umask($self->{saved_umask});  # restore our umask, SA may have clobbered it
30960  }
30961  $self->{initialized_stage} = 4;
30962}
30963
30964sub rundown_child {
30965  my $self = $_[0];
30966  for my $sa_instance (@{$self->{instances}}) {
30967    my $spamassassin_obj = $sa_instance->{spamassassin_obj};
30968    next if !$spamassassin_obj;
30969    do_log(3,'SA rundown_child (%s)', $sa_instance->{instance_name});
30970    $spamassassin_obj->call_plugins("spamd_child_post_connection_close");
30971    umask($self->{saved_umask});  # restore our umask, SA may have clobbered it
30972  }
30973  $self->{initialized_stage} = 5;
30974}
30975
30976sub call_spamassassin($$$$) {
30977  my($self,$msginfo,$lines,$size_limit) = @_;
30978  my(@result); my($mail_obj,$per_msg_status);
30979  my $which_section = 'SA prepare';
30980  my $saved_pid = $$; my $sa_version_num = $self->{version_num};
30981
30982  my $msg = $msginfo->mail_text;  # a file handle or a string ref
30983  my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
30984  $msg = $msg_str_ref  if ref $msg_str_ref;
30985
30986  # pass data to SpamAssassin as ARRAY or GLOB or STRING or STRING_REF
30987  my $data_representation = ref($msg) eq 'SCALAR' ? 'STRING' : 'GLOB';
30988  $data_representation = 'STRING_REF'
30989    if $data_representation eq 'STRING' && $sa_version_num >= 3.004000;
30990  my $data;  # this will be passed to SpamAssassin's parser
30991  local(*F);
30992
30993  if ($data_representation eq 'STRING' ||
30994      $data_representation eq 'STRING_REF') {
30995    $which_section = 'SA msg read';
30996    $data = join('', @$lines);  # a string to be passed to SpamAssassin
30997    if (!defined $msg) {
30998      # empty mail
30999    } elsif (ref $msg eq 'SCALAR') {
31000      $data .= $$msg;
31001    } elsif ($msg->isa('MIME::Entity')) {
31002      die "passing a MIME::Entity object to SpamAssassin is not implemented";
31003    } else {  # read message into memory, yuck
31004      my $file_position = $msginfo->skip_bytes;
31005      $msg->seek($file_position, 0) or die "Can't rewind mail file: $!";
31006      my $nbytes;
31007      while ( $nbytes=$msg->sysread($data, 32768, length $data) ) {
31008        $file_position += $nbytes;
31009        last if defined $size_limit && length($data) > $size_limit;
31010      }
31011      defined $nbytes or die "Error reading: $!";
31012    }
31013    if (defined $size_limit && length($data) > $size_limit) {
31014      substr($data,$size_limit) = "[...]\n";
31015    }
31016    section_time($which_section);
31017
31018  } elsif ($data_representation eq 'ARRAY') {
31019    # read message into memory, yuck - even worse: line-by-line
31020    $which_section = 'SA msg read';  my $ln; my $len = 0;
31021    if (defined $size_limit) { $len += length($_) for @$lines }
31022    $msg->seek($msginfo->skip_bytes, 0) or die "Can't rewind mail file: $!";
31023    for ($! = 0; defined($ln=<$msg>); $! = 0) {  # header section
31024      push(@$lines,$ln);
31025      if (defined $size_limit)
31026        { $len += length($ln); last if $len > $size_limit }
31027      last if $ln eq "\n";
31028    }
31029    defined $ln || $! == 0  or   # returning EBADF at EOF is a perl bug
31030      $! == EBADF ? do_log(0,"Error reading mail header section: %s", $!)
31031                  : die "Error reading mail header section: $!";
31032    if (!defined $size_limit) {
31033      for ($! = 0; defined($ln=<$msg>); $! = 0) { push(@$lines,$ln) }  # body
31034    } else {
31035      for ($! = 0; defined($ln=<$msg>); $! = 0) {  # body
31036        push(@$lines,$ln);
31037        $len += length($ln);  last if $len > $size_limit;
31038      }
31039    }
31040    defined $ln || $! == 0  or   # returning EBADF at EOF is a perl bug
31041      $! == EBADF ? do_log(1,"Error reading mail body: %s", $!)
31042                  : die "Error reading mail body: $!";
31043    $data = $lines;  # array of lines to be passed to SpamAssassin
31044    section_time($which_section);
31045  }
31046
31047  my($rusage_self_before, $rusage_children_before, @sa_cpu_usage);
31048  my $eval_stat;
31049  $which_section = 'SA prelim';
31050  eval {
31051    if ($data_representation eq 'GLOB') { # pass mail as a GLOB to SpamAssassin
31052      ref($msg) ne 'SCALAR'  # expects $msg to be a file handle
31053        or die "panic: data_representation is GLOB, but message is in memory";
31054      do_log(2,"truncating a message passed to SA at %d bytes, orig %d",
31055               $size_limit, $msginfo->msg_size)  if defined $size_limit;
31056      # present a virtual file to SA, an original mail file prefixed by @$lines
31057      tie(*F,'Amavis::IO::FileHandle');
31058      open(F, $msg,$lines,$size_limit) or die "Can't open SA virtual file: $!";
31059      binmode(F) or die "Can't set binmode on a SA virtual file: $!";
31060      $data = \*F;  # a GLOB to be passed to SpamAssassin
31061    }
31062
31063    $which_section = 'SA userconf';
31064    my $sa_default_username = $self->{default_username};
31065    my $per_recip_data = $msginfo->per_recip_data;
31066    $per_recip_data = []  if !$per_recip_data;
31067    my $uconf_maps_ref = ca('sa_userconf_maps');
31068    my $uname_maps_ref = ca('sa_username_maps');
31069    $uconf_maps_ref = []  if !$uconf_maps_ref;
31070    $uname_maps_ref = []  if !$uname_maps_ref;
31071    my(%uconf_filename_available);
31072    my(%sa_configs_hash);  # collects distinct config names and usernames
31073    my $uconf_unsupported = 0;
31074    my $r_ind = 0;
31075    for my $r (@$per_recip_data) {
31076      my($uconf,$uname);
31077      my $recip_addr = $r->recip_addr;
31078      $uconf = lookup2(0, $recip_addr, $uconf_maps_ref)  if @$uconf_maps_ref;
31079      $uname = lookup2(0, $recip_addr, $uname_maps_ref)  if @$uname_maps_ref;
31080      $uconf = ''  if !defined $uconf;
31081      $uname = $sa_default_username  if !defined $uname || $uname eq '';
31082      if ($uconf =~ /^sql:/i) {
31083        $uconf = $uname eq $sa_default_username ? '' : 'sql:'.$uname;
31084      }
31085      if ($uconf =~ /^ldap:/i) {
31086        $uconf = $uname eq $sa_default_username ? '' : 'ldap:'.$uname;
31087      }
31088      if ($sa_version_num < 3.003000 && $uconf ne '') {
31089        $uconf = ''; $uconf_unsupported = 1;
31090      }
31091      if ($uconf eq '') {
31092        # ok, no special config required, just using a default
31093      } elsif ($uconf =~ /^sql:/i) {
31094        # assume data is in SQL, possibly an empty set
31095      } elsif ($uconf =~ /^ldap:/i) {
31096        # assume data is in LDAP, possibly an empty set
31097      } else {
31098        $uconf = "$MYHOME/$uconf"  if $uconf !~ m{^/};
31099        if ($uconf_filename_available{$uconf}) {
31100          # good, already checked and is available, keep it
31101        } elsif (defined $uconf_filename_available{$uconf}) {
31102          # defined but false, already checked and failed, use a default config
31103          $uconf = '';
31104        } else {
31105          # check for existence of a SA user configuration/preferences file
31106          my(@stat_list) = stat($uconf);  # symlinks-friendly
31107          my $errn = @stat_list ? 0 : 0+$!;
31108          my $msg = $errn == ENOENT ? "does not exist"
31109                  : $errn           ? "is inaccessible: $!"
31110                  :  -d _           ? "is a directory"
31111                  : !-f _           ? "is not a regular file"
31112                  : !-r _           ? "is not readable" : undef;
31113          if (defined $msg) {
31114            do_log(1,'SA user config file "%s" %s, ignoring it', $uconf,$msg);
31115            $uconf_filename_available{$uconf} = 0;  # defined but false
31116            $uconf = '';  # ignoring it, use a default config
31117          } else {
31118            $uconf_filename_available{$uconf} = 1;
31119          }
31120        }
31121      }
31122      # collect lists of recipient indices for each unique config/user pair
31123      # the %sa_configs_hash is a two-level hash: on $uconf and $uname
31124      my $p = $sa_configs_hash{$uconf};
31125      if (!$p) { $sa_configs_hash{$uconf} = $p = {} }
31126      if (!exists $p->{$uname}) { $p->{$uname} = $r_ind }
31127      else { $p->{$uname} .= ',' . $r_ind }
31128      $r_ind++;
31129    }
31130    if ($uconf_unsupported) {
31131      do_log(5,'SA user config loading unsupported for SA older than 3.3.0');
31132    }
31133
31134    # refresh $sa_instance->{loaded_user_name}, just in case
31135    for my $sa_instance (@{$self->{instances}}) {
31136      my $spamassassin_obj = $sa_instance->{spamassassin_obj};
31137      next if !$spamassassin_obj;
31138      my $sa_uname = $spamassassin_obj->{username};
31139      $sa_instance->{loaded_user_name} = defined $sa_uname ? $sa_uname : '';
31140    }
31141
31142    my $sa_instance = $self->{instances}[0];
31143    my $curr_conf = $sa_instance->{loaded_user_config};
31144    my $curr_user = $sa_instance->{loaded_user_name};
31145
31146    # switching config files is the most expensive, sort to minimize switching
31147    my(@conf_names);  # a list of config names for which SA needs to be called;
31148                      # sorted: current first, baseline second, then the rest
31149    push(@conf_names, $curr_conf)  if exists $sa_configs_hash{$curr_conf};
31150    push(@conf_names, '')  if $curr_conf ne '' && exists $sa_configs_hash{''};
31151    push(@conf_names,
31152         grep($_ ne '' && $_ ne $curr_conf, keys %sa_configs_hash));
31153
31154    # call SA checking for each distinct SA userprefs config filename and user
31155    for my $conf_user_pair (map { my $c = $_;
31156                                  map([$c,$_], keys %{$sa_configs_hash{$c}})
31157                                } @conf_names) {
31158      my($uconf,$uname) = @$conf_user_pair;
31159      # comma-separated list of recip indices which use this SA config
31160      my $rind_list = $sa_configs_hash{$uconf}{$uname};
31161      if (ll(5)) {
31162        do_log(5, "SA user config: \"%s\", username: \"%s\", %s, %s",
31163                  $uconf, $uname, $rind_list,
31164                  join(', ', map("($_)" . $per_recip_data->[$_]->recip_addr,
31165                                 split(/,/,$rind_list))));
31166      }
31167      my $sa_instance;
31168      if (@{$self->{instances}} <= 1) {
31169        # pick the only choice
31170        $sa_instance = $self->{instances}[0];
31171
31172      } else {
31173        # choosing a suitably-matching SpamAssassin instance
31174        my(@sa_instances_matching_uconf, @sa_instances_matching_both,
31175           @sa_instances_available);
31176        for my $sa_instance (@{$self->{instances}}) {
31177          next if !$sa_instance->{spamassassin_obj};
31178          push(@sa_instances_available, $sa_instance);
31179          if ($sa_instance->{loaded_user_config} eq $uconf) {
31180            push(@sa_instances_matching_uconf, $sa_instance);
31181            if ($sa_instance->{loaded_user_name} eq $uname) {
31182              push(@sa_instances_matching_both, $sa_instance);
31183            }
31184          }
31185        }
31186        my $fit_descr;
31187        if (@sa_instances_matching_both) {
31188          # just pick the first
31189          $sa_instance = $sa_instances_matching_both[0];
31190          $fit_descr = sprintf('exact fit, %d choices',
31191                               scalar @sa_instances_matching_both);
31192        } elsif (@sa_instances_matching_uconf) {
31193          # picking one at random
31194          my $j = @sa_instances_matching_uconf <= 1 ? 0
31195                : int(rand(scalar(@sa_instances_matching_uconf)));
31196          $sa_instance = $sa_instances_available[$j];
31197          $fit_descr = sprintf('good fit: same config, other user, %d choices',
31198                               scalar @sa_instances_matching_uconf);
31199        } elsif ($uconf eq '') {
31200          # the first instance is a good choice for switching to a dflt config
31201          $sa_instance = $self->{instances}[0];
31202          $fit_descr = 'need a default config, picking first';
31203        } elsif (@sa_instances_available <= 1) {
31204          $sa_instance = $sa_instances_available[0];
31205          $fit_descr = 'different config, picking the only one available';
31206        } elsif (@sa_instances_available == 2) {
31207          $sa_instance = $sa_instances_available[1];
31208          $fit_descr = 'different config, picking the second one';
31209        } else {
31210          # picking one at random, preferably not the first
31211          my $j = 1+int(rand(scalar(@sa_instances_available)-1));
31212          $sa_instance = $sa_instances_available[$j];
31213          $fit_descr = 'different config, picking one at random';
31214        }
31215        do_log(2,'SA user config: instance chosen (%s), %s',
31216                 $sa_instance->{instance_name}, $fit_descr);
31217      }
31218
31219      my $curr_conf = $sa_instance->{loaded_user_config};
31220      my $curr_user = $sa_instance->{loaded_user_name};
31221      my $spamassassin_obj = $sa_instance->{spamassassin_obj};
31222
31223      if ($curr_conf ne '' && $curr_conf ne $uconf) {
31224        # revert SA configuration to its initial state
31225        $which_section = 'revert_config';
31226        ref $sa_instance->{conf_backup}
31227          or die "panic, no conf_backup available";
31228        for (qw(username user_dir userstate_dir learn_to_journal)) {
31229          if (exists $sa_instance->{conf_backup_additional}{$_}) {
31230            $spamassassin_obj->{$_} =
31231              $sa_instance->{conf_backup_additional}{$_};
31232          } else {
31233            delete $spamassassin_obj->{$_};
31234          }
31235        }
31236        # config leaks fixed in SpamAssassin 3.3.0, SA bug 6205, 6003, 4179
31237        $spamassassin_obj->copy_config($sa_instance->{conf_backup}, undef)
31238          or die "copy_config: failed to restore";
31239        $sa_instance->{loaded_user_config} = $curr_conf = '';
31240        do_log(5,"SA user config reverted to a saved copy");
31241        section_time($which_section);
31242      }
31243      if ($uconf ne '' && $uconf ne $curr_conf) {
31244        # load SA user configuration/preferences
31245        if (!defined $sa_instance->{conf_backup}) {
31246          $which_section = 'save_config';
31247          do_log(5,"SA user config: saving SA user config");
31248          $sa_instance->{conf_backup} = {};
31249          $spamassassin_obj->copy_config(undef, $sa_instance->{conf_backup})
31250            or die "copy_config: failed to save configuration";
31251          section_time($which_section);
31252        }
31253        $which_section = 'load_config';
31254        # User preferences include scoring options, scores, whitelists
31255        # and blacklists, etc, but do not include rule definitions,
31256        # privileged settings, etc. unless allow_user_rules is enabled;
31257        # and they never include administrator settings
31258        if ($uconf =~ /^sql:/) {
31259          $uconf eq 'sql:'.$uname
31260            or die "panic: loading SA config mismatch: $uname <-> $uconf";
31261          do_log(5,"loading SA user config from SQL %s", $uname);
31262          $spamassassin_obj->load_scoreonly_sql($uname);
31263        } elsif ($uconf =~ /^ldap:/) {
31264          $uconf eq 'ldap:'.$uname
31265            or die "panic: loading SA config mismatch: $uname <-> $uconf";
31266          do_log(5,"loading SA user config from LDAP %s", $uname);
31267          $spamassassin_obj->load_scoreonly_ldap($uname);
31268        } else {
31269          do_log(5,"loading SA user config file %s", $uconf);
31270          $spamassassin_obj->read_scoreonly_config($uconf);
31271        }
31272        $sa_instance->{loaded_user_config} = $curr_conf = $uconf;
31273        section_time($which_section);
31274      }
31275      if ($uname ne $curr_user) {
31276        $which_section = 'SA switch_user';
31277        do_log(5,'SA user config: switching SA (%s) username "%s" -> "%s"',
31278                 $sa_instance->{instance_name}, $curr_user, $uname);
31279        $spamassassin_obj->signal_user_changed({ username => $uname });
31280        $sa_instance->{loaded_user_name} = $curr_user = $uname;
31281        section_time($which_section);
31282      }
31283      ll(3) && do_log(3, "calling SA parse (%s), SA vers %s, %.6f, ".
31284                         "data as %s, recips_ind [%s]%s%s",
31285                         $sa_instance->{instance_name},
31286                         $self->{version}, $sa_version_num,
31287                         $data_representation, $rind_list,
31288                         ($uconf eq '' ? '' : ", conf: \"$uconf\""),
31289                         ($uname eq '' ? '' : ", user: \"$uname\"") );
31290
31291      if ($data_representation eq 'GLOB') {
31292        seek(F,0,0) or die "Can't rewind a SA virtual file: $!";
31293      }
31294      $spamassassin_obj->timer_reset
31295        if $spamassassin_obj->UNIVERSAL::can('timer_reset');
31296
31297      $which_section = 'SA parse';
31298      my($remaining_time, $deadline) = get_deadline('SA check', 1, 5);
31299
31300      my(@mimepart_digests);
31301      for (my(@traversal_stack) = $msginfo->parts_root;
31302           my $part = pop @traversal_stack; ) {  # pre-order tree traversal
31303        my $digest = $part->digest;
31304        push(@mimepart_digests, $digest)  if defined $digest;
31305        push(@traversal_stack, reverse @{$part->children}) if $part->children;
31306      }
31307      do_log(5,'mimepart digest: %s', $_) for @mimepart_digests;
31308
31309      my(%suppl_attrib) = (
31310        'skip_prng_reseed' => 1,  # do not call srand(), we already did it
31311        'return_path'  => $msginfo->sender_smtp,
31312        'recipients'   => [ map(qquote_rfc2821_local($_->recip_addr),
31313                            @$per_recip_data[split(/,/, $rind_list)]) ],
31314        'originating'  => $msginfo->originating ? 1 : 0,
31315        'message_size' => $msginfo->msg_size,
31316        'body_size'    => $msginfo->orig_body_size,
31317        !@mimepart_digests ? ()
31318          : ('mimepart_digests' => \@mimepart_digests),
31319        !c('enable_dkim_verification') ? ()
31320          : ('dkim_signatures' => $msginfo->dkim_signatures_all),
31321        !defined $deadline ? ()
31322          : ('master_deadline' => $deadline),
31323        'rule_hits' => [
31324          # known attributes: rule, area, score, value, ruletype, tflags, descr
31325        # { rule=>'AM:TEST1', score=>0.11 },
31326        # { rule=>'TESTTEST', defscore=>0.22, descr=>'my test' },
31327          !defined $size_limit ? () :
31328            { rule=>'__TRUNCATED', score=>-0.1, area=>'RAW: ', tflags=>'nice',
31329              descr=>"Message size truncated to $size_limit B" },
31330        ],
31331        'amavis_policy_bank_path' => c('policy_bank_path'),
31332      );
31333
31334      ($rusage_self_before, $rusage_children_before) = get_rusage();
31335      $mail_obj = $sa_version_num < 3
31336        ? Mail::SpamAssassin::NoMailAudit->new(data=>$data, add_From_line=>0)
31337        : $spamassassin_obj->parse(
31338            $data_representation eq 'STRING_REF' ? \$data : $data,
31339            0, \%suppl_attrib);
31340      section_time($which_section);
31341
31342      $which_section = 'SA check';
31343      if (@conf_names <= 1) {
31344        do_log(4,"CALLING SA check (%s)", $sa_instance->{instance_name});
31345      } else {
31346        do_log(4,"CALLING SA check (%s) for recips: %s",
31347                 $sa_instance->{instance_name},
31348                 join(", ", @{$suppl_attrib{'recipients'}}));
31349      }
31350      { local($1,$2,$3,$4,$5,$6);  # avoid Perl 5.8.x bug, $1 gets tainted
31351        $per_msg_status = $spamassassin_obj->check($mail_obj);
31352      }
31353      do_log(4,"DONE SA check (%s)", $sa_instance->{instance_name});
31354      section_time($which_section);
31355
31356      $which_section = 'SA collect';
31357      my($spam_level,$spam_report,$spam_summary,%supplementary_info);
31358      { local($1,$2,$3,$4,$5,$6);  # avoid Perl 5.8.x taint bug
31359        if ($sa_version_num < 3) {
31360          $spam_level = $per_msg_status->get_hits;
31361          $supplementary_info{'TESTSSCORES'} = $supplementary_info{'TESTS'} =
31362            $per_msg_status->get_names_of_tests_hit;
31363        } else {
31364          $spam_level = $per_msg_status->get_score;
31365          for my $t (qw(VERSION SUBVERSION RULESVERSION
31366                        TESTS TESTSSCORES ADDEDHEADERHAM ADDEDHEADERSPAM
31367                        AUTOLEARN AUTOLEARNSCORE SC SCRULE SCTYPE
31368                        LANGUAGES RELAYCOUNTRY ASN ASNCIDR DCCB DCCR DCCREP
31369                        DKIMDOMAIN DKIMIDENTITY AWLSIGNERMEAN
31370                        HAMMYTOKENS SPAMMYTOKENS
31371                        CRM114STATUS CRM114SCORE CRM114CACHEID)) {
31372            my $tag_value = $per_msg_status->get_tag($t);
31373            if (defined $tag_value) {
31374              # for some reason tags ASN and ASNCIDR have UTF8 flag on;
31375              # encode any character strings to UTF-8 octets for consistency
31376              safe_encode_utf8_inplace($tag_value);  # to octets if not already
31377              $supplementary_info{$t} = $tag_value;
31378            }
31379          }
31380        }
31381        { # fudge
31382          my $crm114_status = $supplementary_info{'CRM114STATUS'};
31383          my $crm114_score  = $supplementary_info{'CRM114SCORE'};
31384          if (defined $crm114_status && defined $crm114_score) {
31385            $supplementary_info{'CRM114STATUS'} =
31386              sprintf("%s ( %s )", $crm114_status,$crm114_score);
31387          }
31388        }
31389        # get_report() taints $1 and $2 !
31390        $spam_summary = $per_msg_status->get_report;
31391      # $spam_summary = $per_msg_status->get_tag('SUMMARY');
31392        $spam_report  = $per_msg_status->get_tag('REPORT');
31393        safe_encode_utf8_inplace($spam_summary); # to octets (if not already)
31394        safe_encode_utf8_inplace($spam_report);  # to octets (if not already)
31395        # fetch the TIMING tag last:
31396        $supplementary_info{'TIMING'} = $per_msg_status->get_tag('TIMING');
31397        $supplementary_info{'RUSAGE-SA'} = \@sa_cpu_usage;  # filled-in later
31398      }
31399    # section_time($which_section);  # don't bother reporting separately, short
31400
31401      $which_section = 'SA check finish';
31402      if (defined $per_msg_status)
31403        { $per_msg_status->finish; undef $per_msg_status }
31404      if (defined $mail_obj)
31405        { $mail_obj->finish if $sa_version_num >= 3; undef $mail_obj }
31406    # section_time($which_section);  # don't bother reporting separately, short
31407
31408      # returning the result as a data structure instead of modifying
31409      # the $msginfo objects directly is used to make it possible to run
31410      # this subroutine as a subprocess; modifications to $msginfo objects
31411      # would be lost if done in a context of a spawned process
31412      push(@result, {
31413        recip_ind_list => $rind_list, user_config => $uconf,
31414        spam_level => $spam_level,
31415        spam_report => $spam_report, spam_summary => $spam_summary,
31416        supplementary_info => \%supplementary_info,
31417      });
31418    }
31419    1;
31420  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
31421
31422  $which_section = 'SA finish';
31423  if (defined $per_msg_status)  # just in case
31424    { $per_msg_status->finish; undef $per_msg_status }
31425  if (defined $mail_obj)  # just in case
31426    { $mail_obj->finish if $sa_version_num >= 3; undef $mail_obj }
31427  if ($data_representation eq 'GLOB') {
31428    close(F) or die "Can't close SA virtual file: $!";
31429    untie(*F);
31430  }
31431  umask($self->{saved_umask});  # restore our umask, SA may have clobbered it
31432  if ($$ != $saved_pid) {
31433    do_log_safe(-2,"PANIC, SA checking produced a clone process ".
31434                   "of [%s], CLONE [%s] SELF-TERMINATING", $saved_pid,$$);
31435    POSIX::_exit(3);  # SIGQUIT, avoid END and destructor processing
31436  # POSIX::_exit(6);  # SIGABRT, avoid END and destructor processing
31437  }
31438
31439  if ($rusage_self_before && $rusage_children_before) {
31440    my($rusage_self_after, $rusage_children_after) = get_rusage();
31441    @sa_cpu_usage = (
31442      $rusage_self_after->{ru_utime} - $rusage_self_before->{ru_utime},
31443      $rusage_self_after->{ru_stime} - $rusage_self_before->{ru_stime},
31444      $rusage_children_after->{ru_utime} -
31445                                   $rusage_children_before->{ru_utime},
31446      $rusage_children_after->{ru_stime} -
31447                                   $rusage_children_before->{ru_stime} );
31448  }
31449# section_time($which_section);
31450  if (defined $eval_stat) { chomp $eval_stat; die $eval_stat }  # resignal
31451  \@result;
31452}
31453
31454sub check {
31455  my($self,$msginfo) = @_;
31456  $self->{initialized_stage} == 4
31457    or die "Wrong initialization sequence: " . $self->{initialized_stage};
31458  my $scanner_name = $self->{scanner_name};
31459  my $which_section; my $prefix = '';
31460  my($spam_level,$sa_tests,$spam_report,$spam_summary,$supplementary_info_ref);
31461  my $hdr_edits = $msginfo->header_edits;
31462  my $size_limit;
31463  my $mbsl = $self->{options}->{'mail_body_size_limit'};
31464  $mbsl = c('sa_mail_body_size_limit')  if !defined $mbsl;
31465  if (defined $mbsl) {
31466    $size_limit = min(64*1024, $msginfo->orig_header_size) + 1 +
31467                  min($mbsl,   $msginfo->orig_body_size);
31468    # don't bother if slightly oversized, it's faster without size checks
31469    undef $size_limit  if $msginfo->msg_size < $size_limit + 5*1024;
31470  }
31471  # fake a local delivery agent by inserting a Return-Path
31472  $prefix .= sprintf("Return-Path: %s\n", $msginfo->sender_smtp);
31473  $prefix .= sprintf("X-Envelope-To: %s\n",
31474                     join(",\n ",qquote_rfc2821_local(@{$msginfo->recips})));
31475  my $os_fp = $msginfo->client_os_fingerprint;
31476  $prefix .= sprintf("X-Amavis-OS-Fingerprint: %s\n",
31477                     sanitize_str($os_fp))  if defined($os_fp) && $os_fp ne '';
31478  my(@av_tests);
31479  for my $r (@{$msginfo->per_recip_data}) {
31480    my $spam_tests = $r->spam_tests;
31481    push(@av_tests, grep(/^AV[.:].+=/,
31482                 split(/,/, join(',',map($$_,@$spam_tests)))))  if $spam_tests;
31483  }
31484  $prefix .= sprintf("X-Amavis-AV-Status: %s\n",
31485                     sanitize_str(join(',',@av_tests)))  if @av_tests;
31486  $prefix .= sprintf("X-Amavis-PolicyBank: %s\n", c('policy_bank_path'));
31487  $prefix .= sprintf("X-Amavis-MessageSize: %d%s\n", $msginfo->msg_size,
31488                     !defined $size_limit ? '' : ", TRUNCATED to $size_limit");
31489  for my $hf_name (qw(
31490        X-CRM114-Status X-CRM114-CacheID X-CRM114-Notice X-CRM114-Action
31491        X-DSPAM-Result X-DSPAM-Class X-DSPAM-Signature X-DSPAM-Processed
31492        X-DSPAM-Confidence X-DSPAM-Probability X-DSPAM-User X-DSPAM-Factors)) {
31493    my $suppl_attr_val = $msginfo->supplementary_info($hf_name);
31494    if (defined $suppl_attr_val && $suppl_attr_val ne '') {
31495      chomp $suppl_attr_val;
31496      $prefix .= sprintf("%s: %s\n", $hf_name, sanitize_str($suppl_attr_val));
31497    }
31498  }
31499
31500  $which_section = 'SA call';
31501  my($proc_fh,$pid); my $eval_stat; my $results_aref;
31502  eval {
31503    # NOTE ON TIMEOUTS: SpamAssassin may use timer for its own purpose,
31504    # disabling it before returning. It seems it only uses timer when
31505    # external tests are enabled.
31506    local $SIG{ALRM} = sub {
31507      my $s = Carp::longmess("SA TIMED OUT, backtrace:");
31508      # crop at some rather arbitrary limit
31509      substr($s,900-3) = '[...]'  if length($s) > 900;
31510      do_log(-1,"%s",$s);
31511    };
31512    prolong_timer('spam_scan_sa_pre', 1, 4);  # restart the timer
31513    #
31514    # note: array @lines at this point contains only prepended synthesized
31515    # header fields, but may be extended in sub call_spamassassin() by
31516    # reading-in the rest of the message; this may or may not happen in
31517    # a separate process (called through run_as_subprocess or directly);
31518    # each line must be terminated by a \n character, which must be the
31519    # only \n in a line;
31520    #
31521    my(@lines) = split(/^/m, $prefix, -1);  $prefix = undef;
31522
31523    if (!$sa_spawned) {
31524      $results_aref = call_spamassassin($self,$msginfo,\@lines,$size_limit);
31525    } else {
31526      ($proc_fh,$pid) = run_as_subprocess(\&call_spamassassin,
31527                                   $self,$msginfo,\@lines,$size_limit);
31528      my($results,$child_stat) =
31529        collect_results_structured($proc_fh,$pid,'spawned SA',200*1024);
31530      $results_aref = $results->[0]  if defined $results;
31531    }
31532    1;
31533  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
31534  section_time($which_section)  if $sa_spawned;
31535
31536  $which_section = 'SA done';
31537  prolong_timer('spam_scan_sa');  # restart the timer
31538  if ($results_aref) {
31539    # for each group of recipients using the same SA userconf file
31540    for my $h (@$results_aref) {
31541      my $rind_list = $h->{recip_ind_list};
31542      my(@r_list) = @{$msginfo->per_recip_data}[split(/,/,$rind_list)];
31543      my $uconf = $h->{user_config};
31544      $spam_level = $h->{spam_level};
31545      $spam_report = $h->{spam_report}; $spam_summary = $h->{spam_summary};
31546      $supplementary_info_ref = $h->{supplementary_info};
31547      $supplementary_info_ref = {}  if !$supplementary_info_ref;
31548      $sa_tests = $supplementary_info_ref->{'TESTSSCORES'};
31549      add_entropy($spam_level,$sa_tests);
31550      my $score_factor = $self->{options}->{'score_factor'};
31551      if (defined $spam_level && defined $score_factor) {
31552        $spam_level *= $score_factor;
31553      }
31554      do_log(3,"spam_scan: score=%s autolearn=%s tests=[%s] recips=%s",
31555               $spam_level, $supplementary_info_ref->{'AUTOLEARN'},
31556               $sa_tests, $rind_list);
31557      my(%sa_tests_h);
31558      if (defined $sa_tests && $sa_tests ne 'none') {
31559        for my $t (split(/,[ \t]*/, $sa_tests)) {
31560          my($test_name,$score) = split(/=/, $t, 2);
31561          $sa_tests_h{$test_name} = $score;
31562        }
31563      }
31564      my $dkim_adsp_suppress;
31565      if (exists $sa_tests_h{'DKIM_ADSP_DISCARD'}) {
31566        # must honour ADSP 'discardable', suppress a bounce
31567        do_log(2,"spam_scan: dsn_suppress_reason DKIM_ADSP_DISCARD");
31568        $dkim_adsp_suppress = 1;
31569      }
31570      $msginfo->supplementary_info('SCORE-'.$scanner_name, $spam_level);
31571      $msginfo->supplementary_info('VERDICT-'.$scanner_name,
31572        $spam_level >= 5 ? 'Spam' : $spam_level < 1 ? 'Ham' : 'Unknown');
31573      for my $r (@r_list) {
31574        $r->spam_level( ($r->spam_level || 0) + $spam_level );
31575        $r->spam_report($spam_report); $r->spam_summary($spam_summary);
31576        if (!$r->spam_tests) {
31577          $r->spam_tests([ \$sa_tests ]);
31578        } else {
31579          # comes last: here we use push, unlike elsewhere where may do unshift
31580          push(@{$r->spam_tests}, \$sa_tests);
31581        }
31582        if ($dkim_adsp_suppress) {
31583          $r->dsn_suppress_reason('DKIM_ADSP_DISCARD' .
31584                    !defined $_ ? '' : ", $_")  for $r->dsn_suppress_reason;
31585        }
31586      }
31587    }
31588  }
31589  if (defined($msginfo->spam_report) || defined($msginfo->spam_summary)) {
31590    $spam_report = $msginfo->spam_report . ', ' . $spam_report
31591      if $msginfo->spam_report ne '';
31592    $spam_summary = $msginfo->spam_summary . "\n\n" . $spam_summary
31593      if $msginfo->spam_summary ne '';
31594  }
31595  $msginfo->spam_report($spam_report); $msginfo->spam_summary($spam_summary);
31596  for (keys %$supplementary_info_ref) {
31597    $msginfo->supplementary_info($_, $supplementary_info_ref->{$_});
31598  }
31599  if (defined $eval_stat) {  # SA timed out?
31600    kill_proc($pid,'a spawned SA',1,$proc_fh,$eval_stat)  if defined $pid;
31601    undef $proc_fh; undef $pid; chomp $eval_stat;
31602    do_log(-2, "SA failed: %s", $eval_stat);
31603  # die "$eval_stat\n"  if $eval_stat !~ /timed out\b/;
31604  }
31605  1;
31606}
31607
316081;
31609
31610__DATA__
31611#
31612package Amavis::Unpackers;
31613use strict;
31614use re 'taint';
31615use warnings;
31616use warnings FATAL => qw(utf8 void);
31617no warnings 'uninitialized';
31618# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
31619
31620BEGIN {
31621  require Exporter;
31622  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
31623  $VERSION = '2.412';
31624  @ISA = qw(Exporter);
31625  @EXPORT_OK = qw(&init &decompose_part &determine_file_types);
31626  import Amavis::Util qw(untaint min max minmax ll do_log snmp_count
31627                         prolong_timer rmdir_recursively add_entropy);
31628  import Amavis::ProcControl qw(exit_status_str proc_status_ok run_command
31629                         kill_proc collect_results collect_results_structured);
31630  import Amavis::Conf qw(:platform :confvars $file c cr ca);
31631  import Amavis::Timing qw(section_time);
31632  import Amavis::Lookup qw(lookup lookup2);
31633  import Amavis::Unpackers::MIME qw(mime_decode);
31634  import Amavis::Unpackers::NewFilename qw(consumed_bytes);
31635}
31636
31637BEGIN {
31638  use vars qw($filemagic);
31639  eval {
31640    require File::LibMagic;
31641    File::LibMagic->VERSION(1.00);
31642    import File::LibMagic;
31643    $filemagic = File::LibMagic->new;
31644  } or do {
31645    undef $filemagic;
31646  };
31647}
31648
31649use subs @EXPORT_OK;
31650
31651use Errno qw(ENOENT EACCES EINTR EAGAIN);
31652use POSIX qw(SIGALRM);
31653use IO::File qw(O_CREAT O_EXCL O_WRONLY);
31654use Time::HiRes ();
31655use File::Basename qw(basename);
31656use Compress::Zlib 1.35;  # avoid security vulnerability in <= 1.34
31657use Archive::Zip 1.14 qw(:CONSTANTS :ERROR_CODES);
31658
31659# recursively descend into a directory $dir containing potentially unsafe
31660# files with unpredictable names, soft links, etc., rename each regular
31661# nonempty file to a directory $outdir giving it a generated name,
31662# and discard all the rest, including the directory $dir.
31663# Return a pair: number of bytes that 'sanitized' files now occupy,
31664# and a number of parts-objects created.
31665#
31666sub flatten_and_tidy_dir($$$;$$);  # prototype
31667sub flatten_and_tidy_dir($$$;$$) {
31668  my($dir, $outdir, $parent_obj, $item_num_offset, $orig_names) = @_;
31669  do_log(4, 'flatten_and_tidy_dir: processing directory "%s"', $dir);
31670  my $consumed_bytes = 0;
31671  my $item_num = 0; my $parent_placement = $parent_obj->mime_placement;
31672  chmod(0750, $dir) or die "Can't change protection of \"$dir\": $!";
31673  local(*DIR); opendir(DIR,$dir) or die "Can't open directory \"$dir\": $!";
31674  # modifying a directory while traversing it can cause surprises, avoid;
31675  # avoid slurping the whole directory contents into memory
31676  my($f, @rmfiles, @renames, @recurse);
31677  while (defined($f = readdir(DIR))) {
31678    next  if $f eq '.' || $f eq '..';
31679    my $msg;  my $fname = $dir . '/' . $f;
31680    my(@stat_list) = lstat($fname); my $errn = @stat_list ? 0 : 0+$!;
31681    if    ($errn == ENOENT) { $msg = "does not exist" }
31682    elsif ($errn)           { $msg = "inaccessible: $!" }
31683    if (defined $msg) { die "flatten_and_tidy_dir: \"$fname\" $msg," }
31684    add_entropy(@stat_list);
31685    my $newpart_obj = Amavis::Unpackers::Part->new($outdir,$parent_obj);
31686    $item_num++;
31687    $newpart_obj->mime_placement(sprintf("%s/%d", $parent_placement,
31688                                                 $item_num+$item_num_offset) );
31689    # save tainted original member name if available, or a tainted file name
31690    my $original_name = !ref($orig_names) ? undef : $orig_names->{$f};
31691    $newpart_obj->name_declared(defined $original_name ? $original_name : $f);
31692    # untaint, but if $dir happens to still be tainted, we want to know and die
31693    $fname = $dir . '/' . untaint($f);
31694    if (-d _) {
31695      $newpart_obj->attributes_add('D');
31696      push(@recurse, $fname);
31697    } elsif (-l _) {
31698      $newpart_obj->attributes_add('L');
31699      push(@rmfiles, [$fname, 'soft link']);
31700    } elsif (!-f _) {
31701      $newpart_obj->attributes_add('S');
31702      push(@rmfiles, [$fname, 'nonregular file']);
31703    } elsif (-z _) {
31704      push(@rmfiles, [$fname, 'empty file']);
31705    } else {
31706      chmod(0750, $fname)
31707        or die "Can't change protection of file \"$fname\": $!";
31708      my $size = 0 + (-s _);
31709      $newpart_obj->size($size);
31710      $consumed_bytes += $size;
31711      my $newpart = $newpart_obj->full_name;
31712      push(@renames, [$fname, $newpart, $original_name]);
31713    }
31714  }
31715  closedir(DIR) or die "Error closing directory \"$dir\": $!";
31716  my $cnt_u = scalar(@rmfiles);
31717  for my $pair (@rmfiles) {
31718    my($fname,$what) = @$pair;
31719    do_log(5,'flatten_and_tidy_dir: deleting %s "%s"', $what,$fname);
31720    unlink($fname) or die "Can't remove $what \"$fname\": $!";
31721  }
31722  undef @rmfiles;
31723  my $cnt_r = scalar(@renames);
31724  for my $tuple (@renames) {
31725    my($fname,$newpart,$original_name) = @$tuple;
31726    ll(5) && do_log(5,'flatten_and_tidy_dir: renaming "%s"%s to %s', $fname,
31727              !defined $original_name ? '' : " ($original_name)", $newpart);
31728    rename($fname,$newpart) or die "Can't rename \"$fname\" to $newpart: $!";
31729  }
31730  undef @renames;
31731  for my $fname (@recurse) {
31732    do_log(5,'flatten_and_tidy_dir: descending into subdir "%s"', $fname);
31733    my($bytes,$cnt) = flatten_and_tidy_dir($fname, $outdir, $parent_obj,
31734                                    $item_num+$item_num_offset, $orig_names);
31735    $consumed_bytes += $bytes; $item_num += $cnt;
31736  }
31737  rmdir($dir) or die "Can't remove directory \"$dir\": $!";
31738  section_time("ren$cnt_r-unl$cnt_u-files$item_num");
31739  ($consumed_bytes, $item_num);
31740}
31741
31742sub determine_file_types($$) {
31743  my($tempdir, $partslist_ref) = @_;
31744  if ($filemagic) {
31745    determine_file_types_libmagic($tempdir, $partslist_ref);
31746  } elsif (defined $file && $file ne '') {
31747    determine_file_types_fileutility($tempdir, $partslist_ref);
31748  } else {
31749    die "Neither File::LibMagic nor Unix utility file(1) are available";
31750  }
31751}
31752
31753# associate full and short file content types with each part
31754# based on libmagic (uses File::LibMagic module)
31755#
31756sub determine_file_types_libmagic($$) {
31757  my($tempdir, $partslist_ref) = @_;
31758  my(@all_part_list) = grep($_->exists, @$partslist_ref);
31759  my $initial_num_parts = scalar(@all_part_list);
31760  do_log(5, 'using File::LibMagic on %d files', $initial_num_parts);
31761  for my $part (@all_part_list) {
31762    my($type_long, $type_short);
31763    eval {
31764      $type_long = $filemagic->describe_filename($part->full_name);
31765      1;
31766    } or do {
31767      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
31768      do_log(0, 'File::LibMagic::describe_filename failed on %s: %s',
31769             $part->base_name, $eval_stat);
31770    };
31771    if (defined $type_long) {
31772      $type_short = lookup2(0,$type_long,\@map_full_type_to_short_type_maps);
31773      ll(4) && do_log(4, "File-type of %s: %s%s",
31774                         $part->base_name, $type_long,
31775                         (!defined $type_short ? ''
31776                            : !ref $type_short ? "; ($type_short)"
31777                            : '; (' . join(', ',@$type_short) . ')'
31778                         ) );
31779      $part->type_long($type_long); $part->type_short($type_short);
31780      $part->attributes_add('C')
31781        if !ref($type_short) ? $type_short eq 'pgp.enc'  # encrypted?
31782                             : grep($_ eq 'pgp.enc', @$type_short);
31783    }
31784  }
31785  section_time(sprintf('get-file-type%d', $initial_num_parts));
31786  1;
31787}
31788
31789# call 'file(1)' utility for each part,
31790# and associate full and short file content types with each part
31791#
31792sub determine_file_types_fileutility($$) {
31793  my($tempdir, $partslist_ref) = @_;
31794  defined $file && $file ne ''
31795    or die "Unix utility file(1) not available, but is needed";
31796  my(@all_part_list) = grep($_->exists, @$partslist_ref);
31797  my $initial_num_parts = scalar(@all_part_list);
31798  my $cwd = "$tempdir/parts";
31799  if (@all_part_list) { chdir($cwd) or die "Can't chdir to $cwd: $!" }
31800  my($proc_fh,$pid); my $eval_stat;
31801  eval {
31802    while (@all_part_list) {
31803      my(@part_list,@file_list); # collect reasonably small subset of filenames
31804      my $arglist_size = length($file);  # size of a command name itself
31805      while (@all_part_list) {   # collect as many args as safe, at least one
31806        my $nm = $all_part_list[0]->full_name;
31807        local($1); $nm =~ s{^\Q$cwd\E/(.*)\z}{$1}s;  # remove cwd from filename
31808        # POSIX requires 4 kB as a minimum buffer size for program arguments
31809        last  if @file_list && $arglist_size + length($nm) + 1 > 4000;
31810        push(@part_list, shift(@all_part_list));     # swallow the next one
31811        push(@file_list, $nm);  $arglist_size += length($nm) + 1;
31812      }
31813      if (scalar(@file_list) < $initial_num_parts) {
31814        do_log(2, "running file(1) on %d (out of %d) files, arglist size %d",
31815                   scalar(@file_list), $initial_num_parts, $arglist_size);
31816      } else {
31817        do_log(5, "running file(1) on %d files, arglist size %d",
31818                   scalar(@file_list), $arglist_size);
31819      }
31820      ($proc_fh,$pid) = run_command(undef, '&1', $file, @file_list);
31821      my $index = 0; my $ln;
31822      for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
31823        do_log(5, "result line from file(1): %s", $ln);
31824        chomp($ln); local($1,$2);
31825        if ($index > $#file_list) {
31826          do_log(-1,"NOTICE: Skipping unexpected output from file(1): %s",$ln);
31827        } else {
31828          my $part   = $part_list[$index];  # walk through @part_list in sync
31829          my $expect = $file_list[$index];  # walk through @file_list in sync
31830          if ($ln !~ /^(\Q$expect\E):[ \t]*(.*)\z/s) {
31831            # split file name from type
31832            do_log(-1,"NOTICE: Skipping bad output from file(1) ".
31833                      "at [%d, %s], got: %s", $index,$expect,$ln);
31834          } else {
31835            my $type_short; my $actual_name = $1; my $type_long = $2;
31836            $type_short =
31837              lookup2(0,$type_long,\@map_full_type_to_short_type_maps);
31838            ll(4) && do_log(4, "File-type of %s: %s%s",
31839                               $part->base_name, $type_long,
31840                               (!defined $type_short ? ''
31841                                  : !ref $type_short ? "; ($type_short)"
31842                                  : '; (' . join(', ',@$type_short) . ')'
31843                               ) );
31844            $part->type_long($type_long); $part->type_short($type_short);
31845            $part->attributes_add('C')
31846              if !ref($type_short) ? $type_short eq 'pgp.enc'  # encrypted?
31847                                   : grep($_ eq 'pgp.enc', @$type_short);
31848            $index++;
31849          }
31850        }
31851      }
31852      defined $ln || $! == 0 || $! == EAGAIN
31853        or die "Error reading from file(1) utility: $!";
31854      do_log(-1,"unexpected(file): %s",$!)  if !defined($ln) && $! == EAGAIN;
31855      my $err = 0; $proc_fh->close or $err = $!;
31856      my $child_stat = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
31857      undef $proc_fh; undef $pid; my(@errmsg);
31858      # exit status is 1 when result is 'ERROR: ...', accept it mercifully
31859      proc_status_ok($child_stat,$err, 0,1)
31860        or push(@errmsg, "failed, ".exit_status_str($child_stat,$err));
31861      if ($index < @part_list) {
31862        push(@errmsg, sprintf("parsing failure - missing last %d results",
31863                              @part_list - $index));
31864      }
31865      !@errmsg  or die join(", ",@errmsg);
31866      # even though exit status 1 is accepted, log a warning nevertheless
31867      proc_status_ok($child_stat,$err)
31868        or do_log(-1, "file utility failed: %s",
31869                       exit_status_str($child_stat,$err));
31870    }
31871    1;
31872  } or do {
31873    $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
31874    kill_proc($pid,$file,1,$proc_fh,$eval_stat)  if defined $pid;
31875  };
31876  chdir($tempdir) or die "Can't chdir to $tempdir: $!";
31877  section_time(sprintf('get-file-type%d', $initial_num_parts));
31878  if (defined $eval_stat) {
31879    do_log(-2, "file(1) utility (%s) FAILED: %s", $file,$eval_stat);
31880  # die "file(1) utility ($file) error: $eval_stat";
31881  }
31882  1;
31883}
31884
31885sub decompose_mail($$) {
31886  my($tempdir,$file_generator_object) = @_;
31887
31888  my $hold; my(@parts); my $depth = 1;
31889  my($any_undecipherable, $any_encrypted, $over_levels) = (0,0,0);
31890  my $which_section = "parts_decode";
31891  # fetch all not-yet-visited part names, and start a new cycle
31892TIER:
31893  while (@parts = @{$file_generator_object->parts_list}) {
31894    if ($MAXLEVELS > 0 && $depth > $MAXLEVELS) {
31895      $over_levels = 1;
31896      $hold = "Maximum decoding depth ($MAXLEVELS) exceeded";
31897      last;
31898    }
31899    $file_generator_object->parts_list_reset;  # new cycle of names
31900    # clip to avoid very long log entries
31901    my(@chopped_parts) = @parts > 5 ? @parts[0..4] : @parts;
31902    ll(4) && do_log(4,"decode_parts: level=%d, #parts=%d : %s",
31903                     $depth, scalar(@parts),
31904                     join(', ', (map($_->base_name, @chopped_parts)),
31905                     (@chopped_parts >= @parts ? () : "...")) );
31906    for my $part (@parts) {  # test for existence of all expected files
31907      my $fname = $part->full_name;  my $errn = 0;
31908      if ($fname eq '') { $errn = ENOENT }
31909      else {
31910        my(@stat_list) = lstat($fname);
31911        if (@stat_list) { add_entropy(@stat_list) } else { $errn = 0+$! }
31912      }
31913      if ($errn == ENOENT) {
31914        $part->exists(0);
31915      # $part->type_short('no-file')  if !defined $part->type_short;
31916      } elsif ($errn) {
31917        die "decompose_mail: inaccessible file $fname: $!";
31918      } elsif (!-f _) {  # not a regular file
31919        my $what = -l _ ? 'symlink' : -d _ ? 'directory' : 'non-regular file';
31920        do_log(-1, "WARN: decompose_mail: removing unexpected %s %s",
31921                   $what,$fname);
31922        if (-d _) { rmdir_recursively($fname) }
31923        else { unlink($fname) or die "Can't delete $what $fname: $!" }
31924        $part->exists(0);
31925        $part->type_short(-l _ ? 'symlink' : -d _ ? 'dir' : 'special')
31926          if !defined $part->type_short;
31927      } elsif (-z _) {   # empty file
31928        unlink($fname) or die "Can't remove \"$fname\": $!";
31929        $part->exists(0);
31930        $part->type_short('empty')  if !defined $part->type_short;
31931        $part->type_long('empty')   if !defined $part->type_long;
31932      } else {
31933        $part->exists(1);
31934      }
31935    }
31936    if (!defined $file || $file eq '') {
31937      do_log(5,'utility file(1) not available, skipping determine_file_types');
31938    } else {
31939      determine_file_types($tempdir, \@parts);
31940    }
31941    for my $part (@parts) {
31942      if ($part->exists && !defined($hold)) {
31943        my($hold_tmp, $over_levels_tmp) = decompose_part($part, $tempdir);
31944        $hold = $hold_tmp if $hold_tmp;
31945        $over_levels ||= $over_levels_tmp;
31946      }
31947      my $attr = $part->attributes;
31948      if (defined $attr) {
31949        $any_undecipherable++  if index($attr, 'U') >= 0;
31950        $any_encrypted++       if index($attr, 'C') >= 0;
31951      }
31952    }
31953    last TIER  if defined $hold;
31954    $depth++;
31955  }
31956  section_time($which_section); prolong_timer($which_section);
31957  ($hold, $any_undecipherable, $any_encrypted, $over_levels);
31958}
31959
31960# Decompose one part
31961#
31962sub decompose_part($$) {
31963  my($part, $tempdir) = @_;
31964  # possible return values from eval:
31965  # 0 - truly atomic or unknown or archiver failure; consider atomic
31966  # 1 - some archive, successfully unpacked, result replaces original
31967  # 2 - probably unpacked, but keep the original (eg self-extracting archive)
31968  my $hold; my $eval_stat; my($sts, $any_called, $over_levels) = (0,0,0);
31969  eval {
31970    my $type_short = $part->type_short;
31971    my(@ts) = !defined $type_short ? ()
31972                : !ref $type_short ? ($type_short) : @$type_short;
31973    if (@ts) {  # when one or more short types are known
31974      snmp_count("OpsDecType-".join('.',@ts));
31975      for my $dec_tuple (@{ca('decoders')}) {  # first matching decoder wins
31976        next  if !defined $dec_tuple;
31977        my($short_types, $code, @args) = @$dec_tuple;
31978        if ($code && grep(ref $short_types ? $short_types->{$_}
31979                                           : $_ eq $short_types, @ts)) {
31980          $any_called = 1; $sts = &$code($part,$tempdir,@args);
31981          last;
31982        }
31983      }
31984    }
31985    1;
31986  } or do {
31987    $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
31988    my $ll = -1;
31989    if ($eval_stat =~ /\bExceeded storage quota\b.*\bbytes by/ ||
31990        $eval_stat =~ /\bMaximum number of files\b.*\bexceeded/) {
31991      $hold = $eval_stat; $ll = 1; $over_levels = 1;
31992    }
31993    do_log($ll,"Decoding of %s (%s) failed, leaving it unpacked: %s",
31994               $part->base_name, $part->type_long, $eval_stat);
31995    $sts = 2;  # keep the original, along with possible decoded files
31996  };
31997  if ($any_called) {
31998    chdir($tempdir) or die "Can't chdir to $tempdir: $!";  # just in case
31999  }
32000  if ($sts == 1 && lookup2(0,$part->type_long,\@keep_decoded_original_maps)) {
32001    # don't trust this file type or unpacker,
32002    # keep both the original and the unpacked file
32003    ll(4) && do_log(4,"file type is %s, retain original %s",
32004                      $part->type_long, $part->base_name);
32005    $sts = 2;  # keep the original, along with possible decoded files
32006  }
32007  if ($sts == 1) {
32008    ll(5) && do_log(5,"decompose_part: deleting %s", $part->full_name);
32009    unlink($part->full_name)
32010      or die sprintf("Can't unlink %s: %s", $part->full_name, $!);
32011  }
32012  ll(4) && do_log(4,"decompose_part: %s - %s", $part->base_name,
32013                    ['atomic','archive, unpacked','source retained']->[$sts]);
32014  section_time('decompose_part')  if $any_called;
32015  die $eval_stat  if $eval_stat =~ /^timed out\b/;  # resignal timeout
32016  ($hold, $over_levels);
32017}
32018
32019# a trivial wrapper around mime_decode() to adjust arguments and result
32020#
32021sub do_mime_decode($$) {
32022  my($part, $tempdir) = @_;
32023  mime_decode($part,$tempdir,$part);
32024  2;  # probably unpacked, but keep the original mail
32025};
32026
32027#
32028# Uncompression/unarchiving routines
32029# Possible return codes:
32030# 0 - truly atomic or unknown or archiver failure; consider atomic
32031# 1 - some archiver format, successfully unpacked, result replaces original
32032# 2 - probably unpacked, but keep the original (eg self-extracting archive)
32033
32034# if ASCII text, try multiple decoding methods as provided by UUlib
32035# (uuencoded, xxencoded, BinHex, yEnc, Base64, Quoted-Printable)
32036#
32037use vars qw($have_uulib_module);
32038sub do_ascii($$) {
32039  my($part, $tempdir) = @_;
32040  ll(4) && do_log(4,"do_ascii: Decoding part %s", $part->base_name);
32041  if (!defined $have_uulib_module) {
32042    eval {
32043      require Convert::UUlib && ($have_uulib_module = 1);
32044      # avoid an exploitable security hole in Convert::UUlib 1.04 and older
32045      Convert::UUlib->VERSION(1.05);  # 1.08 or newer is preferred!
32046      $have_uulib_module;
32047    } or do {
32048      $have_uulib_module = 0;
32049      chomp $@;  $@ =~ s/ \(you may need to install the .*\z//i;
32050      do_log(5,"do_ascii: module Convert::UULIB unavailable: %s", $@);
32051    };
32052  }
32053  return 0 if !$have_uulib_module;
32054  snmp_count('OpsDecByUUlibAttempt');
32055
32056  # prevent uunconc.c/UUDecode() from trying to create a temp file in '/'
32057  my $old_env_tmpdir = $ENV{TMPDIR}; $ENV{TMPDIR} = "$tempdir/parts";
32058  my $any_errors = 0; my $any_decoded = 0;
32059
32060  alarm(0);  # stop the timer
32061  local($SIG{ALRM}); my($sigset,$action,$oldaction);
32062  if ($] < 5.008) {  # in old Perl signals could be delivered at any time
32063    $SIG{ALRM} = sub { die "timed out\n" };
32064  } elsif ($] < 5.008001) {  # Perl 5.8.0
32065    # 5.8.0 does not have POSIX::SigAction::safe but uses safe signals, which
32066    # means a runaway uulib can't be aborted; tough luck, upgrade your Perl!
32067    $SIG{ALRM} = sub { die "timed out\n" };  # old way, but won't abort
32068  } else {  # Perl >= 5.8.0 has 'safe signals', and SigAction::safe available
32069    # POSIX::sigaction can bypass safe Perl signals on request;
32070    # alternatively, use Perl module Sys::SigAction
32071    $sigset = POSIX::SigSet->new(SIGALRM); $oldaction = POSIX::SigAction->new;
32072    $action = POSIX::SigAction->new(sub { die "timed out\n" },
32073                                    $sigset, &POSIX::SA_RESETHAND);
32074    $action->safe(1);
32075    POSIX::sigaction(SIGALRM,$action,$oldaction)
32076      or die "Can't set ALRM handler: $!";
32077    do_log(4,"do_ascii: Setting sigaction handler, was %d", $oldaction->safe);
32078  }
32079  my $eval_stat;
32080  eval {  # must not go away without calling Convert::UUlib::CleanUp !
32081    my($sts,$count);
32082    prolong_timer('do_ascii_pre');  # restart timer
32083    $sts = Convert::UUlib::Initialize();
32084    $sts = 0  if !defined $sts; # avoid Use of uninit. value in numeric eq (==)
32085    $sts == Convert::UUlib::RET_OK()
32086      or die "Convert::UUlib::Initialize failed: ".
32087             Convert::UUlib::strerror($sts);
32088    my $uulib_version =
32089      Convert::UUlib::GetOption(Convert::UUlib::OPT_VERSION());
32090    !Convert::UUlib::SetOption(Convert::UUlib::OPT_IGNMODE(), 1)
32091      or die "bad uulib OPT_IGNMODE";
32092  # !Convert::UUlib::SetOption(Convert::UUlib::OPT_DESPERATE(), 1)
32093  #   or die "bad uulib OPT_DESPERATE";
32094    if (defined $action) {
32095      $action->safe(0);  # bypass safe Perl signals
32096      POSIX::sigaction(SIGALRM,$action) or die "Can't set ALRM handler: $!";
32097    }
32098    # may take looong time on malformed messages, allow it to be interrupted
32099    ($sts, $count) = Convert::UUlib::LoadFile($part->full_name);
32100    if (defined $action) {
32101      $action->safe(1);  # re-establish safe signal handling
32102      POSIX::sigaction(SIGALRM,$action) or die "Can't set ALRM handler: $!";
32103    }
32104    if ($sts != Convert::UUlib::RET_OK()) {
32105      my $errmsg = Convert::UUlib::strerror($sts) . ": $!";
32106      $errmsg .= ", (???"
32107        . Convert::UUlib::strerror(
32108            Convert::UUlib::GetOption(Convert::UUlib::OPT_ERRNO()))."???)"
32109        if $sts == Convert::UUlib::RET_IOERR();
32110      die "Convert::UUlib::LoadFile (uulib V$uulib_version) failed: $errmsg";
32111    }
32112    ll(4) && do_log(4,"do_ascii: Decoding part %s (%d items), uulib V%s",
32113                      $part->base_name, $count, $uulib_version);
32114    my $uu;
32115    my $item_num = 0; my $parent_placement = $part->mime_placement;
32116    for (my $j = 0; $uu = Convert::UUlib::GetFileListItem($j); $j++) {
32117      $item_num++;
32118      ll(4) && do_log(4,
32119                 "do_ascii(%d): state=0x%02x, enc=%s%s, est.size=%s, name=%s",
32120                  $j, $uu->state, Convert::UUlib::strencoding($uu->uudet),
32121                  ($uu->mimetype ne '' ? ", mimetype=" . $uu->mimetype : ''),
32122                  $uu->size, $uu->filename);
32123      if (!($uu->state & Convert::UUlib::FILE_OK())) {
32124        $any_errors = 1;
32125        do_log(1,"do_ascii: Convert::UUlib info: %s not decodable, %s",
32126                 $j,$uu->state);
32127      } else {
32128        my $newpart_obj = Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32129        $newpart_obj->mime_placement("$parent_placement/$item_num");
32130        $newpart_obj->name_declared($uu->filename);
32131        my $newpart = $newpart_obj->full_name;
32132        if (defined $action) {
32133          $action->safe(0);  # bypass safe Perl signals
32134          POSIX::sigaction(SIGALRM,$action) or die "Can't set ALRM handlr: $!";
32135        }
32136        $! = 0;
32137        $sts = $uu->decode($newpart);  # decode to file $newpart
32138        my $err_decode = "$!";
32139        if (defined $action) {
32140          $action->safe(1);  # re-establish safe signal handling
32141          POSIX::sigaction(SIGALRM,$action) or die "Can't set ALRM handlr: $!";
32142        }
32143        chmod(0750, $newpart) or $! == ENOENT  # chmod, don't panic if no file
32144          or die "Can't change protection of \"$newpart\": $!";
32145        my $statmsg;
32146        my $errn = lstat($newpart) ? 0 : 0+$!;
32147        if    ($errn == ENOENT) { $statmsg = "does not exist"   }
32148        elsif ($errn) { $statmsg = "inaccessible: $!" }
32149        elsif ( -l _) { $statmsg = "is a symlink"     }
32150        elsif ( -d _) { $statmsg = "is a directory"   }
32151        elsif (!-f _) { $statmsg = "not a regular file" }
32152        if (defined $statmsg) { $statmsg = "; file status: $newpart $statmsg" }
32153        my $size = 0 + (-s _);
32154        $newpart_obj->size($size);
32155        consumed_bytes($size, 'do_ascii');
32156        if ($sts == Convert::UUlib::RET_OK() && $errn==0) {
32157          $any_decoded = 1;
32158          do_log(4,"do_ascii: RET_OK%s", $statmsg)  if defined $statmsg;
32159        } elsif ($sts == Convert::UUlib::RET_NODATA() ||
32160                 $sts == Convert::UUlib::RET_NOEND()) {
32161          $any_errors = 1;
32162          do_log(-1,"do_ascii: Convert::UUlib error: %s%s",
32163                    Convert::UUlib::strerror($sts), $statmsg);
32164        } else {
32165          $any_errors = 1;
32166          my $errmsg = Convert::UUlib::strerror($sts) . ":: $err_decode";
32167          $errmsg .= ", " . Convert::UUlib::strerror(
32168                  Convert::UUlib::GetOption(Convert::UUlib::OPT_ERRNO()) )
32169            if $sts == Convert::UUlib::RET_IOERR();
32170          die("Convert::UUlib failed: " . $errmsg . $statmsg);
32171        }
32172      }
32173    }
32174    1;
32175  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
32176  prolong_timer('do_ascii');  # restart timer
32177  if (defined $oldaction) {
32178    POSIX::sigaction(SIGALRM,$oldaction)
32179      or die "Can't restore ALRM handler: $!";
32180  }
32181  Convert::UUlib::CleanUp();
32182  snmp_count('OpsDecByUUlib')  if $any_decoded;
32183  if (defined $old_env_tmpdir) { $ENV{TMPDIR} = $old_env_tmpdir }
32184  else { delete $ENV{TMPDIR} }
32185  if (defined $eval_stat) { chomp $eval_stat; die "do_ascii: $eval_stat\n" }
32186  $any_errors ? 2 : $any_decoded ? 1 : 0;
32187}
32188
32189# use Archive-Zip
32190#
32191sub do_unzip($$;$$) {
32192  my($part, $tempdir, $archiver_dummy, $testing_for_sfx) = @_;
32193  ll(4) && do_log(4, "Unzipping %s", $part->base_name);
32194  # avoid DoS vulnerability in < 2.017, CVE-2009-1391
32195  # Compress::Raw::Zlib->VERSION(2.017);  # module not loaded
32196  snmp_count('OpsDecByArZipAttempt');
32197  my $zip = Archive::Zip->new;
32198  my(@err_nm) = qw(AZ_OK AZ_STREAM_END AZ_ERROR AZ_FORMAT_ERROR AZ_IO_ERROR);
32199  my $retval = 1;
32200  # need to set up a temporary minimal error handler
32201  Archive::Zip::setErrorHandler(sub { return 5 });
32202  my $sts = $zip->read($part->full_name);
32203  Archive::Zip::setErrorHandler(sub { die @_ });
32204  my($any_unsupp_compmeth,$any_zero_length);
32205  my($encryptedcount,$extractedcount) = (0,0);
32206  if ($sts != AZ_OK) {  # not a zip? corrupted zip file? other errors?
32207    if ($testing_for_sfx && $sts == AZ_FORMAT_ERROR) {
32208      # a normal status for executable that is not a self extracting archive
32209      do_log(4, "do_unzip: ok, exe is not a zip sfx: %s (%s)",
32210                $err_nm[$sts], $sts);
32211    } else {
32212      do_log(-1, "do_unzip: not a zip: %s (%s)", $err_nm[$sts], $sts);
32213#     $part->attributes_add('U');  # perhaps not, it flags as **UNCHECKED** too
32214#                                  # many bounces containing chopped-off zip
32215    }
32216    $retval = 0;
32217  } else {
32218    my $item_num = 0; my $parent_placement = $part->mime_placement;
32219    for my $mem ($zip->members) {
32220      my $newpart_obj = Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32221      $item_num++; $newpart_obj->mime_placement("$parent_placement/$item_num");
32222      $newpart_obj->name_declared($mem->fileName);
32223      my $compmeth = $mem->compressionMethod;
32224      if ($compmeth!=COMPRESSION_DEFLATED && $compmeth!=COMPRESSION_STORED) {
32225        $any_unsupp_compmeth = $compmeth;
32226        $newpart_obj->attributes_add('U');
32227      } elsif ($mem->isEncrypted) {
32228        $encryptedcount++;
32229        $newpart_obj->attributes_add('U','C');
32230      } elsif ($mem->isDirectory) {
32231        $newpart_obj->attributes_add('D');
32232      } else {
32233        # want to read uncompressed - set to COMPRESSION_STORED
32234        my $oldc = $mem->desiredCompressionMethod(COMPRESSION_STORED);
32235        $sts = $mem->rewindData;
32236        $sts == AZ_OK or die sprintf("%s: error rew. member data: %s (%s)",
32237                                     $part->base_name, $err_nm[$sts], $sts);
32238        my $newpart = $newpart_obj->full_name;
32239        my $outpart = IO::File->new;
32240        # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
32241        $outpart->open($newpart, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)
32242          or die "Can't create file $newpart: $!";
32243        binmode($outpart) or die "Can't set file $newpart to binmode: $!";
32244        my $size = 0;
32245        while ($sts == AZ_OK) {
32246          my $buf_ref;
32247          ($buf_ref, $sts) = $mem->readChunk;
32248          $sts == AZ_OK || $sts == AZ_STREAM_END
32249            or die sprintf("%s: error reading member: %s (%s)",
32250                           $part->base_name, $err_nm[$sts], $sts);
32251          my $buf_len = length($$buf_ref);
32252          if ($buf_len > 0) {
32253            $size += $buf_len;
32254            $outpart->print($$buf_ref) or die "Can't write to $newpart: $!";
32255            consumed_bytes($buf_len, 'do_unzip');
32256          }
32257        }
32258        $any_zero_length = 1  if $size == 0;
32259        $newpart_obj->size($size);
32260        $outpart->close or die "Error closing $newpart: $!";
32261        $mem->desiredCompressionMethod($oldc);
32262        $mem->endRead;
32263        $extractedcount++;
32264      }
32265    }
32266    snmp_count('OpsDecByArZip');
32267  }
32268  if ($any_unsupp_compmeth) {
32269    $retval = 2;
32270    do_log(-1, "do_unzip: %s, unsupported compression method: %s",
32271               $part->base_name, $any_unsupp_compmeth);
32272  } elsif ($any_zero_length) {  # possible zip vulnerability exploit
32273    $retval = 2;
32274    do_log(1, "do_unzip: %s, members of zero length, archive retained",
32275              $part->base_name);
32276  } elsif ($encryptedcount) {
32277    $retval = 2;
32278    do_log(1,
32279      "do_unzip: %s, %d members are encrypted, %s extracted, archive retained",
32280      $part->base_name, $encryptedcount,
32281      !$extractedcount ? 'none' : $extractedcount);
32282  }
32283  $retval;
32284}
32285
32286# use external decompressor program from the compress/gzip/bzip2/xz/lz4 family
32287#
32288sub do_uncompress($$$) {
32289  my($part, $tempdir, $decompressor) = @_;
32290  ll(4) && do_log(4,"do_uncompress %s by %s", $part->base_name,$decompressor);
32291  my $decompressor_name = basename((split(' ',$decompressor))[0]);
32292  snmp_count("OpsDecBy\u${decompressor_name}");
32293  my $newpart_obj = Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32294  $newpart_obj->mime_placement($part->mime_placement."/1");
32295  my $newpart = $newpart_obj->full_name;
32296  my($type_short, $name_declared) = ($part->type_short, $part->name_declared);
32297  local($1);  my(@rn);  # collect recommended file names
32298  push(@rn,$1)
32299    if $part->type_long =~ /^\S+\s+compressed data, was "(.+)"(\z|, from\b)/;
32300  for my $name_d (!ref $name_declared ? ($name_declared) : @$name_declared) {
32301    next  if $name_d eq '';
32302    my $name = $name_d;
32303    for (!ref $type_short ? ($type_short) : @$type_short) {
32304      $_ eq 'F'    and $name=~s/\.F\z//;
32305      $_ eq 'Z'    and $name=~s/\.Z\z//    || $name=~s/\.tg?z\z/.tar/;
32306      $_ eq 'gz'   and $name=~s/\.gz\z//   || $name=~s/\.tgz\z/.tar/;
32307      $_ eq 'bz'   and $name=~s/\.bz\z//   || $name=~s/\.tbz\z/.tar/;
32308      $_ eq 'bz2'  and $name=~s/\.bz2?\z// || $name=~s/\.tbz2?\z/.tar/;
32309      $_ eq 'xz'   and $name=~s/\.xz\z//   || $name=~s/\.txz\z/.tar/;
32310      $_ eq 'lzma' and $name=~s/\.lzma\z// || $name=~s/\.tlz\z/.tar/;
32311      $_ eq 'lrz'  and $name=~s/\.lrz\z//;
32312      $_ eq 'lzo'  and $name=~s/\.lzo\z//;
32313      $_ eq 'lz4'  and $name=~s/\.lz4\z//;
32314      $_ eq 'rpm'  and $name=~s/\.rpm\z/.cpio/;
32315    }
32316    push(@rn,$name)  if !grep($_ eq $name, @rn);
32317  }
32318  $newpart_obj->name_declared(@rn==1 ? $rn[0] : \@rn)  if @rn;
32319  my($proc_fh,$pid); my $retval = 1;
32320
32321  prolong_timer('do_uncompress_pre');  # restart timer
32322  my $eval_stat;
32323  eval {
32324    ($proc_fh,$pid) =
32325      run_command($part->full_name, '/dev/null', split(' ',$decompressor));
32326    my($rv,$err) = run_command_copy($newpart,$proc_fh,$pid);  # may die
32327    undef $proc_fh; undef $pid;
32328    if (!proc_status_ok($rv,$err)) {
32329#     unlink($newpart) or die "Can't unlink $newpart: $!";
32330      my $msg = sprintf('Error running decompressor %s on %s, %s',
32331                   $decompressor, $part->base_name, exit_status_str($rv,$err));
32332      # bzip2 and gzip use status 2 as a warning about corrupted file
32333      if (proc_status_ok($rv,$err, 2)) {do_log(0,"%s",$msg)} else {die $msg}
32334    }
32335    1;
32336  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
32337  prolong_timer('do_uncompress');  # restart timer
32338  if (defined $eval_stat) {
32339    $retval = 0; chomp $eval_stat;
32340    kill_proc($pid,$decompressor,1,$proc_fh,$eval_stat)  if defined $pid;
32341    undef $proc_fh; undef $pid;
32342    die "do_uncompress: $eval_stat\n";  # propagate failure
32343  }
32344  $retval;
32345}
32346
32347# use Compress::Zlib to inflate
32348#
32349sub do_gunzip($$) {
32350  my($part, $tempdir) = @_;  my $retval = 0;
32351  do_log(4, "Inflating gzip archive %s", $part->base_name);
32352  snmp_count('OpsDecByZlib');
32353  my $gz = Amavis::IO::Zlib->new;
32354  $gz->open($part->full_name,'rb')
32355    or die("do_gunzip: Can't open gzip file ".$part->full_name.": $!");
32356  my $newpart_obj = Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32357  $newpart_obj->mime_placement($part->mime_placement."/1");
32358  my $newpart = $newpart_obj->full_name;
32359  my $outpart = IO::File->new;
32360  # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
32361  $outpart->open($newpart, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)
32362    or die "Can't create file $newpart: $!";
32363  binmode($outpart) or die "Can't set file $newpart to binmode: $!";
32364  my($nbytes,$buff); my $size = 0;
32365  while (($nbytes=$gz->read($buff,16384)) > 0) {
32366    $outpart->print($buff) or die "Can't write to $newpart: $!";
32367    $size += $nbytes; consumed_bytes($nbytes, 'do_gunzip');
32368  }
32369  my $err = defined $nbytes ? 0 : $!;
32370  $newpart_obj->size($size);
32371  $outpart->close or die "Error closing $newpart: $!";
32372  undef $buff;  # release storage
32373  my(@rn);  # collect recommended file name
32374  my $name_declared = $part->name_declared;
32375  for my $name_d (!ref $name_declared ? ($name_declared) : @$name_declared) {
32376    next  if $name_d eq '';
32377    my $name = $name_d;
32378    $name=~s/\.(gz|Z)\z// || $name=~s/\.tgz\z/.tar/;
32379    push(@rn,$name)  if !grep($_ eq $name, @rn);
32380  }
32381  $newpart_obj->name_declared(@rn==1 ? $rn[0] : \@rn)  if @rn;
32382  if (defined $nbytes && $nbytes==0) { $retval = 1 }  # success
32383  else {
32384    do_log(-1, "do_gunzip: Error reading file %s: %s", $part->full_name,$err);
32385    unlink($newpart) or die "Can't unlink $newpart: $!";
32386    $newpart_obj->size(undef); $retval = 0;
32387  }
32388  $gz->close or die "Error closing gzipped file: $!";
32389  $retval;
32390}
32391
32392# DROPED SUPPORT for Archive::Tar; main drawback of this module is: it either
32393# loads an entire tar into memory (horrors!), or when using extract_archive()
32394# it does not relativize absolute paths (which makes it possible to store
32395# members in any directory writable by uid), and does not provide a way to
32396# capture contents of members with the same name. Use pax program instead!
32397#
32398#use Archive::Tar;
32399#sub do_tar($$) {
32400# my($part, $tempdir) = @_;
32401# snmp_count('OpsDecByArTar');
32402# # Work around bug in Archive-Tar
32403# my $tar = eval { Archive::Tar->new($part->full_name) };
32404# if (!defined($tar)) {
32405#   chomp $@;
32406#   do_log(4, "Faulty archive %s: %s", $part->full_name, $@);
32407#   die $@  if $@ =~ /^timed out\b/;  # resignal timeout
32408#   return 0;
32409# }
32410# do_log(4,"Untarring %s", $part->base_name);
32411# my $item_num = 0; my $parent_placement = $part->mime_placement;
32412# my(@list) = $tar->list_files;
32413# for (@list) {
32414#   next  if m{/\z};  # ignore directories
32415#     # this is bad (reads whole file into scalar)
32416#     # need some error handling, too
32417#   my $data = $tar->get_content($_);
32418#   my $newpart_obj = Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32419#   $item_num++; $newpart_obj->mime_placement("$parent_placement/$item_num");
32420#   my $newpart = $newpart_obj->full_name;
32421#   my $outpart = IO::File->new;
32422#   # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
32423#   $outpart->open($newpart, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)
32424#     or die "Can't create file $newpart: $!";
32425#   binmode($outpart) or die "Can't set file $newpart to binmode: $!";
32426#   $outpart->print($data) or die "Can't write to $newpart: $!";
32427#   $newpart_obj->size(length($data));
32428#   consumed_bytes(length($data), 'do_tar');
32429#   $outpart->close or die "Error closing $newpart: $!";
32430# }
32431# 1;
32432#}
32433
32434# use external program to expand 7-Zip archives
32435#
32436sub do_7zip($$$;$) {
32437  my($part, $tempdir, $archiver, $testing_for_sfx) = @_;
32438  ll(4) && do_log(4, "Expanding 7-Zip archive %s", $part->base_name);
32439  my $decompressor_name = basename((split(' ',$archiver))[0]);
32440  snmp_count("OpsDecBy\u${decompressor_name}Attempt");
32441  my $last_line; my $any_encrypted; my $bytes = 0; my $mem_cnt = 0;
32442  my $retval = 1; my($proc_fh,$pid); my $fn = $part->full_name;
32443  prolong_timer('do_7zip_pre');  # restart timer
32444  my $eval_stat;
32445  eval {
32446    ($proc_fh,$pid) = run_command(undef, '&1', $archiver,
32447                                  'l', '-slt', "-w$tempdir/parts", '--', $fn);
32448    my @list;
32449    my $ln; my($name,$size,$attr,$enc); my $entries_cnt = 0;
32450    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
32451      $last_line = $ln  if $ln =~ /\S/;  # keep last nonempty line
32452      chomp($ln); local($1);
32453      if ($ln !~ /\S/) {  # empty line separates members
32454	if (defined $attr && $attr =~ /^D/) {
32455          do_log(5,'do_7zip: member: %s "%s", (skipped directory)',
32456                 $attr,$name);
32457	} elsif (defined $enc && defined $name) {
32458          do_log(5,'do_7zip: member: %s "%s", %s bytes (skipped encrypted)',
32459                 $attr,$name,$size);
32460          # make a phantom entry - carrying only name and attributes
32461          my $parent_placement = $part->mime_placement;
32462          my $newpart_obj =
32463            Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32464          $newpart_obj->mime_placement("$parent_placement/$entries_cnt");
32465          $newpart_obj->name_declared($name);
32466          $newpart_obj->attributes_add('U','C');
32467	} elsif (defined $name || defined $size) {
32468          do_log(5,'do_7zip: member: %s "%s", %s bytes',
32469                 $attr, $name, defined $size ? $size : '?');
32470          if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES) {
32471            die "Maximum number of files ($MAXFILES) exceeded";
32472          }
32473          if (defined $size && $size > 0) {
32474            push(@list, untaint($name));
32475            $bytes += $size; $mem_cnt++;
32476	  }
32477        }
32478        undef $name; undef $size; undef $attr; undef $enc;
32479      }
32480      elsif ($ln =~ /^Path = (.*)\z/s)       { $name = $1 }
32481      elsif ($ln =~ /^Size = ([0-9]+)\z/s)   { $size = $1 }
32482      elsif ($ln =~ /^Attributes = (.*)\z/s) { $attr = $1 }
32483      elsif ($ln =~ /^Encrypted = \+\z/s)    { $enc = $any_encrypted = 1 }
32484      elsif ($ln =~ /^ERROR:.* Can not open encrypted archive\. Wrong password\?\z/s) {
32485        do_log(5,'do_7zip: archive is encrypted');
32486        $part->attributes_add('U','C');
32487      }
32488    }
32489    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading (1): $!";
32490    do_log(-1,"unexpected(do_7zip_1): %s",$!) if !defined($ln) && $! == EAGAIN;
32491    if (defined $name || defined $size) {
32492      do_log(5,'do_7zip: member: %s "%s", %s bytes', $attr,$name,$size);
32493      if (defined $size && $size > 0) { $bytes += $size; $mem_cnt++ }
32494    }
32495    # consume all remaining output to avoid broken pipe
32496    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
32497      $last_line = $ln  if $ln =~ /\S/;
32498    }
32499    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading (2): $!";
32500    do_log(-1,"unexpected(do_7zip_2): %s",$!)  if !defined($ln) && $! == EAGAIN;
32501    my $err = 0; $proc_fh->close or $err = $!;
32502    my $rv = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
32503    undef $proc_fh; undef $pid;  local($1,$2);
32504    if (proc_status_ok($rv,$err,1) && $mem_cnt > 0 && $bytes > 0) { # just warn
32505      do_log(4,"do_7zip: warning, %s", exit_status_str($rv,$err));
32506    } elsif (!proc_status_ok($rv,$err)) {
32507      die sprintf("can't get a list of archive members: %s; %s",
32508                  exit_status_str($rv,$err), $last_line);
32509    }
32510    if ($mem_cnt > 0 || $bytes > 0) {
32511      consumed_bytes($bytes, 'do_7zip-pre', 1);  # pre-check on estimated size
32512      snmp_count("OpsDecBy\u${decompressor_name}");
32513      if (!$any_encrypted) {
32514        # supplying an empty list extracts all files, avoids exceeding the
32515        # argv size limit as there is no need to exclude excrypted members
32516        # (which would result in 7z returning a nonzero status)
32517        @list = ();
32518      }
32519      ($proc_fh,$pid) = run_command(undef, '&1', $archiver, 'x', '-bd', '-y',
32520                          "-w$tempdir/parts", "-o$tempdir/parts/7zip", '--',
32521                          $fn, @list);
32522      collect_results($proc_fh,$pid,$archiver,16384,[0,1]);
32523      undef $proc_fh; undef $pid;
32524      my $errn = lstat("$tempdir/parts/7zip") ? 0 : 0+$!;
32525      if ($errn != ENOENT) {
32526        my $b = flatten_and_tidy_dir("$tempdir/parts/7zip",
32527                                     "$tempdir/parts", $part);
32528        consumed_bytes($b, 'do_7zip');
32529      }
32530    }
32531    1;
32532  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
32533  prolong_timer('do_7zip');  # restart timer
32534  if (defined $eval_stat) {
32535    $retval = 0; chomp $eval_stat;
32536    kill_proc($pid,$archiver,1,$proc_fh,$eval_stat)  if defined $pid;
32537    undef $proc_fh; undef $pid;
32538  # if ($testing_for_sfx) { die "do_7zip: $eval_stat" }
32539  # else { do_log(-1, "do_7zip: %s", $eval_stat) };
32540    die "do_7zip: $eval_stat\n"  # propagate failure
32541  }
32542  $retval;
32543}
32544
32545# use external program to expand RAR archives
32546#
32547sub do_unrar($$$;$) {
32548  my($part, $tempdir, $archiver, $testing_for_sfx) = @_;
32549  ll(4) && do_log(4, "Expanding RAR archive %s", $part->base_name);
32550  my $decompressor_name = basename((split(' ',$archiver))[0]);
32551  snmp_count("OpsDecBy\u${decompressor_name}Attempt");
32552  # unrar exit codes: SUCCESS=0, WARNING=1, FATAL_ERROR=2, CRC_ERROR=3,
32553  #   LOCK_ERROR=4, WRITE_ERROR=5, OPEN_ERROR=6, USER_ERROR=7, MEMORY_ERROR=8,
32554  #   CREATE_ERROR=9, USER_BREAK=255
32555  my(@list); my $hypcount = 0; my $encryptedcount = 0;
32556  my $lcnt = 0; my $member_name; my $bytes = 0; my $last_line;
32557  my $item_num = 0; my $parent_placement = $part->mime_placement;
32558  my $retval = 1; my $fn = $part->full_name; my($proc_fh,$pid);
32559  my $unrarvers = 5;
32560  my(@common_rar_switches) = qw(-c- -p- -idcdp);  # -av-
32561
32562  prolong_timer('do_unrar_pre');  # restart timer
32563  my $eval_stat;
32564  eval {
32565    ($proc_fh,$pid) =
32566      run_command(undef, '&1', $archiver, 'v',@common_rar_switches,'--',$fn);
32567    # jump hoops because there is no simple way to just list all the files
32568    my $ln; my $entries_cnt = 0;
32569    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
32570      $last_line = $ln  if $ln !~ /^\s*$/;  # keep last nonempty line
32571      chomp;
32572      if ($ln =~ /^unexpected end of archive/) {
32573        last;
32574      } elsif ($ln =~ /^------/) {
32575        $hypcount++;
32576        last  if $hypcount >= 2;
32577      } elsif ($hypcount < 1 && $ln =~ /^Encrypted file:/) {
32578        do_log(4,"do_unrar: %s", $ln);
32579        $part->attributes_add('U','C');
32580      } elsif ($hypcount < 1 &&
32581               $ln =~ /^\s+Size\s+Packed Ratio\s+Date\s+Time\s+Attr\s+CRC/) {
32582        do_log(5,"do_unrar: found unrar version < 5");
32583        $unrarvers = 4;
32584      } elsif ($hypcount == 1) {
32585        if ($unrarvers >= 5) {
32586          local($1,$2,$3,$4,$5);
32587          if ($ln !~ /^ ([* ]) \s+ \S+ \s+ (\d+) \s+ (\d+) \s+
32588                      ( \d+ % | --> | <-- | <-> ) \s+
32589                      \S+ \s+ \S+ \s+ \S+ \s+ (.*)/xs) {
32590            do_log($testing_for_sfx ? 4 : -1,
32591                   "do_unrar: can't parse info line for \"%s\" %s",
32592                   $member_name,$ln);
32593          } else {
32594            $member_name = $5;
32595            if ($1 eq '*') {   # member is encrypted
32596              $encryptedcount++; $item_num++;
32597              # make a phantom entry - carrying only name and attributes
32598              my $newpart_obj =
32599                Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32600              $newpart_obj->mime_placement("$parent_placement/$item_num");
32601              $newpart_obj->name_declared($member_name);
32602              $newpart_obj->attributes_add('U','C');
32603            } else {  # makes no sense extracting encrypted files
32604              do_log(5,'do_unrar: member: "%s", size: %s', $member_name,$2);
32605              if ($2 > 0) { $bytes += $2; push(@list, $member_name) }
32606            }
32607            undef $member_name;
32608          }
32609        } else {  # old version of unrar
32610          $lcnt++; local($1,$2,$3);
32611          if ($lcnt % 2 == 0) {  # information line (every other line)
32612            if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
32613              { die "Maximum number of files ($MAXFILES) exceeded" }
32614            if ($ln !~ /^ \s+ (\d+) \s+ (\d+) \s+
32615                        ( \d+% | --> | <-- | <-> )/xs) {
32616              do_log($testing_for_sfx ? 4 : -1,
32617                     "do_unrar: can't parse info line for \"%s\" %s",
32618                     $member_name,$ln);
32619            } elsif (defined $member_name) {
32620              do_log(5,'do_unrar: member: "%s", size: %s', $member_name,$1);
32621              if ($1 > 0) { $bytes += $1; push(@list, $member_name) }
32622            }
32623            undef $member_name;
32624          } elsif ($ln =~ /^(.)(.*)\z/s) {
32625            $member_name = $2; # all but the first character (space or '*')
32626            if ($1 eq '*') {   # member is encrypted
32627              $encryptedcount++; $item_num++;
32628              # make a phantom entry - carrying only name and attributes
32629              my $newpart_obj =
32630                Amavis::Unpackers::Part->new("$tempdir/parts",$part);
32631              $newpart_obj->mime_placement("$parent_placement/$item_num");
32632              $newpart_obj->name_declared($member_name);
32633              $newpart_obj->attributes_add('U','C');
32634              undef $member_name;  # makes no sense extracting encrypted files
32635            }
32636          }
32637        }
32638      }
32639    }
32640    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading (1): $!";
32641    do_log(-1,"unexpected(unrar_1): %s",$!)  if !defined($ln) && $! == EAGAIN;
32642    $ln = undef;  # consume all remaining output to avoid broken pipe
32643    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0)
32644      { $last_line = $ln  if $ln !~ /^\s*$/ }
32645    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading (2): $!";
32646    do_log(-1,"unexpected(unrar_2): %s",$!)  if !defined($ln) && $! == EAGAIN;
32647    my $err = 0; $proc_fh->close or $err = $!;
32648    my $rv = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
32649    undef $proc_fh; undef $pid;  local($1,$2);
32650    if (proc_status_ok($rv,$err, 7)) {       # USER_ERROR
32651      die printf("perhaps this %s does not recognize switches ".
32652                 "-av- and -idcdp, it is probably too old. Upgrade: %s",
32653                 $archiver, 'http://www.rarlab.com/');
32654    } elsif (proc_status_ok($rv,$err, 3)) {  # CRC_ERROR
32655      # NOTE: password protected files in the archive cause CRC_ERROR
32656      do_log(4,"do_unrar: CRC_ERROR - undecipherable, %s",
32657               exit_status_str($rv,$err));
32658      $part->attributes_add('U');
32659    } elsif (proc_status_ok($rv,$err, 1) && @list && $bytes > 0) {
32660                                             # WARNING, probably still ok
32661      do_log(4,"do_unrar: warning, %s", exit_status_str($rv,$err));
32662    } elsif (!proc_status_ok($rv,$err)) {
32663      die("can't get a list of archive members: " .
32664          exit_status_str($rv,$err) ."; ".$last_line);
32665    } elsif (!$bytes && $last_line =~ /^\Q$fn\E is not RAR archive$/) {
32666      chomp($last_line);  die $last_line;
32667    } elsif ($last_line !~ /^\s*(\d+)\s+(\d+)/s) {
32668      do_log(-1,"do_unrar: unable to obtain orig total size: %s", $last_line);
32669    } else {
32670      do_log(4,"do_unrar: summary size: %d, sum of sizes: %d",
32671             $2,$bytes)  if abs($bytes - $2) > 100;
32672      $bytes = $2  if $2 > $bytes;
32673    }
32674    consumed_bytes($bytes, 'do_unrar-pre', 1);  # pre-check on estimated size
32675    if (!@list) {
32676      do_log(4,"do_unrar: no archive members, or not an archive at all");
32677      if ($testing_for_sfx) { return 0 } else { $part->attributes_add('U') }
32678    } else {
32679      snmp_count("OpsDecBy\u${decompressor_name}");
32680      # unrar/rar can make a dir by itself, but can't hurt (sparc64 problem?)
32681      mkdir("$tempdir/parts/rar", 0750)
32682        or die "Can't mkdir $tempdir/parts/rar: $!";
32683      ($proc_fh,$pid) =
32684        run_command(undef, '&1', $archiver, qw(x -inul -ver -o- -kb),
32685                    @common_rar_switches, '--', $fn, "$tempdir/parts/rar/");
32686      collect_results($proc_fh,$pid,$archiver,16384,
32687                      [0,1,3] );  # one of: SUCCESS, WARNING, CRC
32688      undef $proc_fh; undef $pid;
32689      my $errn = lstat("$tempdir/parts/rar") ? 0 : 0+$!;
32690      if ($errn != ENOENT) {
32691        my $b = flatten_and_tidy_dir("$tempdir/parts/rar",
32692                                     "$tempdir/parts", $part);
32693        consumed_bytes($b, 'do_unrar');
32694      }
32695    }
32696    1;
32697  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
32698  prolong_timer('do_unrar');  # restart timer
32699  if ($encryptedcount) {
32700    do_log(1,
32701      "do_unrar: %s, %d members are encrypted, %s extracted, archive retained",
32702      $part->base_name, $encryptedcount, !@list ? 'none' : scalar(@list) );
32703    $retval = 2;
32704  }
32705  if (defined $eval_stat) {
32706    $retval = 0; chomp $eval_stat;
32707    kill_proc($pid,$archiver,1,$proc_fh,$eval_stat)  if defined $pid;
32708    undef $proc_fh; undef $pid;
32709  # if ($testing_for_sfx) { die "do_unrar: $eval_stat" }
32710  # else { do_log(-1, "do_unrar: %s", $eval_stat) };
32711    die "do_unrar: $eval_stat\n"  # propagate failure
32712  }
32713  $retval;
32714}
32715
32716# use external program to expand LHA archives
32717#
32718sub do_lha($$$;$) {
32719  my($part, $tempdir, $archiver, $testing_for_sfx) = @_;
32720  ll(4) && do_log(4, "Expanding LHA archive %s", $part->base_name);
32721  my $decompressor_name = basename((split(' ',$archiver))[0]);
32722  snmp_count("OpsDecBy\u${decompressor_name}Attempt");
32723  # lha needs extension .exe to understand SFX!
32724  # the downside is that in this case it only sees MS files in an archive
32725  my $fn = $part->full_name;
32726  symlink($fn, $fn.".exe")
32727    or die sprintf("Can't symlink %s %s.exe: %s", $fn, $fn, $!);
32728  my(@list); my(@checkerr); my $retval = 1; my($proc_fh,$pid);
32729
32730  prolong_timer('do_lha_pre');  # restart timer
32731  my $eval_stat;
32732  eval {
32733  # ($proc_fh,$pid) = run_command(undef, '&1', $archiver, 'lq', $fn);
32734    ($proc_fh,$pid) = run_command(undef, '&1', $archiver, 'lq', $fn.".exe");
32735    my $ln; my $entries_cnt = 0;
32736    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
32737      chomp($ln); local($1);
32738      if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
32739        { die "Maximum number of files ($MAXFILES) exceeded" }
32740      if ($ln =~ m{/\z}) {
32741        # ignore directories
32742      } elsif ($ln =~ /^LHa: (Warning|Fatal error): /) {
32743        push(@checkerr,$ln)  if @checkerr < 3;
32744      } elsif ($ln=~m{^(?:\S+\s+\d+/\d+|.{23})(?:\s+\S+){5}\s*(\S.*?)\s*\z}s) {
32745        my $name = $1; $name = $1 if $name =~ m{^(.*) -> (.*)\z}s;  # symlink
32746        push(@list, $name);
32747      } else { do_log(5,"do_lha: skip: %s", $ln) }
32748    }
32749    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading: $!";
32750    do_log(-1,"unexpected(do_lha): %s",$!)  if !defined($ln) && $! == EAGAIN;
32751    my $err = 0; $proc_fh->close or $err = $!;
32752    my $child_stat = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
32753    undef $proc_fh; undef $pid;
32754    if (!proc_status_ok($child_stat,$err) || @checkerr) {
32755      die('(' . join(", ",@checkerr) .') ' .exit_status_str($child_stat,$err));
32756    } elsif (!@list) {
32757      $part->attributes_add('U')  if !$testing_for_sfx;
32758      die "no archive members, or not an archive at all";
32759    }
32760    1;
32761  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
32762  prolong_timer('do_lha');  # restart timer
32763  if (defined $eval_stat) {
32764    unlink($fn.".exe") or do_log(-1, "Can't unlink %s.exe: %s", $fn,$!);
32765    $retval = 0; chomp $eval_stat;
32766    kill_proc($pid,$archiver,1,$proc_fh,$eval_stat)  if defined $pid;
32767    undef $proc_fh; undef $pid;
32768  # if ($testing_for_sfx) { die "do_lha: $eval_stat" }
32769  # else { do_log(-1, "do_lha: %s", $eval_stat) };
32770    die "do_lha: $eval_stat\n";  # propagate failure
32771  } else {  # preliminary archive traversal done, now extract files
32772    snmp_count("OpsDecBy\u${decompressor_name}");
32773    my $rv;
32774    eval {
32775      # store_mgr may die, make sure we unlink the .exe file
32776      $rv = store_mgr($tempdir, $part, \@list, $archiver, 'pq', $fn.".exe");
32777      1;
32778    } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
32779    unlink($fn.".exe") or do_log(-1, "Can't unlink %s.exe: %s", $fn,$!);
32780    if (defined $eval_stat) { die "do_lha: $eval_stat\n" } # propagate failure
32781    $rv==0  or die exit_status_str($rv);
32782  }
32783  $retval;
32784}
32785
32786# use external program to expand ARC archives;
32787# works with original arc, or a GPL licensed 'nomarch'
32788# (http://rus.members.beeb.net/nomarch.html)
32789#
32790sub do_arc($$$) {
32791  my($part, $tempdir, $archiver) = @_;
32792  my $decompressor_name = basename((split(' ',$archiver))[0]);
32793  snmp_count("OpsDecBy\u${decompressor_name}");
32794  my $is_nomarch = $archiver =~ /nomarch/i;
32795  ll(4) && do_log(4,"Unarcing %s, using %s",
32796                    $part->base_name, ($is_nomarch ? "nomarch" : "arc") );
32797  my $cmdargs = ($is_nomarch ? '-l -U' : 'ln') . ' ' . $part->full_name;
32798  my($proc_fh,$pid) = run_command(undef, '/dev/null', $archiver,
32799                                  split(' ',$cmdargs));
32800  my(@list); my $ln; my $entries_cnt = 0;
32801  for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
32802    if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
32803      { die "Maximum number of files ($MAXFILES) exceeded" }
32804    push(@list,$ln);
32805  }
32806  defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading: $!";
32807  do_log(-1,"unexpected(do_arc): %s",$!)  if !defined($ln) && $! == EAGAIN;
32808  my $err = 0; $proc_fh->close or $err = $!;
32809  my $child_stat = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
32810  undef $proc_fh; undef $pid;
32811  proc_status_ok($child_stat,$err)
32812    or do_log(-1, 'do_arc: %s',exit_status_str($child_stat,$err));
32813  #*** no spaces in filenames allowed???
32814  local($1);  s/^([^ \t\r\n]*).*\z/$1/s for @list;  # keep only filenames
32815  if (@list) {
32816    # store_mgr may die, allow failure to propagate
32817    my $rv = store_mgr($tempdir, $part, \@list, $archiver,
32818                       ($is_nomarch ? ('-p', '-U') : 'p'), $part->full_name);
32819    do_log(-1, 'arc %', exit_status_str($rv))  if $rv;
32820  }
32821  1;
32822}
32823
32824# use external program to expand ZOO archives
32825#
32826sub do_zoo($$$) {
32827  my($part, $tempdir, $archiver) = @_;
32828  my $is_unzoo = $archiver =~ m{\bunzoo[^/]*\z}i ? 1 : 0;
32829  ll(4) && do_log(4,"Expanding ZOO archive %s, using %s",
32830                    $part->base_name, ($is_unzoo ? "unzoo" : "zoo") );
32831  my $decompressor_name = basename((split(' ',$archiver))[0]);
32832  snmp_count("OpsDecBy\u${decompressor_name}");
32833
32834  my(@list); my $separ_count = 0; my $bytes = 0; my($ln,$last_line);
32835  my $retval = 1; my $fn = $part->full_name; my($proc_fh,$pid);
32836  symlink($fn, "$fn.zoo")  # Zoo needs extension of .zoo!
32837    or die sprintf("Can't symlink %s %s.zoo: %s", $fn,$fn,$!);
32838
32839  prolong_timer('do_zoo_pre');  # restart timer
32840  my $eval_stat; my $entries_cnt = 0;
32841  eval {
32842    ($proc_fh,$pid) = run_command(undef, '&1', $archiver,
32843                                  $is_unzoo ? qw(-l) : qw(l), "$fn.zoo");
32844    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
32845      $last_line = $ln  if $ln !~ /^\s*$/;  # keep last nonempty line
32846      if ($ln =~ /^------/) { $separ_count++ }
32847      elsif ($separ_count == 1) {
32848        local($1,$2);
32849        if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
32850          { die "Maximum number of files ($MAXFILES) exceeded" }
32851        if ($ln !~ /^\s*(\d+)(?:\s+\S+){6}\s+(?:[0-7]{3,})?\s*(.*)$/) {
32852          do_log(3,"do_zoo: can't parse line %s", $ln);
32853        } else {
32854          do_log(5,'do_zoo: member: "%s", size: %s', $2,$1);
32855          if ($1 > 0) { $bytes += $1; push(@list,$2) }
32856        }
32857      }
32858    }
32859    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading: $!";
32860    do_log(-1,"unexpected(do_zoo): %s",$!)  if !defined($ln) && $! == EAGAIN;
32861    my $err = 0; $proc_fh->close or $err = $!;
32862    my $rv = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
32863    undef $proc_fh; undef $pid;  local($1);
32864    if (!proc_status_ok($rv,$err)) {
32865      die("can't get a list of archive members: " .
32866          exit_status_str($rv,$err) ."; ".$last_line);
32867    } elsif ($last_line !~ /^\s*(\d+)\s+\d+%\s+\d+/s) {
32868      do_log(-1,"do_zoo: unable to obtain orig total size: %s", $last_line);
32869    } else {
32870      do_log(4,"do_zoo: summary size: %d, sum of sizes: %d",
32871             $1,$bytes)  if abs($bytes - $1) > 100;
32872      $bytes = $1  if $1 > $bytes;
32873    }
32874    consumed_bytes($bytes, 'do_zoo-pre', 1);  # pre-check on estimated size
32875    $retval = 0  if @list;
32876    if (!$is_unzoo) {
32877      # unzoo cannot cleanly extract to stdout without prepending a clutter
32878      # store_mgr may die
32879      my $rv = store_mgr($tempdir,$part,\@list,$archiver,'xpqqq:',"$fn.zoo");
32880      do_log(-1,"do_zoo (store_mgr) %s", exit_status_str($rv))  if $rv;
32881    } else {  # this code section can handle zoo and unzoo
32882      # but zoo is unsafe in this mode (and so is unzoo, a little less so)
32883      my $cwd = "$tempdir/parts/zoo";
32884      mkdir($cwd, 0750) or die "Can't mkdir $cwd: $!";
32885      chdir($cwd) or die "Can't chdir to $cwd: $!";
32886      # don't use "-j ./" in unzoo, it does not protect from relative paths!
32887      # "-j X" is less bad, but: "unzoo: 'X/h/user/01.lis' cannot be created"
32888      ($proc_fh,$pid) =
32889        run_command(undef, '&1', $archiver,
32890                    $is_unzoo ? qw(-x -j X) : qw(x),
32891                    "$fn.zoo",  $is_unzoo ? '*;*' : () );
32892      collect_results($proc_fh,$pid,$archiver,16384,[0]);
32893      undef $proc_fh; undef $pid;
32894      my $b = flatten_and_tidy_dir("$tempdir/parts/zoo",
32895                                   "$tempdir/parts", $part);
32896      consumed_bytes($b, 'do_zoo');
32897    }
32898    1;
32899  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
32900  prolong_timer('do_zoo');  # restart timer
32901  if (defined $eval_stat) {
32902    $retval = 0; chomp $eval_stat;
32903    kill_proc($pid,$archiver,1,$proc_fh,$eval_stat)  if defined $pid;
32904    undef $proc_fh; undef $pid;
32905    do_log(-1,"do_zoo: %s", $eval_stat);
32906  }
32907  chdir($tempdir) or die "Can't chdir to $tempdir: $!";
32908  unlink("$fn.zoo") or die "Can't unlink $fn.zoo: $!";
32909  if (defined $eval_stat) { die "do_zoo: $eval_stat\n" }  # propagate failure
32910  $retval;
32911}
32912
32913# use external program to expand ARJ archives
32914#
32915sub do_unarj($$$;$) {
32916  my($part, $tempdir, $archiver, $testing_for_sfx) = @_;
32917  do_log(4, "Expanding ARJ archive %s", $part->base_name);
32918  my $decompressor_name = basename((split(' ',$archiver))[0]);
32919  snmp_count("OpsDecBy\u${decompressor_name}Attempt");
32920  # options to arj, ignored by unarj
32921  # provide some password in -g to turn fatal error into 'bad password' error
32922  $ENV{ARJ_SW} = "-i -jo -b5 -2h -jyc -ja1 -gsecret -w$tempdir/parts";
32923  # unarj needs extension of .arj!
32924  my $fn = $part->full_name;
32925  symlink($part->full_name, $fn.".arj")
32926    or die sprintf("Can't symlink %s %s.arj: %s", $fn, $fn, $!);
32927  my $retval = 1; my($proc_fh,$pid);
32928
32929  prolong_timer('do_unarj_pre');  # restart timer
32930  my $eval_stat;
32931  eval {
32932    # obtain total original size of archive members from the index/listing
32933    ($proc_fh,$pid) = run_command(undef, '&1', $archiver, 'l', $fn.".arj");
32934    my $last_line; my $ln;
32935    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0)
32936      { $last_line = $ln  if $ln !~ /^\s*$/ }
32937    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading (1): $!";
32938    do_log(-1,"unexpected(do_unarj_1): %s",$!) if !defined($ln) && $! == EAGAIN;
32939    my $err = 0; $proc_fh->close or $err = $!;
32940    my $rv = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
32941    undef $proc_fh; undef $pid;
32942    if (!proc_status_ok($rv,$err, 0,1,3)) {  # one of: success, warn, CRC err
32943      $part->attributes_add('U')  if !$testing_for_sfx;
32944      die "not an ARJ archive? ".exit_status_str($rv,$err);
32945    } elsif ($last_line =~ /^\Q$fn\E.arj is not an ARJ archive$/) {
32946      die "last line: $last_line";
32947    } elsif ($last_line !~ /^\s*(\d+)\s*files\s*(\d+)/s) {
32948      $part->attributes_add('U')  if !$testing_for_sfx;
32949      die "unable to obtain orig size of files: $last_line, ".
32950          exit_status_str($rv,$err);
32951    } else {
32952      consumed_bytes($2, 'do_unarj-pre', 1); # pre-check on estimated size
32953    }
32954    # unarj has very limited extraction options, arj is much better!
32955    mkdir("$tempdir/parts/arj",0750)
32956      or die "Can't mkdir $tempdir/parts/arj: $!";
32957    chdir("$tempdir/parts/arj")
32958      or die "Can't chdir to $tempdir/parts/arj: $!";
32959    snmp_count("OpsDecBy\u${decompressor_name}");
32960    ($proc_fh,$pid) = run_command(undef, '&1', $archiver, 'e', $fn.".arj");
32961    my($encryptedcount,$skippedcount,$entries_cnt) = (0,0,0);
32962    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
32963      if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
32964        { die "Maximum number of files ($MAXFILES) exceeded" }
32965      $encryptedcount++
32966        if $ln =~ /^(Extracting.*\bBad file data or bad password|File is password encrypted, Skipped)\b/s;
32967      $skippedcount++
32968        if $ln =~ /(\bexists|^File is password encrypted|^Unsupported .*), Skipped\b/s;
32969    }
32970    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading (2): $!";
32971    do_log(-1,"unexpected(do_unarj_2): %s",$!) if !defined($ln) && $! == EAGAIN;
32972    $err = 0; $proc_fh->close or $err = $!;
32973    $rv = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
32974    undef $proc_fh; undef $pid;
32975    chdir($tempdir) or die "Can't chdir to $tempdir: $!";
32976    if (proc_status_ok($rv,$err, 0,1)) {}  # success, warn
32977    elsif (proc_status_ok($rv,$err, 3))    # CRC err
32978      { $part->attributes_add('U')  if !$testing_for_sfx }
32979    else { do_log(0, "unarj: error extracting: %s",exit_status_str($rv,$err)) }
32980    # add attributes to the parent object, because we didn't remember names
32981    # of its scrambled members
32982    $part->attributes_add('U')  if $encryptedcount || $skippedcount;
32983    $part->attributes_add('C')  if $encryptedcount;
32984    my $errn = lstat("$tempdir/parts/arj") ? 0 : 0+$!;
32985    if ($errn != ENOENT) {
32986      my $b = flatten_and_tidy_dir("$tempdir/parts/arj",
32987                                   "$tempdir/parts",$part);
32988      consumed_bytes($b, 'do_unarj');
32989      snmp_count("OpsDecBy\u${decompressor_name}");
32990    }
32991    proc_status_ok($rv,$err, 0,1,3)  # one of: success, warn, CRC err
32992      or die "unarj: can't extract archive members: ".
32993             exit_status_str($rv,$err);
32994    if ($encryptedcount || $skippedcount) {
32995      do_log(1,
32996        "do_unarj: %s, %d members are encrypted, %d skipped, archive retained",
32997        $part->base_name, $encryptedcount, $skippedcount);
32998      $retval = 2;
32999    }
33000    1;
33001  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
33002  prolong_timer('do_unarj');  # restart timer
33003  unlink($fn.".arj") or die "Can't unlink $fn.arj: $!";
33004  if (defined $eval_stat) {
33005    $retval = 0; chomp $eval_stat;
33006    kill_proc($pid,$archiver,1,$proc_fh,$eval_stat)  if defined $pid;
33007    undef $proc_fh; undef $pid;
33008  # if ($testing_for_sfx) { die "do_unarj: $eval_stat" }
33009  # else { do_log(-1, "do_unarj: %s", $eval_stat) };
33010    die "do_unarj: $eval_stat\n"  # propagate failure
33011  }
33012  $retval;
33013}
33014
33015# use external program to expand TNEF archives
33016#
33017sub do_tnef_ext($$$) {
33018  my($part, $tempdir, $archiver) = @_;
33019  do_log(4, "Extracting from TNEF encapsulation (ext) %s", $part->base_name);
33020  my $archiver_name = basename((split(' ',$archiver))[0]);
33021  snmp_count("OpsDecBy\u${archiver_name}");
33022  mkdir("$tempdir/parts/tnef",0750)
33023    or die "Can't mkdir $tempdir/parts/tnef: $!";
33024  my $retval = 1; my($proc_fh,$pid);
33025
33026  prolong_timer('do_tnef_ext_pre');  # restart timer
33027  my $rem_quota = max(10*1024, untaint(consumed_bytes(0,'do_tnef_ext')));
33028  my $eval_stat;
33029  eval {
33030    ($proc_fh,$pid) = run_command(undef, '&1', $archiver,
33031                          '--number-backups', '-x', "$rem_quota",
33032                          '-C', "$tempdir/parts/tnef", '-f', $part->full_name);
33033    collect_results($proc_fh,$pid,$archiver,16384,[0]);
33034    undef $proc_fh; undef $pid;  1;
33035  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
33036  prolong_timer('do_tnef_ext');  # restart timer
33037  if (defined $eval_stat) {
33038    $retval = 0; chomp $eval_stat;
33039    do_log(-1, "tnef_ext: %s", $eval_stat);
33040  }
33041  my $b = flatten_and_tidy_dir("$tempdir/parts/tnef","$tempdir/parts",$part);
33042  if ($b > 0) {
33043    do_log(4, "tnef_ext extracted %d bytes from a tnef container", $b);
33044    consumed_bytes($b, 'do_tnef_ext');
33045  }
33046  if (defined $eval_stat) { die "do_tnef_ext: $eval_stat\n" }  # propagate
33047  $retval;
33048}
33049
33050# use Convert-TNEF
33051#
33052use vars qw($have_tnef_module);
33053sub do_tnef($$) {
33054  my($part, $tempdir) = @_;
33055  do_log(4, "Extracting from TNEF encapsulation (int) %s", $part->base_name);
33056  if (!defined $have_tnef_module) {
33057    eval {
33058      require Convert::TNEF && ($have_tnef_module = 1);
33059    } or do {
33060      $have_tnef_module = 0;
33061      chomp $@;  $@ =~ s/ \(you may need to install the .*\z//i;
33062      do_log(5,"module Convert::TNEF unavailable: %s", $@);
33063    };
33064  }
33065  return 0 if !$have_tnef_module;
33066  snmp_count('OpsDecByTnef');
33067
33068  my $tnef = Convert::TNEF->read_in($part->full_name,
33069       {output_dir=>"$tempdir/parts", buffer_size=>16384, ignore_checksum=>1});
33070  defined $tnef or die "Convert::TNEF failed: ".$Convert::TNEF::errstr;
33071  my $item_num = 0; my $parent_placement = $part->mime_placement;
33072  for my $a ($tnef->message, $tnef->attachments) {
33073    for my $attr_name ('AttachData','Attachment') {
33074      my $dh = $a->datahandle($attr_name);
33075      if (defined $dh) {
33076        my $newpart_obj = Amavis::Unpackers::Part->new("$tempdir/parts",$part);
33077        $item_num++;
33078        $newpart_obj->mime_placement("$parent_placement/$item_num");
33079        $newpart_obj->name_declared([$a->name, $a->longname]);
33080        my $newpart = $newpart_obj->full_name;
33081        my $outpart = IO::File->new;
33082        # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
33083        $outpart->open($newpart, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)
33084          or die "Can't create file $newpart: $!";
33085        binmode($outpart) or die "Can't set file $newpart to binmode: $!";
33086        my $filepath = $dh->path; my $size = 0;
33087        if (defined $filepath) {
33088          my($io,$nbytes,$buff); $dh->binmode(1);
33089          $io = $dh->open("r") or die "Can't open MIME::Body handle: $!";
33090          while (($nbytes=$io->read($buff,16384)) > 0) {
33091            $outpart->print($buff) or die "Can't write to $newpart: $!";
33092            $size += $nbytes; consumed_bytes($nbytes, 'do_tnef_1');
33093          }
33094          defined $nbytes or die "Error reading from MIME::Body handle: $!";
33095          $io->close or die "Error closing MIME::Body handle: $!";
33096          undef $buff;  # release storage
33097        } else {
33098          my $buff = $dh->as_string; my $nbytes = length($buff);
33099          $outpart->print($buff) or die "Can't write to $newpart: $!";
33100          $size += $nbytes; consumed_bytes($nbytes, 'do_tnef_2');
33101        }
33102        $newpart_obj->size($size);
33103        $outpart->close or die "Error closing $newpart: $!";
33104      }
33105    }
33106  }
33107  $tnef->purge  if defined $tnef;
33108  1;
33109}
33110
33111# The pax and cpio utilities usually support the following archive formats:
33112#   cpio, bcpio, sv4cpio, sv4crc, tar (old tar), ustar (POSIX.2 tar).
33113# The utilities from http://heirloom.sourceforge.net/ support
33114# several other tar/cpio variants such as SCO, Sun, DEC, Cray, SGI
33115#
33116sub do_pax_cpio($$$) {
33117  my($part, $tempdir, $archiver) = @_;
33118  my $archiver_name = basename((split(' ',$archiver))[0]);
33119  snmp_count("OpsDecBy\u${archiver_name}");
33120  ll(4) && do_log(4,"Expanding archive %s, using %s",
33121                    $part->base_name,$archiver_name);
33122  my $is_pax = $archiver_name =~ /^cpio/i ? 0 : 1;
33123  do_log(-1,"WARN: Using %s instead of pax can be a security ".
33124            "risk; please add:  \$pax='pax';  to amavisd.conf and check that ".
33125            "the pax(1) utility is available on the system!",
33126            $archiver_name)  if !$is_pax;
33127  my(@cmdargs) = $is_pax ? qw(-v) : qw(-i -t -v);
33128  my($proc_fh,$pid) = run_command($part->full_name, '/dev/null',
33129                                  $archiver, @cmdargs);
33130  my $bytes = 0; local($1,$2); local($_); my $entries_cnt = 0;
33131  for ($! = 0; defined($_=$proc_fh->getline); $! = 0) {
33132    chomp;
33133    next  if /^\d+ blocks\z/;
33134    last  if /^(cpio|pax): (.*bytes read|End of archive volume)/;
33135    if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
33136      { die "Maximum number of files ($MAXFILES) exceeded" }
33137    if (!/^ (?: \S+\s+ ){4} (\d+) \s+ (.+) \z/xs) {
33138      do_log(-1,"do_pax_cpio: can't parse toc line: %s", $_);
33139    } else {
33140      my($size,$mem) = ($1,$2);
33141      if ($mem =~ /^( (?: \s* \S+ ){3} (?: \s+ \d{4}, )? ) \s+ (.+)\z/xs) {
33142        $mem = $2;  # strip away time and date
33143      } elsif ($mem =~ /^\S \s+ (.+)\z/xs) {
33144        # -rwxr-xr-x  1 1121  users 3135 C errorReport.sh
33145        $mem = $1;  # strip away a letter in place of a date (?)
33146      }
33147      $mem = $1 if $is_pax && $mem =~ /^(.*) =[=>] (.*)\z/; # hard or soft link
33148      do_log(5,'do_pax_cpio: size: %5s, member: "%s"', $size,$mem);
33149      $bytes += $size  if $size > 0;
33150    }
33151  }
33152  defined $_ || $! == 0 || $! == EAGAIN  or die "Error reading (1): $!";
33153  do_log(-1,"unexpected(pax_cpio_1): %s",$!)  if !defined($_) && $! == EAGAIN;
33154  # consume remaining output to avoid broken pipe
33155  collect_results($proc_fh,$pid,'do_pax_cpio/1',16384,[0]);
33156  undef $proc_fh; undef $pid;
33157  consumed_bytes($bytes, 'do_pax_cpio/pre', 1);  # pre-check on estimated size
33158  mkdir("$tempdir/parts/arch", 0750)
33159    or die "Can't mkdir $tempdir/parts/arch: $!";
33160  my $name_clash = 0;
33161  my(%orig_names);  # maps filenames to archive member names when possible
33162
33163  prolong_timer('do_pax_cpio_pre');  # restart timer
33164  my $eval_stat;
33165  eval {
33166    chdir("$tempdir/parts/arch")
33167      or die "Can't chdir to $tempdir/parts/arch: $!";
33168    my(@cmdargs) = $is_pax ? qw(-r -k -p am -s /[^A-Za-z0-9_]/-/gp)
33169                       : qw(-i -d --no-absolute-filenames --no-preserve-owner);
33170    ($proc_fh,$pid) = run_command($part->full_name, '&1', $archiver, @cmdargs);
33171    my $output = ''; my $ln; my $entries_cnt = 0;
33172    for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
33173      chomp($ln);
33174      if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
33175        { die "Maximum number of files ($MAXFILES) exceeded" }
33176      if (!$is_pax || $ln !~ /^(.*) >> (\S*)\z/) { $output .= $ln."\n" }
33177      else {  # parse output from pax -s///p
33178        my($member_name,$file_name) = ($1,$2);
33179        if (!exists $orig_names{$file_name}) {
33180          $orig_names{$file_name} = $member_name;
33181        } else {
33182          do_log(0,'do_pax_cpio: member "%s" is hidden by a '.
33183                   'previous archive member "%s", file: %s',
33184                   $member_name, $orig_names{$file_name}, $file_name);
33185          undef $orig_names{$file_name};  # cause it to exist but undefined
33186          $name_clash = 1;
33187        }
33188      }
33189    }
33190    defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading (2): $!";
33191    do_log(-1,"unexpected(pax_cpio_2): %s",$!) if !defined($ln) && $! == EAGAIN;
33192    my $err = 0; $proc_fh->close or $err = $!;
33193    my $child_stat = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
33194    undef $proc_fh; undef $pid; chomp($output);
33195    proc_status_ok($child_stat,$err)
33196      or die(exit_status_str($child_stat,$err).' '.$output);
33197    1;
33198  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
33199  prolong_timer('do_pax_cpio');  # restart timer
33200  chdir($tempdir) or die "Can't chdir to $tempdir: $!";
33201  my $b = flatten_and_tidy_dir("$tempdir/parts/arch", "$tempdir/parts",
33202                               $part, 0, \%orig_names);
33203  consumed_bytes($b, 'do_pax_cpio');
33204  if (defined $eval_stat) {
33205    chomp $eval_stat;
33206    kill_proc($pid,$archiver,1,$proc_fh,$eval_stat)  if defined $pid;
33207    undef $proc_fh; undef $pid;
33208    die "do_pax_cpio: $eval_stat\n";  # propagate failure
33209  }
33210  $name_clash ? 2 : 1;
33211}
33212
33213# command line unpacker from stuffit.com for Linux
33214# decodes Macintosh StuffIt archives and others
33215# (but it appears the Linux version is buggy and a security risk, not to use!)
33216#
33217sub do_unstuff($$$) {
33218  my($part, $tempdir, $archiver) = @_;
33219  my $archiver_name = basename((split(' ',$archiver))[0]);
33220  snmp_count("OpsDecBy\u${archiver_name}");
33221  do_log(4,"Expanding archive %s, using %s", $part->base_name,$archiver_name);
33222  mkdir("$tempdir/parts/unstuff", 0750)
33223    or die "Can't mkdir $tempdir/parts/unstuff: $!";
33224  my($proc_fh,$pid) = run_command(undef, '&1', $archiver,  # '-q',
33225                               "-d=$tempdir/parts/unstuff", $part->full_name);
33226  collect_results($proc_fh,$pid,$archiver,16384,[0]);
33227  undef $proc_fh; undef $pid;
33228  my $b = flatten_and_tidy_dir("$tempdir/parts/unstuff",
33229                               "$tempdir/parts", $part);
33230  consumed_bytes($b, 'do_unstuff');
33231  1;
33232}
33233
33234# ar is a standard Unix binary archiver, also used by Debian packages
33235#
33236sub do_ar($$$) {
33237  my($part, $tempdir, $archiver) = @_;
33238  ll(4) && do_log(4,"Expanding Unix ar archive %s", $part->full_name);
33239  my $archiver_name = basename((split(' ',$archiver))[0]);
33240  snmp_count("OpsDecBy\u${archiver_name}");
33241  my($proc_fh,$pid) = run_command(undef, '/dev/null',
33242                                  $archiver, 'tv', $part->full_name);
33243  my $ln; my $bytes = 0; local($1,$2,$3); my $entries_cnt = 0;
33244  for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
33245    chomp($ln);
33246    if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
33247      { die "Maximum number of files ($MAXFILES) exceeded" }
33248    if ($ln !~ /^(?:\S+\s+){2}(\d+)\s+((?:\S+\s+){3}\S+)\s+(.*)\z/) {
33249      do_log(-1,"do_ar: can't parse contents listing line: %s", $ln);
33250    } else {
33251      do_log(5,"do_ar: member: \"%s\", size: %s", $3,$1);
33252      $bytes += $1  if $1 > 0;
33253    }
33254  }
33255  defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading: $!";
33256  do_log(-1,"unexpected(do_ar): %s",$!)  if !defined($ln) && $! == EAGAIN;
33257  # consume remaining output to avoid broken pipe
33258  collect_results($proc_fh,$pid,'ar-1',16384,[0]);
33259  undef $proc_fh; undef $pid;
33260  consumed_bytes($bytes, 'do_ar-pre', 1);  # pre-check on estimated size
33261  mkdir("$tempdir/parts/ar", 0750)
33262    or die "Can't mkdir $tempdir/parts/ar: $!";
33263  chdir("$tempdir/parts/ar") or die "Can't chdir to $tempdir/parts/ar: $!";
33264  ($proc_fh,$pid) = run_command(undef, '&1', $archiver, 'x', $part->full_name);
33265  collect_results($proc_fh,$pid,'ar-2',16384,[0]);
33266  undef $proc_fh; undef $pid;
33267  chdir($tempdir) or die "Can't chdir to $tempdir: $!";
33268  my $b = flatten_and_tidy_dir("$tempdir/parts/ar","$tempdir/parts",$part);
33269  consumed_bytes($b, 'do_ar');
33270  1;
33271}
33272
33273sub do_cabextract($$$) {
33274  my($part, $tempdir, $archiver) = @_;
33275  do_log(4, "Expanding cab archive %s", $part->base_name);
33276  my $archiver_name = basename((split(' ',$archiver))[0]);
33277  snmp_count("OpsDecBy\u${archiver_name}");
33278  my($proc_fh,$pid) =
33279    run_command(undef, '/dev/null', $archiver, '-l', $part->full_name);
33280  local($1,$2); my $bytes = 0; my $ln; my $entries_cnt = 0;
33281  for ($! = 0; defined($ln=$proc_fh->getline); $! = 0) {
33282    chomp($ln);
33283    next  if $ln =~ /^(?: ?File size|----|Viewing cabinet:|\z)/s;
33284    next  if $ln =~ /^\s*All done, no errors/s;
33285    if ($entries_cnt++, $MAXFILES && $entries_cnt > $MAXFILES)
33286      { die "Maximum number of files ($MAXFILES) exceeded" }
33287    if ($ln !~ /^\s* (\d+) \s* \| [^|]* \| \s (.*) \z/x) {
33288      do_log(-1, "do_cabextract: can't parse toc line: %s", $ln);
33289    } else {
33290      do_log(5, 'do_cabextract: member: "%s", size: %s', $2,$1);
33291      $bytes += $1  if $1 > 0;
33292    }
33293  }
33294  defined $ln || $! == 0 || $! == EAGAIN  or die "Error reading: $!";
33295  do_log(-1,"unexpected(cabextract): %s",$!)  if !defined($ln) && $! == EAGAIN;
33296  # consume remaining output to avoid broken pipe (just in case)
33297  collect_results($proc_fh,$pid,'cabextract-1',16384,[0]);
33298  undef $proc_fh; undef $pid;
33299  mkdir("$tempdir/parts/cab",0750) or die "Can't mkdir $tempdir/parts/cab: $!";
33300  ($proc_fh,$pid) = run_command(undef, '/dev/null', $archiver, '-q', '-d',
33301                                "$tempdir/parts/cab", $part->full_name);
33302  collect_results($proc_fh,$pid,'cabextract-2',16384,[0]);
33303  undef $proc_fh; undef $pid;
33304  my $b = flatten_and_tidy_dir("$tempdir/parts/cab", "$tempdir/parts", $part);
33305  consumed_bytes($b, 'do_cabextract');
33306  1;
33307}
33308
33309sub do_ole($$$) {
33310  my($part, $tempdir, $archiver) = @_;
33311  do_log(4,"Expanding MS OLE document %s", $part->base_name);
33312  my $archiver_name = basename((split(' ',$archiver))[0]);
33313  snmp_count("OpsDecBy\u${archiver_name}");
33314  mkdir("$tempdir/parts/ole",0750) or die "Can't mkdir $tempdir/parts/ole: $!";
33315  my($proc_fh,$pid) = run_command(undef, '&1', $archiver, '-v',
33316                            '-i', $part->full_name, '-d',"$tempdir/parts/ole");
33317  # Not all Microsoft documents contain embedded objects, and we won't know
33318  # until we look. The ripOLE program knows how to check if we do in fact
33319  # have an OLE document; but it exits with code 102 if we don't. This isn't
33320  # really an error, so we add "102" to the list of successful exit codes.
33321  collect_results($proc_fh,$pid,$archiver,16384,[0,102]);
33322  undef $proc_fh; undef $pid;
33323  my $b = flatten_and_tidy_dir("$tempdir/parts/ole", "$tempdir/parts", $part);
33324  if ($b > 0) {
33325    do_log(4, "ripOLE extracted %d bytes from an OLE document", $b);
33326    consumed_bytes($b, 'do_ole');
33327  }
33328  2;  # always keep the original OLE document
33329}
33330
33331# Check for self-extracting archives.  Note that we do not depend on
33332# file magic here since it's not reliable.  Instead we will try each
33333# archiver.
33334#
33335sub do_executable($$@) {
33336  my($part, $tempdir, $unrar, $lha, $unarj) = @_;
33337
33338  ll(4) && do_log(4,"Check whether %s is a self-extracting archive",
33339                    $part->base_name);
33340# # ZIP?
33341# return 2  if eval { do_unzip($part,$tempdir,undef,1) };
33342# chomp $@;
33343# do_log(3, "do_executable: not a ZIP sfx, ignoring: %s", $@)  if $@ ne '';
33344
33345  # RAR?
33346  return 2  if defined $unrar && eval { do_unrar($part,$tempdir,$unrar,1) };
33347  chomp $@;
33348  do_log(3, "do_executable: not a RAR sfx, ignoring: %s", $@)  if $@ ne '';
33349
33350# # LHA?  not safe, tends to crash
33351# return 2  if defined $lha && eval { do_lha($part,$tempdir,$lha,1) };
33352# chomp $@;
33353# do_log(3, "do_executable: not an LHA sfx, ignoring: %s", $@)  if $@ ne '';
33354
33355  # ARJ?
33356  return 2  if defined $unarj && eval { do_unarj($part,$tempdir,$unarj,1) };
33357  chomp $@;
33358  do_log(3, "do_executable: not an ARJ sfx, ignoring: %s", $@)  if $@ ne '';
33359
33360  0;
33361}
33362
33363# my($k,$v,$fn);
33364# while (($k,$v) = each(%::)) {
33365#   local(*e)=$v; $fn=fileno(\*e);
33366#   printf STDOUT ("%-10s %-10s %s\n",$k,$v,$fn)  if defined $fn;
33367# }
33368
33369# Given a file handle (typically opened pipe to a subprocess, as returned
33370# by run_command), copy from it to a specified output file in binary mode.
33371#
33372sub run_command_copy($$$) {
33373  my($outfile, $ifh, $pid) = @_;
33374  my $ofh = IO::File->new;
33375  # O_WRONLY etc. can become tainted in Perl5.8.9 [perlbug #62502]
33376  $ofh->open($outfile, untaint(O_CREAT|O_EXCL|O_WRONLY), 0640)  # calls sysopen
33377    or die "Can't create file $outfile: $!";
33378  binmode($ofh) or die "Can't set file $outfile to binmode: $!";
33379  binmode($ifh) or die "Can't set binmode on pipe: $!";
33380  my($eval_stat, $rv, $rerr); $rerr = 0;
33381  eval {
33382    my($nread, $nwrite, $tosend, $offset, $inbuf);
33383    for (;;) {
33384      $nread = sysread($ifh, $inbuf, 65536);
33385      if (!defined($nread)) {
33386        if ($! == EAGAIN || $! == EINTR) {
33387          Time::HiRes::sleep(0.1);  # just in case
33388        } else {
33389          die "Error reading: $!";
33390        }
33391      } elsif ($nread < 1) {  # sysread returns 0 at eof
33392        last;
33393      } else {
33394        consumed_bytes($nread, 'run_command_copy');
33395        $tosend = $nread; $offset = 0;
33396        while ($tosend > 0) {  # handle partial writes
33397          $nwrite = syswrite($ofh, $inbuf, $tosend, $offset);
33398          if (!defined($nwrite)) {
33399            if ($! == EAGAIN || $! == EINTR) {
33400              Time::HiRes::sleep(0.1);  # just in case
33401            } else {
33402              die "Error writing to $outfile: $!";
33403            }
33404          } elsif ($nwrite < 1) {
33405            Time::HiRes::sleep(0.1);  # just in case
33406          } else {
33407            $tosend -= $nwrite; $offset += $nwrite;
33408          }
33409        }
33410      }
33411    }
33412    $ifh->close or $rerr = $!;
33413    $rv = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
33414    $ofh->close or die "Error closing $outfile: $!";
33415    1;
33416  } or do {
33417    $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
33418    # remember error, close socket ignoring status
33419    $rerr = $!; $ifh->close;
33420    $rv = defined $pid && waitpid($pid,0) > 0 ? $? : undef;
33421    do_log(-1, "run_command_copy: %s", $eval_stat);
33422    $ofh->close  or do_log(-1, "Error closing %s: %s", $outfile,$!);
33423  };
33424  if (defined $eval_stat) { die "run_ccpy: $eval_stat\n" }  # propagate failure
33425  ($rv,$rerr);  # return subprocess termination status and reading/close errno
33426}
33427
33428# extract listed files from archive and store each in a new file
33429#
33430sub store_mgr($$$@) {
33431  my($tempdir, $parent_obj, $list, $archiver, @args) = @_;
33432  my $item_num = 0; my $parent_placement = $parent_obj->mime_placement;
33433  my $retval = 0; my($proc_fh,$pid);
33434  prolong_timer('store_mgr_pre');  # restart timer
33435  my $eval_stat;
33436  eval {
33437    for my $f (@$list) {
33438      next  if $f =~ m{/\z};  # ignore directories
33439      my $newpart_obj =
33440        Amavis::Unpackers::Part->new("$tempdir/parts",$parent_obj);
33441      $item_num++; $newpart_obj->mime_placement("$parent_placement/$item_num");
33442      $newpart_obj->name_declared($f);  # store tainted name
33443      my $newpart = $newpart_obj->full_name;
33444      ll(5) && do_log(5,'store_mgr: extracting "%s" to file %s using %s',
33445                        $f, $newpart, $archiver);
33446      if ($f =~ m{^\.?[A-Za-z0-9_][A-Za-z0-9/._=~-]*\z}) { #presumably safe arg
33447      } else {  # this is not too bad, as run_command does not use shell
33448        do_log(1, 'store_mgr: NOTICE: suspicious file name "%s"', $f);
33449      }
33450      ($proc_fh,$pid) = run_command(undef, '/dev/null',
33451                                    $archiver, @args, untaint($f));
33452      my($rv,$err) = run_command_copy($newpart,$proc_fh,$pid);  # may die
33453      my $ll = proc_status_ok($rv,$err) ? 5 : 1;
33454      ll($ll) && do_log($ll,"store_mgr: extracted by %s, %s",
33455                            $archiver, exit_status_str($rv,$err));
33456      $retval = $rv  if $retval == 0 && $rv != 0;
33457    }
33458    1;
33459  } or do { $eval_stat = $@ ne '' ? $@ : "errno=$!" };
33460  prolong_timer('store_mgr');  # restart timer
33461  if (defined $eval_stat) {
33462    $retval = 0; chomp $eval_stat;
33463    kill_proc($pid,$archiver,1,$proc_fh,$eval_stat)  if defined $pid;
33464    undef $proc_fh; undef $pid;
33465    die "store_mgr: $eval_stat\n";  # propagate failure
33466  }
33467  $retval;  # return the first nonzero status (if any), or 0
33468}
334691;
33470
33471__DATA__
33472#
33473package Amavis::DKIM::CustomSigner;
33474use strict;
33475use re 'taint';
33476use warnings;
33477use warnings FATAL => qw(utf8 void);
33478no warnings 'uninitialized';
33479# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
33480
33481sub new {
33482  my($class,%params) = @_;
33483  bless { %params }, $class;
33484}
33485
33486sub sign_digest {
33487  my($self_key, $digest_alg_name, $digest) = @_;
33488  my $code = $self_key->{CustomSigner};
33489  &$code($digest_alg_name, $digest, %$self_key);
33490}
33491
334921;
33493
33494package Amavis::DKIM;
33495use strict;
33496use re 'taint';
33497use warnings;
33498use warnings FATAL => qw(utf8 void);
33499no warnings 'uninitialized';
33500# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
33501
33502BEGIN {
33503  require Exporter;
33504  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
33505  $VERSION = '2.412';
33506  @ISA = qw(Exporter);
33507  @EXPORT_OK = qw(&dkim_key_postprocess &generate_authentication_results
33508                  &dkim_make_signatures &adjust_score_by_signer_reputation
33509                  &collect_some_dkim_info);
33510  import Amavis::Conf qw(:platform c cr ca $myproduct_name
33511                  %dkim_signing_keys_by_domain
33512                  @dkim_signing_keys_list @dkim_signing_keys_storage);
33513  import Amavis::Util qw(min max minmax untaint ll do_log unique_list
33514                  format_time_interval get_deadline
33515                  idn_to_ascii mail_addr_idn_to_ascii idn_to_utf8
33516                  safe_encode_utf8 proto_encode proto_decode);
33517  import Amavis::rfc2821_2822_Tools qw(split_address quote_rfc2821_local
33518                  qquote_rfc2821_local);
33519  import Amavis::Timing qw(section_time);
33520  import Amavis::Lookup qw(lookup lookup2);
33521}
33522use subs @EXPORT_OK;
33523
33524use IO::File ();
33525use Crypt::OpenSSL::RSA ();
33526use MIME::Base64;
33527use Net::DNS::Resolver;
33528use Mail::DKIM::Verifier 0.31;
33529use Mail::DKIM::Signer   0.31;
33530use Mail::DKIM::TextWrap;
33531use Mail::DKIM::Signature;
33532use Mail::DKIM::DkSignature;
33533
33534# Convert private keys (as strings in PEM format) into RSA objects
33535# and do some pre-processing on @dkim_signing_keys_list entries
33536# (may run unprivileged)
33537#
33538sub dkim_key_postprocess() {
33539  # convert private keys (as strings in PEM format) into RSA objects
33540  for my $ks (@dkim_signing_keys_storage) {
33541    my($pkcs1,$dev,$inode,$fname) = @$ks;
33542    if (ref $pkcs1 && UNIVERSAL::isa($pkcs1,'Crypt::OpenSSL::RSA')) {
33543      # it is already a Crypt::OpenSSL::RSA object
33544    } else {
33545      # assume a string is a private key in PEM format, convert it to RSA obj
33546      $ks->[0] = $pkcs1 = Crypt::OpenSSL::RSA->new_private_key($pkcs1);
33547    }
33548    my $key_size = 8 * $pkcs1->size;
33549    my $minimum_key_bits = c('dkim_minimum_key_bits');
33550    if ($key_size < 1024) {
33551      do_log(0,"NOTE: DKIM %d-bit signing key is shorter than ".
33552               "a recommended RFC 6376 minimum of %d bits, file: %s",
33553               $key_size, 1024, $fname);
33554    } elsif ($minimum_key_bits && $key_size < $minimum_key_bits) {
33555      do_log(0,"INFO: DKIM %d-bit signing key is shorter than ".
33556               "a configured \$dkim_minimum_key_bits of %d bits, file: %s",
33557               $key_size, $minimum_key_bits, $fname);
33558    }
33559  }
33560  for my $ent (@dkim_signing_keys_list) {
33561    my $domain = $ent->{domain};
33562    $dkim_signing_keys_by_domain{$domain} = []
33563      if !$dkim_signing_keys_by_domain{$domain};
33564  }
33565  my $any_wild; my $j = 0;
33566  for my $ent (@dkim_signing_keys_list) {
33567    $ent->{v} = 'DKIM1'  if !defined $ent->{v};  # provide a default
33568    if (defined $ent->{n}) {  # encode n as qp-section (RFC 6376, RFC 2047)
33569      $ent->{n} =~ s{([\000-\037\177=;"])}{sprintf('=%02X',ord($1))}gse;
33570    }
33571    my $domain = $ent->{domain};
33572    if (exists $ent->{g}) {
33573      do_log(0,"INFO: the 'g' tag is historic (RFC 6376), signers are ".
33574               "advised not to include a 'g' tag in key records: ".
33575               "s=%s d=%s g=%s", $ent->{selector}, $domain, $ent->{g});
33576    }
33577    if (ref($domain) eq 'Regexp') {
33578      $ent->{domain_re} = $domain;
33579      $any_wild = sprintf("key#%d, %s", $j+1, $domain)  if !defined $any_wild;
33580    } elsif ($domain =~ /\*/) {
33581      # wildcarded signing domain in a key declaration, evil, asks for trouble!
33582      # support wildcards in signing domain for compatibility with dkim_milter
33583      my $regexp = $domain;
33584      $regexp =~ s/\*{2,}/*/gs;   # collapse successive wildcards
33585      # '*' is a wildcard, quote the rest
33586      $regexp =~ s{ ([@\#/.^\$|*+?(){}\[\]\\]) }
33587                  { $1 eq '*' ? '.*' : '\\'.$1 }xgse;
33588      $regexp = '^' . $regexp . '\\z';  # implicit anchors
33589      $regexp =~ s/^\^\.\*//s;    # remove leading anchor if redundant
33590      $regexp =~ s/\.\*\\z\z//s;  # remove trailing anchor if redundant
33591      $regexp = '(?:)'  if $regexp eq '';  # just in case, non-empty regexp
33592      # presence of {'domain_re'} entry lets get_dkim_key use this regexp
33593      # instead of a direct string comparison with {'domain'}
33594      $ent->{domain_re} = qr{$regexp};  # compiled regexp object
33595      $any_wild = sprintf("key#%d, %s", $j+1, $domain)  if !defined $any_wild;
33596    }
33597    # %dkim_signing_keys_by_domain entries contain lists of indices into
33598    # the @dkim_signing_keys_list of all potentially applicable signing keys.
33599    # This hash (keyed by domain name) avoids linear searching for signing
33600    # keys for all fully-specified domains in @dkim_signing_keys_list.
33601    # Wildcarded entries must still be looked up sequentially at run-time
33602    # to preserve the declared order and the 'first match wins' paradigm.
33603    # Such entries are only supported for compatibility with dkim_milter
33604    # and are evil because amavisd has no quick way of verifying that DNS RR
33605    # really exists, so signatures generated by amavisd can fail when not all
33606    # possible DNS resource records exist for wildcarded signing domains.
33607    #
33608    if (!defined($ent->{domain_re})) { # no regexp, just plain match on domain
33609      push(@{$dkim_signing_keys_by_domain{$domain}}, $j);
33610    } else {  # a wildcard in a signing domain, compatibility with dkim_milter
33611      # wildcarded signing domain potentially matches any _by_domain entry
33612      for my $d (keys %dkim_signing_keys_by_domain) {
33613        push(@{$dkim_signing_keys_by_domain{$d}}, $j);
33614      }
33615      # the '*' entry collects only wildcarded signing keys
33616      $dkim_signing_keys_by_domain{'*'} = []
33617        if !$dkim_signing_keys_by_domain{'*'};
33618      push(@{$dkim_signing_keys_by_domain{'*'}}, $j);
33619    }
33620    $j++;
33621  }
33622  do_log(0,"dkim: wildcard in signing domain (%s), may produce unverifiable ".
33623           "signatures with no published public key, avoid!", $any_wild)
33624        if $any_wild;
33625}
33626
33627# Fetch a private DKIM signing key for a given signing domain, with its
33628# resource-record (RR) constraints compatible with proposed signature options.
33629# The first such key is returned as a hash; if no key is found an empty hash
33630# is returned. When a selector (s) is given it must match the selector of
33631# a key; when algorithm (a) is given, the key type and a hash algorithm must
33632# match the desired use too; the service type (s) must be 'email' or '*';
33633# when identity (i) is given it must match the granularity (g) of a key.
33634# RFC 6376: the "g=" tag has been deprecated in this version of the DKIM
33635# specification (and thus MUST now be ignored), signers are advised not to
33636# include the "g=" tag in key records.
33637#
33638# sign.opts.     key options
33639# ----------     -----------
33640#  d         =>  domain
33641#  s         =>  selector
33642#  a         =>  k, h(list)
33643#  i         =>  g, t=s
33644#
33645sub get_dkim_key(@) {
33646  @_ % 2 == 0 or die "get_dkim_key: a list of pairs is expected as query opts";
33647  my(%options) = @_;  # signature options (v, a, c, d, h, i, l, q, s, t, x, z),
33648    # of which d is required, while s, a and t are optional but taken into
33649    # account in searching for a compatible key - the rest are ignored
33650  my(%key_options);
33651  my $domain = $options{d}; my $selector = $options{s};
33652  defined $domain && $domain ne ''
33653    or die "get_dkim_key: domain is required, but tag 'd' is missing";
33654  $domain   = idn_to_ascii($domain);
33655  $selector = idn_to_ascii($selector)  if defined $selector;
33656  my(@indices) = $dkim_signing_keys_by_domain{$domain} ?
33657                   @{$dkim_signing_keys_by_domain{$domain}} :
33658                 $dkim_signing_keys_by_domain{'*'} ?
33659                   @{$dkim_signing_keys_by_domain{'*'}} : ();
33660  if (@indices) {
33661    $selector = $selector eq '' ? undef : lc($selector)  if defined $selector;
33662    local($1,$2);
33663    my($keytype,$hashalg) =
33664      defined $options{a} && $options{a} =~ /^([a-z0-9]+)-(.*)\z/is ? ($1,$2)
33665                                                              : ('rsa',undef);
33666    my($identity_localpart,$identity_domain) =
33667      !defined($options{i}) ? () : split_address($options{i});
33668    $identity_localpart = ''  if !defined $identity_localpart;
33669    $identity_domain    = ''  if !defined $identity_domain;
33670    $identity_domain =
33671      idn_to_ascii($identity_domain)  if $identity_domain ne '';
33672    # find the first key (associated with a domain) with compatible options
33673    for my $j (@indices) {
33674      my $ent = $dkim_signing_keys_list[$j];
33675      next unless defined $ent->{domain_re} ? $domain =~ $ent->{domain_re}
33676                                            : $domain eq $ent->{domain};
33677      next if defined $selector && $ent->{selector} ne $selector;
33678      next if $keytype ne (exists $ent->{k} ? $ent->{k} : 'rsa');
33679      next if exists $ent->{s} &&
33680              !(grep($_ eq '*' || $_ eq 'email', split(/:/, $ent->{s})) );
33681      next if defined $hashalg && exists $ent->{'h'} &&
33682              !(grep($_ eq $hashalg, split(/:/, $ent->{'h'})) );
33683      if (defined($options{i})) {
33684        if ($identity_domain eq $domain) {
33685          # ok
33686        } elsif (exists $ent->{t} && (grep($_ eq 's', split(/:/,$ent->{t})))) {
33687          next;  # no subdomains allowed
33688        }
33689        # the 'g' tag is now historic, RFC 6376
33690        if (!exists($ent->{g}) || $ent->{g} eq '*') {
33691          # ok
33692        } elsif ($ent->{g} =~ /^ ([^*]*) \* (.*) \z/xs) {
33693          next if $identity_localpart !~ /^ \Q$1\E .* \Q$2\E \z/xs;
33694        } else {
33695          next if $identity_localpart ne $ent->{g};
33696        }
33697      }
33698      %key_options = %$ent;  last;  # found a suitable match
33699    }
33700  }
33701  if (defined $key_options{key_storage_ind}) {
33702    # obtain actual key from @dkim_signing_keys_storage
33703    ($key_options{key}) =
33704      @{$dkim_signing_keys_storage[$key_options{key_storage_ind}]};
33705  }
33706  %key_options;
33707}
33708
33709# send a query to a signing service, collect its response and parse it;
33710# the protocol is much like the AM.PDP protocol, except that attributes
33711# are different
33712#
33713sub query_signing_service($$) {
33714  my($server, $query) = @_;
33715  my($remaining_time, $deadline) = get_deadline('query_signing_service');
33716  my $sock = Amavis::IO::RW->new($server, Eol => "\015\012", Timeout => 10);
33717  $sock or die "Error connecting to a signing server $server: $!";
33718  my $req_id = sprintf("%08x", rand(0x7fffffff));
33719  my $req_id_attr = proto_encode('request_id', $req_id);
33720  $sock->print(join('', map($_."\015\012", (@$query, $req_id_attr, ''))))
33721    or die "Error sending a query to a signing server";
33722  ll(5) && do_log(5, "dkim: query_signing_service, query: %s",
33723                     join('; ', @$query, $req_id_attr));
33724  $sock->flush or die "Error flushing signing server session";
33725  # collect a reply
33726  $sock->timeout(max(2, $deadline - Time::HiRes::time));
33727  my(%attr,$ln); local($1,$2);
33728  while (defined($ln = $sock->get_response_line)) {
33729    last  if $ln eq "\015\012";  # end of a response block
33730    if ($ln =~ /^ ([^=\000\012]*?) = ([^\012]*?) \015\012 \z/xsi) {
33731      $attr{proto_decode($1)} = proto_decode($2);
33732    }
33733  }
33734  $sock->close  or die "Error closing session to a signing server $server: $!";
33735  ll(5) && do_log(5, "dkim: query_signing_service, got: %s",
33736                  join('; ', map($_.'='.$attr{$_}, keys %attr)));
33737  $attr{request_id} eq $req_id
33738    or die "Answer id '$attr{request_id}' from $server ".
33739           "does not match the query id '$req_id'";
33740  \%attr;
33741}
33742
33743# send candidate originator addresses and signature options to a signing
33744# service and let it choose a selector 's' and a domain 'd', thus uniquely
33745# identifying a signing key
33746#
33747sub let_signing_service_choose($$$$) {
33748  my($server, $msginfo, $sender_search_list_ref, $sig_opt_prelim) = @_;
33749  my(@query) = (
33750    proto_encode('request', 'choose_key'),
33751    proto_encode('log_id',  $msginfo->log_id),
33752  );
33753  # provide some additional information potentially useful in decision-making
33754  if ($sig_opt_prelim) {
33755    for my $opt (sort keys %$sig_opt_prelim) {
33756      push(@query, proto_encode('sig.'.$opt, $sig_opt_prelim->{$opt}));
33757    }
33758  }
33759  push(@query, proto_encode('sender', $msginfo->sender_smtp));
33760  for my $r (@{$msginfo->per_recip_data}) {
33761    push(@query, proto_encode('recip', $r->recip_addr_smtp));
33762  }
33763  for my $pair (!$sender_search_list_ref ? () : @$sender_search_list_ref) {
33764    my($addr,$addr_src) = @$pair;
33765    push(@query, proto_encode('candidate', $addr_src,
33766                              qquote_rfc2821_local($addr)));
33767  }
33768  my $attr;
33769  eval {
33770    $attr = query_signing_service($server,\@query);  1;
33771  } or do {
33772    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
33773    do_log(0, "query_signing_service failed: %s", $eval_stat);
33774  };
33775  my(%sig_options, $chosen_addr_src, $chosen_addr);
33776  if ($attr) {
33777    for my $opt (keys %$attr) {
33778      if ($opt =~ /^sig\.(.+)\z/) {
33779        $sig_options{$1} = $attr->{$opt}  if !exists($sig_options{$1});
33780      }
33781    }
33782    if (defined $attr->{chosen_candidate}) {
33783      ($chosen_addr_src, $chosen_addr) =
33784        split(' ', $attr->{chosen_candidate}, 2);
33785    }
33786  }
33787  (!$attr ? undef : \%sig_options,  $chosen_addr_src, $chosen_addr);
33788}
33789
33790# a CustomSigner callback routine passed to Mail::DKIM in place of a key;
33791# the routine will be called by Mail::DKIM::Algorithm::*rsa_sha* routines
33792# instead of calling their own Mail::DKIM::PrivateKey::sign_digest()
33793#
33794sub remote_signer {
33795  my($digest_alg_name, $digest, %args) = @_;
33796  # $digest: header digest (binary), ready for signing,
33797  #          e.g. $algorithm->{header_digest}->digest
33798  my $server  = $args{Server};   # our own info passed back to us
33799  my $msginfo = $args{MsgInfo};  # our own info passed back to us
33800  my(@query) = (
33801    proto_encode('request', 'sign'),
33802    proto_encode('digest_alg', $digest_alg_name),
33803    proto_encode('digest', encode_base64($digest,'')),
33804    proto_encode('s',      $args{Selector}),
33805    proto_encode('d',      $args{Domain}),
33806    proto_encode('log_id', $msginfo->log_id),
33807  );
33808  my($attr, $b, $reason);
33809  eval {
33810    $attr = query_signing_service($server, \@query);  1;
33811  } or do {
33812    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
33813    $reason = $eval_stat;
33814  };
33815  if ($attr) { $b = $attr->{b};  $reason = $attr->{reason} }
33816  if (!defined($b) || $b eq '') {
33817    $reason = 'no signature from a signing server'  if !defined $reason;
33818  # die "Can't sign, $reason, query: " . join('; ',@query) . "\n";
33819    do_log(0, "dkim: can't sign, %s, query: %s", $reason, join('; ',@query));
33820    return '';  # Mail::DKIM::Algorithm::rsa_sha256 doesn't like undef
33821  }
33822  decode_base64($b);  # resulting signature
33823}
33824
33825# prepare requested DKIM signatures for a provided message,
33826# returning them as a list of Mail::DKIM::Signature objects
33827#
33828sub dkim_make_signatures($$;$) {
33829  my($msginfo,$initial_submission,$callback) = @_;
33830  my(@signatures);   # resulting signature objects
33831  my(%sig_options);  # signature options and constraints for choosing a key
33832  my(%key_options);  # options associated with a signing key, IDN as ACE
33833  my(@tried_domains);  # used for logging a failure
33834  my($chosen_addr,$chosen_addr_src); my $do_sign = 0;
33835  my $fm = $msginfo->rfc2822_from;  # authors
33836  my(@rfc2822_from) = !defined($fm) ? () : ref $fm ? @$fm : $fm;
33837  my $allowed_hdrs = cr('allowed_added_header_fields');
33838  my $from_str = join(', ', qquote_rfc2821_local(@rfc2822_from));  # logging
33839  substr($from_str,100) = '[...]'  if length($from_str) > 100;
33840  if (!$allowed_hdrs || !$allowed_hdrs->{lc('DKIM-Signature')}) {
33841    do_log(5, "dkim: inserting a DKIM-Signature header field disabled");
33842  } elsif (!$msginfo->originating) {
33843    do_log(5, "dkim: not signing mail which is not originating from our site");
33844  } elsif ($msginfo->is_in_contents_category(CC_VIRUS)) {
33845    do_log(2, "dkim: not signing infected mail (from inside), From: %s",
33846              $from_str);
33847  } elsif ($msginfo->is_in_contents_category(CC_SPAM)) {
33848    # it is prudent not to sign outgoing spam, otherwise an attacker may be
33849    # able to replay a signed message, re-sending it to other recipients
33850    # in bulk directly from botnets
33851    do_log(2, "dkim: not signing spam (from inside), From: %s", $from_str);
33852  } elsif ($msginfo->is_in_contents_category(CC_SPAMMY)) {
33853    do_log(2, "dkim: not signing suspected spam (from inside), From: %s",
33854              $from_str);
33855  } else {
33856    # Choose a signing key based on the first match on the following
33857    # addresses (in this order): 2822.From, followed by 2822.Resent-From and
33858    # 2822.Resent-Sender address pairs traversed top-down by resent blocks,
33859    # followed by 2822.Sender and 2821.mail_from. We choose to look up
33860    # a From first, as it generates an author domain signature, but the
33861    # search order on remaining entries is admittedly unusual.
33862    # Btw, dkim-milter uses the following search order:
33863    #   Resent-Sender, Resent-From, Sender, From.
33864    # Only a signature based on 2822.From is considered an author domain
33865    # signature, others are just third-party signatures and have no more
33866    # merit than any other third-party signature according to RFC 6376.
33867    #
33868    my $rf = $msginfo->rfc2822_resent_from;
33869    my $rs = $msginfo->rfc2822_resent_sender;
33870    my(@rfc2822_resent_from, @rfc2822_resent_sender);
33871    @rfc2822_resent_from   = @$rf  if defined $rf;
33872    @rfc2822_resent_sender = @$rs  if defined $rs;
33873    my(@search_list); # collects candidate addresses for choosing a signing key
33874    # author addresses go first (typically exactly one, but possibly more)
33875    push(@search_list, map([$_,'From'], @rfc2822_from));
33876    # merge Resent-From and Resent-Sender addresses by resent blocks, top-down;
33877    # a merge is simplified by the fact that there is an equal number of
33878    # resent blocks in @rfc2822_resent_from and @rfc2822_resent_sender lists
33879    while (@rfc2822_resent_from || @rfc2822_resent_sender) {
33880      # for each resent block
33881      while (@rfc2822_resent_from) {
33882        my $addr = shift(@rfc2822_resent_from);
33883        last  if !defined $addr;  # undef delimits resent blocks
33884        push(@search_list, [$addr, 'Resent-From']);
33885      }
33886      while (@rfc2822_resent_sender) {
33887        my $addr = shift(@rfc2822_resent_sender);
33888        last  if !defined $addr;  # undef delimits resent blocks
33889        push(@search_list, [$addr, 'Resent-Sender']);
33890      }
33891    }
33892    push(@search_list, [$msginfo->rfc2822_sender, 'Sender']);
33893    push(@search_list, [$msginfo->sender,      'mail_from']);
33894    { # remove duplicates and empty addresses
33895      my(%addr_seen);
33896      @search_list =
33897        grep { my($a,$src) = @$_; defined $a && $a ne '' && !$addr_seen{$a}++ }
33898             @search_list;
33899    }
33900    ll(2) && do_log(2, "dkim: candidate originators: %s",
33901                    join(", ", map($_->[1].':'.qquote_rfc2821_local($_->[0]),
33902                                   @search_list)));
33903
33904    # dkim_signwith_sd() may provide a ref to a pair [selector,domain] - if
33905    # available (e.g. by a custom hook), it will force signing with a private
33906    # key associated with this selector and domain, otherwise we fall back
33907    # to consulting an external service if available, or else we use our
33908    # built-in algorithm for choosing a selector & domain and their associated
33909    # signing key
33910    #
33911    my $sd_pair = $msginfo->dkim_signwith_sd;
33912    if (ref($sd_pair) eq 'ARRAY') {
33913      my($s,$d) = @$sd_pair;
33914      if (defined $s && $s ne '' && defined $d && $d ne '') {
33915        do_log(5, "dkim: dkim_signwith_sd presets d=%s, s=%s", $d,$s);
33916        $sig_options{s} = $s; $sig_options{d} = $d;
33917      }
33918    }
33919
33920    my $dkim_signing_service = c('dkim_signing_service');
33921    if (defined $dkim_signing_service && $dkim_signing_service ne '') {
33922      # try the signing service: it should provide an 's' and 'd' if it has
33923      # a suitable signing key available, and/or may supply signing options,
33924      # overriding the defaults set so far
33925      my $sig_opt_ref;
33926      ($sig_opt_ref, $chosen_addr_src, $chosen_addr) =
33927        let_signing_service_choose($dkim_signing_service,
33928                                   $msginfo, \@search_list, undef);
33929      if ($sig_opt_ref) {  # merge returned signature options with ours
33930        while (my($k,$v) = each(%$sig_opt_ref)) {
33931          $sig_options{$k} = $v  if defined $v;
33932        }
33933      }
33934    }
33935
33936    my $sobm = ca('dkim_signature_options_bysender_maps');
33937    # last resort: fall back to our local configuration settings
33938    for my $pair (@search_list) {
33939      my($addr,$addr_src) = @$pair;
33940      my($addr_localpart,$addr_domain) = split_address($addr);
33941      # fetch a list of hashes from all entries matching the address
33942      my($dkim_options_ref,$mk_ref);
33943      ($dkim_options_ref,$mk_ref) = lookup2(1,$addr,$sobm)  if $sobm && @$sobm;
33944      $dkim_options_ref = []  if !defined $dkim_options_ref;
33945      # signature options (parenthesized options are set automatically;
33946      # the RFC 6651 (failure reporting) added a tag: r=y) :
33947      #   (v), a, (b), (bh), c, d, (h), i, (l), q, r, s, (t), x, (z)
33948      # place a catchall default at the end of the list of options;
33949      push(@$dkim_options_ref, { c => 'relaxed/simple', a => 'rsa-sha256' });
33950      # start each iteration with the same set of options collected so far
33951      my(%tmp_sig_options) = %sig_options;
33952      # traverse list of hashes from specific to general, first match wins
33953      for my $opts_hash_ref (@$dkim_options_ref) {
33954        next  if ref $opts_hash_ref ne 'HASH';  # just in case
33955        while (my($k,$v) = each(%$opts_hash_ref)) {  # for each entry in a hash
33956          $tmp_sig_options{$k} = $v  if !exists $tmp_sig_options{$k};
33957        }
33958      }
33959      # a default for a signing domain is a domain of each tried address
33960      if (!exists($tmp_sig_options{d})) {
33961        my $d = $addr_domain; $d =~ s/^\@//; $tmp_sig_options{d} = $d;
33962      }
33963      push(@tried_domains, $tmp_sig_options{d});
33964      ll(5) && do_log(5, "dkim: signature options for %s(%s): %s",
33965                      $addr, $addr_src,
33966                      join('; ', map($_.'='.$tmp_sig_options{$_},
33967                                     keys %tmp_sig_options)));
33968
33969      # find a private key associated with a signing domain and selector,
33970      # and meeting constraints
33971      %key_options = get_dkim_key(%tmp_sig_options)
33972        if defined $tmp_sig_options{d} && $tmp_sig_options{d} ne '';
33973    # my(@domain_path); # host.sub.example.com sub.example.com example.com com
33974    # $addr_domain =~ s/^\@//; $addr_domain =~ s/\.\z//;
33975    # if ($addr_domain !~ /\[/) {  # don't split address literals
33976    #   for (my $d=$addr_domain; $d ne ''; $d =~ s/^[^.]*(?:\.|\z)//s)
33977    #     { push(@domain_path,$d) }
33978    # }
33979    # for my $d (@domain_path) {
33980    #   $tmp_sig_options{d} = $d;
33981    #   %key_options = get_dkim_key(%tmp_sig_options);
33982    #   last  if defined $key_options{key};
33983    # }
33984      my $key = $key_options{key};
33985      if (defined $key && $key ne '') {  # found; copy the key and its options
33986        $tmp_sig_options{key} = $key;
33987        $tmp_sig_options{s} = idn_to_utf8($key_options{selector});
33988        $chosen_addr = $addr; $chosen_addr_src = $addr_src;
33989        # merge the just collected signature options into the final set
33990        while (my($k,$v) = each(%tmp_sig_options)) {
33991          $sig_options{$k} = $v  if defined $v;
33992        }
33993        last;
33994      }
33995    }
33996
33997    # provide defaults for 'c' and 'a' tags if missing
33998    $sig_options{c} = 'relaxed/simple'  if !exists $sig_options{c};
33999    $sig_options{a} = 'rsa-sha256'      if !exists $sig_options{a};
34000
34001    # prepare for a second stage of using an external signing service:
34002    # when we do have a 's' and 'd', thus uniquely identifying a signing key,
34003    # but do not have a key ourselves, we'll provide a callback routine
34004    # in place of a key object so that Mail::DKIM will call it at the time
34005    # of signing, and our routine will consult a remote signing service
34006    #
34007    if (!defined $sig_options{key} &&
34008        defined $dkim_signing_service && $dkim_signing_service ne '' &&
34009        defined $sig_options{d} && $sig_options{d} ne '' &&
34010        defined $sig_options{s} && $sig_options{s} ne '') {
34011      my $s = $sig_options{s};  my $d = $sig_options{d};
34012      # let Mail::DKIM use our custom code for signing (pref. 0.38 or later)
34013      $key_options{key} = Amavis::DKIM::CustomSigner->new(
34014           CustomSigner => \&remote_signer, MsgInfo => $msginfo,
34015           Selector => idn_to_ascii($s),
34016           Domain => idn_to_ascii($d),
34017           Server => $dkim_signing_service);
34018      $key_options{selector} = $s;  $key_options{domain} = $d;
34019      $sig_options{key} = $key_options{key};
34020    }
34021
34022    my $sig_opt_d_ace = idn_to_ascii($sig_options{d});
34023    if (!defined $sig_opt_d_ace || $sig_opt_d_ace eq '') {
34024      do_log(2, "dkim: not signing, empty signing domain, From: %s",$from_str);
34025    } elsif (!defined $sig_options{key} || $sig_options{key} eq '') {
34026      do_log(2, "dkim: not signing, no applicable private key for domains %s,".
34027                " s=%s, From: %s",
34028                join(", ",@tried_domains), $sig_options{s}, $from_str);
34029    } else {
34030      # copy key's options to signature options for convenience
34031      for (keys %key_options) {
34032        $sig_options{'KEY.'.$_} = $key_options{$_}  if /^[ghknst]\z/;
34033      }
34034      $sig_options{'KEY.key_ind'} = $key_options{key_ind};
34035
34036      # check matching of identity to a signing domain or provide a default;
34037      # presence of a t=s flag in a public key RR prohibits subdomains in i
34038      my $key_allows_subdomains =
34039        grep($_ eq 's', split(/:/,$sig_options{'KEY.t'})) ? 0 : 1;
34040      if (defined $sig_options{i}) {  # explicitly given, possibly empty
34041        # have mercy: provide a leading '@' if missing
34042        $sig_options{i} = '@'.$sig_options{i}  if $sig_options{i} ne '' &&
34043                                                  $sig_options{i} !~ /\@/;
34044      } elsif (!$key_allows_subdomains) {
34045        # we have no other choice but to keep it at its default @d
34046      } else {  # the public key record permits subdomains
34047        # provide default for i in a form of a sender's domain
34048        local($1);
34049        if ($chosen_addr =~ /\@([^\@]*)\z/) {
34050          my $identity_domain = $1;
34051          if (idn_to_ascii($identity_domain) =~ /.\.\Q$sig_opt_d_ace\E\z/s) {
34052            $sig_options{i} = '@'.$identity_domain;
34053            do_log(5, "dkim: identity defaults to %s", $sig_options{i});
34054          }
34055        }
34056      }
34057      if (!defined $sig_options{i} || $sig_options{i} eq '') {
34058        $do_sign = 1;  # just sign, don't bother with i
34059      } else {  # check if the requested i is compatible with d
34060        local($1);
34061        my $identity_domain = $sig_options{i} =~ /\@([^\@]*)\z/ ? $1 : '';
34062        my $identity_domain_ace = idn_to_ascii($identity_domain);
34063        if (!$key_allows_subdomains && $identity_domain_ace ne $sig_opt_d_ace){
34064          do_log(2, "dkim: not signing, identity domain %s not the same as ".
34065                    "a signing domain %s, flags t=%s, From: %s",
34066                    $sig_options{i}, $sig_options{d}, $sig_options{'KEY.t'},
34067                    $from_str);
34068        } elsif ($key_allows_subdomains &&
34069                 $identity_domain_ace !~ /(?:^|\.)\Q$sig_opt_d_ace\E\z/i) {
34070          do_log(2, "dkim: not signing, identity %s not a subdomain of %s, ".
34071                    "From: %s", $sig_options{i}, $sig_options{d}, $from_str);
34072        } else {
34073          $do_sign = 1;
34074        }
34075      }
34076    }
34077  }
34078  my $sig_opt_d_ace = idn_to_ascii($sig_options{d});
34079  if ($do_sign) {  # avoid adding same signature on multiple passes through MTA
34080    my $sigs_ref = $msginfo->dkim_signatures_valid;
34081    if ($sigs_ref) {
34082      for my $sig (@$sigs_ref) {
34083        if ( idn_to_ascii($sig->domain) eq $sig_opt_d_ace &&
34084             (!defined $sig_options{i} || $sig_options{i} eq $sig->identity)) {
34085          do_log(2, "dkim: not signing, already signed by domain %s, ".
34086                    "From: %s", $sig_opt_d_ace, $from_str);
34087          $do_sign = 0;
34088        }
34089      }
34090    }
34091  }
34092  if ($do_sign) {
34093    # relative expiration time
34094    if (defined $sig_options{ttl} && $sig_options{ttl} > 0) {
34095      my $xt = $msginfo->rx_time + $sig_options{ttl};
34096      $sig_options{x} = int($xt) + ($xt > int($xt) ? 1 : 0);  # ceiling
34097    }
34098    # remove redundant options with RFC 6376 -default values
34099    for my $k (keys %sig_options) { delete $sig_options{$k} if !defined $k }
34100    delete $sig_options{i}  if $sig_options{i} =~ /^\@/ &&
34101                          idn_to_ascii($sig_options{i}) eq '@'.$sig_opt_d_ace;
34102    delete $sig_options{c}  if $sig_options{c} eq 'simple/simple' ||
34103                               $sig_options{c} eq 'simple';
34104    delete $sig_options{q}  if $sig_options{q} eq 'dns/txt';
34105    if (ref $callback eq 'CODE') { &$callback($msginfo,\%sig_options) }
34106    if (ll(2)) {
34107      my $opts = join(', ',map($_ eq 'key' ? ()
34108                            : ($_ . '=>' . safe_encode_utf8($sig_options{$_})),
34109                               sort keys %sig_options));
34110      do_log(2,"dkim: signing (%s), From: %s (%s:%s), %s",
34111               grep(/\@\Q$sig_opt_d_ace\E\z/si,
34112                    map(mail_addr_idn_to_ascii($_), @rfc2822_from))
34113                 ? 'author' : '3rd-party',
34114               $from_str, $chosen_addr_src, qquote_rfc2821_local($chosen_addr),
34115               $opts);
34116    }
34117    my $key = $sig_options{key};
34118    if (UNIVERSAL::isa($key,'Crypt::OpenSSL::RSA')) {
34119      # my $pkcs1 = $key->get_private_key_string;  # most compact
34120      # $pkcs1 =~ s/^---.*?---(?:\r?\n|\z)//gm;  $pkcs1 =~ tr/\r\n//d;
34121      # $key = Mail::DKIM::PrivateKey->load(Data => $pkcs1);
34122      $key = Mail::DKIM::PrivateKey->load(Cork => $key);  # avail since 0.31
34123    } elsif (ref $key) {
34124      # already a Mail::DKIM::PrivateKey or Amavis::DKIM::CustomSigner object
34125    } else {
34126      $key = Mail::DKIM::PrivateKey->load(File => $key);  # read from a file
34127    }
34128
34129    # Sendmail milter interface does not provide a just-generated Received
34130    # header field to milters. Milters therefore need to fabricate a pseudo
34131    # Received header field in order to provide client IP address to a filter.
34132    # Unfortunately it is not possible to reliably fabricate a header field
34133    # which will exactly match the later-inserted one, so we must not sign
34134    # it to avoid a likely possibility of a signature being invalidated.
34135    my $conn = $msginfo->conn_obj;
34136    my $appl_proto = !$conn ? undef : $conn->appl_proto;
34137    my $skip_topmost_received = defined($appl_proto) &&
34138                           ($appl_proto eq 'AM.PDP' || $appl_proto eq 'AM.CL');
34139    my $policyfn = sub {
34140      my $dkim = $_[0];
34141      my $signed_header_fields_ref = cr('signed_header_fields') || {};
34142      my $hfn = $dkim->{header_field_names};
34143      my(@field_names_to_be_signed);
34144      #
34145      # when $signed_header_fields_ref->{$nm} is greater than 1 it indicates
34146      # that one surplus occurrence of a header filed name in an 'h' tag
34147      # should be inserted, consequently prohibiting further instances of
34148      # such header field to be added to a message header section without
34149      # breaking a signature; useful for example for a From and Subject
34150      #
34151      if ($hfn) {
34152        my(%hfn_cnt);
34153        $hfn_cnt{lc $_}++  for @$hfn;
34154        for (@$hfn) {
34155          my $nm = lc($_);
34156          push(@field_names_to_be_signed, $nm);  $hfn_cnt{$nm}--;
34157          if (!$hfn_cnt{$nm} && $signed_header_fields_ref->{$nm} > 1) {
34158            # causes signing one additional null occurrence of a header field
34159            push(@field_names_to_be_signed, $nm);
34160          }
34161        }
34162      }
34163      @field_names_to_be_signed =
34164        grep($signed_header_fields_ref->{$_}, @field_names_to_be_signed);
34165      if ($skip_topmost_received) {  # don't sign topmost Received header field
34166        for my $j (0..$#field_names_to_be_signed) {
34167          if (lc($field_names_to_be_signed[$j]) eq 'received')
34168            { splice(@field_names_to_be_signed,$j,1); last }
34169        }
34170      }
34171      my $expiration;
34172      if (defined $sig_options{x}) {
34173        $expiration = $sig_options{x};
34174        my $j = int($expiration);
34175        $expiration = $expiration > $j ? $j+1 : $j;  # ceiling
34176      }
34177      # RFC 6531 section 3.2: Any domain name to be looked up in the DNS
34178      # MUST conform to and be processed as specified for Internationalizing
34179      # Domain Names in Applications (IDNA) [RFC5890].  When doing lookups,
34180      # the SMTPUTF8-aware SMTP client or server MUST either use a Unicode-
34181      # aware DNS library, or transform the internationalized domain name
34182      # to A-label form (i.e., a fully- qualified domain name that contains
34183      # one or more A-labels but no U-labels) as specified in RFC 5890.
34184      $dkim->add_signature( Mail::DKIM::Signature->new(
34185        Selector  => idn_to_ascii($sig_options{s}),
34186        Domain    => idn_to_ascii($sig_options{d}),
34187        Timestamp => int($msginfo->rx_time),  # floor
34188        Headers   => join(':', reverse @field_names_to_be_signed),
34189        Key       => $key,
34190        !defined $sig_options{c} ? () : (Method     => $sig_options{c}),
34191        !defined $sig_options{a} ? () : (Algorithm  => $sig_options{a}),
34192        !defined $sig_options{q} ? () : (Query      => $sig_options{q}),
34193        !defined $sig_options{i} ? () : (Identity   =>
34194                                mail_addr_idn_to_ascii($sig_options{i})),
34195        !defined $expiration     ? () : (Expiration => $expiration), # ceiling
34196      ));
34197      undef;
34198    };  # end sub
34199
34200    my $dkim_wrapper;
34201    eval {
34202      my $dkim_signer = Mail::DKIM::Signer->new(Policy => $policyfn);
34203      $dkim_signer or die "Could not create a Mail::DKIM::Signer object\n";
34204      #
34205      # NOTE: dkim wrapper will strip bare CR before signing, which suits
34206      # forwarding by SMTP which does the same; with other forwarding methods
34207      # such as a pipe or milter, bare CRs in a message may break signatures
34208      #
34209      # feeding mail to a DKIM signer
34210      $dkim_wrapper = Amavis::Out::SMTP->new_dkim_wrapper($dkim_signer,1);
34211      my $msg = $msginfo->mail_text;  # a file handle or a MIME::Entity object
34212      my $msg_str_ref = $msginfo->mail_text_str;  # have an in-memory copy?
34213      $msg = $msg_str_ref  if ref $msg_str_ref;
34214      my $hdr_edits = $msginfo->header_edits;
34215      $hdr_edits = Amavis::Out::EditHeader->new  if !$hdr_edits;
34216      my($received_cnt,$file_position) =
34217        $hdr_edits->write_header($msginfo,$dkim_wrapper,!$initial_submission);
34218      if (!defined $msg) {
34219        # empty mail
34220      } elsif (ref $msg eq 'SCALAR') {
34221        # do it in chunks, saves memory, cache friendly
34222        while ($file_position < length($$msg)) {
34223          $dkim_wrapper->print(substr($$msg,$file_position,16384))
34224            or die "Can't write to dkim signer: $!";
34225          $file_position += 16384;  # may overshoot, no problem
34226        }
34227      } elsif ($msg->isa('MIME::Entity')) {
34228        $msg->print_body($dkim_wrapper);
34229      } else {
34230        my($nbytes,$buff);
34231        while (($nbytes = $msg->read($buff,16384)) > 0) {
34232          $dkim_wrapper->print($buff) or die "Can't write to dkim signer: $!";
34233        }
34234        defined $nbytes or die "Error reading: $!";
34235      }
34236      $dkim_wrapper->close or die "Can't close dkim wrapper: $!";
34237      undef $dkim_wrapper;
34238      $dkim_signer->CLOSE or die "Can't close dkim signer: $!";
34239      @signatures = $dkim_signer->signatures;
34240      undef $dkim_signer;
34241      1;
34242    } or do {
34243      my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
34244      do_log(0, "dkim: signing error: %s", $eval_stat);
34245    };
34246    if (defined $dkim_wrapper) { $dkim_wrapper->close }  # ignoring status
34247    section_time('fwd-data-dkim');
34248  }
34249
34250  # signatures must have all the required tags: d, s, b, bh; check to make sure
34251  # if (ll(5)) { do_log(5, "dkim: %s", $_->as_string) for @signatures }
34252  my(@sane_signatures);
34253  for my $s (@signatures) {
34254    my(@missing);
34255    for my $pair ( ['d', $s->domain],  ['s', $s->selector],
34256                   ['b', $s->data],   ['bh', $s->body_hash] ) {
34257      my($tag,$val) = @$pair;
34258      push(@missing,$tag)  if !defined($val) || $val eq '';
34259    }
34260    if (!@missing) {
34261      push(@sane_signatures, $s);
34262      # remember just the last one (typically the only one)
34263      $msginfo->dkim_signwith_sd( [$s->selector, $s->domain] );
34264    } else {
34265      do_log(2, "dkim: signature is missing tag %s, skipping: %s",
34266                 join(',',@missing), $s->as_string);
34267    }
34268  }
34269  @sane_signatures;
34270}
34271
34272# Prepare Authentication-Results header fields according to RFC 7601.
34273#
34274sub generate_authentication_results($;$$) {
34275  my($msginfo,$allow_none,$sigs_ref) = @_;
34276  $sigs_ref = $msginfo->dkim_signatures_all  if @_ < 3;  # for all by default
34277  my $authservid = c('myauthservid');
34278  $authservid = c('myhostname')  if !defined $authservid || $authservid eq '';
34279  $authservid = idn_to_ascii($authservid);
34280  # note that RFC 7601 declares A-R header field as structured, which is why
34281  # we are inserting a \n into top-level locations suitable for folding,
34282  # and let sub hdr() choose suitable folding points
34283  my(@results, %all_b, %all_b_valid, %all_b_8);
34284  my($sig_cnt_dk, $sig_cnt_dkim, $result_str) = (0, 0, '');
34285
34286  for my $sig (!$sigs_ref ? () : @$sigs_ref) {  # first pass
34287    my($sig_result, $details, $str);
34288    $sig_result = $sig->result;
34289    if (defined $sig_result) {
34290      $sig_result = lc $sig_result;
34291    } else {
34292      ($sig_result, $details) = ('pass', 'just generated, assumed good');
34293      $sig->result($sig_result, $details);
34294    }
34295    my $valid = $sig_result eq 'pass';
34296    if ($valid) {
34297      my $expiration_time = $sig->expiration;
34298      if (defined $expiration_time &&
34299          $expiration_time =~ /^0*\d{1,10}\z/ &&
34300          $msginfo->rx_time > $expiration_time) {
34301        ($sig_result, $details) = ('fail', 'good, but expired');
34302        $sig->result($sig_result, $details);
34303        $valid = 0;
34304      }
34305    }
34306    if ($sig->isa('Mail::DKIM::DkSignature')) { $sig_cnt_dk++ }
34307                                         else { $sig_cnt_dkim++ };
34308    my $b = $sig->data;
34309    if (defined $b) {
34310      $b =~ tr/ \t\n//d;  # remove FWS, just in case
34311      $all_b_8{substr($b,0,8)}++;
34312      $all_b{$b}++;
34313      $all_b_valid{$b}++  if $valid;
34314    }
34315  }
34316
34317  # RFC 7601 result: none, pass, fail, policy, neutral, temperror, permerror
34318  # Mail::DKIM result: pass, fail, invalid, temperror, none
34319  for my $sig (!$sigs_ref ? () : @$sigs_ref) {  # second pass
34320    my $result_val;  # RFC 7601 result value
34321    my $sig_result = lc $sig->result;
34322    my $details = $sig->result_detail;
34323    my $valid = $sig_result eq 'pass';
34324    if ($valid) {
34325      $result_val = 'pass';
34326    } else {
34327      # map a Mail::DKIM::Signature result into an RFC 7601 result value
34328      $result_val = $sig_result eq 'temperror' ? 'temperror'
34329                  : $sig_result eq 'fail'      ? 'fail'
34330                  : $sig_result eq 'invalid'   ? 'neutral' : 'permerror';
34331    }
34332    my $sdid_ace = idn_to_ascii($sig->domain);
34333    my $str = '';
34334    my $add_header_b;  # RFC 6008, should we add a header.b for this signature?
34335    my $key_size = eval {
34336      my $pk = $sig->get_public_key;
34337      $pk && $pk->cork && $pk->cork->size * 8;
34338    };
34339    if ($sig->isa('Mail::DKIM::DkSignature')) {
34340      $add_header_b = 1  if $sig_cnt_dk > 1;
34341      my $rfc2822_sender = $msginfo->rfc2822_sender;
34342      my $fm = $msginfo->rfc2822_from;
34343      my(@rfc2822_from) = !defined($fm) ? () : ref $fm ? @$fm : $fm;
34344      my $id_ace = defined $sdid_ace ? '@'.$sdid_ace : '';
34345      $str .= ";\n domainkeys=" . $result_val;
34346      $str .= sprintf(' (%d-bit key)', $key_size)  if $key_size;
34347      if (defined $details && $details ne '' && lc $details ne lc $result_val){
34348        local($1);  # turn it into an RFC 2045 quoted-string
34349        $details =~ s{([\000-\037\177"\\])}{\\$1}gs;  # RFC 5322 qtext
34350        $str .= "\n reason=\"$details\"";
34351      }
34352      if (@rfc2822_from && $rfc2822_from[0] =~ /(\@[^\@]*)\z/s &&
34353          idn_to_ascii($1) eq $id_ace) {
34354        $str .= "\n header.from=" .
34355                join(',', map(quote_rfc2821_local($_), @rfc2822_from));
34356      }
34357      if (defined($rfc2822_sender) && $rfc2822_sender =~ /(\@[^\@]*)\z/s &&
34358          idn_to_ascii($1) eq $id_ace) {
34359        $str .= "\n header.sender=" . quote_rfc2821_local($rfc2822_sender);
34360      }
34361    } else {  # a DKIM signature
34362      $add_header_b = 1  if $sig_cnt_dkim > 1;
34363      $str .= ";\n dkim=" . $result_val;
34364      $str .= sprintf(' (%d-bit key)', $key_size)  if $key_size;
34365      if (defined $details && $details ne '' && lc $details ne lc $result_val){
34366        local($1);  # turn it into an RFC 2045 quoted-string
34367        $details =~ s{([\000-\037\177"\\])}{\\$1}gs;  # RFC 5322 qtext
34368        $str .= "\n reason=\"$details\"";
34369      }
34370    }
34371
34372    $str .= "\n header.d=" . $sdid_ace  if defined $sdid_ace;
34373    my $b = $sig->data;
34374    if (defined $b && $add_header_b) {
34375      # RFC 6008: The value associated with this item in the header field
34376      # MUST be at least the first eight characters of the digital signature
34377      # (the "b=" tag from a DKIM-Signature) for which a result is being
34378      # relayed, and MUST be long enough to be unique among the results
34379      # being reported.
34380      $b =~ tr/ \t\n//d;  # remove FWS, just in case
34381      if ($b !~ m{^ [A-Za-z0-9+/]+ =* \z}xs) {  # ensure base64 syntax
34382        do_log(2, "generate_AR: bad signature tag b=%s", $b);
34383      } elsif ($all_b{$b} > 1 && $all_b_valid{$b} && !$valid) {
34384        # exact duplicates: do not report invalid ones if at least one is valid
34385        # RFC 6008 section 6.2.: a cautious implementation could discard
34386        # the false negative in that instance.
34387        do_log(2, "generate_AR: not reporting bad duplicates: %s", $b);
34388        $str = '';  # ditch the report for this signature
34389      } elsif ($all_b_8{$b} > $all_b{$b}) {
34390        do_log(2, "generate_AR: not reporting b for collisions: %s", $b);
34391      } else {
34392        $str .= "\n header.b=" . substr($b,0,8);
34393      }
34394    }
34395    $result_str .= $str;
34396  }
34397  # just provide a single A-R with all results combined
34398  push(@results, $result_str)  if $result_str ne '';
34399  push(@results, ";\n dkim=none")  if !@results && $allow_none;
34400  $_ = sprintf("%s (%s)%s", $authservid, $myproduct_name, $_)  for @results;
34401  @results;  # none, one, or more A-R header field bodies
34402}
34403
34404# adjust spam score for each recipient so that the final spam score
34405# will be shifted towards a fixed score assigned to a signing domain (its
34406# 'reputation', as obtained through @signer_reputation_maps); the formula is:
34407#   adjusted_spam_score = f*reputation + (1-f)*spam_score;  0 <= f <= 1
34408# which has the same semantics as auto_whitelist_factor in SpamAssassin AWL
34409#
34410sub adjust_score_by_signer_reputation($) {
34411  my $msginfo = $_[0];
34412  my $reputation_factor = c('reputation_factor');
34413  $reputation_factor = 0  if $reputation_factor < 0;
34414  $reputation_factor = 1  if $reputation_factor > 1;
34415  my $sigs_ref = $msginfo->dkim_signatures_valid;
34416  if (defined $reputation_factor && $reputation_factor > 0 &&
34417      $sigs_ref && @$sigs_ref) {
34418    my($best_reputation_signer,$best_reputation_score);
34419    my $minimum_key_bits = c('dkim_minimum_key_bits');
34420    my $srm = ca('signer_reputation_maps');
34421    # walk through all valid signatures, find best (smallest) reputation value
34422    for my $sig (@$sigs_ref) {
34423      my $sdid = $sig->domain;
34424      my($val,$key) = lookup2(0, '@'.$sdid, $srm);
34425      if (defined $val &&
34426          (!defined $best_reputation_score || $val < $best_reputation_score)) {
34427        my $key_size;
34428        $key_size = eval {
34429          my $pk = $sig->get_public_key;
34430          $pk && $pk->cork && $pk->cork->size * 8 }  if $minimum_key_bits;
34431        if ($key_size && $key_size < $minimum_key_bits) {
34432          do_log(1, "dkim: reputation for signing domain %s not used, ".
34433                    "valid signature ignored, %d-bit key is shorter than %d",
34434                     $sdid, $key_size, $minimum_key_bits);
34435        } else {
34436          $best_reputation_signer = $sdid;
34437          $best_reputation_score = $val;
34438        }
34439      }
34440    }
34441    if (defined $best_reputation_score) {
34442      my $ll = 2;  # initial log level
34443      for my $r (@{$msginfo->per_recip_data}) {
34444        my $spam_level = $r->spam_level;
34445        next  if !defined $spam_level;
34446        my $new_level = $reputation_factor  * $best_reputation_score
34447                  +  (1-$reputation_factor) * $spam_level;
34448        $r->spam_level($new_level);
34449        my $spam_tests = 'AM.DKIM_REPUT=' .
34450                         (0+sprintf("%.3f", $new_level-$spam_level));
34451        if (!$r->spam_tests) {
34452          $r->spam_tests([ \$spam_tests ]);
34453        } else {
34454          unshift(@{$r->spam_tests}, \$spam_tests);
34455        }
34456        ll($ll) &&
34457          do_log($ll, "dkim: score %.3f adjusted to %.3f due to reputation ".
34458                      "(%s) of a signer domain %s",  $spam_level, $new_level,
34459                      $best_reputation_score, $best_reputation_signer);
34460        $ll = 5;  # reduce log clutter after the first recipient
34461      }
34462    }
34463  }
34464}
34465
34466# check if we have a valid author domain signature, and do
34467# other DKIM pre-processing;  called from collect_some_dkim()
34468#
34469sub collect_some_dkim_info($) {
34470  my $msginfo = $_[0];
34471
34472  my $rfc2822_sender = $msginfo->rfc2822_sender;
34473  my(@rfc2822_from) = $msginfo->rfc2822_from;
34474  # now that we have a parsed From, check if we have a valid
34475  # author domain signature and do other DKIM pre-processing
34476  my(@bank_names, %bn_auth_already_queried);
34477  my $atpbm = ca('author_to_policy_bank_maps');
34478  my(@signatures_valid);
34479  my $sigs_ref = $msginfo->dkim_signatures_all;
34480  my $sig_ind = 0;  # index of a signature in a signature array
34481  for my $sig (!$sigs_ref ? () : @$sigs_ref) {  # for each signature
34482    my $valid = lc($sig->result) eq 'pass';
34483    my($timestamp_age, $creation_time, $expiration_time);
34484    if (!$sig->isa('Mail::DKIM::DkSignature')) {
34485      $creation_time = $sig->timestamp;  # method only implemented for DKIM sig
34486      $timestamp_age = $msginfo->rx_time - $creation_time
34487        if defined $creation_time && $creation_time =~ /^0*\d{1,10}\z/;
34488    }
34489    $expiration_time = $sig->expiration;
34490    my $expired =
34491      defined $expiration_time && $expiration_time =~ /^0*\d{1,10}\z/ &&
34492      ($msginfo->rx_time > $expiration_time ||
34493       ( defined $creation_time && $creation_time =~ /^0*\d{1,10}\z/ &&
34494         $creation_time > $expiration_time )
34495      );
34496
34497    my($pubkey, $key_size, $eval_stat);
34498    eval {
34499      # Mail::DKIM >=0.31 caches a public key result
34500      $pubkey = $sig->get_public_key;  # can die with "not available"
34501      $pubkey or die "No public key";
34502      $key_size = $pubkey->cork && $pubkey->cork->size * 8;
34503      $key_size or die "Can't determine a public key size";
34504      1;
34505    } or do {
34506      $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
34507      do_log(5, "dkim: public key s=%s d=%s, error: %s",
34508                $sig->selector, $sig->domain, $eval_stat);
34509    };
34510    if ($pubkey && ll(5)) {
34511      # RFC 6376: Although the "g=" tag has been deprecated in this version
34512      # of the DKIM specification (and thus MUST now be ignored), signers are
34513      # advised not to include the "g=" tag in key records...
34514      do_log(5, "dkim: public key s=%s d=%s%s, %d-bit key",
34515                $sig->selector, $sig->domain,
34516                join('', map { my $v = $pubkey->get_tag($_);
34517                               defined $v ? " $_=$v" : '' } qw(v g h k t s)),
34518                $key_size||0 );
34519    }
34520
34521    # See if a signature matches address in any of the sender/author fields.
34522    # In the absence of an explicit Sender header field, the first author
34523    # acts as the 'agent responsible for the transmission of the message'.
34524    my(@addr_list) = ($msginfo->sender,
34525                  defined $rfc2822_sender ? $rfc2822_sender : $rfc2822_from[0],
34526                  @rfc2822_from);
34527    my $sdid_ace = idn_to_ascii($sig->domain);
34528    for my $addr (@addr_list) {
34529      next  if !defined $addr;
34530      local($1); my $domain;
34531      $domain = $1  if $addr =~ /\@([^\@]*)\z/s;
34532      # turn addresses in @addr_list into booleans, representing match outcome
34533      $addr = defined $domain && idn_to_ascii($domain) eq $sdid_ace ? 1 : 0;
34534    }
34535
34536  # # Label which header fields are covered by each signature;
34537  # # doesn't work for old DomainKeys signatures where h may be missing
34538  # # and where recurring header fields may only be listed once.
34539  # # NOTE: currently unused and commented out
34540  # { my(%field_counts);
34541  #   my(@signed_header_field_names) = map(lc($_), $sig->headerlist); # 'h' tag
34542  #   $field_counts{$_}++  for @signed_header_field_names;
34543  #   for (my $j=-1;  ; $j--) {   # walk through header fields, bottom-up
34544  #     my($f_ind,$fld) = $msginfo->get_header_field2(undef,$j);
34545  #     last if !defined $f_ind;  # reached the top
34546  #     local $1;
34547  #     my $f_name; $f_name = lc $1 if $fld =~ /^([^:]*?)[ \t]*:/s;
34548  #     if ($field_counts{$f_name} > 0) { # header field is covered by this sig
34549  #       $msginfo->header_field_signed_by($f_ind,$sig_ind);  # store sig index
34550  #       $field_counts{$f_name}--;
34551  #     }
34552  #   }
34553  # }
34554
34555    if ($valid && !$expired) {
34556      push(@signatures_valid, $sig);
34557      my $sig_domain = $sig->domain;
34558      $sig_domain = '?'  if !$sig_domain;  # make sure it is true as a boolean
34559      #
34560      # note that only the author domain signature (based on RFC 2822.From)
34561      # is a valid concept in ADSP; we are also using the same rules to match
34562      # against RFC 2822.Sender and envelope sender address, but results are
34563      # only of informational/curiosity interest and deeper significance
34564      # must not be attributed to dkim_envsender_sig and dkim_sender_sig!
34565      #
34566      $msginfo->dkim_envsender_sig($sig_domain)  if $addr_list[0];
34567      $msginfo->dkim_sender_sig($sig_domain)     if $addr_list[1];
34568      $msginfo->dkim_author_sig($sig_domain)
34569        if grep($_, @addr_list[2..$#addr_list]);  # SDID matches addr
34570      $msginfo->dkim_thirdparty_sig($sig_domain) if !$msginfo->dkim_author_sig;
34571      if (@$atpbm) {  # any author to policy bank name mappings?
34572        for my $j (0..$#rfc2822_from) {  # for each author (usually only one)
34573          my $key_ace = mail_addr_idn_to_ascii($rfc2822_from[$j]);
34574          # query key: as-is author address for author domain signatures, and
34575          # author address with '/@signer-domain' appended for 3rd party sign.
34576          # e.g.: 'user@example.com', 'user@sub.example.com/@example.org'
34577          my $sdid_ace = idn_to_ascii($sig->domain);
34578          for my $opt ( ($addr_list[$j+2] ? '' : ()), '/@'.$sdid_ace ) {
34579            next  if $bn_auth_already_queried{$key_ace.$opt};
34580            my($result,$matchingkey) = lookup2(0,$key_ace,$atpbm,
34581                       Label=>'AuthToPB', $opt eq '' ? () : (AppendStr=>$opt));
34582            $bn_auth_already_queried{$key_ace.$opt} = 1;
34583            next if !$result;
34584            if ($result eq '1') {
34585              # a handy usability trick to supply a hardwired policy bank
34586              # name when acl-style lookup table is used, which can only
34587              # return a boolean (undef, 0, or 1)
34588              $result = 'AUTHOR_APPROVED';
34589            }
34590            my $minimum_key_bits = c('dkim_minimum_key_bits');
34591            # $result is a list of bank names as a comma-separated string
34592            local $1;
34593            my(@pbn) = map(/^\s*(\S.*?)\s*\z/s ? $1 : (), split(/,/, $result));
34594            if (!@pbn) {
34595              # no policy banks specified, nothing to do
34596            } elsif ($key_size && $minimum_key_bits &&
34597                     $key_size < $minimum_key_bits) {
34598              do_log(1, "dkim: policy bank %s by %s NOT LOADED, valid ".
34599                        "signature ignored, %d-bit key is shorter than %d",
34600                        join(',',@pbn), $matchingkey,
34601                        $key_size, $minimum_key_bits);
34602            } else {
34603              push(@bank_names, @pbn);
34604              ll(2) && do_log(2, "dkim: policy bank %s by %s",
34605                                 join(',',@pbn), $matchingkey);
34606            }
34607          }
34608        }
34609      }
34610    }
34611    ll(2) && do_log(2, "dkim: %s%s%s %s signature by d=%s, From: %s, ".
34612                       "a=%s, c=%s, s=%s, i=%s%s%s%s",
34613      $valid  ? 'VALID' : 'FAILED',  $expired ? ', EXPIRED' : '',
34614      $timestamp_age >= -1 ? ''
34615        : ', IN_FUTURE:('.format_time_interval(-$timestamp_age).')',
34616      join('+', (map($_ ? 'Author' : (), @addr_list[2..$#addr_list])),
34617                $addr_list[1] ? 'Sender'   : (),
34618                $addr_list[0] ? 'MailFrom' : (),
34619                !grep($_, @addr_list) ? 'third-party' : ()),
34620      $sig->domain, join(", ", qquote_rfc2821_local(@rfc2822_from)),
34621      $sig->algorithm, scalar($sig->canonicalization),
34622      $sig->selector, $sig->identity,
34623      !$msginfo->originating ? ''
34624        : ', ORIG [' . $msginfo->client_addr . ']:' . $msginfo->client_port,
34625      !defined($msginfo->is_mlist) ? '' : ", m.list(".$msginfo->is_mlist.")",
34626      $valid ? '' : ', '.$sig->result_detail,
34627    );
34628    $sig_ind++;
34629  }
34630  Amavis::load_policy_bank($_,$msginfo) for @bank_names;
34631  $msginfo->originating(c('originating'));
34632  $msginfo->dkim_signatures_valid(\@signatures_valid)  if @signatures_valid;
34633# if (ll(5) && $sig_ind > 0) {
34634#   # show which header fields are covered by which signature
34635#   for (my $j=0; ; $j++) {
34636#     my($f_ind,$fld) = $msginfo->get_header_field2(undef,$j);
34637#     last if !defined $f_ind;
34638#     my(@sig_ind) = $msginfo->header_field_signed_by($f_ind);
34639#     do_log(5, "dkim: %-5s %s.", !@sig_ind ? '' : '['.join(',',@sig_ind).']',
34640#               substr($fld,0,54));
34641#   }
34642# }
34643}
34644
346451;
34646
34647__DATA__
34648#
34649package Amavis::Tools;
34650use strict;
34651use re 'taint';
34652use warnings;
34653use warnings FATAL => qw(utf8 void);
34654no warnings 'uninitialized';
34655# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
34656
34657BEGIN {
34658  require Exporter;
34659  use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
34660  $VERSION = '2.412';
34661  @ISA = qw(Exporter);
34662  @EXPORT_OK = qw(&show_or_test_dkim_public_keys &generate_dkim_private_key
34663                  &convert_dkim_keys_file);
34664  import Amavis::Conf qw(:platform c cr ca
34665                  @dkim_signing_keys_list @dkim_signing_keys_storage);
34666  import Amavis::Util qw(untaint ll do_log
34667                  safe_encode_utf8_inplace idn_to_ascii idn_to_utf8);
34668  import Amavis::rfc2821_2822_Tools qw(rfc2822_timestamp);
34669}
34670use subs @EXPORT_OK;
34671
34672use Errno qw(ENOENT EACCES);
34673use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
34674use Crypt::OpenSSL::RSA ();
34675
34676# Prints DNS TXT resource records for corresponding DKIM private keys (as
34677# previously declared by calls to dkim_key) in a format directly suitable
34678# for inclusion in DNS zone files. If an argument is provided the result is
34679# restricted to listed domains only, otherwise RR for all domains are shown.
34680# Note that a domain may have more than one RR: one RR for each selector.
34681#
34682# When a search argument is provided (even if '.'), the printed list is
34683# sorted according to reversed domain labels (e.g. com.example.sub.host),
34684# entries with the same domain are kept in original order. When there are
34685# no search arguments, the original order is retained.
34686#
34687sub show_or_test_dkim_public_keys($$) {
34688  my($cmd,$args) = @_;
34689  # when list is empty all domains are implied
34690  my(@seek_domains) = map(idn_to_ascii($_), @$args);
34691  my(@sort_list) = map { my $d = lc($dkim_signing_keys_list[$_]->{domain});
34692                         my $d_re = $dkim_signing_keys_list[$_]->{domain_re};
34693                         [$_, $d, $d_re, join('.',reverse split(/\./,$d,-1))] }
34694                       0 .. $#dkim_signing_keys_list;
34695  if (@seek_domains) {  # sort only when there are any search arguments present
34696    @sort_list = sort {$a->[3] cmp $b->[3] || $a->[0] <=> $b->[0]} @sort_list;
34697  }
34698  my $any = 0;
34699  for my $e (@sort_list) {
34700    my($j,$domain,$domain_re) = @$e;  local($1);
34701    safe_encode_utf8_inplace($domain);  # to octets (if not already)
34702    my $domain_ace = idn_to_ascii($domain);
34703    next  if @seek_domains &&
34704             !grep { defined $domain_re ? lc($_) =~ /$domain_re/
34705                     : /^\.(.*)\z/s ?
34706                       $domain_ace eq lc($1) ||
34707                         $domain_ace =~ /(?:\.|\z)\Q$1\E\z/si
34708                     : $domain_ace eq lc($_) } @seek_domains;
34709    $any++;
34710    my $key_opts = $dkim_signing_keys_list[$j];
34711    if ($cmd eq 'testkeys' || $cmd eq 'testkey') {
34712      test_dkim_key(%$key_opts);
34713    } else {
34714      my $selector = $key_opts->{selector};
34715      safe_encode_utf8_inplace($selector);  # to octets (if not already)
34716      my $selector_ace = idn_to_ascii($selector);
34717      my $key_storage_ind = $key_opts->{key_storage_ind};
34718      my($key,$dev,$inode,$fname) =
34719        @{ $dkim_signing_keys_storage[$key_storage_ind] };
34720      my(@pub) = split(/\r?\n/, $key->get_public_key_x509_string);
34721      @pub = grep(!/^---.*?---\z/ && !/^[ \t]*\z/, @pub);
34722      my(@tags) = map($_.'='.$key_opts->{$_},
34723                      grep(defined $key_opts->{$_}, qw(v g h k s t n)));
34724      my $key_size = 8 * $key->size;
34725      printf("; key#%d %d bits, i=%s, d=%s%s\n",
34726             $key_opts->{key_ind} + 1, $key_size,
34727             $selector, $domain,
34728             defined $fname ? ', '.$fname : '');
34729      printf("; CANNOT DECLARE A WILDCARDED LABEL IN DNS, ".
34730             "AVOID OR EDIT MANUALLY!\n")  if defined $key_opts->{domain_re};
34731      printf("%s._domainkey.%s.\t%s TXT (%s)\n\n",
34732             $selector_ace, $domain_ace, '3600',
34733             join('', map("\n" . '  "' . $_ . '"',
34734                          join('; ',@tags,'p='), @pub)) );
34735    }
34736  }
34737  if (!@dkim_signing_keys_list) {
34738    printf("No DKIM private keys declared in a config file.\n");
34739  } elsif (!$any) {
34740    printf("No DKIM private keys match the selection list.\n");
34741  }
34742}
34743
34744sub test_dkim_key(@) {
34745  my(%key_options) = @_;
34746  my $now = Time::HiRes::time;
34747  my $key_storage_ind = $key_options{key_storage_ind};
34748  my($key,$dev,$inode,$fname) =
34749    @{ $dkim_signing_keys_storage[$key_storage_ind] };
34750  if (UNIVERSAL::isa($key,'Crypt::OpenSSL::RSA')) {
34751    $key = Mail::DKIM::PrivateKey->load(Cork => $key);  # avail since 0.31
34752    # my $pkcs1 = $key->get_private_key_string;  # most compact
34753    # $pkcs1 =~ s/^---.*?---(?:\r?\n|\z)//gm;  $pkcs1 =~ tr/\r\n//d;
34754    # $key = Mail::DKIM::PrivateKey->load(Data => $pkcs1);
34755  }
34756  my $domain = idn_to_utf8($key_options{domain});
34757  my $domain_ace = idn_to_ascii($domain);
34758  my $selector_ace = idn_to_ascii($key_options{selector});
34759  my $policyfn = sub {
34760    my $dkim = $_[0];
34761    $dkim->add_signature( Mail::DKIM::Signature->new(
34762      Selector => $selector_ace, Domain => $domain_ace,
34763      Method => 'simple/simple', Algorithm => 'rsa-sha256',
34764      Timestamp => int($now), Expiration => int($now)+24*3600, Key => $key,
34765    )); undef;
34766  };
34767  my $msg = sprintf(
34768    "From: test\@%s\nMessage-ID: <123\@%s>\nDate: %s\nSubject: test\n\ntest\n",
34769    $domain, $domain, rfc2822_timestamp($now));
34770  $msg =~ s{\n}{\015\012}gs;
34771  my(@gen_signatures, @read_signatures);
34772  eval {
34773    my $dkim_signer = Mail::DKIM::Signer->new(Policy => $policyfn);
34774    $dkim_signer or die "Could not create a Mail::DKIM::Signer object";
34775    $dkim_signer->PRINT($msg) or die "Can't write to dkim: $!";
34776    $dkim_signer->CLOSE or die "Can't close dkim signer: $!";
34777    @gen_signatures = $dkim_signer->signatures;
34778  } or do {
34779    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
34780    print STDERR "dkim signing failed: $eval_stat\n";
34781  };
34782  $msg = $_->as_string . "\015\012" . $msg  for @gen_signatures;
34783  eval {
34784    my $dkim_verifier = Mail::DKIM::Verifier->new;
34785    $dkim_verifier or die "Could not create a Mail::DKIM::Verifier object";
34786    $dkim_verifier->PRINT($msg) or die "Can't write to dkim: $!";
34787    $dkim_verifier->CLOSE or die "Can't close dkim_verifier: $!";
34788    @read_signatures = $dkim_verifier->signatures;
34789  } or do {
34790    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
34791    print STDERR "dkim verification failed: $eval_stat\n";
34792  };
34793# printf("%s\n", $fname)  if defined $fname;
34794  printf("TESTING#%d %s: %s => %s\n",
34795         $key_options{key_ind} + 1, $domain,
34796         $_->selector . '._domainkey.' . $_->domain,
34797         $_->result_detail)  for @read_signatures;
34798}
34799
34800sub generate_dkim_private_key(@) {
34801  my($fname,$nbits) = @_;
34802  my $fh;
34803  eval {
34804    $nbits = 1024  if !defined($nbits) || $nbits eq '';
34805    $nbits =~ /^\d+\z/  or die "Number of bits in a key must be numeric\n";
34806    $nbits >= 512
34807      or die "Number of bits is below 512 (suggested 1024..2048)\n";
34808    $nbits <= 4096
34809      or die "Number of bits too large (suggested 1024..2048)\n";
34810    defined $fname && $fname ne ''
34811      or die "File name for a key not provided\n";
34812    $nbits >= 1024
34813      or printf STDERR ("INFO: RFC 6376 states: Signers MUST use RSA keys ".
34814                        "of at least 1024 bits for long-lived keys.\n");
34815    $fh = IO::File->new;
34816    $fh->open(untaint($fname), O_CREAT|O_EXCL|O_RDWR, 0600)
34817      or die "Can't create file \"$fname\": $!\n";
34818    my $rsa = Crypt::OpenSSL::RSA->generate_key($nbits);
34819    $fh->print($rsa->get_private_key_string)
34820      or die "Error writing key to a file \"$fname\": $!\n";
34821    $fh->close or die "Can't close file \"$fname\": $!\n";
34822    undef $fh;
34823    printf STDERR ("Private RSA key successfully written to file \"%s\" ".
34824                   "(%d bits, PEM format) \n", $fname,$nbits);
34825    1;
34826  } or do {
34827    my $eval_stat = $@ ne '' ? $@ : "errno=$!";  chomp $eval_stat;
34828    $fh->close  if defined $fh;  # ignoring status
34829    die "genrsa: $eval_stat\n";
34830  }
34831}
34832
34833# Reads a dkim-filter -compatible key specifications. From the dkim-filter
34834# man page: The keyfile should contain a set of lines of the form
34835# sender-pattern:signing-domain:keypath where sender-pattern is a pattern
34836# to match against message senders (with a special character "*" interpreted
34837# as "zero or more characters"), signing-domain is the domain to announce as
34838# the signing domain when generating signatures (or a '*', implying author's
34839# domain), and keypath is a path to the PEM-formatted private key to be used
34840# for signing messages which match the sender-pattern. The selector used in
34841# the signature will be the filename portion of keypath. A line starting
34842# with "/" is interpreted as a root directory for keys, meaning the keypath
34843# values after that line in the file are taken relative to that path. If a
34844# file referenced by keypath cannot be opened, the filter will try again by
34845# appending ".pem" and then ".private".  '#'-delimited comments and blank
34846# lines are ignored.
34847#
34848sub convert_dkim_keys_file($) {
34849  my $keysfile = $_[0];
34850  my $inp = IO::File->new;
34851  $inp->open($keysfile,'<')
34852    or die "dkim_key_file: Can't open file $keysfile for reading: $!";
34853  my($basedir,@options,@opt_re,%domain_selectors); my $rn = 0; my $ln;
34854  for ($! = 0; defined($ln=$inp->getline); $! = 0) {
34855    chomp($ln); $rn++; local($1); my($selector,$key_fn);
34856    if ($ln =~ /^ \s* (?: \# | \z)/xs) {
34857      # skip empty and all-comment lines
34858    } elsif ($ln =~ m{^/}) {
34859      $basedir = $ln;  $basedir .= '/' if $basedir !~ m{/\z};
34860    } else {
34861      my($sender_pattern, $signing_domain, $keypath) =
34862        map { my $s = $_; $s =~ s/^\s+//; $s =~ s/\s+\z//; $s }
34863            split(/:/, $ln, 3);
34864      defined $sender_pattern && $sender_pattern ne ''
34865        or die "Error in $keysfile, empty sender pattern, line $rn: $ln\n";
34866      defined $keypath && $keypath ne ''  ||  $signing_domain eq ''
34867        or die "Error in $keysfile, empty file name field, line $rn: $ln\n";
34868      $keypath = $basedir . $keypath  if defined $basedir && $keypath !~ m{^/};
34869      for my $ext ('', '.pem', '.private') {
34870        my $errn = stat($keypath.$ext) ? 0 : 0+$!;
34871        if ($errn != ENOENT) { $key_fn = $keypath.$ext; last }
34872      }
34873      defined $key_fn
34874        or die "File $keypath does not exist, $keysfile line $rn: $ln\n";
34875      $selector = lc($1)  if $keypath =~ m{ (?: ^ | / ) ( [^/]+? )
34876                                            (?: \.pem | \.private )? \z }xs;
34877      # must convert sender pattern to unquoted form to match actual addresses
34878      my $sender_domain;
34879      if ($sender_pattern eq '*' || $sender_pattern eq '*@*') {
34880        $sender_pattern = $sender_domain = '*';
34881      } else {
34882        my $sender_localpart;
34883        ($sender_localpart, $sender_domain) =
34884          Amavis::rfc2821_2822_Tools::split_address(
34885           Amavis::rfc2821_2822_Tools::unquote_rfc2821_local($sender_pattern));
34886        $sender_domain =~ s/^\@//;
34887        $sender_pattern = $sender_localpart.'@'.idn_to_ascii($sender_domain);
34888      }
34889      if ($signing_domain eq '*') { $signing_domain = $sender_domain }
34890      $signing_domain = idn_to_ascii($signing_domain);
34891      if ($signing_domain ne '' &&
34892          !$domain_selectors{$signing_domain}{$selector}) {
34893      # dkim_key($signing_domain,$selector,$key_fn);  # declare a signing key
34894        printf("dkim_key(%-18s %-12s '%s');\n",
34895               "'".$signing_domain."',", "'".$selector."',", $key_fn);
34896        $domain_selectors{$signing_domain}{$selector} = 1;
34897      }
34898      if ($signing_domain eq $sender_domain) { $signing_domain = '*' }
34899      push(@options, [$sender_pattern, $signing_domain, $selector]);
34900    }
34901  }
34902  defined $ln || $! == 0  or die "Error reading from $keysfile: $!";
34903  $inp->close or die "Error closing $keysfile: $!";
34904  #
34905  # prepare by_sender signature options lookup table when non-default
34906  # signing is required (e.g. third-party signatures)
34907  #
34908  my $in_options = 0;
34909  for my $opt (@options) {
34910    my($sender_pattern, $signing_domain, $selector) = @$opt;
34911    if ($signing_domain eq '*') {
34912      # implies author domain signature, no need for special options
34913    } else {
34914      $sender_pattern =~ s/\*{2,}/*/gs;   # collapse successive wildcards
34915      $sender_pattern =~  # '*' is a wildcard, quote the rest
34916        s{ ([@\#/.^\$|*+?(){}\[\]\\]) }{ $1 eq '*' ? '.*' : '\\'.$1 }xgse;
34917      $sender_pattern = '^' . $sender_pattern . '\\z';  # implicit anchors
34918      # remove trailing first, leading next, preferring /^.*\z/ -> /^/, not /\z/
34919      $sender_pattern =~ s/\.\*\\z\z//s;  # remove trailing anchor if redundant
34920      $sender_pattern =~ s/^\^\.\*//s;    # remove leading anchor if redundant
34921      $sender_pattern = '(?:)'  if $sender_pattern eq '';  # just in case
34922      $signing_domain = undef if $signing_domain eq '';
34923      $selector = undef       if $selector       eq '';
34924      # case insensitive matching for compatibility with dkim-milter
34925      push(@opt_re, [ qr/$sender_pattern/is =>
34926                        ( !defined($signing_domain) ||
34927                          keys(%{$domain_selectors{$signing_domain}})==1
34928                          ? { d => $signing_domain }
34929                          : { d => $signing_domain, s => $selector } ) ]);
34930      if (!$in_options) {
34931        printf("\n%s\n", '@dkim_signature_options_bysender_maps = (new_RE(');
34932        $in_options = 1;
34933      }
34934      printf("  [ %-30s => { d=>%s%s} ],\n",
34935           'qr/' . $sender_pattern . '/is',
34936           !defined($signing_domain) ? 'undef' : "'".$signing_domain."'",
34937           !defined($signing_domain) ||
34938           keys %{$domain_selectors{$signing_domain}} == 1 ? ''
34939             : !defined($selector) ? ', s=>undef' : ", s=>'".$selector."'");
34940    }
34941  }
34942  printf("%s\n", '));')  if $in_options;
34943# use Devel::Peek qw(Dump);
34944# use Data::Dump (); Data::Dump::dump(@opt_re);
34945# unshift(@dkim_signature_options_bysender_maps,
34946#         Amavis::Lookup::RE->new(@opt_re))  if @opt_re;
34947}
34948
349491;
34950
34951__DATA__
34952#
34953# =============================================================================
34954# This text section governs how a main per-message amavisd-new log entry (at
34955# log level 0) is formed (config variable $log_short_templ). Empty disables it.
34956[?%#D|#|Passed #
34957[? [:ccat|major] |#
34958OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\
34959UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]#
34960 {[:actions_performed]}#
34961,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,]#
34962[? %q ||, quarantine: %q]#
34963[? %Q ||, Queue-ID: %Q]#
34964[? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]#
34965[? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]#
34966[? %i ||, mail_id: %i]#
34967, Hits: [:SCORE]#
34968, size: %z#
34969[? [:partition_tag] ||, pt: [:partition_tag]]#
34970[~[:remote_mta_smtp_response]|["^$"]||[", queued_as: "]]\
34971[remote_mta_smtp_response|[~%x|["queued as ([0-9A-Za-z]+)$"]|["%1"]|["%0"]]|/]#
34972#, Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]#
34973#, From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]#
34974#[? %#T ||, Tests: \[[%T|,]\]]#
34975[? [:dkim|sig_sd]    ||, dkim_sd=[:dkim|sig_sd]]#
34976[? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]#
34977, %y ms#
34978]
34979[?%#O|#|Blocked #
34980[? [:ccat|major|blocking] |#
34981OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\
34982UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]#
34983 {[:actions_performed]}#
34984,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:mail_addr_decode_octets|%s] -> [%O|[:mail_addr_decode_octets|%O]|,]#
34985[? %q ||, quarantine: %q]#
34986[? %Q ||, Queue-ID: %Q]#
34987[? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]#
34988[? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]#
34989[? %i ||, mail_id: %i]#
34990, Hits: [:SCORE]#
34991, size: %z#
34992[? [:partition_tag] ||, pt: [:partition_tag]]#
34993#, Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]#
34994#, From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]#
34995#[? %#T ||, Tests: \[[%T|,]\]]#
34996[? [:dkim|sig_sd]    ||, dkim_sd=[:dkim|sig_sd]]#
34997[? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]#
34998, %y ms#
34999]
35000__DATA__
35001#
35002# =============================================================================
35003# This text section governs how a verbose per-message amavisd-new log entry
35004# is formed (config variable $log_verbose_templ). An empty text will prevent
35005# a verbose log entry, multiline text will produce multiple log entries, one
35006# for each nonempty line. Syntax is explained in the README.customize file.
35007[?%#D|#|Passed #
35008[? [:ccat|major] |#
35009OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\
35010UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]#
35011 {[:actions_performed]}#
35012,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:client_protocol]/[:protocol] [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,]#
35013#, ([ip_trace_public|%x| < ])#
35014, ([ip_proto_trace_public|%x| < ])#
35015[? [:tls_in] ||, tls: [:tls_in]]#
35016[? %q ||, quarantine: %q]#
35017[? %Q ||, Queue-ID: %Q]#
35018[? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]#
35019[? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]#
35020, mail_id: %i#
35021#, secret_id: [:secret_id]#
35022, b: [:substr|[:b64urlenc|[:body_digest]]|0|9]#
35023, Hits: [:SCORE]#
35024, size: %z#
35025[? [:partition_tag] ||, pt: [:partition_tag]]#
35026[~[:remote_mta_smtp_response]|["^$"]||[", queued_as: "]]\
35027[remote_mta_smtp_response|[~%x|["queued as ([0-9A-Za-z]+)$"]|["%1"]|["%0"]]|/]#
35028, Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]#
35029, From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]#
35030[? [:dkim|author] || (dkim:AUTHOR)]#
35031[? [:useragent|name]   ||, [:useragent|name]: [:uquote|[:useragent|body]]]#
35032, helo=[:client_helo]#
35033[? %#T ||, Tests: \[[%T|,]\]]#
35034#[:supplementary_info|VERSION|, SA: %%s]#
35035#[:supplementary_info|RULESVERSION|, rules: %%s]#
35036[? [:banning_rule_key]     ||, b.key=[:banning_rule_key]]#
35037[? [:banning_rule_comment] ||, b.com=[:banning_rule_comment]]#
35038[? [:banning_rule_rhs]     ||, b.rhs=[:banning_rule_rhs]]#
35039[? [:banned_parts_as_attr] ||, b.parts=[:banned_parts_as_attr]]#
35040[:supplementary_info|SCTYPE|, shortcircuit=%%s]#
35041[:supplementary_info|AUTOLEARN|, autolearn=%%s]#
35042[:supplementary_info|AUTOLEARNSCORE|, autolearnscore=%%s]#
35043[? [:supplementary_info|LANGUAGES] ||, languages=[:uquote|[:supplementary_info|LANGUAGES]]]#
35044[? [:supplementary_info|RELAYCOUNTRY] ||, relaycountry=[:uquote|[:supplementary_info|RELAYCOUNTRY]]]#
35045[? [:supplementary_info|ASN] ||, asn=[:uquote|[:supplementary_info|ASN] [:supplementary_info|ASNCIDR]]]#
35046#[? [:supplementary_info|DCCB] ||, dcc=[:supplementary_info|DCCB]:[:uquote|[:supplementary_info|DCCR]]]#
35047#[? [:supplementary_info|DCCREP] ||, dcc_rep=[:supplementary_info|DCCREP]]#
35048#[:supplementary_info|AWLSIGNERMEAN|, signer_avg=%%s]#
35049#[? [:dkim|domain]   ||, dkim_d=[:dkim|domain]]#
35050[? [:dkim|identity]  ||, dkim_i=[:dkim|identity]]#
35051[? [:dkim|sig_sd]    ||, dkim_sd=[:dkim|sig_sd]]#
35052[? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]#
35053[? [:rusage|ru_maxrss] ||, rss=[:rusage|ru_maxrss]]#
35054, %y ms#
35055]
35056[?%#O|#|Blocked #
35057[? [:ccat|major|blocking] |#
35058OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER-[:ccat|minor]|SPAMMY|SPAM|\
35059UNCHECKED[?[:ccat|minor]||-ENCRYPTED|]|BANNED (%F)|INFECTED (%V)]#
35060 {[:actions_performed]}#
35061,[?%p|| %p][?%a||[?%l|| LOCAL] [:client_addr_port]][?%e|| \[%e\]] [:client_protocol]/[:protocol] [:mail_addr_decode_octets|%s] -> [%O|[:mail_addr_decode_octets|%O]|,]#
35062#, ([ip_trace_public|%x| < ])#
35063, ([ip_proto_trace_public|%x| < ])#
35064[? [:tls_in] ||, tls: [:tls_in]]#
35065[? %q ||, quarantine: %q]#
35066[? %Q ||, Queue-ID: %Q]#
35067[? %m ||, Message-ID: [:mail_addr_decode_octets|%m]]#
35068[? %r ||, Resent-Message-ID: [:mail_addr_decode_octets|%r]]#
35069, mail_id: %i#
35070#, secret_id: [:secret_id]#
35071, b: [:substr|[:b64urlenc|[:body_digest]]|0|9]#
35072, Hits: [:SCORE]#
35073, size: %z#
35074[? [:partition_tag] ||, pt: [:partition_tag]]#
35075, Subject: [:dquote|[:mime2utf8|[:header_field_octets|Subject]|100|1]]#
35076, From: [:uquote|[:mail_addr_decode_octets|[:rfc2822_from]]]#
35077[? [:dkim|author] || (dkim:AUTHOR)]#
35078[? [:useragent|name]   ||, [:useragent|name]: [:uquote|[:useragent|body]]]#
35079, helo=[:client_helo]#
35080[? %#T ||, Tests: \[[%T|,]\]]#
35081#[:supplementary_info|VERSION|, SA: %%s]#
35082#[:supplementary_info|RULESVERSION|, rules: %%s]#
35083[? [:banning_rule_key]     ||, b.key=[:banning_rule_key]]#
35084[? [:banning_rule_comment] ||, b.com=[:banning_rule_comment]]#
35085[? [:banning_rule_rhs]     ||, b.rhs=[:banning_rule_rhs]]#
35086[? [:banned_parts_as_attr] ||, b.parts=[:banned_parts_as_attr]]#
35087[:supplementary_info|SCTYPE|, shortcircuit=%%s]#
35088[:supplementary_info|AUTOLEARN|, autolearn=%%s]#
35089[:supplementary_info|AUTOLEARNSCORE|, autolearnscore=%%s]#
35090[? [:supplementary_info|LANGUAGES] ||, languages=[:uquote|[:supplementary_info|LANGUAGES]]]#
35091[? [:supplementary_info|RELAYCOUNTRY] ||, relaycountry=[:uquote|[:supplementary_info|RELAYCOUNTRY]]]#
35092[? [:supplementary_info|ASN] ||, asn=[:uquote|[:supplementary_info|ASN] [:supplementary_info|ASNCIDR]]]#
35093#[? [:supplementary_info|DCCB] ||, dcc=[:supplementary_info|DCCB]:[:uquote|[:supplementary_info|DCCR]]]#
35094#[? [:supplementary_info|DCCREP] ||, dcc_rep=[:supplementary_info|DCCREP]]#
35095#[:supplementary_info|AWLSIGNERMEAN|, signer_avg=%%s]#
35096#[? [:dkim|domain]   ||, dkim_d=[:dkim|domain]]#
35097[? [:dkim|identity]  ||, dkim_i=[:dkim|identity]]#
35098[? [:dkim|sig_sd]    ||, dkim_sd=[:dkim|sig_sd]]#
35099[? [:dkim|newsig_sd] ||, dkim_new=[:dkim|newsig_sd]]#
35100[? [:rusage|ru_maxrss] ||, rss=[:rusage|ru_maxrss]]#
35101, %y ms#
35102]
35103__DATA__
35104#
35105# =============================================================================
35106# This text section governs how a main per-recipient amavisd-new log entry
35107# is formed (config variable $log_recip_templ). An empty text will prevent a
35108# log entry, multi-line text will produce multiple log entries, one for each
35109# nonempty line. Macro %. might be useful, it counts recipients starting
35110# from 1. Syntax is explained in the README.customize file.
35111# Long header fields will be automatically wrapped by the program.
35112#
35113[?%#D|#|Passed #
35114#([:ccat|name|main]) #
35115[? [:ccat|major] |OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER|SPAMMY|SPAM|\
35116UNCHECKED|BANNED (%F)|INFECTED (%V)]#
35117, [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,], Hits: %c#
35118, tag=[:tag_level], tag2=[:tag2_level], kill=[:kill_level]#
35119[~[:remote_mta_smtp_response]|["^$"]||\
35120["queued as ([0-9A-Za-z]+)"]|[", queued_as: %1"]|[", fwd: %0"]]#
35121, %0/%1/%2/%k#
35122]
35123[?%#O|#|Blocked #
35124#([:ccat|name|blocking]) #
35125[? [:ccat|major|blocking] |#
35126OTHER|CLEAN|MTA-BLOCKED|OVERSIZED|BAD-HEADER|SPAMMY|SPAM|\
35127UNCHECKED|BANNED (%F)|INFECTED (%V)]#
35128, [:mail_addr_decode_octets|%s] -> [%D|[:mail_addr_decode_octets|%D]|,], Hits: %c#
35129, tag=[:tag_level], tag2=[:tag2_level], kill=[:kill_level]#
35130, %0/%1/%2/%k#
35131]
35132__DATA__
35133#
35134# =============================================================================
35135# This is a template for (neutral: non-virus, non-spam, non-banned)
35136# DELIVERY STATUS NOTIFICATIONS to sender.
35137# For syntax and customization instructions see README.customize.
35138# The From, To and Date header fields will be provided automatically.
35139# Long header fields will be automatically wrapped by the program.
35140#
35141Subject: [?%#D|Undeliverable mail|Delivery status notification]\
35142[? [:ccat|major] |||, MTA-BLOCKED\
35143|, OVERSIZED message\
35144|, invalid header section[=explain_badh|1]\
35145[?[:ccat|minor]||: bad MIME|: unencoded 8-bit character\
35146|: improper use of control char|: all-whitespace header line\
35147|: header line longer than 998 characters|: header field syntax error\
35148|: missing required header field|: duplicate header field|]\
35149|, UNSOLICITED BULK EMAIL apparently from you\
35150|, UNSOLICITED BULK EMAIL apparently from you\
35151|, contents UNCHECKED\
35152|, BANNED contents type (%F)\
35153|, VIRUS in message apparently from you (%V)\
35154]
35155Message-ID: <DSN%i@%h>
35156
35157[? %#D |#|Your message WAS SUCCESSFULLY RELAYED to:\
35158[%D|\n  [:mail_addr_decode|%D]|]
35159
35160[~[:dsn_notify]|["\\bSUCCESS\\b"]|\
35161and you explicitly requested a delivery status notification on success.\n]\
35162]
35163[? %#N |#|The message WAS NOT relayed to:\
35164[%N|\n  [:mail_addr_decode|%N]|]
35165]
35166[:wrap|78|||This [?%#D|nondelivery|delivery] report was \
35167generated by the program amavisd-new at host %h. \
35168Our internal reference code for your message is %n/%i]
35169
35170# ccat_min 0: other,  1: bad MIME,  2: 8-bit char,  3: NUL/CR,
35171#          4: empty,  5: long,  6: syntax,  7: missing,  8: multiple
35172[? [:explain_badh] ||[? [:ccat|minor]
35173|INVALID HEADER
35174|INVALID HEADER: BAD MIME HEADER SECTION OR BAD MIME STRUCTURE
35175|INVALID HEADER: INVALID NON-ASCII CHARACTERS IN HEADER SECTION
35176|INVALID HEADER: INVALID CONTROL CHARACTERS IN HEADER SECTION
35177|INVALID HEADER: FOLDED HEADER FIELD LINE MADE UP ENTIRELY OF WHITESPACE
35178|INVALID HEADER: HEADER LINE LONGER THAN RFC 5322 LIMIT OF 998 CHARACTERS
35179|INVALID HEADER: HEADER FIELD SYNTAX ERROR
35180|INVALID HEADER: MISSING REQUIRED HEADER FIELD
35181|INVALID HEADER: DUPLICATE HEADER FIELD
35182|INVALID HEADER
35183]
35184[[:wrap|78|  |  |%X]\n]
35185]\
35186#
35187[:wrap|78||  |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]]
35188[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35189[?[:dkim|author]|| (dkim:AUTHOR)]]
35190[? [:header_field|Sender]|#|\
35191[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35192[?[:dkim|sender]|| (dkim:SENDER)]]]
35193[? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35194[? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35195[? %#X|#|[? [:useragent] |#|[:wrap|78||  |[:useragent]]]]
35196[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35197
35198# ccat_min 0: other,  1: bad MIME,  2: 8-bit char,  3: NUL/CR,
35199#          4: empty,  5: long,  6: syntax,  7: missing,  8: multiple
35200[? [:explain_badh] ||[? [:ccat|minor]
35201|# 0: other
35202|# 1: bad MIME
35203|# 2: 8-bit char
35204WHAT IS AN INVALID CHARACTER IN A MAIL HEADER SECTION?
35205
35206  The RFC 5322 document specifies rules for forming internet messages.
35207  It does not allow the use of characters with codes above 127 to be
35208  used directly (non-encoded) in a mail header section.
35209
35210  If such characters (e.g. with diacritics, or non-Latin) from UTF-8
35211  or other character set need to be included in a message header
35212  section, such message needs to be submitted to an SMTPUTF8-capable
35213  mailer (RFC 6532), or these characters need to be properly encoded
35214  according to RFC 2047.
35215
35216  Necessary encoding is normally done transparently by a mail reader
35217  or other mail generating software. If automatic encoding is not
35218  available (e.g. by some old MUA) it is a user's responsibility
35219  to avoid using such characters in a header section, or to encode
35220  them manually. Typically offending header fields in this category
35221  are 'Subject', 'Organization', and comment fields or display names
35222  in e-mail addresses of 'From', 'To', or 'Cc'.
35223
35224  Sometimes such invalid header fields are inserted automatically
35225  by some MUA, MTA, content filter, or other mail handling service.
35226  If this is the case, such service needs to be fixed or properly
35227  configured. Typically the offending header fields in this category
35228  are 'Date', 'Received', 'X-Mailer', 'X-Priority', 'X-Scanned', etc.
35229
35230  If you don't know how to fix or avoid the problem, please report it
35231  to _your_ postmaster or system manager.
35232#
35233[~[:useragent]|^X-Mailer:\\s*Microsoft Outlook Express 6\\.00|["
35234  If using Microsoft Outlook Express as your MUA, make sure its
35235  settings under:
35236     Tools -> Options -> Send -> Mail Sending Format -> Plain & HTML
35237  are: "MIME format" MUST BE selected,
35238  and  "Allow 8-bit characters in headers" MUST NOT be enabled!
35239"]]#
35240|# 3: NUL/CR
35241IMPROPER USE OF CONTROL CHARACTER IN A MESSAGE HEADER SECTION
35242
35243  The RFC 5322 document specifies rules for forming internet messages.
35244  It does not allow the use of control characters NUL and bare CR
35245  to be used directly in a mail header section.
35246|# 4: empty
35247IMPROPERLY FOLDED HEADER FIELD LINE MADE UP ENTIRELY OF WHITESPACE
35248
35249  The RFC 5322 document specifies rules for forming internet messages.
35250  In section '3.2.2. Folding white space and comments' it explicitly
35251  prohibits folding of header fields in such a way that any line of a
35252  folded header field is made up entirely of white-space characters
35253  (control characters SP and HTAB) and nothing else.
35254|# 5: long
35255HEADER LINE LONGER THAN RFC 5322 LIMIT OF 998 CHARACTERS
35256
35257  The RFC 5322 document specifies rules for forming internet messages.
35258  Section '2.1.1. Line Length Limits' prohibits each line of a header
35259  section to be more than 998 characters in length (excluding the CRLF).
35260|# 6: syntax
35261|# 7: missing
35262MISSING REQUIRED HEADER FIELD
35263
35264  The RFC 5322 document specifies rules for forming internet messages.
35265  Section '3.6. Field Definitions' specifies that certain header fields
35266  are required (origination date field and the "From:" originator field).
35267|# 8: multiple
35268DUPLICATE HEADER FIELD
35269
35270  The RFC 5322 document specifies rules for forming internet messages.
35271  Section '3.6. Field Definitions' specifies that certain header fields
35272  must not occur more than once in a message header section.
35273|# other
35274]]#
35275__DATA__
35276#
35277# =============================================================================
35278# This is a template for VIRUS/BANNED SENDER NOTIFICATIONS.
35279# For syntax and customization instructions see README.customize.
35280# The From, To and Date header fields will be provided automatically.
35281# Long header fields will be automatically wrapped by the program.
35282#
35283Subject: [? [:ccat|major]
35284|Clean message from you\
35285|Clean message from you\
35286|Clean message from you (MTA blocked)\
35287|OVERSIZED message from you\
35288|BAD-HEADER in message from you\
35289|Spam claiming to be from you\
35290|Spam claiming to be from you\
35291|A message with UNCHECKED contents from you\
35292|BANNED contents from you (%F)\
35293|VIRUS in message apparently from you (%V)\
35294]
35295[? %m  |#|In-Reply-To: [:mail_addr_decode|%m]]
35296Message-ID: <VS%i@%h>
35297
35298[? [:ccat|major] |Clean|Clean|MTA-BLOCKED|OVERSIZED|INVALID HEADER|\
35299Spammy|Spam|UNCHECKED contents|BANNED CONTENTS ALERT|VIRUS ALERT]
35300
35301Our content checker found
35302[? %#V |#|[:wrap|78|    |  |[? %#V |viruses|virus|viruses]: %V]]
35303[? %#F |#|[:wrap|78|    |  |banned [? %#F |names|name|names]: %F]]
35304[? %#X |#|[[:wrap|78|    |  |%X]\n]]
35305
35306in email presumably from you [:mail_addr_decode|%s]
35307to the following [? %#R |recipients|recipient|recipients]:\
35308[%R|\n-> [:mail_addr_decode|%R]|]
35309
35310Our internal reference code for your message is %n/%i
35311
35312[? %a |#|[:wrap|78||  |First upstream SMTP client IP address: [:client_addr_port] %g]]
35313
35314[:wrap|78||  |Received trace: [ip_proto_trace_all|%x| < ]]
35315
35316[:wrap|78||  |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]]
35317[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35318[?[:dkim|author]|| (dkim:AUTHOR)]]
35319[? [:header_field|Sender]|#|\
35320[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35321[?[:dkim|sender]|| (dkim:SENDER)]]]
35322[? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35323[? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35324[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35325
35326[? %#D |Delivery of the email was stopped!
35327
35328]#
35329[? %#V ||Please check your system for viruses,
35330or ask your system administrator to do so.
35331
35332]#
35333[? %#V |[? %#F ||#
35334The message [?%#D|has been blocked|triggered this warning] because it contains a component
35335(as a MIME part or nested within) with declared name
35336or MIME type or contents type violating our access policy.
35337
35338To transfer contents that may be considered risky or unwanted
35339by site policies, or simply too large for mailing, please consider
35340publishing your content on the web, and only sending a URL of the
35341document to the recipient.
35342
35343Depending on the recipient and sender site policies, with a little
35344effort it might still be possible to send any contents (including
35345viruses) using one of the following methods:
35346
35347- encrypted using pgp, gpg or other encryption methods;
35348
35349- wrapped in a password-protected or scrambled container or archive
35350  (e.g.: zip -e, arj -g, arc g, rar -p, or other methods)
35351
35352Note that if the contents is not intended to be secret, the
35353encryption key or password may be included in the same message
35354for recipient's convenience.
35355
35356We are sorry for inconvenience if the contents was not malicious.
35357
35358The purpose of these restrictions is to avoid the most common
35359propagation methods used by viruses and other malware. These often
35360exploit automatic mechanisms and security holes in more popular
35361mail readers. By requiring an explicit and decisive action from a
35362recipient to decode mail, a danger of automatic malware propagation
35363is largely reduced.
35364#
35365# Details of our mail restrictions policy are available at ...
35366
35367]]#
35368__DATA__
35369#
35370# =============================================================================
35371# This is a template for non-spam (e.g. VIRUS,...) ADMINISTRATOR NOTIFICATIONS.
35372# For syntax and customization instructions see README.customize.
35373# Long header fields will be automatically wrapped by the program.
35374#
35375From: %f
35376Date: %d
35377Subject: [? [:ccat|major] |Clean mail|Clean mail|MTA-blocked mail|\
35378OVERSIZED mail|INVALID HEADER in mail|Spammy|Spam|UNCHECKED contents in mail|\
35379BANNED contents (%F) in mail|VIRUS (%V) in mail]\
35380 FROM [?%l||LOCAL ][?%a||[:client_addr_port] ][:mail_addr_decode|%s]
35381To: [? %#T |undisclosed-recipients:;|[%T|, ]]
35382[? %#C |#|Cc: [%C|, ]]
35383Message-ID: <VA%i@%h>
35384
35385[? %#V |No viruses were found.
35386|A virus was found: %V
35387|Two viruses were found:\n  %V
35388|%#V viruses were found:\n  %V
35389]
35390[? %#F |#|[:wrap|78||  |Banned [?%#F|names|name|names]: %F]]
35391[? %#X |#|Bad header:[\n[:wrap|78|  |  |%X]]]
35392[? %#W |#\
35393|Scanner detecting a virus: %W
35394|Scanners detecting a virus: %W
35395]
35396Content type: [:ccat|name|main]#
35397[? [:ccat|is_blocked_by_nonmain] ||, blocked for [:ccat|name]]
35398Internal reference code for the message is %n/%i
35399
35400[? %a |#|[:wrap|78||  |First upstream SMTP client IP address: [:client_addr_port] %g]]
35401
35402[:wrap|78||  |Received trace: [ip_proto_trace_all|%x| < ]]
35403
35404[:wrap|78||  |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]]
35405[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35406[?[:dkim|author]|| (dkim:AUTHOR)]]
35407[? [:header_field|Sender]|#|\
35408[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35409[?[:dkim|sender]|| (dkim:SENDER)]]]
35410[? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35411[? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35412[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35413[? %q |Not quarantined.|The message has been quarantined as: %q]
35414
35415[? %#S |Notification to sender will not be mailed.
35416
35417]#
35418[? %#D |#|The message WILL BE relayed to:[%D|\n[:mail_addr_decode|%D]|]
35419]
35420[? %#N |#|The message WAS NOT relayed to:[%N|\n[:mail_addr_decode|%N]|]
35421]
35422[? %#V |#|[? %#v |#|Virus scanner output:[\n  %v]
35423]]
35424__DATA__
35425#
35426# =============================================================================
35427# This is a template for VIRUS/BANNED/BAD-HEADER RECIPIENTS NOTIFICATIONS.
35428# For syntax and customization instructions see README.customize.
35429# Long header fields will be automatically wrapped by the program.
35430#
35431From: %f
35432Date: %d
35433Subject: [? [:ccat|major] |Clean mail|Clean mail|MTA-blocked mail|\
35434OVERSIZED mail|INVALID HEADER in mail|Spammy|Spam|UNCHECKED contents in mail|\
35435BANNED contents (%F) in mail|VIRUS (%V) in mail] TO YOU from [:mail_addr_decode|%s]
35436[? [:header_field|To] |To: undisclosed-recipients:;|To: [:header_field|To]]
35437[? [:header_field|Cc] |#|Cc: [:header_field|Cc]]
35438Message-ID: <VR%i@%h>
35439
35440[? %#V |[? %#F ||BANNED CONTENTS ALERT]|VIRUS ALERT]
35441
35442Our content checker found
35443[? %#V |#|[:wrap|78|    |  |[?%#V|viruses|virus|viruses]: %V]]
35444[? %#F |#|[:wrap|78|    |  |banned [?%#F|names|name|names]: %F]]
35445[? %#X |#|[[:wrap|78|    |  |%X]\n]]
35446
35447in an email to you [? %#V |from:|from probably faked sender:]
35448  [:mail_addr_decode|%o]
35449[? %#V |#|claiming to be: [:mail_addr_decode|%s]]
35450
35451Content type: [:ccat|name|main]#
35452[? [:ccat|is_blocked_by_nonmain] ||, blocked for [:ccat|name]]
35453Our internal reference code for your message is %n/%i
35454
35455[? %a |#|[:wrap|78||  |First upstream SMTP client IP address: [:client_addr_port] %g]]
35456
35457[:wrap|78||  |Received trace: [ip_proto_trace_all|%x| < ]]
35458
35459[:wrap|78||  |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]]
35460[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35461[?[:dkim|author]|| (dkim:AUTHOR)]]
35462[? [:header_field|Sender]|#|\
35463[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35464[?[:dkim|sender]|| (dkim:SENDER)]]]
35465[? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35466[? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35467[? [:useragent] |#|[:wrap|78||  |[:useragent]]]
35468[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35469[? %q |Not quarantined.|The message has been quarantined as: %q]
35470
35471Please contact your system administrator for details.
35472__DATA__
35473#
35474# =============================================================================
35475# This is a template for spam SENDER NOTIFICATIONS.
35476# For syntax and customization instructions see README.customize.
35477# The From, To and Date header fields will be provided automatically.
35478# Long header fields will be automatically wrapped by the program.
35479#
35480Subject: Considered UNSOLICITED BULK EMAIL, apparently from you
35481[? %m  |#|In-Reply-To: [:mail_addr_decode|%m]]
35482Message-ID: <SS%i@%h>
35483
35484A message from [:mail_addr_decode|%s]\
35485[%R|\nto: [:mail_addr_decode|%R]|]
35486
35487was considered unsolicited bulk e-mail (UBE).
35488
35489Our internal reference code for your message is %n/%i
35490
35491The message carried your return address, so it was either a genuine mail
35492from you, or a sender address was faked and your e-mail address abused
35493by third party, in which case we apologize for undesired notification.
35494
35495We do try to minimize backscatter for more prominent cases of UBE and
35496for infected mail, but for less obvious cases some balance between
35497losing genuine mail and sending undesired backscatter is sought,
35498and there can be some collateral damage on either side.
35499
35500[? %a |#|[:wrap|78||  |First upstream SMTP client IP address: [:client_addr_port] %g]]
35501
35502[:wrap|78||  |Received trace: [ip_proto_trace_all|%x| < ]]
35503
35504[:wrap|78||  |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]]
35505[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35506[?[:dkim|author]|| (dkim:AUTHOR)]]
35507[? [:header_field|Sender]|#|\
35508[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35509[?[:dkim|sender]|| (dkim:SENDER)]]]
35510[? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35511[? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35512# [? [:useragent] |#|[:wrap|78||  |[:useragent]]]
35513[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35514[? %#X |#|\n[[:wrap|78||  |%X]\n]]
35515
35516[? %#D |Delivery of the email was stopped!
35517]#
35518#
35519# Spam scanner report:
35520# [%A
35521# ]\
35522__DATA__
35523#
35524# =============================================================================
35525# This is a template for spam ADMINISTRATOR NOTIFICATIONS.
35526# For syntax and customization instructions see README.customize.
35527# Long header fields will be automatically wrapped by the program.
35528#
35529From: %f
35530Date: %d
35531Subject: Spam FROM [?%l||LOCAL ][?%a||[:client_addr_port] ][:mail_addr_decode|%s]
35532To: [? %#T |undisclosed-recipients:;|[%T|, ]]
35533[? %#C |#|Cc: [%C|, ]]
35534Message-ID: <SA%i@%h>
35535
35536Content type: [:ccat|name|main]#
35537[? [:ccat|is_blocked_by_nonmain] ||, blocked for [:ccat|name]]
35538Internal reference code for the message is %n/%i
35539
35540[? %a |#|[:wrap|78||  |First upstream SMTP client IP address: [:client_addr_port] %g]]
35541
35542[:wrap|78||  |Received trace: [ip_proto_trace_all|%x| < ]]
35543
35544[:wrap|78||  |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]]
35545[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35546[?[:dkim|author]|| (dkim:AUTHOR)]]
35547[? [:header_field|Sender]|#|\
35548[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35549[?[:dkim|sender]|| (dkim:SENDER)]]]
35550[? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35551[? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35552[? [:useragent] |#|[:wrap|78||  |[:useragent]]]
35553[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35554[? %q |Not quarantined.|The message has been quarantined as: %q]
35555
35556[? %#D |#|The message WILL BE relayed to:[%D|\n[:mail_addr_decode|%D]|]
35557]
35558[? %#N |#|The message WAS NOT relayed to:[%N|\n[:mail_addr_decode|%N]|]
35559]
35560Spam scanner report:
35561[%A
35562]\
35563__DATA__
35564#
35565# =============================================================================
35566# This is a template for the plain text part of a RELEASE FROM A QUARANTINE,
35567# applicable if a chosen release format is 'attach' (not 'resend').
35568#
35569From: %f
35570Date: %d
35571Subject: \[released message\] %j
35572To: [? %#T |undisclosed-recipients:;|[%T|, ]]
35573[? %#C |#|Cc: [%C|, ]]
35574Message-ID: <QRA%i@%h>
35575
35576Please find attached a message which was held in a quarantine,
35577and has now been released.
35578
35579[:wrap|78||  |Return-Path: [:mail_addr_decode|%s][?[:dkim|envsender]|| (OK)]]
35580[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35581[?[:dkim|author]|| (dkim:AUTHOR)]]
35582[? [:header_field|Sender]|#|\
35583[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35584[?[:dkim|sender]|| (dkim:SENDER)]]]
35585# [? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35586# [? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35587# [? [:useragent] |#|[:wrap|78||  |[:useragent]]]
35588[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35589
35590Our internal reference code for the message is %n/%i
35591#
35592[~[:report_format]|["^attach$"]|["[? [:attachment_password] |#|
35593
35594Contents of the attached mail message may pose a threat to your computer or
35595could be a social engineering deception, so it should be handled cautiously.
35596To prevent undesired automatic opening, the attached original mail message
35597has been wrapped in a password-protected ZIP archive.
35598
35599Here is the password that allows opening of the attached archive:
35600
35601  [:attachment_password]
35602
35603Note that the attachment is not strongly encrypted and the password
35604is not a strong secret (being displayed in this non-encrypted text),
35605so this attachment is not suitable for guarding a secret contents.
35606The sole purpose of this password protection it to prevent undesired
35607accidental or automatic opening of a message, either by some filtering
35608software, a virus scanner, or by a mail reader.
35609]"]|]#
35610__DATA__
35611#
35612# =============================================================================
35613# This is a template for the plain text part of a problem/feedback report,
35614# with either the original message included in-line, or attached,
35615# or the message is structured as a FEEDBACK REPORT NOTIFICATIONS format.
35616# See RFC 5965 - "An Extensible Format for Email Feedback Reports".
35617#
35618From: %f
35619Date: %d
35620Subject: Fw: %j
35621To: [? %#T |undisclosed-recipients:;|[%T|, ]]
35622[? %#C |#|Cc: [%C|, ]]
35623Message-ID: <ARF%i@%h>
35624#Auto-Submitted: auto-generated
35625
35626This is an e-mail [:feedback_type] report for a message \
35627[? %a |\nreceived on %d,|received from\nIP address [:client_addr_port] on %d,]
35628
35629[:wrap|78||  |Return-Path: [:mail_addr_decode|%s]]
35630[:wrap|78||  |From: [:mime_decode|[:header_field_octets|From]|100]\
35631[?[:dkim|author]|| (dkim:AUTHOR)]]
35632[? [:header_field|Sender]|#|\
35633[:wrap|78||  |Sender: [:mime_decode|[:header_field_octets|Sender]|100]\
35634[?[:dkim|sender]|| (dkim:SENDER)]]]
35635[? %m |#|[:wrap|78||  |Message-ID: [:mail_addr_decode|%m]]]
35636[? %r |#|[:wrap|78||  |Resent-Message-ID: [:mail_addr_decode|%r]]]
35637[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35638[?[:dkim|author]|#|
35639A first-party DKIM or DomainKeys signature is valid, d=[:dkim|author].]
35640
35641Reporting-MTA: %h
35642Our internal reference code for the message is %n/%i
35643
35644[~[:report_format]|["^(arf|attach|dsn)$"]|["\
35645A complete original message is attached.
35646[~[:report_format]|["^arf$"]|\
35647For more information on the ARF format please see RFC 5965.
35648]"]|["\
35649A complete original message in its pristine form follows:
35650"]]#
35651__DATA__
35652#
35653# =============================================================================
35654# This is a template for the plain text part of an auto response (e.g.
35655# vacation, out-of-office), see RFC 3834.
35656#
35657From: %f
35658Date: %d
35659To: [? %#T |undisclosed-recipients:;|[%T|, ]]
35660[? %#C |#|Cc: [%C|, ]]
35661Reply-To: postmaster@%h
35662Message-ID: <ARE%i@%h>
35663Auto-Submitted: auto-replied
35664[:wrap|76||\t|Subject: Auto: autoresponse to: [:mail_addr_decode|%s]]
35665[? %m  |#|In-Reply-To: [:mail_addr_decode|%m]]
35666Precedence: junk
35667
35668This is an auto-response to a message \
35669[? %a |\nreceived on %d,|received from\nIP address [:client_addr_port] on %d,]
35670envelope sender: [:mail_addr_decode|%s]
35671(author)   From: [:rfc2822_from]
35672[? %j |#|[:wrap|78||  |Subject: [:mime_decode|[:header_field_octets|Subject]|100]]]
35673[?[:dkim|author]|#|
35674A first-party DKIM or DomainKeys signature is valid, d=[:dkim|author].]
35675