
#
# BUGS:
#	cannot handle address with spaces in them
#

package Net::SMTP::Receive;

use vars qw($VERSION);
$VERSION = 0.301;

use strict;
use Socket;
use IO::Socket;
require Sys::Hostname;
use File::Slurp;
use File::Flock;
use Sys::Syslog;
use Time::CTime;
use Net::Ident 'ident_lookup';
use Storable;
require POSIX;
use POSIX qw(O_CREAT O_RDWR);
require File::Sync;

my $server;


#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#
#
# 	The following MUST be replaced in the subclass.		#
#
#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#


sub deliver { my($message) = @_; die "deferred"; }

sub is_delivered { my($message) = @_; die "deferred"; }

#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#
#
# 	The following functions are expected to be replaced 	#
#	in the subclass						#
#
#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#

# error codes:
#
#	4?? - temporary problems - should try again
#	451 - local error
#	452 - insufficient storage
#	421 - shutdown, closing channel
#	5?? - permanent problems - do not try again
#	500 - line too long (??)
#	501 - path too long (??)
#	552 - too many recipients (??)
#	552 - too much mail data (??)
#	554 - transmission/transaction failed
#

sub predeliver { my($server,$client,$msgref) = @_; }

sub prestart { } 

sub do_syslog { 1; } 

sub checkaccess { } 

sub check_mailfrom { 0; }

sub queue_directory { '/var/spool/pmqueue'; }

sub process_only_one_message { 0; }

#
# error codes:
#
#	550 relaying denied.
#	550 user unknown
#

sub check_rcptto { 0; }

#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#
#
# 	It is less likely you'll want to replace these		#
#
#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#

sub port { 'smtp(25)'; }

sub ipaddr { undef; } # INADDR_ANY

sub max_datalength { 20_000_000; }

sub max_recipients { 10_000; }

sub logcode { 'info'; }

sub logfacility { 'mail'; }

sub add_envelope { 0; }

sub do_ident { 1; }

sub progname
{
	$0 =~ /(\S+)/;
	return $0;
}

