summaryrefslogblamecommitdiffstats
path: root/inc/mailer.inc.php
blob: 05b6a08c98f63fba2e88bb110994938d45382eba (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
















                                       


                          


                                                                                                                                      



                                                                            


































                                                                                                                         
                                                                

























































































































                                                                                                                                         
<?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
	{
		return $this->errlog;
	}

	public static function queue(int $configid, array $rcpts, string $subject, string $text)
	{
		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()
	{
		$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 = [];
		foreach ($list as $mails) {
			$delete = [];
			$body = [];
			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
				}
				$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] ?? [];
	}

	/**
	 * @param int $configId
	 * @return Mailer|null
	 */
	public static function instanceFromConfig(int $configId)
	{
		$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']);
	}

}