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