Download | Plain Text | No Line Numbers


  1. #!/usr/bin/perl
  2. #
  3. # Copyright (C) 2008-2025 Manuel Mausz (manuel@mausz.at)
  4. # http://manuel.mausz.at/coding/autoresponder/
  5. #
  6. # This program is free software; you can redistribute it and/or
  7. # modify it under the terms of the GNU General Public License
  8. # as published by the Free Software Foundation; either
  9. # version 2 of the License, or (at your option) any later
  10. # version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program; if not, write to the Free Software Foundation,
  19. # Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
  20.  
  21. use Getopt::Long;
  22. use Pod::Usage;
  23. use Mail::Internet;
  24. use Mail::Address;
  25. use MIME::Lite;
  26. use Encode qw(encode);
  27. use Time::HiRes qw(gettimeofday);
  28. use DBI;
  29. use strict;
  30. use warnings;
  31. use utf8;
  32. use v5.38;
  33. binmode(STDOUT, ':utf8');
  34.  
  35. my $VERSION = '0.8';
  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' => 'UD::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 = Getopt::Long::Parser->new();
  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 = Logger->new(($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->warning("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(my $mailfh, '|-') || exec { $args[0] } @args
  205. or die("Unable to open mailer: $!");
  206. $mailfh->binmode(':utf8');
  207. $reply->{'reply'}->print($mailfh);
  208. $mailfh->close;
  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.\n")
  250. if ($hvalue eq '<>' || $hvalue eq "#@[]" || $hvalue =~ /MAILER-DAEMON$/i);
  251. die("Unable to parse mail address.\n")
  252. if (!(@addrs = Mail::Address->parse($hvalue)));
  253. die("Value isn't exactly one mail address.\n")
  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.\n")
  262. if (!($hvalue = $headers->get($hname)));
  263. chomp($hvalue);
  264. die("Message is either invalid or from a mailer daemon.\n")
  265. if ($hvalue =~ /MAILER-DAEMON$/i || $hvalue =~ /Mail Delivery (?:Sub)?(?:system|service)/i);
  266. die("Unable to parse mail address.\n")
  267. if (!(@addrs = Mail::Address->parse($hvalue)));
  268. die("Value isn't exactly one mail address.\n")
  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.\n")
  279. if ($hvalue =~ /(?:junk|list|bulk|autoreply)/i);
  280. }
  281.  
  282. # check Auto-Submitted
  283. $hname = 'Auto-Submitted';
  284. $headers->unfold($hname);
  285. if ($hvalue = $headers->get($hname))
  286. {
  287. chomp($hvalue);
  288. die("Message is flagged as auto submitted.\n")
  289. if ($hvalue !~ /no/i);
  290. }
  291.  
  292. # check X-Auto-Response-Suppress
  293. $hname = 'X-Auto-Response-Suppress';
  294. $headers->unfold($hname);
  295. if ($hvalue = $headers->get($hname))
  296. {
  297. chomp($hvalue);
  298. die("Message has active uto response suppression.\n")
  299. if ($hvalue =~ /(?:all|oof|autoreply)/i);
  300. }
  301.  
  302. # check X-Spam-Status
  303. $hname = 'X-Spam-Status';
  304. $headers->unfold($hname);
  305. if ($hvalue = $headers->get($hname))
  306. {
  307. chomp($hvalue);
  308. die("Message is junk.\n")
  309. if ($hvalue =~ /yes/i);
  310. }
  311.  
  312. # check for mailing list
  313. foreach ('List-Id', 'List-Help', 'List-Subscribe', 'List-Unsubscribe',
  314. 'List-Post', 'List-Owner', 'List-Archive', 'Mailing-List',
  315. 'X-Mailing-List', 'X-ML-Name', 'X-List')
  316. {
  317. $hname = $_; # explicit assignment needed
  318. die("Message is from mailing list.\n")
  319. if (($hvalue = $headers->get($hname)) && chomp($hvalue));
  320. }
  321.  
  322. # check Subject
  323. $hname = 'Subject';
  324. #$headers->unfold($hname);
  325. if ($hvalue = $headers->get($hname))
  326. {
  327. chomp($hvalue);
  328. die("Message is bounced.\n")
  329. if ($hvalue =~ /^Delivery Status Notification \(Failure\)$/i || $hvalue =~ /Automated reply/i);
  330. $subject = $hvalue;
  331. }
  332.  
  333. # check my own headers
  334. foreach (keys($myheader->%*))
  335. {
  336. $hname = $_; # explicit assignment needed
  337. die("Message is signed by myself. Probably looping.\n")
  338. if (($hvalue = $headers->get($hname)) && chomp($hvalue) && $hvalue eq $myheader->{$hname});
  339. }
  340.  
  341. # check Reply-To
  342. $hname = 'Reply-To';
  343. $headers->unfold($hname);
  344. if ($hvalue = $headers->get($hname))
  345. {
  346. chomp($hvalue);
  347. # we'll only respond to the first mail address
  348. push(@sender, $addrs[0])
  349. if ($hvalue !~ /MAILER-DAEMON$/i && (@addrs = Mail::Address->parse($hvalue)));
  350. }
  351. };
  352. if ($@)
  353. {
  354. my $error = $@;
  355. $log->warning("Unexpected header value '", $hname, "': ", $error);
  356. $log->debug("Header value: '", $hvalue, "'.", $/);
  357. exit(0);
  358. };
  359.  
  360. my $sender = (reverse(@sender))[0];
  361.  
  362. # fetch some additional headers
  363. my $references = $headers->get('References') || "";
  364. my $messageid = $headers->get('Message-ID') || "";
  365. chomp($references);
  366. chomp($messageid);
  367.  
  368. # debug stuff
  369. $log->debug("Sender: ", $sender->format(), $/);
  370. $log->debug("Subject: ", $subject, $/);
  371. $log->debug("References: ", $references, $/);
  372. $log->debug("Message-ID: ", $messageid, $/);
  373.  
  374. return {
  375. sender => $sender,
  376. subject => $subject,
  377. references => $references,
  378. messageid => $messageid
  379. };
  380. }
  381.  
  382. #-------------------------------------------------------------------------------
  383.  
  384. sub generate_reply
  385. {
  386. my ($ardata, $hdata, $myheader) = @_;
  387.  
  388. # generate from
  389. my $from = Mail::Address->new($ardata->{'absendername'}, $ardata->{'absenderemail'})
  390. or die("Unable to parse sender address for reply.");
  391.  
  392. # generate subject
  393. my $subject = $ardata->{'emailbetreff'};
  394. $subject = "Auto: " . $hdata->{'subject'}
  395. if (!length($subject) && length($hdata->{'subject'}));
  396. $subject = "Automated reply"
  397. if (!length($subject));
  398.  
  399. my $addr_encode = sub
  400. {
  401. return encode('MIME-Header', $_) . ' <' . $_[0]->address() . '>'
  402. if (defined(local $_ = $_[0]->phrase()) && length($_));
  403. return $_[0]->address();
  404. };
  405.  
  406. # create reply
  407. $log->debug("Generating reply...", $/);
  408. my $reply = MIME::Lite->new(
  409. From => $addr_encode->($from),
  410. To => $addr_encode->($hdata->{'sender'}),
  411. Subject => encode('MIME-Header', $subject),
  412. Data => $ardata->{'emailtext'},
  413. Type => 'text/plain; charset=UTF-8',
  414. ) or die("Unable to create reply object.");
  415.  
  416. # remove MIME::Lite added header
  417. $reply->delete('X-Mailer');
  418.  
  419. # create message-id + threading
  420. my ($seconds, $microseconds) = gettimeofday();
  421. my $messageid = sprintf("<%x.%06d.%x@%s>", $seconds, $microseconds,
  422. rand(2**32 - 1), $from->host());
  423. $reply->add('Message-ID', $messageid);
  424. $reply->add('In-Reply-To', $hdata->{'messageid'})
  425. if (length($hdata->{'messageid'}));
  426. $reply->add('References', join(' ', $hdata->{'references'}, $hdata->{'messageid'}))
  427. if (length($hdata->{'references'}) || length($hdata->{'messageid'}));
  428.  
  429. # add some other headers
  430. $reply->add($_, $myheader->{$_})
  431. foreach (keys($myheader->%*));
  432. $reply->add('Precedence', 'bulk');
  433. $reply->add('Auto-Submitted', 'auto-replied');
  434. $reply->add('X-Auto-Response-Suppress', 'All');
  435.  
  436. # debug stuff
  437. $log->debug("Created reply:", $/, $reply->as_string(), $/);
  438.  
  439. return {
  440. reply => $reply,
  441. from => $from
  442. };
  443. }
  444.  
  445. #-------------------------------------------------------------------------------
  446.  
  447. package Logger;
  448. use IO::Handle;
  449. use IO::File;
  450. use POSIX ();
  451. use Carp;
  452. use strict;
  453. use warnings;
  454. use v5.38;
  455. use constant ERROR => 0;
  456. use constant WARNING => 1;
  457. use constant INFO => 2;
  458. use constant DEBUG => 3;
  459.  
  460. $VERSION = "0.1";
  461.  
  462. sub new
  463. {
  464. my $class = shift;
  465. my $self = {};
  466. my %args = @_;
  467.  
  468. bless($self, $class);
  469.  
  470. $self->{'handles'}->@* = ();
  471. if (defined($args{'handle'}))
  472. {
  473. my $fh = IO::Handle->new();
  474. $fh->fdopen($args{'handle'}, 'w')
  475. or return undef;
  476. binmode($fh, ':utf8');
  477. push($self->{'handles'}->@*, $fh);
  478. }
  479. if (defined($args{'file'}))
  480. {
  481. my $fh = IO::File->new($args{'file'}, "a")
  482. or return undef;
  483. # this is needed for standard confixx qmail installation
  484. chmod(0660, $args{'file'});
  485. binmode($fh, ':utf8');
  486. push($self->{'handles'}->@*, $fh);
  487. }
  488. croak("No logging destination defined")
  489. if (scalar($self->{'handles'}->@*) <= 0);
  490.  
  491. $self->{'level'} = (defined($args{'level'})) ? $args{'level'} : WARNING;
  492.  
  493. $self;
  494. }
  495.  
  496. sub print
  497. {
  498. my ($self) = shift;
  499. $_->print(POSIX::strftime("[%Y.%m.%d-%H:%M:%S] ", localtime(time)), @_)
  500. foreach ($self->{'handles'}->@*);
  501. return 1;
  502. }
  503.  
  504. sub printlvl
  505. {
  506. my ($self, $level) = (shift, shift);
  507. return
  508. if ($self->{'level'} < $level);
  509. return $self->print(@_);
  510. }
  511.  
  512. sub debug { shift->printlvl(DEBUG, @_); }
  513. sub info { shift->printlvl(INFO, @_); }
  514. sub warning { shift->printlvl(WARNING, @_); }
  515. sub error { shift->printlvl(ERROR, @_); }
  516.  
  517. sub close
  518. {
  519. my ($self) = shift;
  520. $_->close()
  521. foreach ($self->{'handles'}->@*);
  522. }
  523.  
  524. #-------------------------------------------------------------------------------
  525.  
  526. __END__
  527.  
  528. =head1 NAME
  529.  
  530. Autoresponder - Enhanced autoresponder for Parallels Confixx 3.0.0 and above
  531.  
  532. =head1 SYNOPSIS
  533.  
  534. B<autoresponder.pl>
  535. I<E<lt>identE<gt>>
  536. [ B<-l> I<logfile> ]
  537. [ B<-d> I<debuglevel> ]
  538. [ B<-v> ]
  539.  
  540. =head1 OPTIONS
  541.  
  542. =over 4
  543.  
  544. =item I<E<lt>identE<gt>>
  545.  
  546. =item B<-i> I<number>, B<--ident=>I<number>
  547.  
  548. The autoresponders ident number.
  549.  
  550. =item B<-l> I<logfile>, B<--log=>I<logfile>
  551.  
  552. Path to logfile. Default is stderr
  553.  
  554. =item B<-d> I<debuglevel>, B<--debug=>I<debuglevel>
  555.  
  556. Specify debug level. Default is 0=off
  557.  
  558. =item B<-v>, B<--version>
  559.  
  560. Print version number and exit.
  561.  
  562. =item B<-h>, B<--help>
  563.  
  564. Print this usage message.
  565.  
  566. =back
  567.  
  568. =head1 USAGE
  569.  
  570. in F<aliases>:
  571.  
  572. confixx-du-<ident>: "|/usr/bin/autoresponder.pl <ident>"
  573.  
  574. =head1 AUTHOR
  575.  
  576. Manuel Mausz E<lt>manuel@mausz.atE<gt>
  577. L<http://manuel.mausz.at/coding/autoresponder/>
  578.  
  579. Origin code from Parallels GmbH (L<http://www.parallels.com>)
  580.  
  581. =cut
  582.