• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aplus-framework / email / 21764448326

06 Feb 2026 08:11PM UTC coverage: 99.385% (+0.1%) from 99.289%
21764448326

push

github

natanfelles
Update PHPDocs

485 of 488 relevant lines covered (99.39%)

11.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.26
/src/Mailer.php
1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of Aplus Framework Email Library.
4
 *
5
 * (c) Natan Felles <natanfelles@gmail.com>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
namespace Framework\Email;
11

12
use Framework\Email\Debug\EmailCollector;
13
use InvalidArgumentException;
14
use JetBrains\PhpStorm\ArrayShape;
15
use NoDiscard;
16
use SensitiveParameter;
17

18
/**
19
 * Class Mailer.
20
 *
21
 * @package email
22
 */
23
class Mailer
24
{
25
    /**
26
     * @var array<string,mixed>
27
     */
28
    protected array $config = [];
29
    /**
30
     * @var false|resource $socket
31
     */
32
    protected $socket = false;
33
    /**
34
     * @var array<int,array<string,mixed>>
35
     */
36
    protected array $logs = [];
37
    protected EmailCollector $debugCollector;
38
    protected ?string $lastResponse = null;
39

40
    /**
41
     * Mailer constructor.
42
     *
43
     * @param array<string,mixed>|string $username
44
     * @param string|null $password
45
     * @param string $host
46
     * @param int $port
47
     * @param string|null $hostname
48
     */
49
    public function __construct(
50
        #[SensitiveParameter]
51
        array | string $username,
52
        #[SensitiveParameter]
53
        ?string $password = null,
54
        string $host = 'localhost',
55
        int $port = 587,
56
        ?string $hostname = null
57
    ) {
58
        $this->config = \is_array($username)
51✔
59
            ? $this->makeConfig($username)
22✔
60
            : $this->makeConfig([
29✔
61
                'username' => $username,
29✔
62
                'password' => $password,
29✔
63
                'host' => $host,
29✔
64
                'port' => $port,
29✔
65
                'hostname' => $hostname ?? \gethostname(),
29✔
66
            ]);
29✔
67
    }
68

69
    /**
70
     * Disconnect from SMTP server.
71
     */
72
    public function __destruct()
73
    {
74
        $this->disconnect();
11✔
75
    }
76

77
    /**
78
     * Make Base configurations.
79
     *
80
     * @param array<string,mixed> $config
81
     *
82
     * @return array<string,mixed>
83
     */
84
    #[ArrayShape([
85
        'host' => 'string',
86
        'port' => 'int',
87
        'tls' => 'bool',
88
        'options' => 'array',
89
        'username' => 'string|null',
90
        'password' => 'string|null',
91
        'charset' => 'string',
92
        'crlf' => 'string',
93
        'connection_timeout' => 'int',
94
        'response_timeout' => 'int',
95
        'hostname' => 'string',
96
        'keep_alive' => 'bool',
97
        'save_logs' => 'bool',
98
    ])]