sub address_check
{
	my ($server, $addr) = @_;

	return "501parameter unrecognized" unless 
		$addr =~ /^(?:[^"'\s]+|"[^"]*"|'[^']*')+$/;
	return "Domain name rquired" unless $addr =~ /\S\@\S/;
	return "Could not parse" unless $addr =~ /^<\S+\@\S+\S>$/
		|| $addr =~ /[^<]\S*\@\S*[^>]/;
	return '';
}

my $hostname;
sub hostname
{
	return $hostname if $hostname;
	$hostname = Sys::Hostname::hostname();
}

sub handleDie
{
	return if $;
	my ($server, $msg) = @_;
	print STDERR "DIE: $msg\n";
	$server->log("%s", "died: $msg");
	exit(1);
}

sub handleWarn
{
	return if $;
	my ($server, $msg) = @_;
	print STDERR "WARN: $msg\n";
	$server->log("%s", "warning: $msg");
}

sub default_delivery_error { 554; }

sub help
{
	return <<END;
214-This is the perl module Net::STMP::Receive version $VERSION
214-
214-It supports the following normal SMTP commands:
214-    HELO   MAIL   RCPT   DATA
214-    EHLO   QUIT
214-For more information, see the module documenation at
214-http://www.cpan.org
214 End of HELP info
END
}


my $logopen;
sub log
{
	my ($server, $text, @args) = @_;

	if ($server->do_syslog || ! $logopen) {
		Sys::Syslog::openlog($server->progname, "pid", 
				$server->logfacility);
	}
	$logopen = 1;
	syslog($server->logcode, $text, @args)
		if $server->do_syslog;
	printf STDERR "*** $text\n", @args;
}

sub qf_cookie { 'pMR1:'; }


#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#
#
# It is unlikly you'll want to replace any of the following
#
#	#	#	#	#	#	#	#	#
#	#	#	#	#	#	#	#	#

sub server
{
	my ($pkg, %config) = @_;

	$server = { %config };
	bless ($server, $pkg);

	$server->prestart(%config);

	my $port = $config{'Port'} || $server->port();
	my $ip = $config{'IpAddr'};
	$ip = $server->ipaddr() unless defined $ip;

	my $listen = new IO::Socket::INET (
			'LocalAddr' => $ip,
			'LocalPort' => $port,
			'Proto' => 'tcp',
			'Reuse' => 1,
			'Listen' => 20,
		);

	unless ($listen) {
		$server->log("Could not bind to port %s:%s: %s", $ip, $port, $!);
		return 0;
	}

	$server->log("Now listening at %s:%s", $ip, $port);

	$SIG{'__DIE__'} = \&handleDieSig;
	$SIG{'__WARN__'} = \&handleWarnSig;

	$server->mainloop($listen);
}

sub mainloop
{
	my ($server, $listen) = @_;
	for(;;) {
		my $client = $listen->accept;

		if ($server->process_only_one_message()) {
			close($listen);
			$server->talk($client);

			again();
			return;
		}

		my $pid;
		for (;;) {
			$pid = fork();
			if (! defined($pid)) {
				warn("fork: $!");
				sleep 1;
				redo;
			}
			if ($pid == 0) {
				my $pid2;
				for (;;) {
					$pid2 = fork();
					if (! defined($pid2)) {
						warn("fork: $!");
						sleep 1;
						redo;
					}
					last;
				}
				if ($pid2 == 0) {
					$listen->close;
					exit($server->talk($client));
				}
				exit(0);
			}
			last;
		}
		close($client);

		rand(1);
		rand(1);
		rand(1);

		waitpid($pid,0);
	}
}

sub talk
{
	my ($server, $client) = @_;

	$server->log("Accepted connection from %s", $client->peerhost());

	$client->sockopt(SO_KEEPALIVE, 1);

	$server->{'PEERADDR'} = $client->peerhost();
	$server->{'PEERHOST'} = gethostbyaddr($client->peeraddr(), AF_INET);

	if ($server->do_ident) {
		my ($username, $opsys, $error) = ident_lookup($client, 30);
		if (! defined $username) {
			$server->log("Ident lookup failed %s: %s", $client->peerhost(), $error);
		} else {
			$server->log("Ident lookup %s: %s", $client->peerhost(), $username);
			$server->{'IDENT'} = $username;
		}
	}
	$server->checkaccess($client, $server->{'IDENT'});

	my $hostname = $server->hostname();
	my $date = strftime("%c GMT", gmtime(time));
	print $client "220 $hostname ESMTP Net::SMTP::Receive $VERSION; $date\r\n";

	$server->reset();

	while(<$client>) {
		s/\r?\n$//;
		if (/^help\b/i) {
			print $client $server->help();
		} elsif (/^quit/i) {
			print $client "221 $hostname closing connection\r\n";
			$server->log("Closing connection to %s", $client->peerhost());
			last;
		} elsif (s/^helo\b//i) {
			s/^\s*//;
			s/\s*\r?\n$//;
			$server->{'HELO'} = $_;
			print $client "220 hello $_!\r\n";
		} elsif (/^mail\b/i) {
			$server->mail($client,$_);
		} elsif (/^rcpt\b/i) {
			$server->rcpt($client,$_);
		} elsif (/^data\b/i) {
			$server->data($client,$_);
		} elsif (/^(noop|vrfy|expn|verb|etrn|dsn)\b/i) {
			print $client "501 Command not implemented\r\n";
		} elsif (/^(send|soml|saml|turn)\b/i) {
			print $client "501 Command unusual\r\n";
		} elsif (/^rset\b/i) {
			$server->reset();
			print $client "250 Reset state\r\n";
		} elsif (/^ehlo\b/i) {
			s/^\s*//;
			s/\s*\r?\n$//;
			$server->{'HELO'} = $_;
			my $h = $server->hostname;
			print $client "220-$h says hi, we support:\r\n";
			print $client "220 8BITMIME\r\n";
		} else {
			m/^(\.+?)\b/;
			print $client qq'500 Command unrecognized: "$1"\r\n';
		}
	}
	$server->log("Lost idle connection to %s", $client->peerhost())
		unless $_ && /^quit/i;
	eval {$client->close;};
	return 0;
}

sub stageok
{
	my ($server, $client, $func, $func2) = @_;
	return 1 if $func eq $server->{'STATE'};
	return 1 if $func2 eq $server->{'STATE'};
	print $client "503 Need $server->{STATE} command\r\n";
	return 0;
}

sub mail
{
	my ($server, $client, $command) = @_;

	return unless $server->stageok($client, 'MAIL');

	unless ($command =~ s/^mail\s+//i) {
		print $client "500 mail command unrecognized\r\n";
		return;
	}
	$command =~ s/\s+$//;
	unless ($command =~ s/^from://i) {
		print $client "501 syntax error at \"$command\"\r\n";
		return;
	}
	$command =~ s/^\s+//;
	if ($command =~ s/\s+body=(\S+)\s*$//i) {
		my $mt = $1;
		if ("\U$mt" eq '8BITMIME' || "\U$mt" eq '7BIT') {
			$server->{'MIMETYPE'} = $mt;
		} else {
			print $client "502-Body type $mt not implemented\r\n";
		}
	}
	my $addr = $command;
	my $aer = $addr eq '<>' 
		? ''
		: $server->address_check($addr);
	if ($aer) {
		$aer =~ s/^(\d\d\d)//;
		my $ec = $1 || '553';
		print $client "$ec $addr... $aer\r\n";
		$server->log("mail from error: %s... %s %s", $addr, $ec, $aer);
		return;
	}
	my $er = $server->check_mailfrom($addr);
	if ($er) {
		$er =~ s/^(\d\d\d)//;
		my $ec = $1 || '553';
		print $client "$ec $addr... $er\r\n";
		$server->log("mail from error: %s... %s %s", $addr, $ec, $er);
		return;
	}
	$server->{'FROM'} = $addr;
	$server->{'STATE'} = 'RCPT';
	print $client "250 $addr... Sender OK\r\n";
}

sub rcpt
{
	my ($server, $client, $command) = @_;

	return unless $server->stageok($client, 'DATA', 'RCPT');

	unless ($command =~ s/^rcpt\s+//i) {
		print $client "500 rcpt command unrecognized\r\n";
		return;
	}
	$command =~ s/\s+$//;
	unless ($command =~ s/^to://i) {
		print $client "501 syntax error at \"$command\"\r\n";
		return;
	}
	$command =~ s/^\s+//;
	my $addr = $command;
	my $aer = $server->address_check($addr);
	if ($aer) {
		$aer =~ s/^(\d\d\d)//;
		my $ec = $1 || '553';
		print $client "$ec $addr... $aer\r\n";
		$server->log("rcpt to error: %s... %s %s", $addr, $ec, $aer);
		return;
	}
	my ($er, @newaddr) = $server->check_rcptto($addr);
	if ($er) {
		$er =~ s/^(\d\d\d)//;
		my $ec = $1 || '553';
		print $client "$ec $addr... $er\r\n";
		$server->log("rcpt to error: %s... %s %s", $addr, $ec, $er);
		return;
	}
	if ($#{$server->{'TO'}} > $server->max_recipients) {
		$server->log("Too many recipients: %s", $client->peerhost());
		print $client "552 Too many recipients\r\n";
		$server->reset();
		return;
	}
	push(@{$server->{'TO'}}, (@newaddr ? @newaddr : $addr));
	$server->{'STATE'} = 'DATA';
	print $client "250 $addr... Recipient OK\r\n";
}

sub data
{
	my ($server, $client) = @_;
	return unless $server->stageok($client,'DATA');

	print $client $server->{'MIMETYPE'}
		? "354 Send $server->{MIMETYPE} message, ending in CLRF.CLRF\r\n"
		: qq'354 Enter mail, end with "." on a line by itself\r\n';

	my $eight = $server->{MIMETYPE} && "\U$server->{MIMETYPE}" eq '8BITMIME';
	my $p = 0;
	my $len;
	my @msg;

	my $date = strftime("%c GMT", gmtime(time));
	my $h3 = $server->hostname();
	my $from = $server->{FROM};
	$from =~ s/^<(.*)>$/$1/;
	$from = "mailer-daemon\@$h3" if $from eq '';

	push (@msg,"From $from $date\n");
	if ($server->add_envelope) {
		for my $t (@{$server->{'TO'}}) {
			push (@msg, "X-Envelope-To: $t\n");
		}
	}

	my $h0 = $server->{'PEERHOST'};
	my $h1 = $server->{'PEERADDR'} || $h0;
	my $h2 = $server->{'HELO'} || $h1;

	push(@msg, "Received: from $h2 ($h1 [$h0]) by $h3 ($VERSION); $date\n");
	while(<$client>) {
		if ($eight ? ($p && $_ eq ".\r\n") : /^\.[\r\n]*$/) {
			if ($len >= $server->max_datalength) {
				$server->log("Message via %s rejected: too long", $client->peerhost());
				print $client "552 Max data length exceeded\r\n";
				return;
			}
			my $id;
			undef $@;
			eval { $id = $server->enqueue($client, \@msg); };
			if ($@) {
				my $x = $@;
				$x =~ s/[\r\n]/... /g;
				$x =~ s/^(\d\d\d)//;
				my $er = $1 || $server->default_delivery_error;
				print $client "$er Message rejected: $x\r\n";
				$server->log("Message via %s rejected: %s", $client->peerhost(), "$er $x");
				$server->reset();
				return;
			}
			$server->log("Message %s via %s accepted: %s", $id, $client->peerhost(), $id);
			print $client "250 $id Message accepted for delivery\r\n";
			$server->reset();

			$server->runqueue($id);

			again() if $server->process_only_one_message();

			return;
		}
		$p = s/\r\n$/\n/;
		$len += length($_) if defined $_;
		if ($len < $server->max_datalength) {
			push(@msg, $_);
		}
	}
	$server->log("Lost connection to %s", $client->peerhost());
	$server->reset();
	return;
}

sub enqueue
{
	my ($server, $client, $msgref) = @_;

	$server->predeliver($client, $msgref);

	my $qd = $server->queue_directory();
	my $id = int(rand(100_000_000));
	for (;;) {
		$id-- while -e "$qd/$id.pqf";
		lock "$qd/$id.pqf", undef, 'nonblocking'
			or next;
		last if ! -s "$qd/$id.pqf";
		unlock "$qd/$id.pqf";
		$id--;
	}

	$server->{'TIME'} = time;
	$server->{'ID'} = $id;
	$server->{'TEXTFILE'} = "$qd/$id.txt";
	$server->syncwrite($server->{'TEXTFILE'}, join('',@$msgref));

	my $newfrozen = Storable::nfreeze($server);
	$server->syncwrite("$qd/$id.pqf.new", $server->qf_cookie, $newfrozen);

	rename("$qd/$id.pqf.new", "$qd/$id.pqf")
		or die "rename $qd/$id.pqf.new -> $qd/$id.pqf: $!";

	unlock "$qd/$id.pqf";

	$server->log("messaged assigned id %s and queued", $id);
	return $id;
}

sub showqueue
{
	my ($ref, @id) = @_;

	my $lead = "\t\tMail Queue\n-id-\t\t-size-\-status-\n";
	my $pkg = ref $ref || $ref;
	my $qd = ${pkg}->queue_directory;
	unless (@id) {
		for my $f (read_dir($qd)) {
			next unless $f =~ /^(\d+)\.pqf$/;
			push(@id, $1);
		}
	}
	for my $id (@id) {
		my $file = "$qd/$id.pqf";
		lock $file;
		if (! -s $file) {
			unlock $file;
			unlink $file;
			next;
		}
		my $size = -s "$qd/$id.txt";
		$lead .= "$id  \t$size\t";
		my $frozen = read_file($file);
		my $cookie = ${pkg}->qf_cookie;
		unless ($frozen =~ /^\Q$cookie\E(.*)$/s) {
			unlock $file;
			print "$lead\tCorrupt queue file\n";
			next;
		}
		my $message = Storable::thaw $1;
		print "${lead}From $message->{'FROM'}: $message->{'ERROR'}\n";

		for my $to (@{$message->{'TO'}}) {
			die "TO is a reference in $id" if ref $to;
			my $status = $message->is_delivered($to);
			print "\t\t$to: $status\n";
		}
		unlock $file;
	} continue {
		$lead = '';
	}
	print "Mail Queue is empty\n" if $lead;
}

sub runqueue
{
	my ($ref, @id) = @_;

	my $pkg = ref $ref || $ref;
	my $qd = ${pkg}->queue_directory;
	unless (@id) {
		for my $f (read_dir($qd)) {
			next unless $f =~ /^(\d+)\.pqf$/;
			push(@id, $1);
		}
	}
	my $file;
	for my $id (@id) {
		$file = "$qd/$id.pqf";
		lock $file;
		if (! -s $file) {
			unlock $file;
			unlink $file;
			next;
		}
		${pkg}->log("Processing mailq queue file %s", $file);
		my $frozen = read_file($file);
		my $cookie = ${pkg}->qf_cookie;
		unless ($frozen =~ /^\Q$cookie\E(.*)$/s) {
			unlock $file;
			${pkg}->log("Corrupt mail queue file: %s", $file);
			next;
		}
		my $message = Storable::thaw $1;
		if ($message->is_delivered) {
			${pkg}->log("Message %s is delivered", $id);
			unlink "$qd/$id.txt";
			unlink $file;
#$message->resave;
			next;
		}
		${pkg}->log("Attempting delivery of %s", $id);
		eval { $message->deliver(); };
		$message->{'ERROR'} = $@;
		$message->{'LASTQRUN'} = time;
		if ($message->is_delivered) {
			${pkg}->log("Message %s is delivered", $id);
			unlink "$qd/$id.txt";
			unlink $file;
#$message->resave;
			next;
		} else {
			${pkg}->log("Delivery error: %s", $message->{'ERROR'})
				if $message->{'ERROR'};
			${pkg}->log("Message %s is still pending", $id);
			$message->resave;
		}
	} continue {
		unlock $file;
	}
}

sub resave
{
	my ($message) = @_;
	my $newfrozen = Storable::nfreeze($message);
	my $qd = $message->queue_directory;
	my $id = $message->{'ID'};
	$message->syncwrite("$qd/$id.pqf.new", $message->qf_cookie, $newfrozen);
	lock "$qd/$id.pqf.new";
	rename("$qd/$id.pqf.new", "$qd/$id.pqf")
		or die "rename $qd/$id.pqf.new -> $qd/$id.pqf: $!";
	unlock "$qd/$id.pqf";
	File::Flock::lock_rename ("$qd/$id.pqf.new", "$qd/$id.pqf");
}

sub textfile
{
	my ($message) = @_;
	$message->{'TEXTFILE'};
}

sub handleDieSig
{
	$server->handleDie(@_);
}


sub handleWarnSig
{
	$server->handleWarn(@_);
}

sub syncwrite
{
	my ($server, $f, @data) = @_;
	no strict;

	local(*F,*O);
	open(F, ">$f") || die "open >$f: $!";
	$O = select(F);
	$| = 1;
	select($O);
	(print F @data) || die "write $f: $!";
	File::Sync::fsync_fd(fileno(F)) || die "fsync $f: $!";
	close(F) || die "close $f: $!";
	return 1;
}

sub reset
{
	$server->{'STATE'} = 'MAIL';
	undef $server->{'FROM'};
	$server->{'TO'} = [];
	$server->{'MIMETYPE'} = '';
	undef $server->{'HELO'};
	undef $server->{'TIME'};
	undef $server->{'ID'};
	undef $server->{'TEXTFILE'};
}

sub again
{
	print STDERR "\n\nHIT RETURN TO PROCESS ANOTHER\n\n";
	my $x = <STDIN>;
}

1;
