56 | */
57 | protected static array $headers = [
58 | 'auto-submitted' => 'Auto-Submitted',
59 | 'bcc' => 'Bcc',
60 | 'cc' => 'Cc',
61 | 'comments' => 'Comments',
62 | 'content-type' => 'Content-Type',
63 | 'date' => 'Date',
64 | 'dkim-signature' => 'DKIM-Signature',
65 | 'from' => 'From',
66 | 'in-reply-to' => 'In-Reply-To',
67 | 'keywords' => 'Keywords',
68 | 'list-unsubscribe-post' => 'List-Unsubscribe-Post',
69 | 'message-id' => 'Message-ID',
70 | 'mime-version' => 'MIME-Version',
71 | 'mt-priority' => 'MT-Priority',
72 | 'original-from' => 'Original-From',
73 | 'original-recipient' => 'Original-Recipient',
74 | 'original-subject' => 'Original-Subject',
75 | 'priority' => 'Priority',
76 | 'received' => 'Received',
77 | 'received-spf' => 'Received-SPF',
78 | 'references' => 'References',
79 | 'reply-to' => 'Reply-To',
80 | 'resent-bcc' => 'Resent-Bcc',
81 | 'resent-cc' => 'Resent-Cc',
82 | 'resent-date' => 'Resent-Date',
83 | 'resent-from' => 'Resent-From',
84 | 'resent-message-id' => 'Resent-Message-ID',
85 | 'resent-sender' => 'Resent-Sender',
86 | 'resent-to' => 'Resent-To',
87 | 'return-path' => 'Return-Path',
88 | 'sender' => 'Sender',
89 | 'subject' => 'Subject',
90 | 'to' => 'To',
91 | 'x-mailer' => 'X-Mailer',
92 | 'x-priority' => 'X-Priority',
93 | ];
94 |
95 | /**
96 | * Get a correct header name.
97 | *
98 | * @param string $name The header name in any case
99 | *
100 | * @return string The correct name or the same if it is unknown
101 | */
102 | public static function getName(string $name) : string
103 | {
104 | return static::$headers[\strtolower($name)] ?? $name;
105 | }
106 |
107 | /**
108 | * Set a correct header name.
109 | *
110 | * @param string $name The header name in the correct case
111 | *
112 | * @return void
113 | */
114 | public static function setName(string $name) : void
115 | {
116 | static::$headers[\strtolower($name)] = $name;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/Debug/EmailCollector.php:
--------------------------------------------------------------------------------
1 |
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\Debug;
11 |
12 | use Framework\Debug\Collector;
13 | use Framework\Debug\Debugger;
14 | use Framework\Email\Header;
15 | use Framework\Email\Mailer;
16 |
17 | /**
18 | * Class EmailCollector.
19 | *
20 | * @package email
21 | */
22 | class EmailCollector extends Collector
23 | {
24 | protected Mailer $mailer;
25 |
26 | public function setMailer(Mailer $mailer) : static
27 | {
28 | $this->mailer = $mailer;
29 | return $this;
30 | }
31 |
32 | public function getActivities() : array
33 | {
34 | $activities = [];
35 | foreach ($this->getData() as $index => $data) {
36 | $activities[] = [
37 | 'collector' => $this->getName(),
38 | 'class' => static::class,
39 | 'description' => 'Send message ' . ($index + 1),
40 | 'start' => $data['start'],
41 | 'end' => $data['end'],
42 | ];
43 | }
44 | return $activities;
45 | }
46 |
47 | public function getContents() : string
48 | {
49 | \ob_start();
50 | if (!isset($this->mailer)) {
51 | echo 'This collector has not been added to a Mailer instance.
';
52 | return \ob_get_clean(); // @phpstan-ignore-line
53 | }
54 | echo $this->showHeader();
55 | if (!$this->hasData()) {
56 | echo 'No messages have been sent.
';
57 | return \ob_get_clean(); // @phpstan-ignore-line
58 | }
59 | $count = \count($this->getData()); ?>
60 | Sent = $this->getTotalMessagesSent() ?> of =
61 | $count ?> message= $count === 1 ? '' : 's' ?>:
62 |
63 | getData() as $index => $data) : ?>
65 | Message = $index + 1 ?>
66 | Status:
67 | = $data['code'] === 250 ? 'OK' : 'Error' ?>
68 | Last Response: = $data['last_response'] ?>
69 | From: = \htmlentities($data['from']) ?>
70 |
71 | Recipients: = \htmlentities(\implode(', ', $data['recipients'])) ?>
72 |
73 | Size: = Debugger::convertSize($data['length']) ?>
74 |
75 | Time Sending: = Debugger::roundSecondsToMilliseconds($data['end'] - $data['start']) ?> ms
76 |
77 | Headers
78 |
79 |
80 |
81 | | Name |
82 | Value |
83 |
84 |
85 |
86 | $value): ?>
87 |
88 | | = \htmlentities(Header::getName($name)) ?> |
89 | = \htmlentities($value) ?> |
90 |
91 |
92 |
93 |
94 |
96 | HTML Message
97 | = \htmlentities($data['html']) ?>
98 |
101 | Plain Message
102 | = \htmlentities($data['plain']) ?>
103 |
106 | Attachments
107 |
108 |
109 |
110 | | File |
111 |
112 |
113 |
114 |
115 |
116 | | = \htmlentities(\realpath($attachment)); // @phpstan-ignore-line?> |
117 |
118 |
119 |
120 |
121 |
124 | Inline Attachments
125 |
126 |
127 |
128 | | Content-ID |
129 | File |
130 |
131 |
132 |
133 | $filename): ?>
134 |
135 | | = \htmlentities($cid) ?> |
136 | = \htmlentities(\realpath($filename)); // @phpstan-ignore-line?> |
137 |
138 |
139 |
140 |
141 | mailer->getConfigs();
151 | ?>
152 | Host: = \htmlentities($configs['host']) ?>
153 | Port: = \htmlentities((string) $configs['port']) ?>
154 | getData() as $data) {
162 | if ($data['code'] === 250) {
163 | $result++;
164 | }
165 | }
166 | return $result;
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/Mailer.php:
--------------------------------------------------------------------------------
1 |
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 JetBrains\PhpStorm\ArrayShape;
14 | use SensitiveParameter;
15 |
16 | /**
17 | * Class Mailer.
18 | *
19 | * @package email
20 | */
21 | class Mailer
22 | {
23 | /**
24 | * @var array
25 | */
26 | protected array $config = [];
27 | /**
28 | * @var false|resource $socket
29 | */
30 | protected $socket = false;
31 | /**
32 | * @var array>
33 | */
34 | protected array $logs = [];
35 | protected EmailCollector $debugCollector;
36 | protected ?string $lastResponse = null;
37 |
38 | /**
39 | * Mailer constructor.
40 | *
41 | * @param array|string $username
42 | * @param string|null $password
43 | * @param string $host
44 | * @param int $port
45 | * @param string|null $hostname
46 | */
47 | public function __construct(
48 | #[SensitiveParameter]
49 | array | string $username,
50 | #[SensitiveParameter]
51 | ?string $password = null,
52 | string $host = 'localhost',
53 | int $port = 587,
54 | ?string $hostname = null
55 | ) {
56 | $this->config = \is_array($username)
57 | ? $this->makeConfig($username)
58 | : $this->makeConfig([
59 | 'username' => $username,
60 | 'password' => $password,
61 | 'host' => $host,
62 | 'port' => $port,
63 | 'hostname' => $hostname ?? \gethostname(),
64 | ]);
65 | }
66 |
67 | /**
68 | * Disconnect from SMTP server.
69 | */
70 | public function __destruct()
71 | {
72 | $this->disconnect();
73 | }
74 |
75 | /**
76 | * Make Base configurations.
77 | *
78 | * @param array $config
79 | *
80 | * @return array
81 | */
82 | #[ArrayShape([
83 | 'host' => 'string',
84 | 'port' => 'int',
85 | 'tls' => 'bool',
86 | 'options' => 'array',
87 | 'username' => 'string|null',
88 | 'password' => 'string|null',
89 | 'charset' => 'string',
90 | 'crlf' => 'string',
91 | 'connection_timeout' => 'int',
92 | 'response_timeout' => 'int',
93 | 'hostname' => 'string',
94 | 'keep_alive' => 'bool',
95 | 'save_logs' => 'bool',
96 | ])]
97 | protected function makeConfig(#[SensitiveParameter] array $config) : array
98 | {
99 | return \array_replace_recursive([
100 | 'host' => 'localhost',
101 | 'port' => 587,
102 | 'tls' => true,
103 | 'options' => [
104 | 'ssl' => [
105 | 'allow_self_signed' => false,
106 | 'verify_peer' => true,
107 | 'verify_peer_name' => true,
108 | ],
109 | ],
110 | 'username' => null,
111 | 'password' => null,
112 | 'charset' => 'utf-8',
113 | 'crlf' => "\r\n",
114 | 'connection_timeout' => 10,
115 | 'response_timeout' => 5,
116 | 'hostname' => \gethostname(),
117 | 'keep_alive' => false,
118 | 'save_logs' => false,
119 | ], $config);
120 | }
121 |
122 | /**
123 | * Get a config value.
124 | *
125 | * @param string $key The config key
126 | *
127 | * @return mixed The config value
128 | */
129 | public function getConfig(string $key) : mixed
130 | {
131 | return $this->config[$key];
132 | }
133 |
134 | /**
135 | * Get all configs.
136 | *
137 | * @return array
138 | */
139 | #[ArrayShape([
140 | 'host' => 'string',
141 | 'port' => 'int',
142 | 'tls' => 'bool',
143 | 'options' => 'array',
144 | 'username' => 'string|null',
145 | 'password' => 'string|null',
146 | 'charset' => 'string',
147 | 'crlf' => 'string',
148 | 'connection_timeout' => 'int',
149 | 'response_timeout' => 'int',
150 | 'hostname' => 'string',
151 | 'keep_alive' => 'bool',
152 | 'save_logs' => 'bool',
153 | ])]
154 | public function getConfigs() : array
155 | {
156 | return $this->config;
157 | }
158 |
159 | protected function setLastResponse(?string $lastResponse) : static
160 | {
161 | if ($lastResponse === null) {
162 | $this->lastResponse = null;
163 | return $this;
164 | }
165 | $parts = \explode(\PHP_EOL, $lastResponse);
166 | $this->lastResponse = $parts[\array_key_last($parts)];
167 | return $this;
168 | }
169 |
170 | /**
171 | * Get the last response.
172 | *
173 | * @return string|null The last response or null if there is none
174 | */
175 | public function getLastResponse() : ?string
176 | {
177 | return $this->lastResponse;
178 | }
179 |
180 | protected function connect() : bool
181 | {
182 | if ($this->socket && ($this->getConfig('keep_alive') === true)) {
183 | return $this->sendCommand('EHLO ' . $this->getConfig('hostname')) === 250;
184 | }
185 | $this->disconnect();
186 | $this->socket = @\stream_socket_client(
187 | $this->getConfig('host') . ':' . $this->getConfig('port'),
188 | $errorCode,
189 | $errorMessage,
190 | (float) $this->getConfig('connection_timeout'),
191 | \STREAM_CLIENT_CONNECT,
192 | \stream_context_create($this->getConfig('options'))
193 | );
194 | if ($this->socket === false) {
195 | $error = 'Socket connection error ' . $errorCode . ': ' . $errorMessage;
196 | $this->addLog('', $error);
197 | $this->setLastResponse($error);
198 | return false;
199 | }
200 | $this->addLog('', $this->getResponse());
201 | $this->sendCommand('EHLO ' . $this->getConfig('hostname'));
202 | if ($this->getConfig('tls')) {
203 | $this->sendCommand('STARTTLS');
204 | \stream_socket_enable_crypto($this->socket, true, \STREAM_CRYPTO_METHOD_TLS_CLIENT);
205 | $this->sendCommand('EHLO ' . $this->getConfig('hostname'));
206 | }
207 | return $this->authenticate();
208 | }
209 |
210 | protected function disconnect() : bool
211 | {
212 | if (\is_resource($this->socket)) {
213 | $this->sendCommand('QUIT');
214 | $closed = \fclose($this->socket);
215 | }
216 | $this->socket = false;
217 | return $closed ?? true;
218 | }
219 |
220 | /**
221 | * @see https://datatracker.ietf.org/doc/html/rfc2821#section-4.2.3
222 | * @see https://datatracker.ietf.org/doc/html/rfc4954#section-4.1
223 | *
224 | * @return bool
225 | */
226 | protected function authenticate() : bool
227 | {
228 | if ($this->getConfig('username') === null) {
229 | $this->setLastResponse('Username is not set');
230 | return false;
231 | }
232 | if ($this->getConfig('password') === null) {
233 | $this->setLastResponse('Password is not set');
234 | return false;
235 | }
236 | $code = $this->sendCommand('AUTH LOGIN');
237 | if ($code === 503) { // Already authenticated
238 | return true;
239 | }
240 | if ($code !== 334) {
241 | return false;
242 | }
243 | $code = $this->sendCommand(\base64_encode($this->getConfig('username')));
244 | if ($code !== 334) {
245 | return false;
246 | }
247 | $code = $this->sendCommand(\base64_encode($this->getConfig('password')));
248 | return $code === 235;
249 | }
250 |
251 | /**
252 | * Send an Email Message.
253 | *
254 | * @param Message $message The Message instance
255 | *
256 | * @return bool True if successful, otherwise false
257 | */
258 | public function send(Message $message) : bool
259 | {
260 | if (isset($this->debugCollector)) {
261 | $start = \microtime(true);
262 | $code = $this->sendMessage($message);
263 | $end = \microtime(true);
264 | $this->debugCollector->addData([
265 | 'start' => $start,
266 | 'end' => $end,
267 | 'code' => $code,
268 | 'last_response' => $this->getLastResponse(),
269 | 'from' => $message->getFromAddress() ?? $this->getConfig('username'),
270 | 'length' => \strlen((string) $message),
271 | 'recipients' => $message->getRecipients(),
272 | 'headers' => $message->getHeaders(),
273 | 'plain' => $message->getPlainMessage(),
274 | 'html' => $message->getHtmlMessage(),
275 | 'attachments' => $message->getAttachments(),
276 | 'inlineAttachments' => $message->getInlineAttachments(),
277 | ]);
278 | return $code === 250;
279 | }
280 | return $this->sendMessage($message) === 250;
281 | }
282 |
283 | protected function sendMessage(Message $message) : false | int
284 | {
285 | if (!$this->connect()) {
286 | return false;
287 | }
288 | $message->setMailer($this);
289 | $from = $message->getFromAddress() ?? $this->getConfig('username');
290 | $this->sendCommand('MAIL FROM: <' . $from . '>');
291 | foreach ($message->getRecipients() as $address) {
292 | $this->sendCommand('RCPT TO: <' . $address . '>');
293 | }
294 | $this->sendCommand('DATA');
295 | $code = $this->sendCommand(
296 | $message . $this->getConfig('crlf') . '.'
297 | );
298 | if ($this->getConfig('keep_alive') !== true) {
299 | $this->disconnect();
300 | }
301 | return $code;
302 | }
303 |
304 | /**
305 | * Get Mail Server response.
306 | *
307 | * @return string
308 | */
309 | protected function getResponse() : string
310 | {
311 | $response = '';
312 | // @phpstan-ignore-next-line
313 | \stream_set_timeout($this->socket, $this->getConfig('response_timeout'));
314 | // @phpstan-ignore-next-line
315 | while (($line = \fgets($this->socket, 512)) !== false) {
316 | $response .= \trim($line) . "\n";
317 | if (isset($line[3]) && $line[3] === ' ') {
318 | break;
319 | }
320 | }
321 | return \trim($response);
322 | }
323 |
324 | /**
325 | * Send command to mail server.
326 | *
327 | * @param string $command
328 | *
329 | * @return int Response code
330 | */
331 | protected function sendCommand(string $command) : int
332 | {
333 | // @phpstan-ignore-next-line
334 | \fwrite($this->socket, $command . $this->getConfig('crlf'));
335 | $response = $this->getResponse();
336 | $this->addLog($command, $response);
337 | // The last command could be: "EHLO $host".
338 | // And the last response is an empty string.
339 | // So, we ignore empty responses...
340 | if ($response !== '') {
341 | $this->setLastResponse($response);
342 | }
343 | return $this->makeResponseCode($response);
344 | }
345 |
346 | /**
347 | * @see https://tools.ietf.org/html/rfc2821#section-4.2.3
348 | * @see https://en.wikipedia.org/wiki/List_of_SMTP_server_return_codes
349 | *
350 | * @param string $response
351 | *
352 | * @return int
353 | */
354 | private function makeResponseCode(string $response) : int
355 | {
356 | return (int) \substr($response, 0, 3);
357 | }
358 |
359 | /**
360 | * Get an array of logs.
361 | *
362 | * Contains commands and responses from the Mailer server.
363 | *
364 | * @return array>
365 | */
366 | public function getLogs() : array
367 | {
368 | return $this->logs;
369 | }
370 |
371 | /**
372 | * Reset logs.
373 | *
374 | * @return static
375 | */
376 | public function resetLogs() : static
377 | {
378 | $this->logs = [];
379 | return $this;
380 | }
381 |
382 | /**
383 | * @param string $command
384 | * @param string $response
385 | *
386 | * @return static
387 | */
388 | protected function addLog(string $command, string $response) : static
389 | {
390 | if (!$this->getConfig('save_logs')) {
391 | return $this;
392 | }
393 | $this->logs[] = [
394 | 'command' => $command,
395 | 'responses' => \explode(\PHP_EOL, $response),
396 | ];
397 | return $this;
398 | }
399 |
400 | /**
401 | * Set the debug collector.
402 | *
403 | * @param EmailCollector $collector The debug collector
404 | *
405 | * @return static
406 | */
407 | public function setDebugCollector(EmailCollector $collector) : static
408 | {
409 | $collector->setMailer($this);
410 | $this->debugCollector = $collector;
411 | return $this;
412 | }
413 |
414 | /**
415 | * Create a new Message instance.
416 | *
417 | * @return Message
418 | */
419 | public function createMessage() : Message
420 | {
421 | return (new Message())->setMailer($this);
422 | }
423 | }
424 |
--------------------------------------------------------------------------------
/src/Message.php:
--------------------------------------------------------------------------------
1 |
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 DateTime;
13 | use JetBrains\PhpStorm\Language;
14 | use LogicException;
15 | use Random\RandomException;
16 |
17 | /**
18 | * Class Message.
19 | *
20 | * @package email
21 | */
22 | class Message implements \Stringable
23 | {
24 | /**
25 | * The Mailer instance.
26 | *
27 | * @var Mailer
28 | */
29 | protected Mailer $mailer;
30 | /**
31 | * The message boundary.
32 | *
33 | * @var string
34 | */
35 | protected string $boundary;
36 | /**
37 | * @var array
38 | */
39 | protected array $headers = [
40 | 'mime-version' => '1.0',
41 | ];
42 | /**
43 | * A list of attachments with Content-Disposition equals `attachment`.
44 | *
45 | * @var array The filenames
46 | */
47 | protected array $attachments = [];
48 | /**
49 | * An associative array of attachments with Content-Disposition equals `inline`.
50 | *
51 | * @var array The Content-ID's as keys and the filenames as values
52 | */
53 | protected array $inlineAttachments = [];
54 | /**
55 | * The plain text message.
56 | *
57 | * @var string
58 | */
59 | protected string $plainMessage;
60 | /**
61 | * The HTML message.
62 | *
63 | * @var string
64 | */
65 | protected string $htmlMessage;
66 | /**
67 | * An associative array used in the `To` header.
68 | *
69 | * @var array The email addresses as keys and the optional
70 | * name as values
71 | */
72 | protected array $to = [];
73 | /**
74 | * An associative array used in the `Cc` header.
75 | *
76 | * @var array The email addresses as keys and the optional
77 | * name as values
78 | */
79 | protected array $cc = [];
80 | /**
81 | * An associative array used in the `Bcc` header.
82 | *
83 | * @var array The email addresses as keys and the optional
84 | * name as values
85 | */
86 | protected array $bcc = [];
87 | /**
88 | * An associative array used in the `Reply-To` header.
89 | *
90 | * @var array The email addresses as keys and the optional
91 | * name as values
92 | */
93 | protected array $replyTo = [];
94 | /**
95 | * The values used in the `From` header.
96 | *
97 | * @var array The email address as in the index 0 and the
98 | * optional name in the index 1
99 | */
100 | protected array $from = [];
101 | /**
102 | * The message Date.
103 | *
104 | * @var string|null
105 | */
106 | protected ?string $date = null;
107 |
108 | /**
109 | * Render the Message as string.
110 | *
111 | * @return string
112 | */
113 | public function __toString() : string
114 | {
115 | return $this->renderData();
116 | }
117 |
118 | /**
119 | * Set the Mailer instance.
120 | *
121 | * @param Mailer $mailer The Mailer instance
122 | *
123 | * @return static
124 | */
125 | public function setMailer(Mailer $mailer) : static
126 | {
127 | $this->mailer = $mailer;
128 | return $this;
129 | }
130 |
131 | protected function getCrlf() : string
132 | {
133 | if (isset($this->mailer)) {
134 | return $this->mailer->getConfig('crlf');
135 | }
136 | return "\r\n";
137 | }
138 |
139 | protected function getCharset() : string
140 | {
141 | if (isset($this->mailer)) {
142 | return $this->mailer->getConfig('charset');
143 | }
144 | return 'utf-8';
145 | }
146 |
147 | /**
148 | * Set the boundary.
149 | *
150 | * @param string|null $boundary
151 | *
152 | * @throws RandomException
153 | *
154 | * @return static
155 | */
156 | public function setBoundary(?string $boundary = null) : static
157 | {
158 | $this->boundary = $boundary ?? \bin2hex(\random_bytes(16));
159 | return $this;
160 | }
161 |
162 | /**
163 | * Get the boundary.
164 | *
165 | * @throws RandomException
166 | *
167 | * @return string
168 | */
169 | public function getBoundary() : string
170 | {
171 | if (!isset($this->boundary)) {
172 | $this->setBoundary();
173 | }
174 | return $this->boundary;
175 | }
176 |
177 | /**
178 | * Remove a header.
179 | *
180 | * @param string $name The header name
181 | *
182 | * @return static
183 | */
184 | public function removeHeader(string $name) : static
185 | {
186 | unset($this->headers[\strtolower($name)]);
187 | return $this;
188 | }
189 |
190 | /**
191 | * Set a header.
192 | *
193 | * @param string $name The header name
194 | * @param string $value The header value
195 | *
196 | * @return static
197 | */
198 | public function setHeader(string $name, string $value) : static
199 | {
200 | $this->headers[\strtolower($name)] = $value;
201 | return $this;
202 | }
203 |
204 | /**
205 | * Get a header.
206 | *
207 | * @param string $name The header name
208 | *
209 | * @return string|null The header value or null if not set
210 | */
211 | public function getHeader(string $name) : ?string
212 | {
213 | return $this->headers[\strtolower($name)] ?? null;
214 | }
215 |
216 | /**
217 | * Get all headers set.
218 | *
219 | * @return array The header names, in lowercase, as keys and
220 | * the values as values
221 | */
222 | public function getHeaders() : array
223 | {
224 | return $this->headers;
225 | }
226 |
227 | /**
228 | * Get header lines.
229 | *
230 | * @return array The header lines
231 | */
232 | public function getHeaderLines() : array
233 | {
234 | $lines = [];
235 | foreach ($this->getHeaders() as $name => $value) {
236 | $lines[] = Header::getName($name) . ': ' . $value;
237 | }
238 | return $lines;
239 | }
240 |
241 | protected function renderHeaders() : string
242 | {
243 | return \implode($this->getCrlf(), $this->getHeaderLines());
244 | }
245 |
246 | protected function prepareHeaders() : void
247 | {
248 | if (!$this->getDate()) {
249 | $this->setDate();
250 | }
251 | $multipart = $this->getInlineAttachments() ? 'related' : 'mixed';
252 | $this->setHeader(
253 | Header::CONTENT_TYPE,
254 | 'multipart/' . $multipart . '; boundary="mixed-' . $this->getBoundary() . '"'
255 | );
256 | }
257 |
258 | protected function renderData() : string
259 | {
260 | $boundary = $this->getBoundary();
261 | $crlf = $this->getCrlf();
262 | $this->prepareHeaders();
263 | $data = $this->renderHeaders() . $crlf . $crlf;
264 | $data .= '--mixed-' . $boundary . $crlf;
265 | $data .= 'Content-Type: multipart/alternative; boundary="alt-' . $boundary . '"'
266 | . $crlf . $crlf;
267 | $data .= $this->renderPlainMessage();
268 | $data .= $this->renderHtmlMessage();
269 | $data .= '--alt-' . $boundary . '--' . $crlf . $crlf;
270 | $data .= $this->renderAttachments();
271 | $data .= $this->renderInlineAttachments();
272 | $data .= '--mixed-' . $boundary . '--';
273 | return $data;
274 | }
275 |
276 | /**
277 | * Set the text/plain message.
278 | *
279 | * @param string $message The text/plain message
280 | *
281 | * @return static
282 | */
283 | public function setPlainMessage(string $message) : static
284 | {
285 | $this->plainMessage = $message;
286 | return $this;
287 | }
288 |
289 | /**
290 | * Get the text/plain message.
291 | *
292 | * @return string|null The message or null if not set
293 | */
294 | public function getPlainMessage() : ?string
295 | {
296 | return $this->plainMessage ?? null;
297 | }
298 |
299 | protected function renderPlainMessage() : ?string
300 | {
301 | $message = $this->getPlainMessage();
302 | return $message !== null ? $this->renderMessage($message, 'text/plain') : null;
303 | }
304 |
305 | /**
306 | * Set the text/html message.
307 | *
308 | * @param string $message The text/html message
309 | *
310 | * @return static
311 | */
312 | public function setHtmlMessage(#[Language('HTML')] string $message) : static
313 | {
314 | $this->htmlMessage = $message;
315 | return $this;
316 | }
317 |
318 | /**
319 | * Get the text/html message.
320 | *
321 | * @return string|null The text/html message or null if not set
322 | */
323 | public function getHtmlMessage() : ?string
324 | {
325 | return $this->htmlMessage ?? null;
326 | }
327 |
328 | protected function renderHtmlMessage() : ?string
329 | {
330 | $message = $this->getHtmlMessage();
331 | return $message !== null ? $this->renderMessage($message) : null;
332 | }
333 |
334 | protected function renderMessage(
335 | string $message,
336 | string $contentType = 'text/html'
337 | ) : string {
338 | $message = \base64_encode($message);
339 | $crlf = $this->getCrlf();
340 | $part = '--alt-' . $this->getBoundary() . $crlf;
341 | $part .= 'Content-Type: ' . $contentType . '; charset='
342 | . $this->getCharset() . $crlf;
343 | $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
344 | $part .= \chunk_split($message) . $crlf;
345 | return $part;
346 | }
347 |
348 | /**
349 | * Get a lis of attachments.
350 | *
351 | * @return array Array of filenames
352 | */
353 | public function getAttachments() : array
354 | {
355 | return $this->attachments;
356 | }
357 |
358 | /**
359 | * Add a filename to be attached.
360 | *
361 | * @param string $filename The filename
362 | *
363 | * @return static
364 | */
365 | public function addAttachment(string $filename) : static
366 | {
367 | $this->attachments[] = $filename;
368 | return $this;
369 | }
370 |
371 | /**
372 | * Set a filename to be attached inline (image).
373 | *
374 | * @param string $filename The filename
375 | * @param string $cid The Content-ID
376 | *
377 | * @return static
378 | */
379 | public function setInlineAttachment(string $filename, string $cid) : static
380 | {
381 | $this->inlineAttachments[$cid] = $filename;
382 | return $this;
383 | }
384 |
385 | /**
386 | * Get a lis of inline attachments.
387 | *
388 | * @return array Content-IDs as keys and filenames as values
389 | */
390 | public function getInlineAttachments() : array
391 | {
392 | return $this->inlineAttachments;
393 | }
394 |
395 | protected function renderAttachments() : string
396 | {
397 | $part = '';
398 | $crlf = $this->getCrlf();
399 | foreach ($this->getAttachments() as $attachment) {
400 | if (!\is_file($attachment)) {
401 | throw new LogicException('Attachment file not found: ' . $attachment);
402 | }
403 | $filename = \pathinfo($attachment, \PATHINFO_BASENAME);
404 | $filename = \htmlspecialchars($filename, \ENT_QUOTES | \ENT_HTML5);
405 | $contents = \file_get_contents($attachment);
406 | $contents = \base64_encode($contents); // @phpstan-ignore-line
407 | $part .= '--mixed-' . $this->getBoundary() . $crlf;
408 | $part .= 'Content-Type: ' . $this->getContentType($attachment)
409 | . '; name="' . $filename . '"' . $crlf;
410 | $part .= 'Content-Disposition: attachment; filename="' . $filename . '"' . $crlf;
411 | $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
412 | $part .= \chunk_split($contents) . $crlf;
413 | }
414 | return $part;
415 | }
416 |
417 | protected function getContentType(string $filename) : string
418 | {
419 | return \mime_content_type($filename) ?: 'application/octet-stream';
420 | }
421 |
422 | protected function renderInlineAttachments() : string
423 | {
424 | $part = '';
425 | $crlf = $this->getCrlf();
426 | foreach ($this->getInlineAttachments() as $cid => $filename) {
427 | if (!\is_file($filename)) {
428 | throw new LogicException('Inline attachment file not found: ' . $filename);
429 | }
430 | $contents = \file_get_contents($filename);
431 | $contents = \base64_encode($contents); // @phpstan-ignore-line
432 | $part .= '--mixed-' . $this->getBoundary() . $crlf;
433 | $part .= 'Content-ID: ' . $cid . $crlf;
434 | $part .= 'Content-Type: ' . $this->getContentType($filename) . $crlf;
435 | $part .= 'Content-Disposition: inline' . $crlf;
436 | $part .= 'Content-Transfer-Encoding: base64' . $crlf . $crlf;
437 | $part .= \chunk_split($contents) . $crlf;
438 | }
439 | return $part;
440 | }
441 |
442 | /**
443 | * Set the Subject header.
444 | *
445 | * @param string $subject The header value
446 | *
447 | * @return static
448 | */
449 | public function setSubject(string $subject) : static
450 | {
451 | $this->setHeader(Header::SUBJECT, $subject);
452 | return $this;
453 | }
454 |
455 | /**
456 | * Get the Subject header.
457 | *
458 | * @return string|null The header value or null if not set
459 | */
460 | public function getSubject() : ?string
461 | {
462 | return $this->getHeader(Header::SUBJECT);
463 | }
464 |
465 | /**
466 | * Add address and name in the To header.
467 | *
468 | * @param string $address The email address
469 | * @param string|null $name The name or null to don't set
470 | *
471 | * @return static
472 | */
473 | public function addTo(string $address, ?string $name = null) : static
474 | {
475 | $this->to[$address] = $name;
476 | $this->setHeader(Header::TO, static::formatAddressList($this->to));
477 | return $this;
478 | }
479 |
480 | /**
481 | * Get items of the To header.
482 | *
483 | * @return array Emails as keys and names as values
484 | */
485 | public function getTo() : array
486 | {
487 | return $this->to;
488 | }
489 |
490 | /**
491 | * Remove all items of the To header.
492 | *
493 | * @return static
494 | */
495 | public function removeTo() : static
496 | {
497 | $this->to = [];
498 | return $this;
499 | }
500 |
501 | /**
502 | * Add address and name in the Cc header.
503 | *
504 | * @param string $address The email address
505 | * @param string|null $name The name or null to don't set
506 | *
507 | * @return static
508 | */
509 | public function addCc(string $address, ?string $name = null) : static
510 | {
511 | $this->cc[$address] = $name;
512 | $this->setHeader(Header::CC, static::formatAddressList($this->cc));
513 | return $this;
514 | }
515 |
516 | /**
517 | * Get items of the Cc header.
518 | *
519 | * @return array Emails as keys and names as values
520 | */
521 | public function getCc() : array
522 | {
523 | return $this->cc;
524 | }
525 |
526 | /**
527 | * Remove all items of the Cc header.
528 | *
529 | * @return static
530 | */
531 | public function removeCc() : static
532 | {
533 | $this->cc = [];
534 | return $this;
535 | }
536 |
537 | /**
538 | * @return array
539 | */
540 | public function getRecipients() : array
541 | {
542 | $recipients = \array_replace($this->getTo(), $this->getCc());
543 | return \array_keys($recipients);
544 | }
545 |
546 | /**
547 | * Add address and name in the Bcc header.
548 | *
549 | * @param string $address The email address
550 | * @param string|null $name The name or null to don't set
551 | *
552 | * @return static
553 | */
554 | public function addBcc(string $address, ?string $name = null) : static
555 | {
556 | $this->bcc[$address] = $name;
557 | $this->setHeader(Header::BCC, static::formatAddressList($this->bcc));
558 | return $this;
559 | }
560 |
561 | /**
562 | * Get items of the Bcc header.
563 | *
564 | * @return array Emails as keys and names as values
565 | */
566 | public function getBcc() : array
567 | {
568 | return $this->bcc;
569 | }
570 |
571 | /**
572 | * Remove all items of the Bcc header.
573 | *
574 | * @return static
575 | */
576 | public function removeBcc() : static
577 | {
578 | $this->bcc = [];
579 | return $this;
580 | }
581 |
582 | /**
583 | * Add address and name in the Reply-To header.
584 | *
585 | * @param string $address The email address
586 | * @param string|null $name The name or null to don't set
587 | *
588 | * @return static
589 | */
590 | public function addReplyTo(string $address, ?string $name = null) : static
591 | {
592 | $this->replyTo[$address] = $name;
593 | $this->setHeader(Header::REPLY_TO, static::formatAddressList($this->replyTo));
594 | return $this;
595 | }
596 |
597 | /**
598 | * Get items of the Reply-To header.
599 | *
600 | * @return array Emails as keys and names as values
601 | */
602 | public function getReplyTo() : array
603 | {
604 | return $this->replyTo;
605 | }
606 |
607 | /**
608 | * Remove all items of the Reply-To header.
609 | *
610 | * @return static
611 | */
612 | public function removeReplyTo() : static
613 | {
614 | $this->replyTo = [];
615 | return $this;
616 | }
617 |
618 | /**
619 | * Set the From header.
620 | *
621 | * @param string $address The email address
622 | * @param string|null $name The name or null to don't set
623 | *
624 | * @return static
625 | */
626 | public function setFrom(string $address, ?string $name = null) : static
627 | {
628 | $this->from = [$address, $name];
629 | $this->setHeader(Header::FROM, static::formatAddress($address, $name));
630 | return $this;
631 | }
632 |
633 | /**
634 | * Get the From header items.
635 | *
636 | * @return array email address in key 0 and name in key 1
637 | */
638 | public function getFrom() : array
639 | {
640 | return $this->from;
641 | }
642 |
643 | /**
644 | * Get the email address of the From header.
645 | *
646 | * @return string|null The email or null if not set
647 | */
648 | public function getFromAddress() : ?string
649 | {
650 | return $this->from[0] ?? null;
651 | }
652 |
653 | /**
654 | * Get the name of the From header.
655 | *
656 | * @return string|null The name or null if not set
657 | */
658 | public function getFromName() : ?string
659 | {
660 | return $this->from[1] ?? null;
661 | }
662 |
663 | /**
664 | * Remove all items of the From header.
665 | *
666 | * @return static
667 | */
668 | public function removeFrom() : static
669 | {
670 | $this->from = [];
671 | return $this;
672 | }
673 |
674 | /**
675 | * Set the Date header.
676 | *
677 | * @param DateTime|null $datetime A custom DateTime or null to set the
678 | * current datetime
679 | *
680 | * @return static
681 | */
682 | public function setDate(?DateTime $datetime = null) : static
683 | {
684 | $date = $datetime ? $datetime->format('r') : \date('r');
685 | $this->setHeader(Header::DATE, $date);
686 | return $this;
687 | }
688 |
689 | /**
690 | * Get the Date header.
691 | *
692 | * @return string|null The header value or null if not set
693 | */
694 | public function getDate() : ?string
695 | {
696 | return $this->getHeader(Header::DATE);
697 | }
698 |
699 | /**
700 | * Set the X-Priority header.
701 | *
702 | * @param XPriority $priority The {@see XPriority} case
703 | *
704 | * @return static
705 | */
706 | public function setXPriority(XPriority $priority) : static
707 | {
708 | $this->setHeader(Header::X_PRIORITY, (string) $priority->value);
709 | return $this;
710 | }
711 |
712 | /**
713 | * Get the X-Priority header.
714 | *
715 | * @return XPriority|null The {@see XPriority} case or null
716 | */
717 | public function getXPriority() : ?XPriority
718 | {
719 | $header = $this->getHeader(Header::X_PRIORITY);
720 | if ($header === null) {
721 | return null;
722 | }
723 | return XPriority::from((int) $header);
724 | }
725 |
726 | /**
727 | * Set the X-Mailer header.
728 | *
729 | * @param string|null $xMailer The X-Mailer header or null to set the default
730 | *
731 | * @return static
732 | */
733 | public function setXMailer(?string $xMailer = null) : static
734 | {
735 | $xMailer ??= 'Aplus Mailer';
736 | $this->setHeader(Header::X_MAILER, $xMailer);
737 | return $this;
738 | }
739 |
740 | /**
741 | * Get the X-Mailer header.
742 | *
743 | * @return string|null The X-Mailer header or null
744 | */
745 | public function getXMailer() : ?string
746 | {
747 | return $this->getHeader(Header::X_MAILER);
748 | }
749 |
750 | protected static function formatAddress(string $address, ?string $name = null) : string
751 | {
752 | return $name !== null ? '"' . $name . '" <' . $address . '>' : $address;
753 | }
754 |
755 | /**
756 | * @param array $addresses
757 | *
758 | * @return string
759 | */
760 | protected static function formatAddressList(array $addresses) : string
761 | {
762 | $data = [];
763 | foreach ($addresses as $address => $name) {
764 | $data[] = static::formatAddress($address, $name);
765 | }
766 | return \implode(', ', $data);
767 | }
768 | }
769 |
--------------------------------------------------------------------------------