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.7';
  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-Auto-Response-Suppress
  283. $hname = 'X-Auto-Response-Suppress';
  284. $headers->unfold($hname);
  285. if ($hvalue = $headers->get($hname))
  286. {
  287. chomp($hvalue);
  288. die("Message is has auto response suppress header.")
  289. if ($hvalue =~ /(?:all|oof|autoreply)/i);
  290. }
  291.  
  292. # check X-Spam-Status
  293. $hname = 'X-Spam-Status';
  294. $headers->unfold($hname);
  295. if ($hvalue = $headers->get($hname))
  296. {
  297. chomp($hvalue);
  298. die("Message is junk.")
  299. if ($hvalue =~ /yes/i);
  300. }
  301.  
  302. # check for mailing list
  303. foreach ('List-Id', 'List-Help', 'List-Subscribe', 'List-Unsubscribe',
  304. 'List-Post', 'List-Owner', 'List-Archive', 'Mailing-List',
  305. 'X-Mailing-List', 'X-ML-Name', 'X-List')
  306. {
  307. $hname = $_; # explicit assignment needed
  308. die("Message is from mailing list.")
  309. if (($hvalue = $headers->get($hname)) && chomp($hvalue));
  310. }
  311.  
  312. # check Subject
  313. $hname = 'Subject';
  314. #$headers->unfold($hname);
  315. if ($hvalue = $headers->get($hname))
  316. {
  317. chomp($hvalue);
  318. die("Message is bounced.")
  319. if ($hvalue =~ /^Delivery Status Notification \(Failure\)$/i || $hvalue =~ /Automated reply/i);
  320. $subject = $hvalue;
  321. }
  322.  
  323. # check my own headers
  324. foreach (keys(%$myheader))
  325. {
  326. $hname = $_; # explicit assignment needed
  327. die("Message is signed by myself. Probably looping.")
  328. if (($hvalue = $headers->get($hname)) && chomp($hvalue) && $hvalue eq $myheader->{$hname});
  329. }
  330.  
  331. # check Reply-To
  332. $hname = 'Reply-To';
  333. $headers->unfold($hname);
  334. if ($hvalue = $headers->get($hname))
  335. {
  336. chomp($hvalue);
  337. # we'll only respond to the first mail address
  338. push(@sender, $addrs[0])
  339. if ($hvalue !~ /MAILER-DAEMON$/i && (@addrs = Mail::Address->parse($hvalue)));
  340. }
  341. };
  342. if ($@)
  343. {
  344. my $error = $@;
  345. $log->info("Invalid header '", $hname, "': ", $error);
  346. $log->debug("Header value: '", $hvalue, "'.", $/);
  347. exit(0);
  348. };
  349.  
  350. my $sender = (reverse(@sender))[0];
  351.  
  352. # fetch some additional headers
  353. my $references = $headers->get('References') || "";
  354. my $messageid = $headers->get('Message-ID') || "";
  355. chomp($references);
  356. chomp($messageid);
  357.  
  358. # debug stuff
  359. $log->debug("Sender: ", $sender->format(), $/);
  360. $log->debug("Subject: ", $subject, $/);
  361. $log->debug("References: ", $references, $/);
  362. $log->debug("Message-ID: ", $messageid, $/);
  363.  
  364. return {
  365. sender => $sender,
  366. subject => $subject,
  367. references => $references,
  368. messageid => $messageid
  369. };
  370. }
  371.  
  372. #-------------------------------------------------------------------------------
  373.  
  374. sub generate_reply
  375. {
  376. my ($ardata, $hdata, $myheader) = @_;
  377.  
  378. # generate from
  379. my $from = Mail::Address->new($ardata->{'absendername'}, $ardata->{'absenderemail'})
  380. or die("Unable to parse sender address for reply.");
  381.  
  382. # generate subject
  383. my $subject = $ardata->{'emailbetreff'};
  384. $subject = "Auto: " . $hdata->{'subject'}
  385. if (!length($subject) && length($hdata->{'subject'}));
  386. $subject = "Automated reply"
  387. if (!length($subject));
  388.  
  389. my $addr_encode = sub
  390. {
  391. return encode('MIME-Header', $_) . ' <' . $_[0]->address() . '>'
  392. if (defined(local $_ = $_[0]->phrase()) && length($_));
  393. return $_[0]->address();
  394. };
  395.  
  396. # create reply
  397. $log->debug("Generating reply...", $/);
  398. my $reply = MIME::Lite->new(
  399. From => $addr_encode->($from),
  400. To => $addr_encode->($hdata->{'sender'}),
  401. Subject => encode('MIME-Header', $subject),
  402. Data => $ardata->{'emailtext'},
  403. Type => 'text/plain; charset=UTF-8',
  404. ) or die("Unable to create reply object.");
  405.  
  406. # create message-id + threading
  407. my ($seconds, $microseconds) = gettimeofday();
  408. my $messageid = sprintf("<%x.%06d.%x@%s>", $seconds, $microseconds,
  409. rand(2**32 - 1), $from->host());
  410. $reply->add('Message-ID', $messageid);
  411. $reply->add('In-Reply-To', $hdata->{'messageid'})
  412. if (length($hdata->{'messageid'}));
  413. $reply->add('References', join(' ', $hdata->{'references'}, $hdata->{'messageid'}))
  414. if (length($hdata->{'references'}) || length($hdata->{'messageid'}));
  415.  
  416. # add some other headers
  417. foreach (keys(%$myheader))
  418. {
  419. $reply->add($_ = $myheader->{$_});
  420. }
  421. $reply->add('Precedence', 'bulk');
  422. $reply->add('Auto-Submitted', 'auto-replied');
  423. $reply->add('X-Auto-Response-Suppress', 'All');
  424.  
  425. # debug stuff
  426. $log->debug("Created reply:", $/, $reply->as_string(), $/);
  427.  
  428. return {
  429. reply => $reply,
  430. from => $from
  431. };
  432. }
  433.  
  434. #-------------------------------------------------------------------------------
  435.  
  436. package Logger;
  437. use IO::Handle;
  438. use IO::File;
  439. use POSIX ();
  440. use Carp;
  441. use strict;
  442. use warnings;
  443. use constant ERROR => 0;
  444. use constant WARNING => 1;
  445. use constant INFO => 2;
  446. use constant DEBUG => 3;
  447.  
  448. $VERSION = "0.1";
  449.  
  450. sub new
  451. {
  452. my $class = shift;
  453. my $self = {};
  454. my %args = @_;
  455.  
  456. bless($self, $class);
  457.  
  458. @{$self->{'handles'}} = ();
  459. if (defined($args{'handle'}))
  460. {
  461. my $fh = new IO::Handle();
  462. $fh->fdopen($args{'handle'}, 'w')
  463. or return undef;
  464. binmode($fh, ':utf8');
  465. push(@{$self->{'handles'}}, $fh);
  466. }
  467. if (defined($args{'file'}))
  468. {
  469. my $fh = new IO::File($args{'file'}, "a")
  470. or return undef;
  471. # this is needed for standard confixx qmail installation
  472. chmod(0660, $args{'file'});
  473. binmode($fh, ':utf8');
  474. push(@{$self->{'handles'}}, $fh);
  475. }
  476. croak("No logging destination defined")
  477. if (scalar(@{$self->{'handles'}}) <= 0);
  478.  
  479. $self->{'level'} = (defined($args{'level'})) ? $args{'level'} : WARNING;
  480.  
  481. $self;
  482. }
  483.  
  484. sub print
  485. {
  486. my ($self) = shift;
  487. foreach my $fh (@{$self->{'handles'}})
  488. {
  489. $fh->print(POSIX::strftime("[%Y.%m.%d-%H:%M:%S] ", localtime(time)), @_);
  490. }
  491. return 1;
  492. }
  493.  
  494. sub printlvl
  495. {
  496. my ($self, $level) = (shift, shift);
  497. return
  498. if ($self->{'level'} < $level);
  499. return $self->print(@_);
  500. }
  501.  
  502. sub debug { shift->printlvl(DEBUG, @_); }
  503. sub info { shift->printlvl(INFO, @_); }
  504. sub warning { shift->printlvl(WARNING, @_); }
  505. sub error { shift->printlvl(ERROR, @_); }
  506.  
  507. sub close
  508. {
  509. my ($self) = shift;
  510. foreach my $fh (@{$self->{'handles'}})
  511. {
  512. $fh->close();
  513. }
  514. }
  515.  
  516. #-------------------------------------------------------------------------------
  517.  
  518. __END__
  519.  
  520. =head1 NAME
  521.  
  522. Autoresponder - Enhanced autoresponder for Parallels Confixx 3.0.0 and above
  523.  
  524. =head1 SYNOPSIS
  525.  
  526. B<autoresponder.pl>
  527. I<E<lt>identE<gt>>
  528. [ B<-l> I<logfile> ]
  529. [ B<-d> I<debuglevel> ]
  530. [ B<-v> ]
  531.  
  532. =head1 OPTIONS
  533.  
  534. =over 4
  535.  
  536. =item I<E<lt>identE<gt>>
  537.  
  538. =item B<-i> I<number>, B<--ident=>I<number>
  539.  
  540. The autoresponders ident number.
  541.  
  542. =item B<-l> I<logfile>, B<--log=>I<logfile>
  543.  
  544. Path to logfile. Default is stderr
  545.  
  546. =item B<-d> I<debuglevel>, B<--debug=>I<debuglevel>
  547.  
  548. Specify debug level. Default is 0=off
  549.  
  550. =item B<-v>, B<--version>
  551.  
  552. Print version number and exit.
  553.  
  554. =item B<-h>, B<--help>
  555.  
  556. Print this usage message.
  557.  
  558. =back
  559.  
  560. =head1 USAGE
  561.  
  562. in F<aliases>:
  563.  
  564. confixx-du-<ident>: "|/usr/bin/autoresponder.pl <ident>"
  565.  
  566. =head1 AUTHOR
  567.  
  568. Manuel Mausz E<lt>manuel@mausz.atE<gt>
  569. L<http://manuel.mausz.at/coding/autoresponder/>
  570.  
  571. Origin code from Parallels GmbH (L<http://www.parallels.com>)
  572.  
  573. =cut
  574.