#
#   MailScanner - SMTP E-Mail Virus Scanner
#   Copyright (C) 2002  Julian Field
#
#   $Id: Postfix.pm,v 1.1.2.45 2004/05/31 13:16:33 jkf Exp $
#
#   This program is free software; you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation; either version 2 of the License, or
#   (at your option) any later version.
#
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, write to the Free Software
#   Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
#   The author, Julian Field, can be contacted by email at
#      Jules@JulianField.net
#   or by paper mail at
#      Julian Field
#      Dept of Electronics & Computer Science
#      University of Southampton
#      Southampton
#      SO17 1BJ
#      United Kingdom
#


package MailScanner::Sendmail;

use strict 'vars';
use strict 'refs';
no  strict 'subs'; # Allow bare words for parameter %'s

use DirHandle;

use vars qw($VERSION);

### The package version, both in 1.23 style *and* usable by MakeMaker:
$VERSION = substr q$Revision: 1.1.2.45 $, 10;

# Command-line options you need to give to sendmail to sensibly process a
# message that is piped to it. Still need to add "-f" for specifying the
# envelope sender address. This is usually local postmaster.
my $SendmailOptions = "-t -oi -oem -F MailScanner -f";
my $RunAsUser = 0;
my $UnsortedBatchesLeft;


# Attributes are
#
# $DFileRegexp                  set by new
# $HFileRegexp                  set by new
# $TFileRegexp                  set by new
# $QueueFileRegexp              set by new
# $LockType                     set by new
#


# If the sendmail and/or sendmail2 config variables aren't set, then
# set them to something sensible. This will need to be different
# for Exim.
sub initialise {
  $RunAsUser = MailScanner::Config::Value('runasuser');
  $RunAsUser = $RunAsUser?getpwnam($RunAsUser):0;

  MailScanner::Config::Default('sendmail', '/usr/sbin/sendmail');
  MailScanner::Config::Default('sendmail2',
                               MailScanner::Config::Value('sendmail'));
  $MailScanner::SMDiskStore::HashDirDepth = 0;
  $UnsortedBatchesLeft = 0; # Disable queue-clearing mode
}

# Constructor.
# Takes dir => directory queue resides in
# REVISO LEOH
sub new {
  my $type = shift;
  my $this = {};

  # These need to be improved
  # No change for V4
  $this->{HDFileRegexp} = '^([\\dA-F]+)$';
  $this->{TFileRegexp} = '^tf-' . $$ . '-([\\dA-F]+)$';
  # JKF Must fix this once I know what it's for.
  $this->{QueueFileRegexp} = '^([\\d]+-[\\d]+)$';

  $this->{LockType} = "flock";

  bless $this, $type;
  return $this;
}

# Required vars are:
#
#ZZ# DFileRegexp:
#ZZ# A regexp that will verify that a filename is a valid
#ZZ# "DFile" name and leave the queue id in $1 if it is.
#ZZ#
# HDFileRegexp:
# A regexp that will verify that a filename is a valid
# "HDFile" name and leave the queue id in $1 if it is.
#
# TFileRegexp:
# A regexp that will verify that a filename is a valid
# "TFile" name and leave the queue id in $1 if it is.
#
# QueueFileRegexp:
# A regexp that will match any legitimate queue file name
# and leave the queue id in $1.
#
# LockType:
# The way we should usually do spool file locking for
# this MTA ("posix" or "flock")
#
# Required subs are:
#
#ZZ# DFileName: 
#ZZ# Take a queue ID and return
#ZZ# filename for data queue file
#ZZ#
# HDFileName:
# Take a queue ID and return
# filename for envelope and data queue file (input)
#
# HDOutFileName:
# Take a queue ID and return
# filename for envelope and data queue file (output)
#
# TFileName:
# Take a queue ID and return
# filename for temp queue file
#
# BuildMessageCmd:
# Return the shell command to take a mailscanner header file
# and an MTA message file, and build a plain text message
# (complete with headers)
#
# ReadQf:
# Read an envelope queue file (sendmail qf) and build
# an array of lines which together form all the mail headers.
#
# AddHeader:
# Given a current set of headers (string), and another header
# (key string, value string), return the set of headers with the new one
# added.
#
# DeleteHeader:
# Given a current set of headers (string), and another header
# (string), return the set of headers with the new one removed.
#
# ReplaceHeader:
# Given a current set of headers (string), and another header
# (key string, value string), return the set of headers with the new one
# in place of any existing occurence of the header.
#
# AppendHeader:
# Given a current set of headers (string), another header
# (key string, value string), and a separator string,
# return the set of headers with the new value
# appended to any existing occurrence of the header.
#
# PrependHeader:
# Given a current set of headers (string), another header
# (key string, value string), and a separator string,
# return the set of headers with the new value
# prepended to the front of any existing occurrence of the header.
# Do the header matching in a case-insensitive way.
#
# TextStartsHeader:
# Given a current set of headers (string), another header (string)
# and a search string,
# return true if the search string appears at the start of the
# text of the header.
# Do the matching in a case-insensitive way.
#
# AddRecipients:
# Return list of QF file lines for the passed recipients, which
# are comma-separated (with optional spaces with the commas).
#
# KickMessage:
# Given id, tell MTA to make a delivery attempt.
#
# CreateQf:
# Given a Message object, return a string containing the entire
# header file for this MTA.
#

# NOTE -- These were in list above; I believe that they are
# implementation details and should not be used outside this
# file. Looking further, they appear to be commented out here
# as well as in the Exim module -- nwp
#
# Internal subs:
#
# ConstructHeaders:
# Build a set of headers (in a string) ready to go into an MTA
# envelope file.
#
# ReadEnvelope:
# Given filehandle open for reading, read envelope lines into
# string and return it.
# 
# SplitEnvelope:
# Given complete envelope string, separate out header lines and
# return 2 strings, one containing the main part of the envelope,
# the other containing the headers.
#
# MergeEnvelope:
# Given main envelope body (from SplitEnvelope at the moment) and
# string of headers, merge them to form a complete envelope.
#
# MergeEnvelopeParts:
# Given filehandle open for reading, merge envelope data (excepting
# headers) from filehandle with headers from string, and return new
# envelope data in string, ready to be written back to new
# envelope queue file.
#