99
    protected function makeConfig(#[SensitiveParameter] array $config) : array
100
    {
101
        $config = \array_replace_recursive([
51✔
102
            'host' => 'localhost',
51✔
103
            'port' => 587,
51✔
104
            'tls' => true,
51✔
105
            'options' => [
51✔
106
                'ssl' => [
51✔
107
                    'allow_self_signed' => false,
51✔
108
                    'verify_peer' => true,
51✔
109
                    'verify_peer_name' => true,
51✔
110
                ],
51✔
111
            ],
51✔
112
            'username' => null,
51✔
113
            'password' => null,
51✔
114
            'charset' => 'utf-8',
51✔
115
            'crlf' => "\r\n",
51✔
116
            'connection_timeout' => 10,
51✔
117
            'response_timeout' => 5,
51✔
118
            'hostname' => \gethostname(),
51✔
119
            'keep_alive' => false,
51✔
120
            'save_logs' => false,
51✔
121
        ], $config);
51✔
122
        $this->validateConfigKeys(\array_keys($config));
51✔
123
        return $config;
51✔
124
    }
125

126
    /**
127
     * @param array<int,string> $keys
128
     *
129
     * @return void
130
     */
131
    protected function validateConfigKeys(array $keys) : void
132
    {
133
        foreach ($keys as $key) {
51✔
134
            if (!\in_array($key, [
51✔
135
                'host',
51✔
136
                'port',
51✔
137
                'tls',
51✔
138
                'options',
51✔
139
                'username',
51✔
140
                'password',
51✔
141
                'charset',
51✔
142
                'crlf',
51✔
143
                'connection_timeout',
51✔
144
                'response_timeout',
51✔
145
                'hostname',
51✔
146
                'keep_alive',
51✔
147
                'save_logs',
51✔
148
            ], true)) {
51✔
149
                throw new InvalidArgumentException('Invalid config key: ' . $key);
1✔
150
            }
151
        }
152
    }
153

154
    /**
155
     * Get a config value.
156
     *
157
     * @param string $key The config key
158
     *
159
     * @return mixed The config value
160
     */
161
    public function getConfig(string $key) : mixed
162
    {
163
        return $this->config[$key];
22✔
164
    }
165

166
    /**
167
     * Get all configs.
168
     *
169
     * @return array<string,mixed>
170
     */
171
    #[ArrayShape([
172
        'host' => 'string',
173
        'port' => 'int',
174
        'tls' => 'bool',
175
        'options' => 'array',
176
        'username' => 'string|null',
177
        'password' => 'string|null',
178
        'charset' => 'string',
179
        'crlf' => 'string',
180
        'connection_timeout' => 'int',
181
        'response_timeout' => 'int',
182
        'hostname' => 'string',
183
        'keep_alive' => 'bool',
184
        'save_logs' => 'bool',
185
    ])]
186
    public function getConfigs() : array
187
    {
188
        return $this->config;
4✔
189
    }
190

191
    protected function setLastResponse(?string $lastResponse) : static
192
    {
193
        if ($lastResponse === null) {
12✔
194
            $this->lastResponse = null;
1✔
195
            return $this;
1✔
196
        }
197
        $parts = \explode(\PHP_EOL, $lastResponse);
12✔
198
        $this->lastResponse = $parts[\array_key_last($parts)];
12✔
199
        return $this;
12✔
200
    }
201

202
    /**
203
     * Get the last response.
204
     *
205
     * @return string|null The last response or null if there is none
206
     */
207
    public function getLastResponse() : ?string
208
    {
209
        return $this->lastResponse;
8✔
210
    }
211

212
    protected function connect() : bool
213
    {
214
        if ($this->socket && ($this->getConfig('keep_alive') === true)) {
11✔
215
            return $this->sendCommand('EHLO ' . $this->getConfig('hostname')) === 250;
1✔
216
        }
217
        $this->disconnect();
11✔
218
        $this->socket = @\stream_socket_client(
11✔
219
            $this->getConfig('host') . ':' . $this->getConfig('port'),
11✔
220
            $errorCode,
11✔
221
            $errorMessage,
11✔
222
            (float) $this->getConfig('connection_timeout'),
11✔
223
            \STREAM_CLIENT_CONNECT,
11✔
224
            \stream_context_create($this->getConfig('options'))
11✔
225
        );
11✔
226
        if ($this->socket === false) {
11✔
227
            $error = 'Socket connection error ' . $errorCode . ': ' . $errorMessage;
1✔
228
            $this->addLog('', $error);
1✔
229
            $this->setLastResponse($error);
1✔
230
            return false;
1✔
231
        }
232
        $this->addLog('', $this->getResponse());
10✔
233
        $this->sendCommand('EHLO ' . $this->getConfig('hostname'));
10✔
234
        if ($this->getConfig('tls')) {
10✔
235
            $this->sendCommand('STARTTLS');
10✔
236
            \stream_socket_enable_crypto($this->socket, true, \STREAM_CRYPTO_METHOD_TLS_CLIENT);
10✔
237
            $this->sendCommand('EHLO ' . $this->getConfig('hostname'));
10✔
238
        }
239
        return $this->authenticate();
10✔
240
    }
