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']); } }