# Do conditional once at include time

#my($MTA) = MailScanner::Config::Value('mta');
#
#print STDERR "MTA is \"" . MailScanner::Config::Value('mta') . "\"\n";
#
#print STDERR "We are running zmail\n";
#
#MailScanner::Log::InfoLog("Configuring mailscanner for zmail...");

  sub HDFileName {
    my($this, $id) = @_;
    #my($dir1, $dir2, $file);
    #$id =~ /^(.)(.)(.+)$/;
    #($dir1, $dir2, $file) = ($1,$2,$3);
    #return "$dir1/$dir2/$file";
    return "$id";
  }

  # Give it a temp file name, changes the file name to 
  # a new one for the outgoing queue.
  sub HDOutFileName {
    my($file) = @_;

    #print STDERR "HDOutFileName $file\n";

    my $dir = $file;
    $dir =~ s/\/[^\/]+$//;

    # Bad hash key $file = sprintf("%05X%lX", time % 1000000, (stat($file))[1]);
    # Add 1 so the number is never zero (defensive programming)
    $file = sprintf("%05X%lX", int(rand 1000000)+1, (stat($file))[1]);
    #print STDERR "New Filename is $file\n";

    if ($MailScanner::SMDiskStore::HashDirDepth == 2) {
      $file =~ /^(.)(.)/;
      return ($dir,$1,$2,$file);
    } elsif ($MailScanner::SMDiskStore::HashDirDepth == 1) {
      $file =~ /^(.)/;
      return ($dir,$1,$file);
    } else {
      MailScanner::Log::WarnLog("Postfix dir depth has not been set!");
    }
  }

  # No change for V4
  sub TFileName {
    my($this, $id) = @_;
    return "temp-$$-$id";
  }

  # Change for V4: returns lower-case $from and @to
  sub ReadQf {
    my($this, $message) = @_;

    my($RQf) = $message->{store}{inhdhandle};

    my(@results, $msginfo, $from);
    my($ip, $TOline);
    my($Line, $Flags);
    my($MsgContSize, $DataOffset, $NumRecips);
    my($TOFound, $FROMFound, $IPFound);
    my($rectype, $recdata);

    #print STDERR "In ReadQf\n";
    #$message->{store}->print();
    $message->{nobody} = 0; # If there is no message body we just get X at end of headers

    # Just in case we get a message with no headers at all
    @{$message->{headers}} = ();


    # Seek to the start of the file in case anyone read the file
    # between me opening it and locking it.
    seek $RQf, 0, 0;

    # Read the initial record.
    # Provides Message content size, data offset and recipient count
    ($rectype, $recdata) = ReadRecord($RQf);
    #print "1st $rectype is \"$recdata\"\n";
    MailScanner::Log::WarnLog("Syntax error in Postfix queue file, didn't " .
                              "start with a C record") unless $rectype eq 'C';
    $recdata =~ /^([0-9 ]{15}) ([0-9 ]{15}) ([0-9 ]{15})( ([0-9 ]{15}))?$/;
    ($MsgContSize, $DataOffset, $NumRecips) = ($1+0, $2+0, $3+0);

    # If $5 is set then we have a new data structure in the file
    $MailScanner::Postfix::DataStructure = 0;
    if ($5 ne "") {
      $MailScanner::Postfix::DataStructure = 1;
      $message->{PostfixQmgrOpts} = $5+0;
    }

    $MsgContSize =~ s/^\s*//;
    $DataOffset  =~ s/^\s*//;
    $NumRecips   =~ s/^\s*//;
    #print STDERR "MsgContSize=$MsgContSize DataOffset=$DataOffset NumRecips=$NumRecips\n";
    push @{$message->{metadata}}, "$rectype$recdata";
    #print STDERR "Content size = $MsgContSize\n";
    #print STDERR "Data offset  = $DataOffset\n";
    #print STDERR "Num Recips   = $NumRecips\n";

    # Read records until we hit the start of the message M record
    #print STDERR "Reading pre data\n";
    while(($rectype, $recdata) = ReadRecord($RQf)) {
      #print STDERR "Got $rectype $recdata\n";
      if ($rectype eq 'M') {
        # Message starts here
        push @{$message->{metadata}}, "$rectype$recdata";
        last;
      } elsif ($rectype eq 'S') {
        # Sender address
        $recdata =~ s/^\<//;
        $recdata =~ s/\<$//;
        $message->{from} = lc($recdata);
        $FROMFound = 1;
      #JKF 20040322 } elsif ($rectype eq 'R') {
      } elsif ($rectype eq 'O') {
        # Recipient address
        $recdata =~ s/^\<//;
        $recdata =~ s/\<$//;
        push @{$message->{to}}, lc($recdata);
        push @{$message->{metadata}}, "$rectype$recdata";
        $TOFound = 1;
        #print STDERR "Pre R Recip $recdata\n";
      #JKF 20040322 } elsif ($rectype eq 'O') {
      } elsif ($rectype eq 'R') {
        # Original recipients are handled by MS as normal recipients,
        # but are put back into the 'O' originalrcpts list in the
        # replacement message.
        # Original recipient address
        $recdata =~ s/^\<//;
        $recdata =~ s/\<$//;
        $recdata = lc($recdata);
        push @{$message->{to}}, $recdata;
        push @{$message->{metadata}}, "$rectype$recdata";
        #JKF 20040322 $message->{originalrecips}{"$recdata"} = 1;
        $message->{postfixrecips}{"$recdata"} = 1;
        $TOFound = 1;
        #print STDERR "Pre O Recip $recdata\n";
      } else {
        # Some other record type. Just store it and move on.
        push @{$message->{metadata}}, "$rectype$recdata";
      }
    }

    # We are now at the start of the message. Read the headers until
    # we get an empty N record which is the blank line just after the
    # headers.
    #print STDERR "Reading message body\n";
    while(!$message->{nobody} && (($rectype, $recdata) = ReadRecord($RQf))) {
      #print STDERR "Reading headers: $rectype, $recdata\n";
      if ($rectype eq 'X') {
        #push @{$message->{metadata}}, "$rectype$recdata";
        $message->{nobody} = 1; # Found end of message before message body text
        last;
      }
      last if $rectype eq 'N' && $recdata eq "";
      push @{$message->{headers}}, $recdata; # Headers have no leading N
      if ($recdata =~ /^Subject:\s+(\S.*)$/i) {
        $message->{subject} = $1;
        next;
      }
      if (!$IPFound && $recdata =~ /^Received: .+\[(\d+\.\d+\.\d+\.\d+)\]/i) {
        $message->{clientip} = $1;
        $IPFound = 1;
      } elsif (!$IPFound && $recdata =~ /^Received: .+\[([\dabcdef.:]+)\]/i) {
        # It is an IPv6 address
        $message->{clientip} = $1;
        $IPFound = 1;
      } elsif (!$IPFound &&
               $recdata =~ /^Received: .+\(Postfix/i) {
        $message->{clientip} = '127.0.0.1';  #spoof local sender from localhost
        $IPFound = 1;
      }
    }
    # Must remember to add empty "X" record after the message data.

    # We are now at the end of the headers. Jump straight to the metadata
    # after the message.
    seek $RQf, $MsgContSize+$DataOffset, 0;

    # We are now in the metadata after the message.
    #print STDERR "Reading post data\n";
    while(($rectype, $recdata) = ReadRecord($RQf)) {
      #print STDERR "Metadata type $rectype data \"$recdata\"\n";
      if ($rectype eq 'E') {
        push @{$message->{metadata}}, "$rectype$recdata";
        last;
      }
      #JKF 20040322 if ($rectype eq 'R') {
      if ($rectype eq 'O') {
        # Recipient address
        $recdata =~ s/^\<//;
        $recdata =~ s/\<$//;
        push @{$message->{to}}, lc($recdata);
        push @{$message->{metadata}}, "$rectype$recdata";
        $TOFound = 1;
        #print STDERR "Post R Recip $recdata\n";
      #JKF 20040322 } elsif ($rectype eq 'O') {
      } elsif ($rectype eq 'R') {
        # These recipients are used in the message handling in MS,
        # but must be put back in the 'O' list in the new message.
        # Original recipient address
        $recdata =~ s/^\<//;
        $recdata =~ s/\<$//;
        $recdata = lc($recdata);
        push @{$message->{to}}, $recdata;
        push @{$message->{metadata}}, "$rectype$recdata";
        #JKF 20040322 $message->{originalrecips}{"$recdata"} = 1;
        $message->{postfixrecips}{"$recdata"} = 1;
        $TOFound = 1;
        #print STDERR "Post O Recip $recdata\n";
      } else {
        # Some other record type. Just store it and move on.
        push @{$message->{metadata}}, "$rectype$recdata";
      }
    }
      
    # We now have all the pre-message records followed by the M record
    # followed by the post-message records including the X record and the
    # terminating E record. We can add recipient R records just before
    # the last last metadata record (so we keep the E at the end).
    # The message headers and body get put in just after the M record.

    # Every postfix file should at least define the sender, 1 recipient and
    # the IP address. Everything else is optional, and is preserved as
    # MailScanner may not understand all the types of line.
    #print STDERR "Found FROM\n" if $FROMFound;
    #print STDERR "Found TO\n"   if $TOFound;
    #print STDERR "Found IP\n"   if $IPFound;
    #print STDERR "Successfully ReadQf!\n" if $FROMFound && $TOFound && $IPFound;

    # If we didn't find an IP address, then put in 0.0.0.0 so that at least
    # we have something there
    $message->{clientip} = '0.0.0.0' unless $IPFound;

    return 1 if $FROMFound && $TOFound; # && $IPFound;
    #MailScanner::Log::WarnLog("Batch: Found invalid queue file for " .
    #                          "message %s", $message->{id});
    return 0;
  }


  # Read a Postfix record. These are structured as follows:
  # First 1 byte to show the record type. These are nice easy-to-read ASCII.
  # Then 1 or more bytes to show the length. These are encoded so that
  # the bottom 7 bits of each byte hold length data, and the 8th (top) bit
  # is 1 if there is another length byte. The most significant bytes are
  # given first.
  # Then 0 or more bytes of data. No terminator.
  sub ReadRecord {
    my($fh) = @_;
    my($type, $len, $shift, $len_byte, $data);

    # Get the record type
    read $fh, $type, 1 or return (undef,undef);

    # Get the length
    $len = 0;
    $shift = 0;
    while (1) {
      read $fh, $len_byte, 1;
      $len_byte = ord $len_byte;
      if ($shift >= 39) {
        MailScanner::Log::WarnLog("Postfix record too long in ReadRecord()");
        return (undef, undef);
      }
      #print STDERR "ReadRecord: Got length byte $len_byte\n";
      #sleep 1;
      $len |= (($len_byte & 0x7F) << $shift);
      last if ($len_byte & 0x80) == 0;
      $shift += 7;
    }

    # Get the data
    $data = "";
    read $fh, $data, $len if $len;

    $data =~ s/\0//g; # Remove any null bytes
    #print STDERR "ReadRecord: $type \"$data\"\n";
    return ($type, $data);
  }

  # Add all the message headers to the metadata so it's ready to be
  # mangled and output to disk. Puts the headers at the end.
  # Can be passed in a string containing all the headers.
  # This is usually the output of stringify_output (MIME-Tools).
  # JKF: @headers doesn't include leading "H" header indicator.
  #      @metadata includes leading "H" but no \n characters.
  #      The input to this function can be a "\n"-separated string of
  #      new header lines. This is useful as the SpamCheck header can
  #      be flowed over multiple lines, but still be passed into here
  #      as a single header.
  sub AddHeadersToQf {
    my $this = shift;
    my($message, $headers) = @_;

    my($header, $h, @headerswithouth);
    my($records, $pos);

    if ($headers) {
      #print STDERR "AddHeadersToQf: Headers are $headers\n";
      @headerswithouth = split(/\n/, $headers);
    } else {
      #print STDERR "AddHeadersToQf: Message-Headers are " .
      #             join("\n", @{$message->{headers}}) . "\n";;
      @headerswithouth = @{$message->{headers}};
    }

    # Make complete records ready for insertion in the metadata
    foreach (@headerswithouth) {
      s/^/N/;
    }

    # Look through for the M record that indicates start of message.
    # Insert each line of the headers as an N record just after that.
    $pos = 0;
    $pos++ while($message->{metadata}[$pos] !~ /^M/);
    # $pos now points at M record
    $pos++;
    # Now at position to insert header records
    #print STDERR "Adding headers at $pos\n";
    splice @{$message->{metadata}}, $pos, 0, @headerswithouth;
    #print STDERR "Metadata is now:\n" . join("\n", @{$message->{metadata}}) . "End of metadata\n";
  }

  # Add a header. Needs to look for the position of the M record again
  # so it knows where to insert it.
  sub AddHeader {
    my($this, $message, $newkey, $newvalue) = @_;

    # Find the X record
    my($pos);
    $pos = $#{$message->{metadata}};
    $pos-- while ($pos >= 0 && $message->{metadata}[$pos] !~ /^X/);
    #print STDERR "*** AddHeader $newkey $newvalue at position $pos\n";

    # Need to split the new header data into the 1st line and a list of
    # continuation lines, creating a new N record for each continuation
    # line.
    my(@lines, $line, $firstline);
    @lines = split(/\n/, $newvalue);
    $firstline = shift @lines;
    # We want a list of N records
    foreach (@lines) {
      s/^/N/;
    }

    # Insert the lines at position $pos
    splice @{$message->{metadata}}, $pos, 0, "N$newkey $firstline", @lines;
  }

  # Delete a header. Must be in an N line plus any continuation N lines
  # that immediately follow it.
  sub DeleteHeader {
    my($this, $message, $key) = @_;

    my($pos);
    $pos = 0;
    $pos++ while ($message->{metadata}[$pos] !~ /^M/);
    # Now points at the M record
    $pos++;
    # Now points at first N record
    while ($pos < @{$message->{metadata}}) {
      if ($message->{metadata}[$pos] =~ /^N$key/i) {
        # We have found the start of 1 occurrence of this header
        splice @{$message->{metadata}}, $pos, 1;
        # Delete continuation lines
        while($message->{metadata}[$pos] =~ /^N\s/) {
          splice @{$message->{metadata}}, $pos, 1;
        }
        next;
      }
      $pos++;
    }
  }

  sub ReplaceHeader {
    my($this, $message, $key, $newvalue) = @_;

    $this->DeleteHeader($message, $key);
    $this->AddHeader($message, $key, $newvalue);
  }

  # Append to the end of a header if it exists.
  sub AppendHeader {
    my($this, $message, $key, $newvalue, $sep) = @_;

    my($linenum, $oldlocation, $totallines);

    # Try to find the old header
    $oldlocation = -1;
    $totallines = @{$message->{metadata}};

    # Find the start of the header
    for($linenum=0; $linenum<$totallines; $linenum++) {
      last if $message->{metadata}[$linenum] =~ /^M/;
    }
    for($linenum++; $linenum<$totallines; $linenum++) {
      next unless $message->{metadata}[$linenum] =~ /^N$key/i;
      $oldlocation = $linenum;
      last;
    }

    # Didn't find it?
    if ($oldlocation<0) {
      $this->AddHeader($message, $key, $newvalue);
      return;
    }

    # Find the last line of the header
    do {
      $oldlocation++;
    } while($linenum<$totallines &&
            $message->{metadata}[$oldlocation] =~ /^N\s/);
    $oldlocation--;

    # Need to split the new header data into the 1st line and a list of
    # continuation lines, creating a new N record for each continuation
    # line.
    my(@lines, $line, $firstline);
    @lines = split(/\n/, $newvalue);
    $firstline = shift @lines;
    # We want a list of N records
    foreach (@lines) {
      s/^/N/;
    }
    # Add 1st line onto the end of the header
    $message->{metadata}[$oldlocation] .= "$sep$firstline";
    # Insert any continuation lines into the metadata just after the 1st line
    splice @{$message->{metadata}}, $oldlocation+1, 0, @lines;
  }

  # Insert text at the start of a header if it exists.
  sub PrependHeader {
    my($this, $message, $key, $newvalue, $sep) = @_;

    my($linenum, $oldlocation, $totallines);

    # Try to find the old header
    $oldlocation = -1;
    $totallines = @{$message->{metadata}};

    # Find the start of the header
    for($linenum=0; $linenum<$totallines; $linenum++) {
      last if $message->{metadata}[$linenum] =~ /^M/;
    }
    for($linenum++; $linenum<$totallines; $linenum++) {
      next unless $message->{metadata}[$linenum] =~ /^N$key/i;
      $oldlocation = $linenum;
      last;
    }

    # Didn't find it?
    if ($oldlocation<0) {
      $this->AddHeader($message, $key, $newvalue);
      return;
    }

    $message->{metadata}[$oldlocation] =~ s/^N$key\s+/N$key $newvalue$sep/i;
  }

  sub TextStartsHeader {
    my($this, $message, $key, $text) = @_;

    my($linenum, $oldlocation, $totallines);

    # Try to find the old header
    $oldlocation = -1;
    $totallines = @{$message->{metadata}};

    # Find the start of the header
    for($linenum=0; $linenum<$totallines; $linenum++) {
      last if $message->{metadata}[$linenum] =~ /^M/;
    }
    for($linenum++; $linenum<$totallines; $linenum++) {
      next unless $message->{metadata}[$linenum] =~ /^N$key/i;
      $oldlocation = $linenum;
      last;
    }

    # Didn't find it?
    return 0 if $oldlocation<0;

    return 1 if $message->{metadata}[$oldlocation] =~
                                   /^N$key\s+\Q$text\E/i;
    return 0;
  }

  # BUG BUG BUG This contains a problem where it will not
  # find the text on the end of a multi-line header. Need to
  # flag multi-line headers so change the final regexp.
  sub TextEndsHeader {
    my($this, $message, $key, $text) = @_;

    my($linenum, $oldlocation, $lastline, $totallines);

    # Try to find the old header
    $oldlocation = -1;
    $totallines = @{$message->{metadata}};

    # Find the start of the header
    for($linenum=0; $linenum<$totallines; $linenum++) {
      last if $message->{metadata}[$linenum] =~ /^M/;
    }
    for($linenum++; $linenum<$totallines; $linenum++) {
      next unless $message->{metadata}[$linenum] =~ /^N$key/i;
      $oldlocation = $linenum;
      last;
    }

    # Didn't find it?
    return 0 if $oldlocation<0;

    # Find the last line of the header
    $lastline = $oldlocation;
    do {
      $lastline++;
    } while($lastline<$totallines &&
            $message->{metadata}[$lastline] =~ /^N\s/);
    $lastline--;
    $key = '\s' unless $lastline == $oldlocation;

    return 1 if $message->{metadata}[$lastline] =~
                                   /^N$key.+\Q$text\E$/i;
    return 0;
  }


  # Add recipient R records to the end of the metadata, just before
  # the terminating E record
  sub AddRecipients {
    my $this = shift;
    my($message, @recips) = @_;

    my $totallines = @{$message->{metadata}};

    foreach (@recips) {
      s/^/R/;
    }

    # Changed 2 to 1 in next line for Postfix 2.1
    splice @{$message->{metadata}}, $totallines-1, 0, @recips;
    #print STDERR "AddRecipients: " . join(',',@recips) . "\n";
    #print STDERR "metadata is \"" . join("\n", @{$message->{metadata}}) . "\n";
  }

  # Delete the original recipients from the message. We'll add some
  # using AddRecipients later.
  sub DeleteRecipients {
    my $this = shift;
    my($message) = @_;

    #print STDERR "Deleting Recipients!\n";
    my($linenum);
    for ($linenum=0; $linenum<@{$message->{metadata}}; $linenum++) {
      # Looking for "recipient" lines
      # Should allow 'O' here as well
      next unless $message->{metadata}[$linenum] =~ /^[RO]/;
      # Have found the right line
      #print STDERR "Deleting recip " . $message->{metadata}[$linenum] . "\n";
      splice(@{$message->{metadata}}, $linenum, 1);
      $linenum--; # Study the same line again
    }
  }


  # Send an I down the FIFO to the Postfix queue manager, so that it reads
  # its incoming queue.
  sub KickMessage {
    my(@ids) = @_;
    my($idlist);

    # Using the spool directory with the last element chopped off,
    # find the public directory wth the qmgr FIFO in it. Send an I
    # to that FIFO.
    my $public = MailScanner::Config::Value('outqueuedir');
    $public =~ s/[^\/]+$/public/;
    my $fh = new FileHandle;
    $fh->open(">$public/qmgr") or
      MailScanner::Log::WarnLog("KickMessage failed as couldn't write to " .
                                "%s, %s", "$public/qmgr", $!);
    print $fh "I";
    $fh->close;

    return 0;
  }

  # Does not exist in Postfix as there is only 1 file per message.
  #sub CreateQf {
  #  my($message) = @_;
  #
  #  return join("\n", @{$message->{metadata}}) . "\n\n";
  #}

  # Produce a string containing everything that goes before the first
  # N record of the message, including all the headers and the separator
  # line.
  sub PreDataString {
    #my $this = shift;
    my($message) = @_;

    my($linenum, $result, $type, $data, $to, $preNlen);

    #print STDERR "In PreDataString\n";
    # Output all the metadata records up until (& including) the M record.
    $linenum = 0;
    foreach (@{$message->{metadata}}) {
      /^(.)(.*)$/;
      ($type, $data) = ($1, $2);
      #print STDERR "PreData1 Type $type Data $data\n";
      last if $type eq 'M';
      $result .= Record2String($type, $data);
      # Make the S record appear just after the T record
      # as that's where Postfix likes to see it.
      $result .= Record2String('S', $message->{from}) if $type eq 'T';
      #print STDERR "PreData $type $data\n";
      $linenum++;
    }
    # The recipients are already in the pre-message string.
    ## Add the recipients
    ## If there is more than 1 recipient, then place original recips in the
    ## 'O' list. If only 1 then just put it in an 'R' record.
    #if (scalar(@{$message->{to}}) > 1 && defined($message->{originalrecips})) {
    #  # There are several recips and there is an originalrecips list
    #  my $RecordType;
    #  foreach $to (@{$message->{to}}) {
    #    $RecordType = $message->{originalrecips}{"$to"}?'O':'R';
    #    $result .= Record2String($RecordType, $to);
    #  }
    #} else {
    #  foreach $to (@{$message->{to}}) {
    #    $result .= Record2String('R', $to);
    #  }
    #}

    # Add the M record to mark the start of the headers
    $result .= Record2String('M', $data);
    $linenum++;

    # Store the length of th estring so far as we need to return it
    $preNlen = length($result);

    # Add the headers
    foreach(; $linenum<@{$message->{metadata}}; $linenum++) {
      $_ = $message->{metadata}[$linenum];
      /^(.)(.*)$/;
      ($type, $data) = ($1, $2);
      #print STDERR "PreData2 Type $type Data $data\n";
      last if $type eq 'X';
      $result .= Record2String($type, $data);
      #print STDERR "Pre $type $data\n";
    }

    # Add the header-body separator line if there is a message body
    #print STDERR "No body flag is " . $message->{nobody} . "\n";
    $result .= Record2String('N', "") unless $message->{nobody};

    #print STDERR "Result of PreDataString is $result\n";
    # Return the string and the length of the data before any N records
    return ($result, $preNlen);
  }

  sub PostDataString {
    #my $this = shift;
    my($message) = @_;

    my($result, $type, $data);
    my($record, $recordnum);
    $result = Record2String('X', "");

    $recordnum = @{$message->{metadata}} - 1;
    $recordnum-- while($message->{metadata}[$recordnum] !~ /^X/);
    for($recordnum++; $recordnum<@{$message->{metadata}}; $recordnum++) {
      $record = $message->{metadata}[$recordnum];
      $record =~ /^(.)(.*)$/;
      ($type, $data) = ($1, $2);
      $result .= Record2String($type, $data);
      #print STDERR "Post $type $data\n";
    }

    return $result;
  }


  sub Record2String {
    my($rectype, $recdata) = @_;

    return "" if $rectype eq ""; # Catch empty records

    my($result, $len_byte, $len_rest);
    $result = "";
    $result .= $rectype;

    $len_rest = length($recdata);
    do {
        $len_byte = $len_rest & 0x7F;
        $len_byte |= 0x80 if $len_rest >>= 7;
        $result .= pack 'C', $len_byte;
    } while ($len_rest != 0);
    $result .= $recdata;
  }


  # Append, add or replace a given header with a given value.
  sub AddMultipleHeader {
    my $this = shift;
    my($message, $headername, $headervalue, $separator) = @_;

    my($multiple) = MailScanner::Config::Value('multipleheaders', $message);
    $this->AppendHeader ($message,
                         MailScanner::Config::Value(lc($headername), $message),
                         $headervalue, $separator)
      if $multiple eq 'append';

    $this->AddHeader    ($message,
                         MailScanner::Config::Value(lc($headername), $message),
                         $headervalue)
      if $multiple eq 'add';

    $this->ReplaceHeader($message,
                         MailScanner::Config::Value(lc($headername), $message),
                         $headervalue)
      if $multiple eq 'replace';
  }


  # Send an email message containing all the headers and body in a string.
  # Also passed in the sender's address.
  sub SendMessageString {
    my $this = shift;
    my($message, $email, $sender) = @_;

    my($fh);

    $fh = new FileHandle;
    $fh->open('|' . MailScanner::Config::Value('sendmail', $message) .
              " $SendmailOptions '" . $sender . "'")
      or MailScanner::Log::WarnLog("Could not send email message, %s", $!),
         return 0;
    $fh->print($email);
    $fh->close();

    1;
  }


  # Send an email message containing the attached MIME entity.
  # Also passed in the sender's address.
  sub SendMessageEntity {
    my $this = shift;
    my($message, $entity, $sender) = @_;

    my($fh);

    $fh = new FileHandle;
    $fh->open('|' . MailScanner::Config::Value('sendmail', $message) .
              " $SendmailOptions '" . $sender . "'")
      or MailScanner::Log::WarnLog("Could not send email entity, %s", $!),
           return 0;
    $entity->print($fh);
    $fh->close();

    1;
  }


# Work out the hash directory depth from the current directory.
# It is either ./dir1/files or ./dir1/dir2/files.
# I need to open ./dir then read from it. If I find a dir in there
# then the depth must be 2. Otherwise the depth is 1?
# If I find nothing then sleep and work it out again.
sub FindHashDirDepth {
  my($mta) = @_;

  my($delay, $foundanything, $here, $dir1name, $filename, $dir1, $filecount);

  $delay = MailScanner::Config::Value('queuescaninterval');
  $foundanything = 0;
  $here = new DirHandle;
  $dir1 = new DirHandle;

  #MailScanner::Log::WarnLog("JKF: Hash dir depth value being calculated");
  while(1) {
    $filecount = 0;
    $here->open('.')
      or MailScanner::Log::DieLog("Cannot open directory . when finding depth");
    while(defined($dir1name = $here->read())) {
      #MailScanner::Log::WarnLog("JKF: Reading %s from dir .", $dir1name);
      next if $dir1name eq '.' || $dir1name eq '..';
      $filecount++ if -f $dir1name;
      next unless -d $dir1name;
      $dir1->open($dir1name)
        or MailScanner::Log::DieLog("Cannot open dir %s when finding depth",
                                    $dir1name);
      while(defined($filename = $dir1->read())) {
        #MailScanner::Log::WarnLog("JKF: Reading %s from inner dir %s",
        #                          $filename, $dir1name);
        next if $filename eq '.' || $filename eq '..';
        if (-f "$dir1name/$filename" && $filename =~ /$mta->{HDFileRegexp}/) {
          # We have found a queue file inside dir1
          $dir1->close();
          $here->close();
          #MailScanner::Log::InfoLog("Postfix queue structure is depth 1");
          return 1;
        }
        if (-d "$dir1name/$filename" && $filename =~ /^.$/) {
          # We have found another hashing directory, so it must be depth 2
          $dir1->close();
          $here->close();
          #MailScanner::Log::InfoLog("Postfix queue structure is depth 2");
          return 2;
        }
      }
      $dir1->close();
    }
    $here->close();

    # Didn't find anything at all, so sleep waiting for a file or a dir
    # to appear in the queue.
    MailScanner::Log::WarnLog("Messages found but no hashed queue directories. Please enable hashed queues for incoming and deferred with a depth of 1 or 2. See the Postfix documentation for hash_queue_names and hash_queue_depth")
      if $filecount>0;
    sleep($delay);
  }
}


  # Create a MessageBatch object by reading the queue and filling in
  # the passed-in batch object.
  sub CreateBatch {
    my $this = shift;
    my($batch) = @_;

    my($queuedirname, $queuedir, $queue1dir, $queue2dir, $MsgsInQueue);
    my($DirtyMsgs, $DirtyBytes, $CleanMsgs, $CleanBytes);
    my($HitLimit1, $HitLimit2, $HitLimit3, $HitLimit4);
    my($MaxCleanB, $MaxCleanM, $MaxDirtyB, $MaxDirtyM);
    my(%ModDate, $mta, $file, $file1, $file2, $tmpdate);
    my(@SortedFiles, $id, $newmessage, @queuedirnames);
    my($batchempty, $h1, $h2, $delay, $CriticalQueueSize);
    my($nlinks, $headerfileumask, $invalidfiles);

    $queuedir  = new DirHandle;
    $queue1dir = new DirHandle;
    $queue2dir = new DirHandle;
    $MsgsInQueue = 0;
    $delay     = MailScanner::Config::Value('queuescaninterval');
    #print STDERR "Inq = " . $global::MS->{inq} . "\n";
    #print STDERR "dir = " . $global::MS->{inq}{dir} . "\n";
    @queuedirnames = @{$global::MS->{inq}{dir}};

    ($MaxCleanB, $MaxCleanM, $MaxDirtyB, $MaxDirtyM)
                      = MailScanner::MessageBatch::BatchLimits();

    # If there are too many messages in the queue, start processing in
    # directory storage order instead of date order.
    $CriticalQueueSize = MailScanner::Config::Value('criticalqueuesize');

    # Set what we will need the umask to be
    $headerfileumask = $global::MS->{work}->{fileumask};

    do {
      $batch->{messages} = {};
      # Statistics logging
      $batch->{totalbytes} = 0;
      $batch->{totalmessages} = 0;

      #
      # Now do the actual work
      #
      $DirtyMsgs  = 0;
      $DirtyBytes = 0;
      $CleanMsgs  = 0;
      $CleanBytes = 0;
      $MsgsInQueue = 0;
      %ModDate = ();
      @SortedFiles = ();
      $HitLimit1  = 0;
      $HitLimit2  = 0;
      $HitLimit3  = 0;
      $HitLimit4  = 0;
      $invalidfiles = "";

      my @aux_queuedirnames=@queuedirnames;

      # Loop through each of the inq directories
      while( defined($queuedirname=splice(@aux_queuedirnames,
                    ($UnsortedBatchesLeft<=0 ? 0 : int(rand(@aux_queuedirnames))),1))) {
        #print STDERR "Scanning dir $queuedirname\n";
        unless (chdir $queuedirname) {
          MailScanner::Log::WarnLog("Cannot cd to dir %s to read messages, %s",
                                    $queuedirname, $!);
          next;
        }
        $mta = $global::MS->{mta};

        # If we haven't found the hash directory depth yet, then work it out
        #MailScanner::Log::WarnLog("JKF: About to work out hash dir depth");
        $MailScanner::SMDiskStore::HashDirDepth = FindHashDirDepth($mta)
          unless $MailScanner::SMDiskStore::HashDirDepth;
        #MailScanner::Log::WarnLog("JKF: Hash dir depth is %d",
        #  $MailScanner::SMDiskStore::HashDirDepth);

        $queuedir->open('.')
          or MailScanner::Log::DieLog("Cannot open queue dir %s for reading " .
                                      "message batch, %s", $queuedirname, $!);
        #print STDERR "Searching " . $queuedirname . " for messages\n";

        # Got to read directories and child directories here and find
        # files in the the child directories.
        while(defined($file = $queuedir->read())) {
          next if $file eq '.' || $file eq '..';
          next unless -d $file;
          $queue1dir->open($file) or next;
          while(defined($file1 = $queue1dir->read())) {
            next if $file1 eq '.' || $file1 eq '..' || $file1 eq 'core';
            if ($MailScanner::SMDiskStore::HashDirDepth==1) {
              next unless $file1 =~ /$mta->{HDFileRegexp}/;
              push @SortedFiles, "$queuedirname/$file/$file1";
              if ($UnsortedBatchesLeft<=0) {
                # Running normally
                ($nlinks, $tmpdate) = (stat("$file/$file1"))[3,9]; # 9 = mtime
                next if -z _;
                next unless -f _;
                next unless -R _;
                next if $nlinks>1; # Catch files being moved into "deferred"
                $ModDate{"$queuedirname/$file/$file1"} = $tmpdate;
              }
              $MsgsInQueue++;
              #print STDERR "Stored depth 1 message file $file1\n";
              next;
            } else {
              # It is depth 2 so read another dir down
              next unless -d "$file/$file1";
              $queue2dir->open("$file/$file1") or next;
              while(defined($file2 = $queue2dir->read())) {
                next if $file2 eq '.' || $file2 eq '..' || $file2 eq 'core';
                next unless $file2 =~ /$mta->{HDFileRegexp}/;
                push @SortedFiles, "$queuedirname/$file/$file1/$file2";
                if ($UnsortedBatchesLeft<=0) {
                  # Running normally
                  ($nlinks, $tmpdate) = (stat("$file/$file1/$file2"))[3,9];
                  next if -z _; # Skip 0-length queue files
                  next unless -f _;
                  next unless -R _;
                  next if $nlinks>1; # Files being moved into "deferred"
                  $ModDate{"$queuedirname/$file/$file1/$file2"} = $tmpdate;
                }
                $MsgsInQueue++;
                #print STDERR "Stored depth 2 message file $file2\n";
              }
              $queue2dir->close;
            }
          }
          $queue1dir->close;
        }
        $queuedir->close;
      }

      # Not sorting the queue will save us considerably more time than
      # just skipping the sort operation, as it will enable the next bit
      # of code to just use the files nearest the beginning of the directory.
      # This should make the directory lookups much faster on filesystems
      # with slow directory lookups (e.g. anything except xfs).
      $UnsortedBatchesLeft = 40
        if $CriticalQueueSize>0 && $MsgsInQueue>=$CriticalQueueSize;
      # SortedFiles is array of full pathnames now, not just filenames
      if ($UnsortedBatchesLeft>0) {
        $UnsortedBatchesLeft--;
      } else {
        @SortedFiles = sort { $ModDate{$a} <=> $ModDate{$b} } keys %ModDate;
      }

      $batchempty = 1;

      # Keep going until end of dir or have reached every imposed limit. This
      # now processes the files oldest first to make for fairer queue cleanups.
      #print STDERR "Files are " . join(', ', @SortedFiles) . "\n";
      umask $headerfileumask; # Started creating files
      while(defined($file = shift @SortedFiles) &&
            $HitLimit1+$HitLimit2+$HitLimit3+$HitLimit4<1) {

        # In accelerated queue-clearing mode, so we don't know anything yet
        if ($UnsortedBatchesLeft>0) {
          $nlinks = (stat $file)[3];
          next if -z _; # Skip 0-length queue files
          next unless -f _;
          next unless -R _;
          next if $nlinks>1; # Files being moved into "deferred"
        }

        # Yes I know this is a hack but it will help isolate the problem
        #next if $ModDate{$file} > time-3;

        # must separate next two lines or $1 gets re-tainted by being part of
        # same expression as $file [mumble mumble grrr mumble mumble]
        #print STDERR "Reading file $file from list\n";
        # Split pathname into dir and file again
        ($queuedirname, $h1, $h2, $file) = ($1,$2,$3,$4)
          if $MailScanner::SMDiskStore::HashDirDepth == 2 &&
             $file =~ /^(.*)\/(.)\/(.)\/([^\/]+)$/;
        ($queuedirname, $h1, $file) = ($1,$2,$3)
          if $MailScanner::SMDiskStore::HashDirDepth == 1 &&
             $file =~ /^(.*)\/(.)\/([^\/]+)$/;
        next unless $file =~ /$mta->{HDFileRegexp}/;
        $id = $1;

        #print STDERR "Adding $id to batch\n";

        # Lock and read the qf file. Skip this message if the lock fails.
        $newmessage = MailScanner::Message->new($id, $queuedirname);
        if ($newmessage eq 'INVALID') {
          $invalidfiles .= "$id ";
          next;
        }
        next unless $newmessage;

        $batch->{messages}{"$id"} = $newmessage;
        #print STDERR "Added $id to batch\n";
        $batchempty = 0;

        if (MailScanner::Config::Value("virusscan", $newmessage)) {
          $newmessage->NeedsScanning(1);
          $DirtyMsgs++;
          $DirtyBytes += $newmessage->{size};
          $HitLimit3 = 1
            if $DirtyMsgs>=$MaxDirtyM;
          $HitLimit4 = 1
            if $DirtyBytes>=$MaxDirtyB;
          $newmessage->WriteHeaderFile(); # Write the file of headers
        } else {
          $newmessage->NeedsScanning(0);
          $CleanMsgs++;
          $CleanBytes += $newmessage->{size};
          $HitLimit1 = 1
            if $CleanMsgs>=$MaxCleanM;
          $HitLimit2 = 1
            if $CleanBytes>=$MaxCleanB;
          # Will have to add a WriteHeaderFile() here to implement
          # single-file archiving of messages.
          $newmessage->WriteHeaderFile(); # Write the file of headers
        }
      }
      umask 0077; # Safety net as stopped creating files

      # Wait a bit until I check the queue again
      sleep($delay) if $batchempty;
    } while $batchempty; # Keep trying until we get something

    # Log the number of invalid messages found
    MailScanner::Log::InfoLog("New Batch: Found invalid queue files: %s",
                              $invalidfiles)
      if $invalidfiles;
    # Log the size of the queue if it is more than 1 batch
    MailScanner::Log::InfoLog("New Batch: Found %d messages waiting",
                              $MsgsInQueue)
      if $MsgsInQueue > ($DirtyMsgs+$CleanMsgs);

    MailScanner::Log::InfoLog("New Batch: Forwarding %d unscanned messages, " .
                              "%d bytes", $CleanMsgs, $CleanBytes)
      if $CleanMsgs;
    MailScanner::Log::InfoLog("New Batch: Scanning %d messages, %d bytes",
                              $DirtyMsgs, $DirtyBytes)
      if $DirtyMsgs;

    #MailScanner::Log::InfoLog("New Batch: Archived %d $ArchivedMsgs messages",
    #                          $ArchivedMsgs)
    #  if $ArchivedMsgs;

    $batch->{dirtymessages} = $DirtyMsgs;
    $batch->{dirtybytes}    = $DirtyBytes;

    # Logging stats
    $batch->{totalmessages} = $DirtyMsgs  + $CleanMsgs;
    $batch->{totalbytes}    = $DirtyBytes + $CleanBytes;

    #print STDERR "Dirty stats are $DirtyMsgs msgs, $DirtyBytes bytes\n";
  }


# Return the array of headers from this message, optionally with a
# separator on the end of each one.
# This is in Sendmail.pm as the storage of the headers array is specific
# to the MTA being used.
sub OriginalMsgHeaders {
  my $this = shift;
  my($message, $separator) = @_;

  # No separator so just return the array
  return @{$message->{headers}} unless $separator;

  # There is a separator
  my($h,@result);
  foreach $h (@{$message->{headers}}) {
    push @result, $h . $separator;
  }
  #print STDERR "OriginalMsgHeaders: Result is \"" . @result . "\"\n";
  return @result;
}


# Check that the queue directory passed in is flat and contains
# no queue sub-directories. For some MTA's this may be a no-op.
# For sendmail it matters a lot! Sendmail will put different files in
# different directories if there are subdirectories called things like
# qf, xf, tf or df. Also directories called q1, q2, etc. are a sure
# sign that sendmail is running queue groups, which MailScanner cannot
# handle.
#
# Called from main mailscanner script
#
sub CheckQueueIsFlat {
  my($dir) = @_;

  # This is a no-op for Postfix as we have to support the hash
  # directory structure.
  return 1;
}

1;
