package Qpsmtpd; use strict; use Carp; use Qpsmtpd::Connection; use Qpsmtpd::Transaction; use Qpsmtpd::Constants; use Qpsmtpd::Plugin; use Mail::Address (); use Mail::Header (); use Sys::Hostname; use IPC::Open2; use Data::Dumper; use POSIX qw(strftime); BEGIN{$^W=0;} use Net::DNS; BEGIN{$^W=1;} $Qpsmtpd::VERSION = "0.11-dev"; my $TRACE_LEVEL = 6; # $SIG{ALRM} = sub { respond(421, "Game over pal, game over. You got a # timeout; I just can't wait that long..."); exit }; sub new { my $proto = shift; my $class = ref($proto) || $proto; my %args = @_; my $self = bless ({ args => \%args }, $class); my (@commands) = qw(ehlo helo rset mail rcpt data help vrfy noop quit); my (%commands); @commands{@commands} = ('') x @commands; # this list of valid commands should probably be a method or a set of methods $self->{_commands} = \%commands; $self; } # # method to get the configuration. It just calls get_qmail_config by # default, but it could be overwritten to look configuration up in a # database or whatever. # sub config { my ($self, $c) = @_; my %defaults = ( me => hostname, timeout => 1200, ); if (wantarray) { my @config = $self->get_qmail_config($c); @config = @{$defaults{$c}} if (!@config and $defaults{$c}); return @config; } else { return ($self->get_qmail_config($c) || $defaults{$c}); } }; sub log { my ($self, $trace, @log) = @_; warn join(" ", $$, @log), "\n" if $trace <= $TRACE_LEVEL; } sub dispatch { my $self = shift; my ($cmd) = lc shift; #$self->respond(553, $state{dnsbl_blocked}), return 1 # if $state{dnsbl_blocked} and ($cmd eq "rcpt"); $self->respond(500, "Unrecognized command"), return 1 if ($cmd !~ /^(\w{1,12})$/ or !exists $self->{_commands}->{$1}); $cmd = $1; if (1 or $self->{_commands}->{$cmd} and $self->can($cmd)) { my ($result) = eval { $self->$cmd(@_) }; $self->log(0, "XX: $@") if $@; return $result if defined $result; return $self->fault("command '$cmd' failed unexpectedly"); } return; } sub fault { my $self = shift; my ($msg) = shift || "program fault - command not performed"; print STDERR "$0[$$]: $msg ($!)\n"; return $self->respond(451, "Internal error - try again later - " . $msg); } sub start_conversation { my $self = shift; # this should maybe be called something else than "connect", see # lib/Qpsmtpd/TcpServer.pm for more confusion. my ($rc, $msg) = $self->run_hooks("connect"); if ($rc != DONE) { $self->respond(220, $self->config('me') ." ESMTP qpsmtpd " . $self->version ." ready; send us your mail, but not your spam."); } } sub transaction { my $self = shift; return $self->{_transaction} || $self->reset_transaction(); } sub reset_transaction { my $self = shift; return $self->{_transaction} = Qpsmtpd::Transaction->new(); } sub connection { my $self = shift; return $self->{_connection} || ($self->{_connection} = Qpsmtpd::Connection->new()); } sub helo { my ($self, $hello_host, @stuff) = @_; my $conn = $self->connection; return $self->respond (503, "but you already said HELO ...") if $conn->hello; $conn->hello("helo"); $conn->hello_host($hello_host); $self->transaction; $self->respond(250, $self->config('me') ." Hi " . $conn->remote_info . " [" . $conn->remote_ip ."]; I am so happy to meet you."); } sub ehlo { my ($self, $hello_host, @stuff) = @_; my $conn = $self->connection; return $self->respond (503, "but you already said HELO ...") if $conn->hello; $conn->hello("ehlo"); $conn->hello_host($hello_host); $self->transaction; $self->respond(250, $self->config("me") . " Hi " . $conn->remote_info . " [" . $conn->remote_ip ."]", "PIPELINING", "8BITMIME", ($self->config('databytes') ? "SIZE ". ($self->config('databytes'))[0] : ()), ); } sub mail { my $self = shift; return $self->respond(501, "syntax error in parameters") if $_[0] !~ m/^from:/i; # -> from RFC2821 # The MAIL command (or the obsolete SEND, SOML, or SAML commands) # begins a mail transaction. Once started, a mail transaction # consists of a transaction beginning command, one or more RCPT # commands, and a DATA command, in that order. A mail transaction # may be aborted by the RSET (or a new EHLO) command. There may be # zero or more transactions in a session. MAIL (or SEND, SOML, or # SAML) MUST NOT be sent if a mail transaction is already open, # i.e., it should be sent only if no mail transaction had been # started in the session, or it the previous one successfully # concluded with a successful DATA command, or if the previous one # was aborted with a RSET. # sendmail (8.11) rejects a second MAIL command. # qmail-smtpd (1.03) accepts it and just starts a new transaction. # Since we are a qmail-smtpd thing we will do the same. $self->reset_transaction; unless ($self->connection->hello) { return $self->respond(503, "please say hello first ..."); } else { my $from_parameter = join " ", @_; $self->log(2, "full from_parameter: $from_parameter"); my ($from) = ($from_parameter =~ m/^from:\s*(\S+)/i)[0]; #warn "$$ from email address : $from\n" if $TRACE; if ($from eq "<>" or $from =~ m/\[undefined\]/) { $from = Mail::Address->new("<>"); } else { $from = (Mail::Address->parse($from))[0]; } return $self->respond(501, "could not parse your mail from command") unless $from; my ($rc, $msg) = $self->run_hooks("mail", $from); if ($rc == DONE) { return 1; } elsif ($rc == DENY) { $msg ||= $from->format . ', denied'; $self->log(2, "deny mail from " . $from->format . " ($msg)"); $self->respond(550, $msg); } elsif ($rc == DENYSOFT) { $msg ||= $from->format . ', temporarily denied'; $self->log(2, "denysoft mail from " . $from->format . " ($msg)"); $self->respond(450, $msg); } else { # includes OK $self->log(2, "getting mail from ".$from->format); $self->respond(250, $from->format . ", sender OK - how exciting to get mail from you!"); $self->transaction->sender($from); } } } sub rcpt { my $self = shift; return $self->respond(501, "syntax error in parameters") unless $_[0] =~ m/^to:/i; return(503, "Use MAIL before RCPT") unless $self->transaction->sender; my ($rcpt) = ($_[0] =~ m/to:(.*)/i)[0]; $rcpt = $_[1] unless $rcpt; $rcpt = (Mail::Address->parse($rcpt))[0]; return $self->respond(501, "could not parse recipient") unless $rcpt; my ($rc, $msg) = $self->run_hooks("rcpt", $rcpt); if ($rc == DONE) { return 1; } elsif ($rc == DENY) { $msg ||= 'relaying denied'; $self->respond(550, $msg); } elsif ($rc == DENYSOFT) { $msg ||= 'relaying denied'; return $self->respond(550, $msg); } elsif ($rc == OK) { $self->respond(250, $rcpt->format . ", recipient ok"); return $self->transaction->add_recipient($rcpt); } else { return $self->respond(450, "Could not determine of relaying is allowed"); } return 0; } sub get_qmail_config { my ($self, $config) = (shift, shift); $self->log(6, "trying to get config for $config"); if ($self->{_config_cache}->{$config}) { return wantarray ? @{$self->{_config_cache}->{$config}} : $self->{_config_cache}->{$config}->[0]; } my $configdir = '/var/qmail/control'; my ($name) = ($0 =~ m!(.*?)/([^/]+)$!); $configdir = "$name/config" if (-e "$name/config/$config"); open CF, "<$configdir/$config" or warn "$$ could not open configfile $config: $!", return; my @config = ; chomp @config; @config = grep { $_ and $_ !~ m/^\s*#/ and $_ =~ m/\S/} @config; close CF; $self->log(8, "returning get_config for $config ",Data::Dumper->Dump([\@config], [qw(config)])); $self->{_config_cache}->{$config} = \@config; return wantarray ? @config : $config[0]; } sub help { my $self = shift; $self->respond(214, "This is qpsmtpd " . $self->version, "See http://develooper.com/code/qpsmtpd/", 'To report bugs or send comments, mail to .'); } sub version { $Qpsmtpd::VERSION; } sub noop { my $self = shift; warn Data::Dumper->Dump([\$self], [qw(self)]); $self->respond(250, "OK"); } sub vrfy { shift->respond(252, "Just try sending a mail and we'll see how it turns out ..."); } sub rset { my $self = shift; $self->reset_transaction; $self->respond(250, "OK"); } sub quit { my $self = shift; my ($rc, $msg) = $self->run_hooks("quit"); if ($rc != DONE) { $self->respond(221, $self->config('me') . " closing connection. Have a wonderful day."); } $self->disconnect(); } sub disconnect { my $self = shift; $self->run_hooks("disconnect"); } sub data { my $self = shift; $self->respond(503, "MAIL first"), return 1 unless $self->transaction->sender; $self->respond(503, "RCPT first"), return 1 unless $self->transaction->recipients; $self->respond(354, "go ahead"); my $buffer = ''; my $size = 0; my $i = 0; my $max_size = ($self->config('databytes'))[0] || 0; # this should work in scalar context my $blocked = ""; my %matches; my $in_header = 1; my $complete = 0; $self->log(6, "max_size: $max_size / size: $size"); my $header = Mail::Header->new(Modify => 0, MailFrom => "COERCE"); my $timeout = $self->config('timeout'); while () { $complete++, last if $_ eq ".\r\n"; $i++; $self->respond(451, "See http://develooper.com/code/qpsmtpd/barelf.html"), exit if $_ eq ".\n"; # add a transaction->blocked check back here when we have line by line plugin access... unless (($max_size and $size > $max_size)) { s/\r\n$/\n/; if ($in_header and m/^\s*$/) { $in_header = 0; my @header = split /\n/, $buffer; # ... need to check that we don't reformat any of the received lines. # # 3.8.2 Received Lines in Gatewaying # When forwarding a message into or out of the Internet environment, a # gateway MUST prepend a Received: line, but it MUST NOT alter in any # way a Received: line that is already in the header. $header->extract(\@header); $buffer = ""; # FIXME - call plugins to work on just the header here; can # save us buffering the mail content. } if ($in_header) { $buffer .= $_; } else { $self->transaction->body_write($_); } $size += length $_; } #$self->log(5, "size is at $size\n") unless ($i % 300); alarm $timeout; } $self->log(6, "max_size: $max_size / size: $size"); $self->transaction->header($header); $header->add("Received", "from ".$self->connection->remote_info ." (HELO ".$self->connection->hello_host . ") (".$self->connection->remote_ip . ") by ".$self->config('me')." (qpsmtpd/".$self->version .") with SMTP; ". (strftime('%Y-%m-%d %TZ', gmtime)), 0); # if we get here without seeing a terminator, the connection is # probably dead. $self->respond(451, "Incomplete DATA"), return 1 unless $complete; #$self->respond(550, $self->transaction->blocked),return 1 if ($self->transaction->blocked); $self->respond(552, "Message too big!"),return 1 if $max_size and $size > $max_size; my ($rc, $msg) = $self->run_hooks("data_post"); if ($rc == DONE) { return 1; } elsif ($rc == DENY) { $self->respond(552, $msg || "Message denied"); } elsif ($rc == DENYSOFT) { $self->respond(452, $msg || "Message denied temporarily"); } else { $self->queue($self->transaction); } # DATA is always the end of a "transaction" return $self->reset_transaction; } sub queue { my ($self, $transaction) = @_; # these bits inspired by Peter Samuels "qmail-queue wrapper" pipe(MESSAGE_READER, MESSAGE_WRITER) or fault("Could not create message pipe"), exit; pipe(ENVELOPE_READER, ENVELOPE_WRITER) or fault("Could not create envelope pipe"), exit; my $child = fork(); not defined $child and fault(451, "Could not fork"), exit; if ($child) { # Parent my $oldfh = select(MESSAGE_WRITER); $| = 1; select(ENVELOPE_WRITER); $| = 1; select($oldfh); close MESSAGE_READER or fault("close msg reader fault"),exit; close ENVELOPE_READER or fault("close envelope reader fault"), exit; $transaction->header->add("X-SMTPD", "qpsmtpd/".$self->version.", http://develooper.com/code/qpsmtpd/"); $transaction->header->print(\*MESSAGE_WRITER); $transaction->body_resetpos; while (my $line = $transaction->body_getline) { print MESSAGE_WRITER $line; } close MESSAGE_WRITER; my @rcpt = map { "T" . $_->address } $transaction->recipients; my $from = "F".($transaction->sender->address|| "" ); print ENVELOPE_WRITER "$from\0", join("\0",@rcpt), "\0\0" or respond(451,"Could not print addresses to queue"),exit; close ENVELOPE_WRITER; waitpid($child, 0); my $exit_code = $? >> 8; $exit_code and respond(451, "Unable to queue message ($exit_code)"), exit; $self->respond(250, "Queued."); } elsif (defined $child) { # Child close MESSAGE_WRITER or die "could not close message writer in parent"; close ENVELOPE_WRITER or die "could not close envelope writer in parent"; open(STDIN, "<&MESSAGE_READER") or die "b1"; open(STDOUT, "<&ENVELOPE_READER") or die "b2"; unless (exec '/var/qmail/bin/qmail-queue') { die "should never be here!"; } } } sub load_plugins { my $self = shift; my @plugins = $self->config('plugins'); my ($name) = ($0 =~ m!(.*?)/([^/]+)$!); my $dir = "$name/plugins"; $self->log(2, "loading plugins from $dir"); for my $plugin (@plugins) { $self->log(3, "Loading $plugin"); my $plugin_name = $plugin; # Escape everything into valid perl identifiers $plugin_name =~ s/([^A-Za-z0-9_\/])/sprintf("_%2x",unpack("C",$1))/eg; # second pass cares for slashes and words starting with a digit $plugin_name =~ s{ (/+) # directory (\d?) # package's first character }[ "::" . (length $2 ? sprintf("_%2x",unpack("C",$2)) : "") ]egx; my $sub; open F, "$dir/$plugin" or die "could not open $dir/$plugin: $!"; { local $/ = undef; $sub = ; } close F; my $package = "Qpsmtpd::Plugin::$plugin_name"; my $line = "\n#line 1 $dir/$plugin\n"; my $eval = join( "\n", "package $package;", 'use Qpsmtpd::Constants;', "require Qpsmtpd::Plugin;", 'use vars qw(@ISA);', '@ISA = qw(Qpsmtpd::Plugin);', "sub plugin_name { qq[$plugin_name] }", $line, $sub, "\n", # last line comment without newline? ); #warn "eval: $eval"; $eval =~ m/(.*)/s; $eval = $1; eval $eval; die "eval $@" if $@; my $plug = $package->new(qpsmtpd => $self); $plug->register($self); } } sub run_hooks { my ($self, $hook) = (shift, shift); if ($self->{_hooks}->{$hook}) { my @r; for my $code (@{$self->{_hooks}->{$hook}}) { eval { (@r) = &{$code}($self->transaction, @_); }; $@ and $self->log(0, "FATAL PLUGIN ERROR: ", $@) and next; $self->log(1, "a $hook hook returned undef!") and next unless defined $r[0]; last unless $r[0] == DECLINED; } return @r; } warn "Did not run any hooks ..."; return (0, ''); } sub _register_hook { my $self = shift; my ($hook, $code) = @_; #my $plugin = shift; # see comment in Plugin.pm:register_hook $self->{_hooks} ||= {}; my $hooks = $self->{_hooks}; push @{$hooks->{$hook}}, $code; } 1;