241

242
    protected function disconnect() : bool
243
    {
244
        if (\is_resource($this->socket)) {
15✔
245
            $this->sendCommand('QUIT');
10✔
246
            $closed = \fclose($this->socket);
10✔
247
        }
248
        $this->socket = false;
15✔
249
        return $closed ?? true;
15✔
250
    }
251

252
    /**
253
     * @see https://datatracker.ietf.org/doc/html/rfc2821#section-4.2.3
254
     * @see https://datatracker.ietf.org/doc/html/rfc4954#section-4.1
255
     *
256
     * @return bool
257
     */
258
    protected function authenticate() : bool
259
    {
260
        if ($this->getConfig('username') === null) {
10✔
261
            $this->setLastResponse('Username is not set');
1✔
262
            return false;
1✔
263
        }
264
        if ($this->getConfig('password') === null) {
9✔
265
            $this->setLastResponse('Password is not set');
1✔
266
            return false;
1✔
267
        }
268
        $code = $this->sendCommand('AUTH LOGIN');
8✔
269
        if ($code === 503) { // Already authenticated
8✔
270
            return true;
×
271
        }
272
        if ($code !== 334) {
8✔
273
            return false;
×
274
        }
275
        $code = $this->sendCommand(\base64_encode($this->getConfig('username')));
8✔
276
        if ($code !== 334) {
8✔
277
            return false;
×
278
        }
279
        $code = $this->sendCommand(\base64_encode($this->getConfig('password')));
8✔
280
        return $code === 235;
8✔
281
    }
282

283
    /**
284
     * Send an Email Message.
285
     *
286
     * @param Message $message The Message instance
287
     *
288
     * @return bool True if successful, otherwise false
289
     */
290
    #[NoDiscard]
291
    public function send(Message $message) : bool
292
    {
293
        if (isset($this->debugCollector)) {
11✔
294
            $start = \microtime(true);
2✔
295
            $code = $this->sendMessage($message);
2✔
296
            $end = \microtime(true);
2✔
297
            $success = $this->isSuccessCode($code);
2✔
298
            $this->debugCollector->addData([
2✔
299
                'start' => $start,
2✔
300
                'end' => $end,
2✔
301
                'code' => $code,
2✔
302
                'success' => $success,
2✔
303
                'last_response' => $this->getLastResponse(),
2✔
304
                'from' => $message->getFromAddress(),
2✔
305
                'length' => \strlen((string) $message),
2✔
306
                'recipients' => $message->getRecipients(),
2✔
307
                'headers' => $message->getHeaders(),
2✔
308
                'plain' => $message->getPlainMessage(),
2✔
309
                'html' => $message->getHtmlMessage(),
2✔
310
                'attachments' => $message->getAttachments(),
2✔
311
                'inlineAttachments' => $message->getInlineAttachments(),
2✔
312
            ]);
2✔
313
            return $success;
2✔
314
        }
315
        return $this->isSuccessCode($this->sendMessage($message));
9✔
316
    }
317

318
    protected function sendMessage(Message $message) : false | int
319
    {
320
        $message->setMailer($this);
11✔
321
        $message->validate();
11✔
322
        if (!$this->connect()) {
11✔
323
            return false;
5✔
324
        }
325
        $this->sendCommand('MAIL FROM: <' . $message->getFromAddress() . '>');
6✔
326
        foreach ($message->getRecipients() as $address) {
6✔
327
            $this->sendCommand('RCPT TO: <' . $address . '>');
6✔
328
        }
329
        $this->sendCommand('DATA');
6✔
330
        $code = $this->sendCommand(
6✔
331
            $message . $this->getConfig('crlf') . '.'
6✔
332
        );
6✔
333
        if ($this->getConfig('keep_alive') !== true) {
6✔
334
            $this->disconnect();
5✔
335
        }
336
        return $code;
6✔
337
    }
