summaryrefslogtreecommitdiffstats
path: root/inc/mailer.inc.php
blob: bfdcd32046e2ec6dc365d6f04632ad946e43a3df (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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']);
	}

}