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