338

339
    /**
340
     * Get Mail Server response.
341
     *
342
     * @return string
343
     */
344
    protected function getResponse() : string
345
    {
346
        $response = '';
10✔
347
        // @phpstan-ignore-next-line
348
        \stream_set_timeout($this->socket, $this->getConfig('response_timeout'));
10✔
349
        // @phpstan-ignore-next-line
350
        while (($line = \fgets($this->socket, 512)) !== false) {
10✔
351
            $response .= \trim($line) . "\n";
10✔
352
            if (isset($line[3]) && $line[3] === ' ') {
10✔
353
                break;
10✔
354
            }
355
        }
356
        return \trim($response);
10✔
357
    }
358

359
    /**
360
     * Send command to mail server.
361
     *
362
     * @param string $command
363
     *
364
     * @return int Response code
365
     */
366
    protected function sendCommand(string $command) : int
367
    {
368
        // @phpstan-ignore-next-line
369
        \fwrite($this->socket, $command . $this->getConfig('crlf'));
10✔
370
        $response = $this->getResponse();
10✔
371
        $this->addLog($command, $response);
10✔
372
        // The last command could be: "EHLO $host".
373
        // And the last response is an empty string.
374
        // So, we ignore empty responses...
375
        if ($response !== '') {
10✔
376
            $this->setLastResponse($response);
10✔
377
        }
378
        return $this->makeResponseCode($response);
10✔
379
    }
380

381
    /**
382
     * @see https://tools.ietf.org/html/rfc2821#section-4.2.3
383
     * @see https://en.wikipedia.org/wiki/List_of_SMTP_server_return_codes
384
     *
385
     * @param string $response
386
     *
387
     * @return int
388
     */
389
    private function makeResponseCode(string $response) : int
390
    {
391
        return (int) \substr($response, 0, 3);
10✔
392
    }
393

394
    /**
395
     * Get an array of logs.
396
     *
397
     * Contains commands and responses from the Mailer server.
398
     *
399
     * @return array<int,array<string,mixed>>
400
     */
401
    public function getLogs() : array
402
    {
403
        return $this->logs;
3✔
404
    }
405

406
    /**
407
     * Reset logs.
408
     *
409
     * @return static
410
     */
411
    public function resetLogs() : static
412
    {
413
        $this->logs = [];
1✔
414
        return $this;
1✔
415
    }
416

417
    /**
418
     * @param string $command
419
     * @param string $response
420
     *
421
     * @return static
422
     */
423
    protected function addLog(string $command, string $response) : static
424
    {
425
        if (!$this->getConfig('save_logs')) {
11✔
426
            return $this;
7✔
427
        }
428
        $this->logs[] = [
4✔
429
            'command' => $command,
4✔
430
            'responses' => \explode(\PHP_EOL, $response),
4✔
431
        ];
4✔
432
        return $this;
4✔
433
    }
434

435
    /**
436
     * Set the debug collector.
437
     *
438
     * @param EmailCollector $collector The debug collector
439
     *
440
     * @return static
441
     */
442
    public function setDebugCollector(EmailCollector $collector) : static
443
    {
444
        $collector->setMailer($this);
4✔
445
        $this->debugCollector = $collector;
4✔
446
        return $this;
4✔
447
    }
448

449
    /**
450
     * Create a new Message instance.
451
     *
452
     * @return Message
453
     */
454
    public function createMessage() : Message
455
    {
456
        return (new Message())->setMailer($this);
1✔
457
    }
458

459
    protected function isSuccessCode(false | int $code) : bool
460
    {
461
        if ($code === false) {
13✔
462
            return false;
6✔
463
        }
464
        if ($code === 354 && $this->getConfig('keep_alive')) {
8✔
465
            return true;
1✔
466
        }
467
        return $code === 250 || $code === 0;
7✔
468
    }
469
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc