Download | Plain Text | No Line Numbers


  1. #!/usr/bin/perl
  2. #
  3. # Copyright (C) 2008-2016 Manuel Mausz (manuel@mausz.at)
  4. # http://manuel.mausz.at/coding/autoresponder/
  5. # Origin code copyright (c) 2004-2008 Parallels GmbH (http://www.parallels.com)
  6. #
  7. # This program is free software; you can redistribute it and/or
  8. # modify it under the terms of the GNU General Public License
  9. # as published by the Free Software Foundation; either
  10. # version 2 of the License, or (at your option) any later
  11. # version.
  12. #
  13. # This program is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License
  19. # along with this program; if not, write to the Free Software Foundation,
  20. # Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
  21.  
  22. use Getopt::Long;
  23. use Pod::Usage;
  24. use Mail::Internet;
  25. use Mail::Address;
  26. use MIME::Lite;
  27. use Encode qw(encode);
  28. use Time::HiRes qw(gettimeofday);
  29. use DBI;
  30. use strict;
  31. use warnings;
  32. use utf8;
  33. binmode(STDOUT, ':utf8');
  34.  
  35. my $VERSION = '0.6';
  36.  
  37. #-------------------------------------------------------------------------------
  38. # DATABASE TABLE LAYOUT
  39. #-------------------------------------------------------------------------------
  40.  
  41. # this table is required for running this software and is used for detecing
  42. # loops. create this table inside the confixx database.
  43. #
  44. # CREATE TABLE `autoresponder_seen` (
  45. # `ident` int(11) NOT NULL,
  46. # `sender` varchar(255) NOT NULL,
  47. # `time` datetime NOT NULL,
  48. # `server_id` varchar(32) NOT NULL,
  49. # KEY `ident` (`ident`),
  50. # KEY `server_id` (`server_id`)
  51. # );
  52. #
  53. # ALTER TABLE `autoresponder` CHANGE `emailtext` `emailtext` TEXT NOT NULL;
  54.  
  55. #-------------------------------------------------------------------------------
  56. # SENDMAIL INFORMATION
  57. #-------------------------------------------------------------------------------
  58.  
  59. # if you're using sendmail be sure to modify sendmail.cf so it will pass the
  60. # header "Return-Path" to the executable. this can usually be done by adding
  61. # the char "P" to the field F in line "prog".
  62. # e.g. from:
  63. # Mprog, P=/usr/sbin/smrsh, F=lsDFMoqeu9, S=EnvFromL/HdrFromL, ....
  64. # to:
  65. # Mprog, P=/usr/sbin/smrsh, F=lsDFMoqeu9P, S=EnvFromL/HdrFromL, ....
  66.  
  67. #-------------------------------------------------------------------------------
  68. # START OF CONFIGURATION
  69. #-------------------------------------------------------------------------------
  70.  
  71. # confixx database configuration
  72. # grep from confixx_main.conf
  73. my %db = (
  74. dsn => 'INSERT VALUE OF $db_address FROM confixx_main.conf',
  75. user => 'INSERT VALUE OF $dbUser FROM confixx_main.conf',
  76. pass => 'INSERT VALUE OF $dbPw FROM confixx_main.conf'
  77. );
  78.  
  79. # confixx installationid/serverid
  80. my $cfx_serverid = 'INSERT VALUE OF $ServerID FROM confixx_main.conf';
  81.  
  82. # path to mailer for sending the reply
  83. my $mailer = 'INSERT VALUE OF $bin_sendmail FROM confixx_main.conf';
  84.  
  85. # used to specify the period in which addresses are kept and are not responded
  86. # to, specified in seconds. should be at least set to 1 day (=86400).
  87. my $respond_period = 86400;
  88.  
  89. # this/these header/s will be added to every generated reply
  90. # and checked to detect some loops more easily
  91. my %myheader = ('X-Mailer' => 'Confixx Autoresponder');
  92.  
  93. # this can be enabled if its important to exit with exitcode 0 (=success)
  94. # on all errors. this means "mail sent successfully" although an error occured.
  95. # useful for not keeping "error-generating" mails in the mailserver queue.
  96. #$SIG{'__DIE__'} = sub { our $log; (defined($log) && $log->can('error')) ? $log->error("Error: ", @_) : print STDERR "Error: ", @_; exit(0); };
  97.  
  98. # default debugging options. may be useful to watch for errors without
  99. # passing commandline options. see --help for description
  100. my $debuglvl = 0;
  101. my $logfile = undef;
  102. #my $debuglvl = 3;
  103. #my $logfile = "/tmp/autoresponder.log";
  104.  
  105. #-------------------------------------------------------------------------------
  106. # END OF CONFIGURATION
  107. #-------------------------------------------------------------------------------
  108.  
  109. my %args = ();
  110. my $getopt = new Getopt::Long::Parser();
  111. $getopt->configure('prefix_pattern=--|-');
  112. $getopt->getoptions(
  113. \%args,
  114. 'i|ident=i',
  115. 'd|debug=i',
  116. 'h|help',
  117. 'l|log=s',
  118. 'v|version',
  119. ) or pod2usage(2);
  120.  
  121. pod2usage(1)
  122. if $args{'h'};
  123.  
  124. if ($args{'v'})
  125. {
  126. print "Autoresponder enhanced v", $VERSION, " for Parallels Confixx 3.0.0 and above", $/;
  127. exit 0;
  128. }
  129.  
  130. # create logger
  131. $args{'d'} = $args{'d'} || $debuglvl || 0;
  132. $args{'l'} = $args{'l'} || $logfile || undef;
  133. our $log = new Logger(($args{'l'}) ? (file => $args{'l'}) : (handle => \*STDERR), level => $args{'d'})
  134. or die("Couldn't create logger: $!");
  135.  
  136. # fetch ident
  137. my $ident = $args{'i'} || $ARGV[0];
  138. pod2usage(-msg => "Error: No ident given.")
  139. if (!defined($ident) || length($ident) <= 0);
  140. $log->debug("Got ident value: ", $ident, $/);
  141.  
  142. # parse mail
  143. $log->debug("Parsing mail...", $/);
  144. my $mail = Mail::Internet->new(\*STDIN)
  145. or die("Unable to parse mail.");
  146.  
  147. # parse headers
  148. $log->debug("Parsing headers...", $/);
  149. my $hdata = check_headers($mail->head(), \%myheader);
  150.  
  151. # connect to database
  152. $log->debug("Connecting to database...", $/);
  153. my $dbh = DBI->connect($db{'dsn'}, $db{'user'}, $db{'pass'})
  154. or die("Can't connect to database: ".DBI->errstr);
  155. $dbh->{'mysql_enable_utf8'} = 1;
  156.  
  157. # remove old entries in database
  158. $log->debug("Removing outdated entries from database...", $/);
  159. $dbh->do(
  160. q(DELETE FROM `autoresponder_seen` WHERE `time` < UTC_TIMESTAMP() - INTERVAL ? SECOND AND `server_id`=?),
  161. undef, ($respond_period, $cfx_serverid)
  162. ) or die("Unable to execute database query: ", DBI->errstr);
  163.  
  164. # check database for loops
  165. $log->debug("Checking if sender is already known...", $/);
  166. my @cnt = $dbh->selectrow_array(
  167. q(SELECT COUNT(*) FROM `autoresponder_seen` WHERE `ident`=? AND `sender`=? AND `server_id`=?),
  168. undef, ($ident, $hdata->{'sender'}->address(), $cfx_serverid)
  169. ) or die("Unable to execute database query: ", DBI->errstr);
  170. if (scalar($cnt[0]) > 0)
  171. {
  172. $log->info("Loop detected.", $/);
  173. exit(0);
  174. }
  175.  
  176. # fetch reply data from database
  177. $log->debug("Fetching autoresponder data...", $/);
  178. my $ardata = $dbh->selectrow_hashref(
  179. q(SELECT `absenderemail`, `absendername`, `emailbetreff`, `emailtext`, `kunde`,
  180. NOW() between `valid_from` and `valid_till` as `active`
  181. FROM `autoresponder` WHERE `ident`=? AND `server_id`=?),
  182. undef, ($ident, $cfx_serverid)
  183. );
  184. if (!defined($ardata))
  185. {
  186. $log->warning("Unable to fetch autoresponder data from database. Maybe wrong ident.", $/);
  187. exit(0);
  188. }
  189.  
  190. if (!$ardata->{'active'})
  191. {
  192. $log->info("Autoresponder is not active.", $/);
  193. exit(0);
  194. }
  195.  
  196. $ardata->{'emailtext'} =~ s/\r//g;
  197.  
  198. # create reply
  199. my $reply = generate_reply($ardata, $hdata, \%myheader);
  200.  
  201. # send mail via mailer executable
  202. $log->debug("Sending reply...", $/);
  203. my @args = ($mailer, '-ti', '-f', $reply->{'from'}->address());
  204. open(MAIL, '|-') || exec { $args[0] } @args
  205. or die("Unable to open mailer: $!");
  206. binmode(MAIL, ':utf8');
  207. $reply->{'reply'}->print(\*MAIL);
  208. close(MAIL);
  209.  
  210. # insert sender into database
  211. $log->debug("Inserting sender into database...", $/);
  212. $dbh->do(
  213. q(INSERT INTO `autoresponder_seen` SET `ident`=?, `sender`=?, `time`=UTC_TIMESTAMP(), `server_id`=?),
  214. undef, ($ident, $hdata->{'sender'}->address(), $cfx_serverid)
  215. ) or die("Unable to execute database query: ", DBI->errstr);
  216.  
  217. # cleanup
  218. $log->info("Reply sent successfully.", $/);
  219. $dbh->disconnect();
  220. $log->close();
  221.  
  222. #-------------------------------------------------------------------------------
  223.  
  224. sub check_headers
  225. {
  226. my ($headers, $myheader) = @_;
  227. my ($hname, $hvalue) = undef;
  228. my (@sender, @addrs) = ();
  229. my $subject = "";
  230. our $log;
  231.  
  232. eval
  233. {
  234. local $SIG{'__DIE__'};
  235.  
  236. # qmail delivers return-path via environment
  237. $headers->replace($hname, $ENV{'RPLINE'})
  238. if (defined($ENV{'RPLINE'}));
  239.  
  240. # debug stuff
  241. $log->debug("Headers of incoming mail:", $/, $mail->head()->as_string());
  242.  
  243. # check Return-Path
  244. $hname = 'Return-Path';
  245. $headers->unfold($hname);
  246. if ($hvalue = $headers->get($hname))
  247. {
  248. chomp($hvalue);
  249. die("Message is either invalid, bounced or from a mailer daemon.")
  250. if ($hvalue eq '<>' || $hvalue eq "#@[]" || $hvalue =~ /MAILER-DAEMON$/i);
  251. die("Unable to parse mail address.")
  252. if (!(@addrs = Mail::Address->parse($hvalue)));
  253. die("Value isn't exactly one mail address.")
  254. if (scalar(@addrs) != 1);
  255. push(@sender, $addrs[0]);
  256. }
  257.  
  258. # check From
  259. $hname = 'From';
  260. $headers->unfold($hname);
  261. die("No value.")
  262. if (!($hvalue = $headers->get($hname)));
  263. chomp($hvalue);
  264. die("Message is either invalid or from a mailer daemon.")
  265. if ($hvalue =~ /MAILER-DAEMON$/i || $hvalue =~ /Mail Delivery (Sub)?(system|service)/i);
  266. die("Unable to parse mail address.")
  267. if (!(@addrs = Mail::Address->parse($hvalue)));
  268. die("Value isn't exactly one mail address.")
  269. if (scalar(@addrs) != 1);
  270. push(@sender, $addrs[0]);
  271.  
  272. # check Precedence
  273. $hname = 'Precedence';
  274. $headers->unfold($hname);
  275. if ($hvalue = $headers->get($hname))
  276. {
  277. chomp($hvalue);
  278. die("Message is either junk, bulk or from mailing list.")
  279. if ($hvalue =~ /(junk|list|bulk|autoreply)/i);
  280. }
  281.  
  282. # check X-Spam-Status
  283. $hname = 'X-Spam-Status';
  284. $headers->unfold($hname);
  285. if ($hvalue = $headers->get($hname))
  286. {
  287. chomp($hvalue);
  288. die("Message is junk.")
  289. if ($hvalue =~ /yes/i);
  290. }
  291.  
  292. # check for mailing list
  293. foreach ('List-Id', 'List-Help', 'List-Subscribe', 'List-Unsubscribe',
  294. 'List-Post', 'List-Owner', 'List-Archive', 'Mailing-List',
  295. 'X-Mailing-List', 'X-ML-Name', 'X-List')
  296. {
  297. $hname = $_; # explicit assignment needed
  298. die("Message is from mailing list.")
  299. if (($hvalue = $headers->get($hname)) && chomp($hvalue));
  300. }
  301.  
  302. # check Subject
  303. $hname = 'Subject';
  304. #$headers->unfold($hname);
  305. if ($hvalue = $headers->get($hname))
  306. {
  307. chomp($hvalue);
  308. die("Message is bounced.")
  309. if ($hvalue =~ /^Delivery Status Notification \(Failure\)$/i || $hvalue =~ /Automated reply/i);
  310. $subject = $hvalue;
  311. }
  312.  
  313. # check my own headers
  314. foreach (keys(%$myheader))
  315. {
  316. $hname = $_; # explicit assignment needed
  317. die("Message is signed by myself. Probably looping.")
  318. if (($hvalue = $headers->get($hname)) && chomp($hvalue) && $hvalue eq $myheader->{$hname});
  319. }
  320.  
  321. # check Reply-To
  322. $hname = 'Reply-To';
  323. $headers->unfold($hname);
  324. if ($hvalue = $headers->get($hname))
  325. {
  326. chomp($hvalue);
  327. # we'll only respond to the first mail address
  328. push(@sender, $addrs[0])
  329. if ($hvalue !~ /MAILER-DAEMON$/i && (@addrs = Mail::Address->parse($hvalue)));
  330. }
  331. };
  332. if ($@)
  333. {
  334. my $error = $@;
  335. $log->info("Invalid header '", $hname, "': ", $error);
  336. $log->debug("Header value: '", $hvalue, "'.", $/);
  337. exit(0);
  338. };
  339.  
  340. my $sender = (reverse(@sender))[0];
  341.  
  342. # fetch some additional headers
  343. my $references = $headers->get('References') || "";
  344. my $messageid = $headers->get('Message-ID') || "";
  345. chomp($references);
  346. chomp($messageid);
  347.  
  348. # debug stuff
  349. $log->debug("Sender: ", $sender->format(), $/);
  350. $log->debug("Subject: ", $subject, $/);
  351. $log->debug("References: ", $references, $/);
  352. $log->debug("Message-ID: ", $messageid, $/);
  353.  
  354. return {
  355. sender => $sender,
  356. subject => $subject,
  357. references => $references,
  358. messageid => $messageid
  359. };
  360. }
  361.  
  362. #-------------------------------------------------------------------------------
  363.  
  364. sub generate_reply
  365. {
  366. my ($ardata, $hdata, $myheader) = @_;
  367.  
  368. # generate from
  369. my $from = Mail::Address->new($ardata->{'absendername'}, $ardata->{'absenderemail'})
  370. or die("Unable to parse sender address for reply.");
  371.  
  372. # generate subject
  373. my $subject = $ardata->{'emailbetreff'};
  374. $subject = "Auto: " . $hdata->{'subject'}
  375. if (!length($subject) && length($hdata->{'subject'}));
  376. $subject = "Automated reply"
  377. if (!length($subject));
  378.  
  379. my $addr_encode = sub
  380. {
  381. return encode('MIME-Header', $_) . ' <' . $_[0]->address() . '>'
  382. if (defined(local $_ = $_[0]->phrase()) && length($_));
  383. return $_[0]->address();
  384. };
  385.  
  386. # create reply
  387. $log->debug("Generating reply...", $/);
  388. my $reply = MIME::Lite->new(
  389. From => $addr_encode->($from),
  390. To => $addr_encode->($hdata->{'sender'}),
  391. Subject => encode('MIME-Header', $subject),
  392. Data => $ardata->{'emailtext'},
  393. Type => 'text/plain; charset=UTF-8',
  394. ) or die("Unable to create reply object.");
  395.  
  396. # create message-id + threading
  397. my ($seconds, $microseconds) = gettimeofday();
  398. my $messageid = sprintf("<%x.%06d.%x@%s>", $seconds, $microseconds,
  399. rand(2**32 - 1), $from->host());
  400. $reply->add('Message-ID', $messageid);
  401. $reply->add('In-Reply-To', $hdata->{'messageid'})
  402. if (length($hdata->{'messageid'}));
  403. $reply->add('References', join(' ', $hdata->{'references'}, $hdata->{'messageid'}))
  404. if (length($hdata->{'references'}) || length($hdata->{'messageid'}));
  405.  
  406. # add some other headers
  407. foreach (keys(%$myheader))
  408. {
  409. $reply->add($_ = $myheader->{$_});
  410. }
  411. $reply->add('Precedence', 'bulk');
  412. $reply->add('Auto-Submitted', 'auto-replied');
  413.  
  414. # debug stuff
  415. $log->debug("Created reply:", $/, $reply->as_string(), $/);
  416.  
  417. return {
  418. reply => $reply,
  419. from => $from
  420. };
  421. }
  422.  
  423. #-------------------------------------------------------------------------------
  424.  
  425. package Logger;
  426. use IO::Handle;
  427. use IO::File;
  428. use POSIX ();
  429. use Carp;
  430. use strict;
  431. use warnings;
  432. use constant ERROR => 0;
  433. use constant WARNING => 1;
  434. use constant INFO => 2;
  435. use constant DEBUG => 3;
  436.  
  437. $VERSION = "0.1";
  438.  
  439. sub new
  440. {
  441. my $class = shift;
  442. my $self = {};
  443. my %args = @_;
  444.  
  445. bless($self, $class);
  446.  
  447. @{$self->{'handles'}} = ();
  448. if (defined($args{'handle'}))
  449. {
  450. my $fh = new IO::Handle();
  451. $fh->fdopen($args{'handle'}, 'w')
  452. or return undef;
  453. binmode($fh, ':utf8');
  454. push(@{$self->{'handles'}}, $fh);
  455. }
  456. if (defined($args{'file'}))
  457. {
  458. my $fh = new IO::File($args{'file'}, "a")
  459. or return undef;
  460. # this is needed for standard confixx qmail installation
  461. chmod(0660, $args{'file'});
  462. binmode($fh, ':utf8');
  463. push(@{$self->{'handles'}}, $fh);
  464. }
  465. croak("No logging destination defined")
  466. if (scalar(@{$self->{'handles'}}) <= 0);
  467.  
  468. $self->{'level'} = (defined($args{'level'})) ? $args{'level'} : WARNING;
  469.  
  470. $self;
  471. }
  472.  
  473. sub print
  474. {
  475. my ($self) = shift;
  476. foreach my $fh (@{$self->{'handles'}})
  477. {
  478. $fh->print(POSIX::strftime("[%Y.%m.%d-%H:%M:%S] ", localtime(time)), @_);
  479. }
  480. return 1;
  481. }
  482.  
  483. sub printlvl
  484. {
  485. my ($self, $level) = (shift, shift);
  486. return
  487. if ($self->{'level'} < $level);
  488. return $self->print(@_);
  489. }
  490.  
  491. sub debug { shift->printlvl(DEBUG, @_); }
  492. sub info { shift->printlvl(INFO, @_); }
  493. sub warning { shift->printlvl(WARNING, @_); }
  494. sub error { shift->printlvl(ERROR, @_); }
  495.  
  496. sub close
  497. {
  498. my ($self) = shift;
  499. foreach my $fh (@{$self->{'handles'}})
  500. {
  501. $fh->close();
  502. }
  503. }
  504.  
  505. #-------------------------------------------------------------------------------
  506.  
  507. __END__
  508.  
  509. =head1 NAME
  510.  
  511. Autoresponder - Enhanced autoresponder for Parallels Confixx 3.0.0 and above
  512.  
  513. =head1 SYNOPSIS
  514.  
  515. B<autoresponder.pl>
  516. I<E<lt>identE<gt>>
  517. [ B<-l> I<logfile> ]
  518. [ B<-d> I<debuglevel> ]
  519. [ B<-v> ]
  520.  
  521. =head1 OPTIONS
  522.  
  523. =over 4
  524.  
  525. =item I<E<lt>identE<gt>>
  526.  
  527. =item B<-i> I<number>, B<--ident=>I<number>
  528.  
  529. The autoresponders ident number.
  530.  
  531. =item B<-l> I<logfile>, B<--log=>I<logfile>
  532.  
  533. Path to logfile. Default is stderr
  534.  
  535. =item B<-d> I<debuglevel>, B<--debug=>I<debuglevel>
  536.  
  537. Specify debug level. Default is 0=off
  538.  
  539. =item B<-v>, B<--version>
  540.  
  541. Print version number and exit.
  542.  
  543. =item B<-h>, B<--help>
  544.  
  545. Print this usage message.
  546.  
  547. =back
  548.  
  549. =head1 USAGE
  550.  
  551. in F<aliases>:
  552.  
  553. confixx-du-<ident>: "|/usr/bin/autoresponder.pl <ident>"
  554.  
  555. =head1 AUTHOR
  556.  
  557. Manuel Mausz E<lt>manuel@mausz.atE<gt>
  558. L<http://manuel.mausz.at/coding/autoresponder/>
  559.  
  560. Origin code from Parallels GmbH (L<http://www.parallels.com>)
  561.  
  562. =cut
  563.