# $Id: $
=head1 NAME
Mail::Vacation - implements unix vacation program
=head1 DESCRIPTION
Reimplentation of the unix vacation program, with the intention
of using various authorisation and control configurations, eg LDAP.
=cut
package Mail::Vacation;
=head1 SYNOPSIS
use Mail::Vacation;
$o_vac = Mail::Vacation->new($config) or die("failed :-(");
$o_vac->start;
$o_vac->process($o_mail_internet);
$o_vac->finish;
$i_isok = $o_vac->isok;
@s_msgs = $o_vac->messages;
=head1 ABSTRACT
Perl implementation of the vacation mail handling program, with the
intention of using various authorisation and control configurations,
eg LDAP. Logging to syslog.
=head1 NOTES
Configure this instance in the 'vacation.conf' file
Logging is via syslog to /var/log/messages, (make sure syslogd is running!)
=head1 SCRIPTS
=over4
=item vacation.pl
standard vacation script expecting /home/$user/.vacation message file to operate
=item cleanup.pl
cleans configurable time-expired repliedtodbdir entries via cron job
=item test.cgi
vanilla 'hello world' script to test httpd installation against script directory
=cut
use 5.00;
use strict;
use warnings;
$| = 1;
our $VERSION = '0.05';
our $DEBUG = $Mail::Vacation::DEBUG || 0;
=head1 SEE ALSO
Config::General::Extended
Mail::Internet
Sys::Syslog
=cut
use Carp qw(croak);
use Config::General;
use Data::Dumper;
use Date::Manip;
use DB_File::Lock;
use Fcntl qw(:flock O_RDWR O_CREAT O_RDONLY);
use Mail::Address;
use Mail::Internet;
use Mail::Util;
use Mail::Mailer;
use Sys::Syslog;
=head2 EXPORT
None by default.
require Exporter;
our @ISA = qw(Exporter);
our %EXPORT_TAGS = ( 'all' => [ qw( ) ] );
our @EXPORT_OK = ( @{ $EXPORT_TAGS{'all'} } );
our @EXPORT = qw( );
=item new
Create new Mail::Vacation object
$o_vac = Mail::Vacation->new($config_file);
=cut
sub new {
my $proto = shift;
my $class = ref($proto) ? ref($proto) : $proto;
my $config = shift || '';
my $self = bless({}, $class);
return $self->_reset($config);
}
# Reread config file and reset object variables to defaults
#
# $o_vac = $o_vac->_reset;
sub _reset {
my $self = shift;
my $config = shift || $self->{_configfile} || ' ';
unless ($config =~ /\w/o) {
$self = $self->_error("no configfile($config) ".Dumper($self));
}
%{$self} = (
%{$self},
'_configfile' => $config,
'_config' => $$self{_config} || {}, # o_conf
'_connected' => 0,
'_errors' => 0,
'_isok' => 1,
'_handler' => {},
'_messages' => [],
);
$self = $self->_configure($config);
return $self;
}
# Return the config object, value/s if given key/s
#
# $o_conf = $o_vac->_config;
#
# $val = $o_vac->_config('key');
#
# @attrs = $o_vac->_config('attributes');
sub _config {
my $self = shift;
my @keys = @_;
my @vals = ();
my $o_conf = $self->{_config} || '';
unless (ref($o_conf)) {
$self = $self->_error("no configuration object($o_conf)");
} else {
unless (@keys) {
push(@vals, $o_conf);
} else {
foreach my $key (@keys) {
# push(@vals, $o_conf->{config}{$key}); # General
push(@vals, $o_conf->$key()); # Extended
}
}
}
return wantarray ? @vals : $vals[0];
}
# Read in the configuration file and set up the object
#
# $o_vac->_configure($configfile);
sub _configure {
my $self = shift;
my $config = shift || ' ';
unless (openlog('vacation', 'cons,pid', 'user')) {
$self = $self->_error("can't open syslog for vacation $!"); # syslog
} else {
# my $o_conf = $self->{_config} = Config::General::Extended->new($config); # deprecated
my $o_conf = $self->{_config} = Config::General->new(-ConfigFile=>$config,-ExtendedAccess=>1);
unless (ref($o_conf)) {
$self = $self->_error("no configuration($config) object($o_conf)");
} else {
$self->_log("configuration($config) object($o_conf) ok");
}
}
return $self;
}
=item start
Things to do before we process anything
$o_vac->start();
=cut
sub start {
my $self = shift;
# nothing to do
return $self;
}
=item finish
Finishing stuff goes here
$o_vac->finish();
=cut
sub finish {
my $self = shift;
# nothing to do
return $self;
}
=item process
Process the given mail
$o_vac->process($o_main_internet, [h_test]);
=cut
sub process {
my $self = shift;
my $o_int = shift || '';
my $h_test= shift || ''; # unsupported
unless (ref($o_int) eq 'Mail::Internet') {
$self = $self->_error("invalid process mail object($o_int)");
} else {
if ($self->_looks_ok($o_int)) {
my ($from, $s_msg, $a_fwd) = $self->_onvacation($h_test);
if ($s_msg) {
$self = $self->_reply($o_int, $s_msg);
$self = $self->_forward($o_int, $a_fwd) if $self;
}
}
}
return $self;
}
# Checks incoming mail is valid.
#
# $o_vac->_looks_ok($o_int);
sub _looks_ok {
my $self = shift;
my $o_int = shift || '';
unless (ref($o_int) eq 'Mail::Internet') {
$self = $self->_error("invalid looks_ok mail object($o_int)");
} else {
my $o_hdr = $o_int->head;
my $xloop = $o_hdr->get('X-Mail-Vacation') || '';
if ($xloop =~ /\w/o) {
$self = $self->_error("mail seen before($xloop)") if $xloop =~ /\w/o;
} else {
my $xlist = $o_hdr->get('X-Mailing-List') || '';
if ($xlist =~ /\w/o) {
$self = $self->_error("mailing list($xlist)") if $xlist =~ /\w/o;
}
}
}
return $self;
}
# Returns user name in B field
#
# $user = $o_vac->_int2user($o_int);
sub _int2user {
my $self = shift;
my $o_int = shift;
my $user = '';
unless (ref($o_int) eq 'Mail::Internet') {
$self = $self->_error("invalid int2user mail object($o_int)");
} else {
my $addr = $o_int->head->get('To');
my ($o_addr) = Mail::Address->parse($addr);
$user = $o_addr->user if ref($o_addr);
unless ($user =~ /\w+/o) {
$self = $self->_error("missing required user($user) from address($addr)");
}
}
return $user;
}
# Returns true or false, whether given date is within vacation period
#
# $i_trueorfalse = $o_vac->_invacation(\%dates); # start=>$x,now=>$y,end=>$z
sub _invacation {
my $self = shift;
my $h_dates = shift || '';
my $i_in = 0;
unless (ref($h_dates) eq 'HASH') {
$self->_error("invalid date hash ref($h_dates)");
} else {
my %attr = $self->_config->hash('attributes');
my $startkey = $attr{start} || 'start';
my $endkey = $attr{end} || 'end';
my %date = map { lc($_) => $h_dates->{$_} } keys %{$h_dates};
my $start = ParseDate($date{$startkey} || '');
my $date = ParseDate($date{now} || 'now');
my $end = ParseDate($date{$endkey} || '99991120'); # year 10000 problem ?-)
unless ($start =~ /\d+/o) {
$self->_error("invalid start date($start)");
} else {
unless ($date =~ /\d+/o) {
$self->_error("invalid current date($date)");
} else {
unless ($end =~ /\d+/o) {
$self->_error("invalid end date($end)");
} else {
$i_in = ((&Date_Cmp($start, $date) < 0) && (&Date_Cmp($date, $end) < 0)) ? 1 : 0;
$self->_log("start($start) date($date) end($end) => i_int($i_in)");
}
}
}
}
return $i_in;
}
# Returns from address and message if this mail addressee is on vacation.
# Also returns array ref of forwarding email addresses, if applicable.
#
# ($from, $message, \@fwd) = $o_vac->_onvacation([h_test]);
sub _onvacation {
my $self = shift;
my $h_test= shift || ''; # unsupported
my $msg = '';
my @fwd = ();
my $from = $self->_mailfrom();#$o_int);
my %env = $self->_config->hash('env');
my $user = $ENV{$env{user}} || '';
unless ($user =~ /\w+/o) {
$self = $self->_error("missing required env user($user)");
} else {
my $vac = $self->_config('homedir')."/$user/.vacation";
# my $fwd = $self->_config('homedir')."/$user/.forward";
my $o_vac = tie my @vac, 'Tie::File', $vac, 'mode' => O_RDONLY;
unless (ref($o_vac)) {
$self = $self->_error("problem reading $vac $!");
} else {
$msg = join("\n", @vac);
$msg =~ s/\015?\012/\n/gmos; # crlf
$self = $self->_log("found vacation message chars(@{[length($msg)]})");
# untie @vac; # scope
}
}
return ($from, $msg, \@fwd);
}
# Return from address for use in reply ($USER)
#
# $from = $o_vac->_mailfrom($o_int);
sub _mailfrom {
my $self = shift;
my $o_int = shift; # ignored
my %env = $self->_config->hash('env');
my $from = qq|"$ENV{$env{extension}}" <$ENV{$env{local}}\@$ENV{$env{domain}}>|;
return $from;
}
# track and reply to sender with given message
#
# $o_vac->_reply($o_int, $message);
sub _reply {
my $self = shift;
my $o_int = shift || '';
my $s_msg = shift || '';
unless (ref($o_int) eq 'Mail::Internet' && $s_msg =~ /\w+/mos) {
$self = $self->_error("invalid reply mail object($o_int) or message($s_msg)");
} else {
my $o_hdr = $o_int->head;
my ($from) = $o_hdr->get('Reply-To') || $o_hdr->get('From') || '';
my @tocc =($o_hdr->get('To'), $o_hdr->get('Cc'));
my ($to, @cc) = $self->_track($from, \@tocc); #
my $cc = join(', ', @cc) || '';
unless ($to && $to =~ /\w+\@\w+/o) {
$to = '' unless $to;
@cc = () unless @cc;
$self = $self->_error("missing to($to) => nothing to do");
} else {
my $o_reply = $o_int->reply;
$o_reply->head->cleanup();
$o_reply->head->replace('From', $self->_mailfrom($o_int));
$o_reply->head->replace('X-Mail-Vacation', ref($self)." v$Mail::Vacation::VERSION $$");
$o_reply->remove_sig();
if ($s_msg) {
my $sendmail = $self->_config('sendmail');
my $o_send = Mail::Mailer->new($sendmail);
unless ($o_send) {
$self = $self->_error("cannot open reply mailer($sendmail)");
} else {
my $h_hdrs = $o_reply->head->header_hashref;
unless ($o_send->open($h_hdrs)) {
$self = $self->_error("cannot open for reply header: ".Dumper($o_reply->head));
} else {
unless (print $o_send $s_msg) {
$self = $self->_error("cannot print reply body($s_msg)");
} else {
unless ($o_send->close) {
$self = $self->_error("cannot close reply $o_send mail");
} else {
$self->_log("replied to($to) cc($cc) was from($from) via $o_send");
}
}
}
}
}
}
}
return $self;
}
# track and forward to given forwarding addresses
#
# $o_vac->_forward($o_int, $a_fwd);
sub _forward {
my $self = shift;
my $o_int = shift || '';
my $a_fwd = shift || '';
unless (ref($o_int) eq 'Mail::Internet' && ref($a_fwd) eq 'ARRAY') {
$self = $self->_error("invalid reply mail object($o_int) or forwarding addrs($a_fwd)");
} else {
my $o_hdr = $o_int->head;
my ($to, @cc) = my @addrs = @{$a_fwd};
unless (defined($to) && $to =~ /\w+/o) {
$self->_log("no forwarding addresses(@addrs) - nothing to do");
} else {
my $o_head = $o_int->head;
$o_head->cleanup();
$o_head->replace('To', $to);
$o_head->replace('Cc', join(',', @cc));
$o_head->replace('X-Mail-Vacation', ref($self)." v$Mail::Vacation::VERSION $$");
my $s_msg = join("\n", @{$o_int->body});
if ($s_msg) {
my $sendmail = $self->_config('sendmail');
my $o_send = Mail::Mailer->new($sendmail);
unless ($o_send) {
$self = $self->_error("cannot open new forwarder($sendmail)");
} else {
my $h_hdrs = $o_head->header_hashref;
unless ($o_send->open($h_hdrs)) {
$self = $self->_error("cannot open forward header: ".Dumper($o_head));
} else {
unless (print $o_send $s_msg) {
$self = $self->_error("cannot print forward body($s_msg)");
} else {
unless ($o_send->close) {
$self = $self->_error("cannot close forward $o_send mail");
} else {
my $from = $o_head->get('From');
$self->_log("forwarded to($to) cc(@cc) was from($from) via $o_send");
}
}
}
}
}
}
}
return $self;
}
# Retrieve the users requested attribute=value pairs data in hashref/s
#
# ($userdata) = $o_vac->_retrieve('filter' => '(uname=richardf)');
#
# @hash_refs = $o_vac->_retrieve(%search_parameters);
sub _retrieve {
my $self = shift;
my %pars = @_;
my @hret = ();
unless (keys %pars >= 1 && defined($pars{filter}) && $pars{filter} =~ /.+/o) {
$self->_error("missing required search parameters: ".Dumper(\%pars));
} else {
my %data = ();
$data{'message'} = 'unimplemented';
push(@hret, \%data);
}
return @hret;
}
# Track this/these address/es in the replied-to db.
#
# Returns appropriate addresses to reply to.
#
# ($to, @cc) = $o_vac->_track($from, \@addresses);
sub _track {
my $self = shift;
my $xfrom = shift;
my $a_tocc = shift || [];
my %addr = ();
my $dir = $self->_config('repliedtodbdir') || '';
unless (-d $dir && -x _) {
$self = $self->_error("invalid replied to db dir($dir) $!");
} else {
my ($o_from) = Mail::Address->parse($xfrom);
my $from = ref($o_from) ? $o_from->address : '';
unless ($from =~ /\w+\@\w+/io) {
$self = $self->_error("missing required from($xfrom) address($from)");
} else {
my $file = "$dir/$from";
unless (tie my %hash, 'DB_File::Lock', $file, O_CREAT|O_RDWR, 0666, $DB_HASH, 'write') {
$self = $self->_error("failed to _track repliedto db($file) $!");
} else {
unless (ref($a_tocc) eq 'ARRAY') {
$self->_log("missing to or ccs addrs: ".Dumper($a_tocc));
} else {
my $now = &ParseDate('now');
my $min = &DateCalc( 'now', '- '.($self->_config('expirytime')||14).' days'); # 12 (days)
foreach my $xaddr (@{$a_tocc}) {
my ($o_addr) = Mail::Address->parse($xaddr);
my $addr = ref($o_addr) ? lc($o_addr->address) : '';
if ($addr =~ /\w+\@\w+/o) { # get put del
$addr =~ tr/[A-Z]/[a-z]/;
my $date = $hash{$addr} || 0; # get
$addr{$addr}++ unless &Date_Cmp($date, $min) > 0;
$hash{$addr} = $date || $now; # store
}
}
}
untie(%hash); # really
unlink "$file.lock" if -e "$file.lock"; # still ?!
}
}
}
return keys %addr;
}
=item cleanup
Cleanup replied-to dbs based on (now - expirytime) or optionally given
date. All entries older than given date will be removed, to clean a file,
you can empty it by giving a date of, for example, '99999999'.
$o_vac->cleanup($date, [opt_a_ref_addresses]);
=cut
sub cleanup {
my $self = shift;
my $date = shift || '';
my $a_addrs= shift || '';
my $dir = $self->_config('repliedtodbdir') || '';
unless (-d $dir && -x _) {
$self = $self->_error("invalid replied to db dir($dir) $!");
} else {
$date = $date ? &ParseDate($date) : &DateCalc('now', '- '.$self->_config('expirytime').' days'); # 12 (days)
unless (opendir(DIR, $dir)) {
$self = $self->_error("can't open repliedtodbdir $dir: $!");
} else {
my $addrs = (ref($a_addrs) eq 'ARRAY') ? join('|', map { quotemeta($_) } @{$a_addrs}) : '\w';
my @addrs = grep { /$addrs/o && /\@/o && !/\.lock$/ && -f "$dir/$_" } readdir(DIR);
closedir DIR;
my $i_del = 0;
my @trim = ();
ADDR:
foreach my $addr (@addrs) {
my %hash = ();
my $file = "$dir/$addr";
unless (tie %hash, 'DB_File::Lock', $file, O_CREAT|O_RDWR, 0666, $DB_HASH, 'write') {
$self->_error("failed to _cleanup repliedto db($file) $!");
} else {
foreach my $targ (keys %hash) {
my $stored = $hash{$targ} || 0; # get
if (&Date_Cmp($stored, $date) < 0) {
delete $hash{$targ};
$i_del++;
}
}
push(@trim, $addr) unless keys %hash >= 1;
}
untie(%hash); # really
unlink "$file.lock" if -e "$file.lock"; # still ?!
}
$self->_log("deleted $i_del entries from ".@addrs." files");
if (@trim >= 1) {
my $i_rem = unlink map { "$dir/$_" } @trim;
unless ($i_rem == @trim) {
$self = $self->_error("Failed to remove($i_rem) ".@trim." empty $dir entries");
}
}
}
}
return $self;
}
# Send message to syslog if $Mail::Vacation::DEBUG is set
#
# $o_vac->_log($msg);
sub _log {
my $self = shift;
$self = $self->_xlog(@_) if $Mail::Vacation::DEBUG;
return $self;
}
sub _xlog {
my $self = shift;
unless (syslog('info', join(' ', @_))) {
print STDERR "nosyslog($$): ".join(' ', @_, "\n");
}
return $self;
}
# Send error message to syslog.
#
# $o_vac->_error($msg); # <- returns undef
sub _error {
my $self = shift;
$self->_log('error: ', @_);
$self->{_isok} = 0;
$self->{_error}++;
push(@{$self->{_messages}}, @_);
return undef;
}
=item isok
Return current valid value
$i_isok = $o_vac->isok;
=cut
sub isok {
my $self = shift;
return $self->{_isok};
}
=item messages
Return current messages
print $o_vac->messages;
=cut
sub messages {
my $self = shift;
return @{$self->{_messages}};
}
# Send error message to syslog and die.
#
# $o_vac->_fatal($msg); # <- die's
sub _fatal {
my $self = shift;
$self->_xlog('error: ', @_);
croak(@_);
}
# Dumps the object
#
# $o_vac->_dump;
sub _dump {
my $self = shift;
return Dumper($self);
}
# Return Mail::Internet object from dir/file:
#
# my $o_int = $o_vac->_file2minet($filename);
sub _file2minet {
my $self = shift;
my $file = shift;
my $o_int = '';
unless ($file) {
$self->_error("no mail file($file)");
} else {
my $FH = FileHandle->new("< $file");
unless (defined($FH)) {
undef $o_int;
$self->_error("FileHandle($FH) not defined for file ($file): $!");
} else {
$o_int = Mail::Internet->new($FH);
close $FH;
unless (ref($o_int)) {
$self->_error("Mail($o_int) not retrieved from file($file)");
}
}
}
return $o_int;
}
# Setup Mail::Internet object from given args, body is default unless given.
#
# my $o_int = $o_vac->_setup_int(\%header, [$body]); # 'to' => 'to@x.net'
#
# my $o_int = $o_vac->_setup_int( $header, [$body]); # or could be folded
sub _setup_int {
my $self = shift;
my $header = shift || '';
my $body = shift || 'no-body-given';
my $o_int = undef;
my %header = ();
if (ref($header) eq 'HASH') {
%header = %{$header};
} else {
if ($header =~ /^([^:]+:\s*\w+.*)/mo) {
$header =~ s/\r?\n\s+/ /gos; # unfold
%header = ($header =~ /^([^:]+):(.*)$/gmo);
} else {
$self->_error("Can't setup int from invalid header($header)!");
}
}
if (keys %header) {
my $o_hdr = Mail::Header->new;
TAG:
foreach my $tag (keys %header) {
my @tags = (ref($header{$tag})) eq 'ARRAY' ? @{$header{$tag}} : ($header{$tag});
$tag =~ tr/\n/ /d; # strays
$tag =~ s/^\s+//o; #
$tag =~ s/\s+$//o; #
if ($tag =~ /^\w+(\-\w+)*/) {
$o_hdr->add($tag, @tags);
} else {
$self->_error("*** problem with tag($tag)");
}
}
$o_hdr->add('Message-Id', $self->_get_rand_msgid) unless $o_hdr->get('Message-Id');
$o_hdr->add('Subject', q|some irrelevant subject|) unless $o_hdr->get('Subject');
$o_int = Mail::Internet->new(
'Header' => $o_hdr,
'Body' => [map { "$_\n" } split("\n", $body)]
);
my $to = $o_int->head->get('To') || '';
my $from = $o_int->head->get('From') || '';
if (!($to =~ /\w+/o && $from =~ /\w+/o)) {
$self->_error("Invalid mail($o_int) => to($to) from($from)");
undef $o_int;
}
}
return $o_int;
}
# Returns randomised recognisableid . processid . rand(time)
#
# my $it = $o_vac->_get_rand_msgid(); # <19870502_$$.$time.$count@rfi.net>
sub _get_rand_msgid {
my $self = shift;
my $domain = Mail::Util::maildomain();
if ($^O eq 'MSWin32') {
$domain = $ENV{'USERDOMAIN'};
} else {
require Sys::Hostname;
$domain = Sys::Hostname::hostname();
}
my $msgid = '<'.(join('_',
ref($self), $$, time.'@'.$domain
)).'>';
return $msgid;
}
# clean up, close syslog.
sub DESTROY {
my $self = shift;
closelog(); # syslog
}
=head1 AUTHOR
Richard Foley, Erichard.foley@rfi.netE
=head1 COPYRIGHT AND LICENSE
Copyright 2002 by Richard Foley
Sponsered by Octogon Gmbh, Feldafing, Germany
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=cut
1;