summaryrefslogtreecommitdiffstats
path: root/inc/mailer.inc.php
diff options
context:
space:
mode:
Diffstat (limited to 'inc/mailer.inc.php')
-rw-r--r--inc/mailer.inc.php186
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