diff options
Diffstat (limited to 'inc/mailer.inc.php')
-rw-r--r-- | inc/mailer.inc.php | 186 |
1 files changed, 186 insertions, 0 deletions
diff --git a/inc/mailer.inc.php b/inc/mailer.inc.php new file mode 100644 index 00000000..bfdcd320 --- /dev/null +++ b/inc/mailer.inc.php @@ -0,0 +1,186 @@ +<?php + +declare(strict_types=1); + +class Mailer +{ + + /** @var array|null */ + private static $configs = null; + + /** @var array */ + private $curlOptions; + + /** @var string|null */ + private $replyTo; + + /** @var string */ + private $errlog = ''; + + /** @var string */ + private $from; + + // $keys = array('host', 'port', 'ssl', 'senderAddress', 'replyTo', 'username', 'password', 'serverName'); + public function __construct(string $hosturi, bool $startTls, string $from, string $user, string $pass, string $replyTo = null) + { + $this->from = $from; + if (preg_match('/[^<>"\'\s]+@[^<>"\'\s]+/i', $from, $out)) { + $from = $out[0]; + } + $this->curlOptions = [ + CURLOPT_URL => $hosturi, + CURLOPT_USE_SSL => $startTls ? CURLUSESSL_ALL : CURLUSESSL_TRY, + CURLOPT_MAIL_FROM => $from, + CURLOPT_UPLOAD => 1, + CURLOPT_VERBOSE => 1, // XXX + ]; + if (!empty($user)) { + $this->curlOptions += [ + CURLOPT_LOGIN_OPTIONS => 'AUTH=NTLM;AUTH=DIGEST-MD5;AUTH=CRAM-MD5;AUTH=PLAIN;AUTH=LOGIN', + CURLOPT_USERNAME => $user, + CURLOPT_PASSWORD => $pass, + ]; + } + $this->replyTo = $replyTo; + } + + /** + * Send a mail to given list of recipients. + * @param string[] $rcpts Recipients + * @param string $subject Mail subject + * @param string $text Mail body + * @return int curl error code, CURLE_OK on success + */ + public function send(array $rcpts, string $subject, string $text): int + { + // Convert all line breaks to CRLF while trying to avoid introducing additional ones + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = str_replace("\n", "\r\n", $text); + $mail = ["Date: " . date('r')]; + foreach ($rcpts as $r) { + $mail[] = self::mimeEncode('To', $r); + } + $mail[] = self::mimeEncode('Content-Type', 'text/plain; charset="utf-8"'); + $mail[] = self::mimeEncode('Subject', $subject); + $mail[] = self::mimeEncode('From', $this->from); + $mail[] = self::mimeEncode('Message-ID', '<' . Util::randomUuid() . '@rfcpedant.example.org>'); + if (!empty($this->replyTo)) { + $mail[] = self::mimeEncode('Reply-To', $this->replyTo); + } + + $mail = implode("\r\n", $mail) . "\r\n\r\n" . $text; + $c = curl_init(); + $pos = 0; + $this->curlOptions[CURLOPT_MAIL_RCPT] = array_map(function ($mail) { + return preg_replace('/\s.*$/', '', $mail); + }, $rcpts); + $this->curlOptions[CURLOPT_READFUNCTION] = function($ch, $fp, $len) use (&$pos, $mail) { + $txt = substr($mail, $pos, $len); + $pos += strlen($txt); + return $txt; + }; + $err = fopen('php://temp', 'w+'); + $this->curlOptions[CURLOPT_STDERR] = $err; + curl_setopt_array($c, $this->curlOptions); + curl_exec($c); + rewind($err); + $this->errlog = stream_get_contents($err); + fclose($err); + return curl_errno($c); + } + + public function getLog(): string + { + // Remove repeated "Expire" messages, keep only last one + return preg_replace('/^\* Expire in \d+ ms for.*[\\r\\n]+(?=\* Expire)/m','$1', $this->errlog); + } + + public static function queue(int $configid, array $rcpts, string $subject, string $text): void + { + foreach ($rcpts as $rcpt) { + Database::exec("INSERT INTO mail_queue (rcpt, subject, body, dateline, configid) + VALUES (:rcpt, :subject, :body, UNIX_TIMESTAMP(), :config)", + ['rcpt' => $rcpt, 'subject' => $subject, 'body' => $text, 'config' => $configid]); + } + } + + public static function flushQueue(): void + { + $list = Database::queryGroupList("SELECT Concat(configid, rcpt, subject) AS keyval, + mailid, configid, rcpt, subject, body, dateline FROM mail_queue + WHERE nexttry <= UNIX_TIMESTAMP() LIMIT 20"); + $cutoff = time() - 43200; // 12h + $mailers = []; + // Loop over mails, grouped by configid-rcpt-subject + foreach ($list as $mails) { + $delete = []; + $body = []; + // Loop over individual mails in current group + foreach ($mails as $mail) { + $delete[] = $mail['mailid']; + if ($mail['dateline'] < $cutoff) { + EventLog::info("Dropping queued mail '{$mail['subject']}' for {$mail['rcpt']} as it's too old."); + continue; // Ignore, too old + } + $body[] = ' *** ' . date('d.m.Y H:i:s', $mail['dateline']) . "\r\n" + . $mail['body']; + } + if (!empty($body) && isset($mail)) { + $body = implode("\r\n\r\n - - - - -\r\n\r\n", $body); + if (!isset($mailers[$mail['configid']])) { + $mailers[$mail['configid']] = self::instanceFromConfig($mail['configid']); + if ($mailers[$mail['configid']] === null) { + EventLog::failure("Invalid mailer config id: " . $mail['configid']); + } + } + if (($mailer = $mailers[$mail['configid']]) !== null) { + $ret = $mailer->send([$mail['rcpt']], $mail['subject'], $body); + if ($ret !== CURLE_OK) { + Database::exec("UPDATE mail_queue SET nexttry = UNIX_TIMESTAMP() + 7200 + WHERE mailid IN (:ids)", ['ids' => $delete]); + $delete = []; + EventLog::info("Error sending mail '{$mail['subject']}' for {$mail['rcpt']}.", + $mailer->getLog()); + } + } + } + // Now delete these, either sending succeeded or it's too old + if (!empty($delete)) { + Database::exec("DELETE FROM mail_queue WHERE mailid IN (:ids)", ['ids' => $delete]); + } + } + } + + private static function mimeEncode(string $field, string $string): string + { + if (preg_match('/[\r\n\x7f-\xff]/', $string)) { + return iconv_mime_encode($field, $string); + } + return "$field: $string"; + } + + private static function getConfig(int $configId): array + { + if (!is_array(self::$configs)) { + self::$configs = Database::queryIndexedList("SELECT configid, host, port, `ssl`, senderaddress, replyto, + username, password + FROM mail_config"); + } + return self::$configs[$configId] ?? []; + } + + public static function instanceFromConfig(int $configId): ?Mailer + { + $config = self::getConfig($configId); + if (empty($config)) + return null; + if ($config['ssl'] === 'IMPLICIT') { + $uri = "smtps://{$config['host']}:{$config['port']}"; + } else { + $uri = "smtp://{$config['host']}:{$config['port']}"; + } + return new Mailer($uri, $config['ssl'] === 'EXPLICIT', $config['senderaddress'], $config['username'], + $config['password'], $config['replyto']); + } + +}
\ No newline at end of file |