├── .phpstorm.meta.php ├── src ├── Mail │ ├── Signer.php │ ├── Mailer.php │ ├── exceptions.php │ ├── FallbackMailer.php │ ├── SendmailMailer.php │ ├── DkimSigner.php │ ├── SmtpMailer.php │ ├── MimePart.php │ └── Message.php └── Bridges │ └── MailDI │ └── MailExtension.php ├── composer.json ├── license.md └── readme.md /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | */ 18 | public array $onFailure = []; 19 | 20 | /** @var Mailer[] */ 21 | private array $mailers; 22 | 23 | private int $retryCount; 24 | 25 | /** in miliseconds */ 26 | private int $retryWaitTime; 27 | 28 | 29 | /** 30 | * @param Mailer[] $mailers 31 | */ 32 | public function __construct(array $mailers, int $retryCount = 3, int $retryWaitTime = 1000) 33 | { 34 | $this->mailers = $mailers; 35 | $this->retryCount = $retryCount; 36 | $this->retryWaitTime = $retryWaitTime; 37 | } 38 | 39 | 40 | /** 41 | * Sends email. 42 | * @throws FallbackMailerException 43 | */ 44 | public function send(Message $mail): void 45 | { 46 | if (!$this->mailers) { 47 | throw new Nette\InvalidArgumentException('At least one mailer must be provided.'); 48 | } 49 | 50 | $failures = []; 51 | for ($i = 0; $i < $this->retryCount; $i++) { 52 | if ($i > 0) { 53 | usleep($this->retryWaitTime * 1000); 54 | } 55 | 56 | foreach ($this->mailers as $mailer) { 57 | try { 58 | $mailer->send($mail); 59 | return; 60 | 61 | } catch (SendException $e) { 62 | $failures[] = $e; 63 | Nette\Utils\Arrays::invoke($this->onFailure, $this, $e, $mailer, $mail); 64 | } 65 | } 66 | } 67 | 68 | $e = new FallbackMailerException('All mailers failed to send the message.'); 69 | $e->failures = $failures; 70 | throw $e; 71 | } 72 | 73 | 74 | public function addMailer(Mailer $mailer): static 75 | { 76 | $this->mailers[] = $mailer; 77 | return $this; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Mail/SendmailMailer.php: -------------------------------------------------------------------------------- 1 | signer = $signer; 28 | return $this; 29 | } 30 | 31 | 32 | /** 33 | * Sets whether to use the envelope sender (-f option) in the mail command. 34 | */ 35 | public function setEnvelopeSender(bool $state = true): static 36 | { 37 | $this->envelopeSender = $state; 38 | return $this; 39 | } 40 | 41 | 42 | /** 43 | * Sends email. 44 | * @throws SendException 45 | */ 46 | public function send(Message $mail): void 47 | { 48 | if (!function_exists('mail')) { 49 | throw new SendException('Unable to send email: mail() has been disabled.'); 50 | } 51 | 52 | $tmp = clone $mail; 53 | $tmp->setHeader('Subject', null); 54 | $tmp->setHeader('To', null); 55 | 56 | $data = $this->signer 57 | ? $this->signer->generateSignedMessage($tmp) 58 | : $tmp->generateMessage(); 59 | $parts = explode(Message::EOL . Message::EOL, $data, 2); 60 | 61 | $cmd = $this->commandArgs; 62 | if ($this->envelopeSender && ($from = $mail->getFrom())) { 63 | $cmd .= ' -f' . key($from); 64 | } 65 | 66 | $args = [ 67 | (string) $mail->getEncodedHeader('To'), 68 | (string) $mail->getEncodedHeader('Subject'), 69 | $parts[1], 70 | $parts[0], 71 | $cmd, 72 | ]; 73 | 74 | $res = Nette\Utils\Callback::invokeSafe('mail', $args, function (string $message) use (&$info): void { 75 | $info = ": $message"; 76 | }); 77 | if ($res === false) { 78 | throw new SendException("Unable to send email$info."); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Licenses 2 | ======== 3 | 4 | Good news! You may use Nette Framework under the terms of either 5 | the New BSD License or the GNU General Public License (GPL) version 2 or 3. 6 | 7 | The BSD License is recommended for most projects. It is easy to understand and it 8 | places almost no restrictions on what you can do with the framework. If the GPL 9 | fits better to your project, you can use the framework under this license. 10 | 11 | You don't have to notify anyone which license you are using. You can freely 12 | use Nette Framework in commercial projects as long as the copyright header 13 | remains intact. 14 | 15 | Please be advised that the name "Nette Framework" is a protected trademark and its 16 | usage has some limitations. So please do not use word "Nette" in the name of your 17 | project or top-level domain, and choose a name that stands on its own merits. 18 | If your stuff is good, it will not take long to establish a reputation for yourselves. 19 | 20 | 21 | New BSD License 22 | --------------- 23 | 24 | Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com) 25 | All rights reserved. 26 | 27 | Redistribution and use in source and binary forms, with or without modification, 28 | are permitted provided that the following conditions are met: 29 | 30 | * Redistributions of source code must retain the above copyright notice, 31 | this list of conditions and the following disclaimer. 32 | 33 | * Redistributions in binary form must reproduce the above copyright notice, 34 | this list of conditions and the following disclaimer in the documentation 35 | and/or other materials provided with the distribution. 36 | 37 | * Neither the name of "Nette Framework" nor the names of its contributors 38 | may be used to endorse or promote products derived from this software 39 | without specific prior written permission. 40 | 41 | This software is provided by the copyright holders and contributors "as is" and 42 | any express or implied warranties, including, but not limited to, the implied 43 | warranties of merchantability and fitness for a particular purpose are 44 | disclaimed. In no event shall the copyright owner or contributors be liable for 45 | any direct, indirect, incidental, special, exemplary, or consequential damages 46 | (including, but not limited to, procurement of substitute goods or services; 47 | loss of use, data, or profits; or business interruption) however caused and on 48 | any theory of liability, whether in contract, strict liability, or tort 49 | (including negligence or otherwise) arising in any way out of the use of this 50 | software, even if advised of the possibility of such damage. 51 | 52 | 53 | GNU General Public License 54 | -------------------------- 55 | 56 | GPL licenses are very very long, so instead of including them here we offer 57 | you URLs with full text: 58 | 59 | - [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html) 60 | - [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html) 61 | -------------------------------------------------------------------------------- /src/Bridges/MailDI/MailExtension.php: -------------------------------------------------------------------------------- 1 | Expect::bool(false), 25 | 'host' => Expect::string()->dynamic(), 26 | 'port' => Expect::int()->dynamic(), 27 | 'username' => Expect::string('')->dynamic(), 28 | 'password' => Expect::string('')->dynamic(), 29 | 'secure' => Expect::anyOf(null, 'ssl', 'tls')->dynamic(), // deprecated 30 | 'encryption' => Expect::anyOf(null, 'ssl', 'tls')->dynamic(), 31 | 'timeout' => Expect::int(20)->dynamic(), 32 | 'context' => Expect::arrayOf('array')->dynamic(), 33 | 'clientHost' => Expect::string()->dynamic(), 34 | 'persistent' => Expect::bool(false)->dynamic(), 35 | 'dkim' => Expect::anyOf( 36 | Expect::null(), 37 | Expect::structure([ 38 | 'domain' => Expect::string()->required()->dynamic(), 39 | 'selector' => Expect::string()->required()->dynamic(), 40 | 'privateKey' => Expect::string()->required(), 41 | 'passPhrase' => Expect::string()->dynamic(), 42 | ])->castTo('array'), 43 | ), 44 | ])->castTo('array'); 45 | } 46 | 47 | 48 | public function loadConfiguration(): void 49 | { 50 | $builder = $this->getContainerBuilder(); 51 | 52 | $mailer = $builder->addDefinition($this->prefix('mailer')) 53 | ->setType(Nette\Mail\Mailer::class); 54 | 55 | if ($this->config['dkim']) { 56 | $dkim = $this->config['dkim']; 57 | $dkim['privateKey'] = Nette\Utils\FileSystem::read($dkim['privateKey']); 58 | unset($this->config['dkim']); 59 | 60 | $signer = $builder->addDefinition($this->prefix('signer')) 61 | ->setType(Nette\Mail\Signer::class) 62 | ->setFactory(Nette\Mail\DkimSigner::class, $dkim); 63 | 64 | $mailer->addSetup('setSigner', [$signer]); 65 | } 66 | 67 | if ($this->config['smtp']) { 68 | $mailer->setFactory(Nette\Mail\SmtpMailer::class, [ 69 | 'host' => $this->config['host'] ?? ini_get('SMTP'), 70 | 'port' => isset($this->config['host']) ? $this->config['port'] : (int) ini_get('smtp_port'), 71 | 'username' => $this->config['username'], 72 | 'password' => $this->config['password'], 73 | 'encryption' => $this->config['encryption'] ?? $this->config['secure'], 74 | 'persistent' => $this->config['persistent'], 75 | 'timeout' => $this->config['timeout'], 76 | 'clientHost' => $this->config['clientHost'], 77 | 'streamOptions' => $this->config['context'] ?: null, 78 | ]); 79 | 80 | } else { 81 | $mailer->setFactory(Nette\Mail\SendmailMailer::class); 82 | } 83 | 84 | if ($this->name === 'mail') { 85 | $builder->addAlias('nette.mailer', $this->prefix('mailer')); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Mail/DkimSigner.php: -------------------------------------------------------------------------------- 1 | build(); 51 | 52 | if (preg_match("~(.*?\r\n\r\n)(.*)~s", $message->getEncodedMessage(), $parts)) { 53 | [, $header, $body] = $parts; 54 | 55 | return rtrim($header, "\r\n") . "\r\n" . $this->getSignature($message, $header, $this->normalizeNewLines($body)) . "\r\n\r\n" . $body; 56 | } 57 | 58 | throw new SignException('Malformed email'); 59 | } 60 | 61 | 62 | protected function getSignature(Message $message, string $header, string $body): string 63 | { 64 | $parts = []; 65 | foreach ( 66 | [ 67 | 'v' => '1', 68 | 'a' => 'rsa-sha256', 69 | 'q' => 'dns/txt', 70 | 'l' => strlen($body), 71 | 's' => $this->selector, 72 | 't' => $this->getTime(), 73 | 'c' => 'relaxed/simple', 74 | 'h' => implode(':', $this->getSignedHeaders($message)), 75 | 'd' => $this->domain, 76 | 'bh' => $this->computeBodyHash($body), 77 | 'b' => '', 78 | ] as $key => $value 79 | ) { 80 | $parts[] = $key . '=' . $value; 81 | } 82 | 83 | return $this->computeSignature($header, self::DkimSignature . ': ' . implode('; ', $parts)); 84 | } 85 | 86 | 87 | protected function computeSignature(string $rawHeader, string $signature): string 88 | { 89 | $selectedHeaders = array_merge($this->signHeaders, [self::DkimSignature]); 90 | 91 | $rawHeader = preg_replace("/\r\n[ \t]+/", ' ', rtrim($rawHeader, "\r\n") . "\r\n" . $signature); 92 | 93 | $parts = []; 94 | foreach ($test = explode("\r\n", $rawHeader) as $key => $header) { 95 | if (str_contains($header, ':')) { 96 | [$heading, $value] = explode(':', $header, 2); 97 | 98 | if (($index = array_search($heading, $selectedHeaders, strict: true)) !== false) { 99 | $parts[$index] = 100 | trim(strtolower($heading), " \t") . ':' . 101 | trim(preg_replace("/[ \t]{2,}/", ' ', $value), " \t"); 102 | } 103 | } 104 | } 105 | 106 | ksort($parts); 107 | 108 | return $signature . $this->sign(implode("\r\n", $parts)); 109 | } 110 | 111 | 112 | /** @throws SignException */ 113 | protected function sign(string $value): string 114 | { 115 | $privateKey = openssl_pkey_get_private($this->privateKey, $this->passPhrase); 116 | if (!$privateKey) { 117 | throw new SignException('Invalid private key'); 118 | } 119 | 120 | if (openssl_sign($value, $signature, $privateKey, 'sha256WithRSAEncryption')) { 121 | return base64_encode($signature); 122 | } 123 | 124 | return ''; 125 | } 126 | 127 | 128 | protected function computeBodyHash(string $body): string 129 | { 130 | return base64_encode( 131 | pack( 132 | 'H*', 133 | hash('sha256', $body), 134 | ), 135 | ); 136 | } 137 | 138 | 139 | protected function normalizeNewLines(string $s): string 140 | { 141 | $s = str_replace(["\r\n", "\n"], "\r", $s); 142 | $s = str_replace("\r", "\r\n", $s); 143 | return rtrim($s, "\r\n") . "\r\n"; 144 | } 145 | 146 | 147 | protected function getSignedHeaders(Message $message): array 148 | { 149 | return array_filter($this->signHeaders, fn($name) => $message->getHeader($name) !== null); 150 | } 151 | 152 | 153 | protected function getTime(): int 154 | { 155 | return time(); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Mail/SmtpMailer.php: -------------------------------------------------------------------------------- 1 | context = $streamOptions === null 47 | ? stream_context_get_default() 48 | : stream_context_create($streamOptions); 49 | 50 | if ($clientHost === null) { 51 | $this->clientHost = isset($_SERVER['HTTP_HOST']) && preg_match('#^[\w.-]+$#D', $_SERVER['HTTP_HOST']) 52 | ? $_SERVER['HTTP_HOST'] 53 | : 'localhost'; 54 | } else { 55 | $this->clientHost = $clientHost; 56 | } 57 | } 58 | 59 | 60 | public function setSigner(Signer $signer): static 61 | { 62 | $this->signer = $signer; 63 | return $this; 64 | } 65 | 66 | 67 | /** 68 | * Sends email. 69 | * @throws SmtpException 70 | */ 71 | public function send(Message $mail): void 72 | { 73 | $tmp = clone $mail; 74 | $tmp->setHeader('Bcc', null); 75 | if (!$tmp->getHeader('To') && !$tmp->getHeader('Cc')) { 76 | // missing recipient headers make some mailers (e.g., sendmail) nervous -> set 'To' like many MTAs do 77 | $tmp->setHeader('To', 'undisclosed-recipients: ;'); 78 | } 79 | 80 | $data = $this->signer 81 | ? $this->signer->generateSignedMessage($tmp) 82 | : $tmp->generateMessage(); 83 | 84 | try { 85 | if (!$this->connection) { 86 | $this->connect(); 87 | } 88 | 89 | if ( 90 | ($from = $mail->getHeader('Return-Path')) 91 | || ($from = array_keys((array) $mail->getHeader('From'))[0] ?? null) 92 | ) { 93 | $this->write("MAIL FROM:<$from>", 250); 94 | } 95 | 96 | foreach (array_merge( 97 | (array) $mail->getHeader('To'), 98 | (array) $mail->getHeader('Cc'), 99 | (array) $mail->getHeader('Bcc'), 100 | ) as $email => $name) { 101 | $this->write("RCPT TO:<$email>", [250, 251]); 102 | } 103 | 104 | $this->write('DATA', 354); 105 | $data = preg_replace('#^\.#m', '..', $data); 106 | $this->write($data); 107 | $this->write('.', 250); 108 | 109 | if (!$this->persistent) { 110 | $this->write('QUIT', 221); 111 | $this->disconnect(); 112 | } 113 | } catch (SmtpException $e) { 114 | if ($this->connection) { 115 | $this->disconnect(); 116 | } 117 | 118 | throw $e; 119 | } 120 | } 121 | 122 | 123 | /** 124 | * Connects and authenticates to SMTP server. 125 | */ 126 | protected function connect(): void 127 | { 128 | $port = $this->port ?? ($this->encryption === self::EncryptionSSL ? 465 : 25); 129 | $this->connection = @stream_socket_client(// @ is escalated to exception 130 | ($this->encryption === self::EncryptionSSL ? 'ssl://' : '') . $this->host . ':' . $port, 131 | $errno, 132 | $error, 133 | $this->timeout, 134 | STREAM_CLIENT_CONNECT, 135 | $this->context, 136 | ); 137 | if (!$this->connection) { 138 | throw new SmtpException($error ?: error_get_last()['message'], $errno); 139 | } 140 | 141 | stream_set_timeout($this->connection, $this->timeout, 0); 142 | $this->read(); // greeting 143 | 144 | if ($this->encryption === self::EncryptionTLS) { 145 | $this->write("EHLO $this->clientHost", 250); 146 | $this->write('STARTTLS', 220); 147 | if (!stream_socket_enable_crypto( 148 | $this->connection, 149 | true, 150 | STREAM_CRYPTO_METHOD_TLS_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, 151 | )) { 152 | throw new SmtpException('Unable to connect via TLS.'); 153 | } 154 | 155 | $this->write("EHLO $this->clientHost"); 156 | $ehloResponse = $this->read(); 157 | if ((int) $ehloResponse !== 250) { 158 | throw new SmtpException('SMTP server did not accept EHLO with error: ' . trim($ehloResponse)); 159 | } 160 | } else { 161 | $this->write("EHLO $this->clientHost"); 162 | $ehloResponse = $this->read(); 163 | if ((int) $ehloResponse !== 250) { 164 | $this->write("HELO $this->clientHost", 250); 165 | } 166 | } 167 | 168 | if ($this->username !== '') { 169 | $authMechanisms = []; 170 | if (preg_match('~^250[ -]AUTH (.*)$~im', $ehloResponse, $matches)) { 171 | $authMechanisms = explode(' ', trim($matches[1])); 172 | } 173 | 174 | if (in_array('PLAIN', $authMechanisms, strict: true)) { 175 | $credentials = $this->username . "\0" . $this->username . "\0" . $this->password; 176 | $this->write('AUTH PLAIN ' . base64_encode($credentials), 235, 'PLAIN credentials'); 177 | } else { 178 | $this->write('AUTH LOGIN', 334); 179 | $this->write(base64_encode($this->username), 334, 'username'); 180 | if ($this->password !== '') { 181 | $this->write(base64_encode($this->password), 235, 'password'); 182 | } 183 | } 184 | } 185 | } 186 | 187 | 188 | /** 189 | * Disconnects from SMTP server. 190 | */ 191 | protected function disconnect(): void 192 | { 193 | fclose($this->connection); 194 | $this->connection = null; 195 | } 196 | 197 | 198 | /** 199 | * Writes data to server and checks response against expected code if some provided. 200 | * @param int|int[] $expectedCode 201 | */ 202 | protected function write(string $line, int|array|null $expectedCode = null, ?string $message = null): void 203 | { 204 | fwrite($this->connection, $line . Message::EOL); 205 | if ($expectedCode) { 206 | $response = $this->read(); 207 | if (!in_array((int) $response, (array) $expectedCode, strict: true)) { 208 | throw new SmtpException('SMTP server did not accept ' . ($message ?: $line) . ' with error: ' . trim($response)); 209 | } 210 | } 211 | } 212 | 213 | 214 | /** 215 | * Reads response from server. 216 | */ 217 | protected function read(): string 218 | { 219 | $data = ''; 220 | $endtime = $this->timeout > 0 ? time() + $this->timeout : 0; 221 | 222 | while (is_resource($this->connection) && !feof($this->connection)) { 223 | $line = @fgets($this->connection); // @ is escalated to exception 224 | if ($line === '' || $line === false) { 225 | $info = stream_get_meta_data($this->connection); 226 | if ($info['timed_out'] || ($endtime && time() > $endtime)) { 227 | throw new SmtpException('Connection timed out.'); 228 | } elseif ($info['eof']) { 229 | throw new SmtpException('Connection has been closed unexpectedly.'); 230 | } 231 | } 232 | 233 | $data .= $line; 234 | if (preg_match('#^.{3}(?:[ \r\n]|$)#D', $line)) { 235 | break; 236 | } 237 | } 238 | 239 | return $data; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Nette Mail](https://github.com/nette/mail/assets/194960/8b2371bd-0976-443c-b60a-460eb8b3222f)](https://doc.nette.org/en/mail) 2 | 3 | [![Downloads this Month](https://img.shields.io/packagist/dm/nette/mail.svg)](https://packagist.org/packages/nette/mail) 4 | [![Tests](https://github.com/nette/mail/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/mail/actions) 5 | [![Coverage Status](https://coveralls.io/repos/github/nette/mail/badge.svg?branch=master)](https://coveralls.io/github/nette/mail?branch=master) 6 | [![Latest Stable Version](https://poser.pugx.org/nette/mail/v/stable)](https://github.com/nette/mail/releases) 7 | [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/mail/blob/master/license.md) 8 | 9 |   10 | 11 | 12 | Introduction 13 | ------------ 14 | 15 | Are you going to send emails such as newsletters or order confirmations? Nette Framework provides the necessary tools with a very nice API. 16 | 17 | Documentation can be found on the [website](https://doc.nette.org/en/mail). 18 | 19 |   20 | 21 | 22 | [Support Me](https://github.com/sponsors/dg) 23 | -------------------------------------------- 24 | 25 | Do you like Nette Mail? Are you looking forward to the new features? 26 | 27 | [![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg) 28 | 29 | Thank you! 30 | 31 |   32 | 33 | 34 | Installation 35 | ------------ 36 | 37 | ```shell 38 | composer require nette/mail 39 | ``` 40 | 41 | It requires PHP version 8.0 and supports PHP up to 8.5. 42 | 43 |   44 | 45 | 46 | Creating Emails 47 | =============== 48 | 49 | Email is a [Nette\Mail\Message](https://api.nette.org/3.0/Nette/Mail/Message.html) object: 50 | 51 | ```php 52 | $mail = new Nette\Mail\Message; 53 | $mail->setFrom('John ') 54 | ->addTo('peter@example.com') 55 | ->addTo('jack@example.com') 56 | ->setSubject('Order Confirmation') 57 | ->setBody("Hello, Your order has been accepted."); 58 | ``` 59 | 60 | All parameters must be encoded in UTF-8. 61 | 62 | In addition to specifying recipients with the `addTo()` method, you can also specify the recipient of copy with `addCc()`, or the recipient of blind copy with `addBcc()`. All these methods, including `setFrom()`, accepts addressee in three ways: 63 | 64 | ```php 65 | $mail->setFrom('john.doe@example.com'); 66 | $mail->setFrom('john.doe@example.com', 'John Doe'); 67 | $mail->setFrom('John Doe '); 68 | ``` 69 | 70 | The body of an email written in HTML is passed using the `setHtmlBody()` method: 71 | 72 | ```php 73 | $mail->setHtmlBody('

Hello,

Your order has been accepted.

'); 74 | ``` 75 | 76 | You don't have to create a text alternative, Nette will generate it automatically for you. And if the email does not have a subject set, it will be taken from the `` element. 77 | 78 | Images can also be extremely easily inserted into the HTML body of an email. Just pass the path where the images are physically located as the second parameter, and Nette will automatically include them in the email: 79 | 80 | ```php 81 | // automatically adds /path/to/images/background.gif to the email 82 | $mail->setHtmlBody( 83 | '<b>Hello</b> <img src="background.gif">', 84 | '/path/to/images', 85 | ); 86 | ``` 87 | 88 | The image embedding algorithm supports the following patterns: `<img src=...>`, `<body background=...>`, `url(...)` inside the HTML attribute `style` and special syntax `[[...]]`. 89 | 90 | Can sending emails be even easier? 91 | 92 | Emails are like postcards. Never send passwords or other credentials via email. 93 | 94 | 95 | 96 | Attachments 97 | ----------- 98 | 99 | You can, of course, attach attachments to email. Use the `addAttachment(string $file, string $content = null, string $contentType = null)`. 100 | 101 | ```php 102 | // inserts the file /path/to/example.zip into the email under the name example.zip 103 | $mail->addAttachment('/path/to/example.zip'); 104 | 105 | // inserts the file /path/to/example.zip into the email under the name info.zip 106 | $mail->addAttachment('info.zip', file_get_contents('/path/to/example.zip')); 107 | 108 | // attaches new example.txt file contents "Hello John!" 109 | $mail->addAttachment('example.txt', 'Hello John!'); 110 | ``` 111 | 112 | 113 | Templates 114 | --------- 115 | 116 | If you send HTML emails, it's a great idea to write them in the [Latte](https://latte.nette.org) template system. How to do it? 117 | 118 | ```php 119 | $latte = new Latte\Engine; 120 | $params = [ 121 | 'orderId' => 123, 122 | ]; 123 | 124 | $mail = new Nette\Mail\Message; 125 | $mail->setFrom('John <john@example.com>') 126 | ->addTo('jack@example.com') 127 | ->setHtmlBody( 128 | $latte->renderToString('/path/to/email.latte', $params), 129 | '/path/to/images', 130 | ); 131 | ``` 132 | 133 | File `email.latte`: 134 | 135 | ```latte 136 | <html> 137 | <head> 138 | <meta charset="utf-8"> 139 | <title>Order Confirmation 140 | 145 | 146 | 147 |

Hello,

148 | 149 |

Your order number {$orderId} has been accepted.

150 | 151 | 152 | ``` 153 | 154 | Nette automatically inserts all images, sets the subject according to the `` element, and generates text alternative for HTML body. 155 | 156 |  <!----> 157 | 158 | 159 | Sending Emails 160 | ============== 161 | 162 | Mailer is class responsible for sending emails. It implements the [Nette\Mail\Mailer](https://api.nette.org/3.0/Nette/Mail/Mailer.html) interface and several ready-made mailers are available which we will introduce. 163 | 164 | 165 | 166 | SendmailMailer 167 | -------------- 168 | 169 | The default mailer is SendmailMailer which uses PHP function `mail()`. Example of use: 170 | 171 | ```php 172 | $mailer = new Nette\Mail\SendmailMailer; 173 | $mailer->send($mail); 174 | ``` 175 | 176 | If you want to set `returnPath` and the server still overwrites it, use `$mailer->commandArgs = '-fmy@email.com'`. 177 | 178 | 179 | SmtpMailer 180 | ---------- 181 | 182 | To send mail via the SMTP server, use `SmtpMailer`. 183 | 184 | ```php 185 | $mailer = new Nette\Mail\SmtpMailer( 186 | host: 'smtp.gmail.com', 187 | username: 'franta@gmail.com', 188 | password: '*****', 189 | encryption: Nette\Mail\SmtpMailer::EncryptionSSL, 190 | ); 191 | $mailer->send($mail); 192 | ``` 193 | 194 | The following additional parameters can be passed to the constructor: 195 | 196 | * `port` - if not set, the default 25 or 465 for `ssl` will be used 197 | * `timeout` - timeout for SMTP connection 198 | * `persistent` - use persistent connection 199 | * `clientHost` - client designation 200 | * `streamOptions` - allows you to set [SSL context options](https://www.php.net/manual/en/context.ssl.php) for connection 201 | 202 | 203 | FallbackMailer 204 | -------------- 205 | 206 | It does not send email but sends them through a set of mailers. If one mailer fails, it repeats the attempt at the next one. If the last one fails, it starts again from the first one. 207 | 208 | ```php 209 | $mailer = new Nette\Mail\FallbackMailer([ 210 | $smtpMailer, 211 | $backupSmtpMailer, 212 | $sendmailMailer, 213 | ]); 214 | $mailer->send($mail); 215 | ``` 216 | 217 | Other parameters in the constructor include the number of repeat and waiting time in milliseconds. 218 | 219 | 220 |  <!----> 221 | 222 | DKIM 223 | ==== 224 | 225 | DKIM (DomainKeys Identified Mail) is a trustworthy email technology that also helps detect spoofed messages. The sent message is signed with the private key of the sender's domain and this signature is stored in the email header. 226 | The recipient's server compares this signature with the public key stored in the domain's DNS records. By matching the signature, it is shown that the email actually originated from the sender's domain and that the message was not modified during the transmission of the message. 227 | 228 | ```php 229 | $signer = new Nette\Mail\DkimSigner( 230 | domain: 'nette.org', 231 | selector: 'dkim', 232 | privateKey: file_get_contents('../dkim/dkim.key'), 233 | passPhrase: '****', 234 | ); 235 | 236 | $mailer = new Nette\Mail\SendmailMailer; // or SmtpMailer 237 | $mailer->setSigner($signer); 238 | $mailer->send($mail); 239 | ``` 240 | -------------------------------------------------------------------------------- /src/Mail/MimePart.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /** 4 | * This file is part of the Nette Framework (https://nette.org) 5 | * Copyright (c) 2004 David Grudl (https://davidgrudl.com) 6 | */ 7 | 8 | declare(strict_types=1); 9 | 10 | namespace Nette\Mail; 11 | 12 | use Nette; 13 | use Nette\Utils\Strings; 14 | use function addcslashes, base64_encode, chunk_split, iconv_mime_encode, is_array, ltrim, preg_match, preg_replace, quoted_printable_encode, rtrim, str_ends_with, str_repeat, str_replace, stripslashes, strlen, strrpos, strspn, substr; 15 | 16 | 17 | /** 18 | * MIME message part. 19 | * 20 | * @property-deprecated string $body 21 | */ 22 | class MimePart 23 | { 24 | use Nette\SmartObject; 25 | 26 | /** encoding */ 27 | public const 28 | EncodingBase64 = 'base64', 29 | Encoding7Bit = '7bit', 30 | Encoding8Bit = '8bit', 31 | EncodingQuotedPrintable = 'quoted-printable'; 32 | 33 | /** @internal */ 34 | public const EOL = "\r\n"; 35 | 36 | public const LineLength = 76; 37 | 38 | /** value (RFC 2231), encoded-word (RFC 2047) */ 39 | private const 40 | SequenceValue = 1, 41 | SequenceWord = 2; 42 | 43 | private array $headers = []; 44 | private array $parts = []; 45 | private string $body = ''; 46 | 47 | 48 | /** 49 | * Sets a header. 50 | * @param string|array|null $value value or pair email => name 51 | */ 52 | public function setHeader(string $name, string|array|null $value, bool $append = false): static 53 | { 54 | if (!$name || preg_match('#[^a-z0-9-]#i', $name)) { 55 | throw new Nette\InvalidArgumentException("Header name must be non-empty alphanumeric string, '$name' given."); 56 | } 57 | 58 | if ($value == null) { // intentionally == 59 | if (!$append) { 60 | unset($this->headers[$name]); 61 | } 62 | } elseif (is_array($value)) { // email 63 | $tmp = &$this->headers[$name]; 64 | if (!$append || !is_array($tmp)) { 65 | $tmp = []; 66 | } 67 | 68 | foreach ($value as $email => $recipient) { 69 | if ($recipient === null) { 70 | // continue 71 | } elseif (!Strings::checkEncoding($recipient)) { 72 | Nette\Utils\Validators::assert($recipient, 'unicode', "header '$name'"); 73 | } elseif (preg_match('#[\r\n]#', $recipient)) { 74 | throw new Nette\InvalidArgumentException('Name must not contain line separator.'); 75 | } 76 | 77 | Nette\Utils\Validators::assert($email, 'email', "header '$name'"); 78 | $tmp[$email] = $recipient; 79 | } 80 | } else { 81 | $value = (string) $value; 82 | if (!Strings::checkEncoding($value)) { 83 | throw new Nette\InvalidArgumentException('Header is not valid UTF-8 string.'); 84 | } 85 | 86 | $this->headers[$name] = preg_replace('#[\r\n]+#', ' ', $value); 87 | } 88 | 89 | return $this; 90 | } 91 | 92 | 93 | /** 94 | * Returns a header. 95 | */ 96 | public function getHeader(string $name): mixed 97 | { 98 | return $this->headers[$name] ?? null; 99 | } 100 | 101 | 102 | /** 103 | * Removes a header. 104 | */ 105 | public function clearHeader(string $name): static 106 | { 107 | unset($this->headers[$name]); 108 | return $this; 109 | } 110 | 111 | 112 | /** 113 | * Returns an encoded header. 114 | */ 115 | public function getEncodedHeader(string $name): ?string 116 | { 117 | $offset = strlen($name) + 2; // colon + space 118 | 119 | if (!isset($this->headers[$name])) { 120 | return null; 121 | 122 | } elseif (is_array($this->headers[$name])) { 123 | $s = ''; 124 | foreach ($this->headers[$name] as $email => $name) { 125 | if ($name != null) { // intentionally == 126 | $s .= self::encodeSequence($name, $offset, self::SequenceWord); 127 | $email = " <$email>"; 128 | } 129 | 130 | $s .= self::append($email . ',', $offset); 131 | } 132 | 133 | return ltrim(substr($s, 0, -1)); // last comma 134 | 135 | } elseif (preg_match('#^(\S+; (?:file)?name=)"(.*)"$#D', $this->headers[$name], $m)) { // Content-Disposition 136 | $offset += strlen($m[1]); 137 | return $m[1] . self::encodeSequence(stripslashes($m[2]), $offset, self::SequenceValue); 138 | 139 | } else { 140 | return ltrim(self::encodeSequence($this->headers[$name], $offset)); 141 | } 142 | } 143 | 144 | 145 | /** 146 | * Returns all headers. 147 | */ 148 | public function getHeaders(): array 149 | { 150 | return $this->headers; 151 | } 152 | 153 | 154 | /** 155 | * Sets Content-Type header. 156 | */ 157 | public function setContentType(string $contentType, ?string $charset = null): static 158 | { 159 | $this->setHeader('Content-Type', $contentType . ($charset ? "; charset=$charset" : '')); 160 | return $this; 161 | } 162 | 163 | 164 | /** 165 | * Sets Content-Transfer-Encoding header. 166 | */ 167 | public function setEncoding(string $encoding): static 168 | { 169 | $this->setHeader('Content-Transfer-Encoding', $encoding); 170 | return $this; 171 | } 172 | 173 | 174 | /** 175 | * Returns Content-Transfer-Encoding header. 176 | */ 177 | public function getEncoding(): string 178 | { 179 | return $this->getHeader('Content-Transfer-Encoding'); 180 | } 181 | 182 | 183 | /** 184 | * Adds or creates new multipart. 185 | */ 186 | public function addPart(?self $part = null): self 187 | { 188 | return $this->parts[] = $part ?? new self; 189 | } 190 | 191 | 192 | /** 193 | * Sets textual body. 194 | */ 195 | public function setBody(string $body): static 196 | { 197 | $this->body = $body; 198 | return $this; 199 | } 200 | 201 | 202 | /** 203 | * Gets textual body. 204 | */ 205 | public function getBody(): string 206 | { 207 | return $this->body; 208 | } 209 | 210 | 211 | /********************* building ****************d*g**/ 212 | 213 | 214 | /** 215 | * Returns encoded message. 216 | */ 217 | public function getEncodedMessage(): string 218 | { 219 | $output = ''; 220 | $boundary = '--------' . Nette\Utils\Random::generate(); 221 | 222 | foreach ($this->headers as $name => $value) { 223 | $output .= $name . ': ' . $this->getEncodedHeader($name); 224 | if ($this->parts && $name === 'Content-Type') { 225 | $output .= ';' . self::EOL . "\tboundary=\"$boundary\""; 226 | } 227 | 228 | $output .= self::EOL; 229 | } 230 | 231 | $output .= self::EOL; 232 | 233 | $body = $this->body; 234 | if ($body !== '') { 235 | switch ($this->getEncoding()) { 236 | case self::EncodingQuotedPrintable: 237 | $output .= quoted_printable_encode($body); 238 | break; 239 | 240 | case self::EncodingBase64: 241 | $output .= rtrim(chunk_split(base64_encode($body), self::LineLength, self::EOL)); 242 | break; 243 | 244 | case self::Encoding7Bit: 245 | $body = preg_replace('#[\x80-\xFF]+#', '', $body); 246 | // break omitted 247 | 248 | case self::Encoding8Bit: 249 | $body = str_replace(["\x00", "\r"], '', $body); 250 | $body = str_replace("\n", self::EOL, $body); 251 | $output .= $body; 252 | break; 253 | 254 | default: 255 | throw new Nette\InvalidStateException('Unknown encoding.'); 256 | } 257 | } 258 | 259 | if ($this->parts) { 260 | if (!str_ends_with($output, self::EOL)) { 261 | $output .= self::EOL; 262 | } 263 | 264 | foreach ($this->parts as $part) { 265 | $output .= '--' . $boundary . self::EOL . $part->getEncodedMessage() . self::EOL; 266 | } 267 | 268 | $output .= '--' . $boundary . '--'; 269 | } 270 | 271 | return $output; 272 | } 273 | 274 | 275 | /********************* QuotedPrintable helpers ****************d*g**/ 276 | 277 | 278 | /** 279 | * Converts a 8 bit header to a string. 280 | */ 281 | private static function encodeSequence(string $s, int &$offset = 0, ?int $type = null): string 282 | { 283 | if ( 284 | (strlen($s) < self::LineLength - 3) && // 3 is tab + quotes 285 | strspn($s, "!\"#$%&\\'()*+,-./0123456789:;<>@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^`abcdefghijklmnopqrstuvwxyz{|}~=? _\r\n\t") === strlen($s) 286 | ) { 287 | if ($type && preg_match('#[^ a-zA-Z0-9!\#$%&\'*+/?^_`{|}~-]#', $s)) { // RFC 2822 atext except = 288 | return self::append('"' . addcslashes($s, '"\\') . '"', $offset); 289 | } 290 | 291 | return self::append($s, $offset); 292 | } 293 | 294 | $o = ''; 295 | if ($offset >= 55) { // maximum for iconv_mime_encode 296 | $o = self::EOL . "\t"; 297 | $offset = 1; 298 | } 299 | 300 | $s = iconv_mime_encode(str_repeat(' ', $old = $offset), $s, [ 301 | 'scheme' => 'B', // Q is broken 302 | 'input-charset' => 'UTF-8', 303 | 'output-charset' => 'UTF-8', 304 | ]); 305 | 306 | $offset = strlen($s) - strrpos($s, "\n"); 307 | $s = substr($s, $old + 2); // adds ': ' 308 | if ($type === self::SequenceValue) { 309 | $s = '"' . $s . '"'; 310 | } 311 | 312 | $s = str_replace("\n ", "\n\t", $s); 313 | return $o . $s; 314 | } 315 | 316 | 317 | private static function append(string $s, int &$offset = 0): string 318 | { 319 | if ($offset + strlen($s) > self::LineLength) { 320 | $offset = 1; 321 | $s = self::EOL . "\t" . $s; 322 | } 323 | 324 | $offset += strlen($s); 325 | return $s; 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/Mail/Message.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /** 4 | * This file is part of the Nette Framework (https://nette.org) 5 | * Copyright (c) 2004 David Grudl (https://davidgrudl.com) 6 | */ 7 | 8 | declare(strict_types=1); 9 | 10 | namespace Nette\Mail; 11 | 12 | use Nette; 13 | use Nette\Utils\Strings; 14 | use function addcslashes, array_map, array_reverse, basename, date, explode, finfo_buffer, finfo_open, implode, is_numeric, ltrim, php_uname, preg_match, preg_replace, rtrim, str_replace, strcasecmp, stripslashes, strlen, substr, substr_replace, trim, urldecode; 15 | use const FILEINFO_MIME_TYPE; 16 | 17 | 18 | /** 19 | * Mail provides functionality to compose and send both text and MIME-compliant multipart email messages. 20 | * 21 | * @property-deprecated string $subject 22 | * @property-deprecated string $htmlBody 23 | */ 24 | class Message extends MimePart 25 | { 26 | /** Priority */ 27 | public const 28 | High = 1, 29 | Normal = 3, 30 | Low = 5; 31 | 32 | #[\Deprecated('use Message::High')] 33 | public const HIGH = self::High; 34 | 35 | #[\Deprecated('use Message::Normal')] 36 | public const NORMAL = self::Normal; 37 | 38 | #[\Deprecated('use Message::Low')] 39 | public const LOW = self::Low; 40 | 41 | public static array $defaultHeaders = [ 42 | 'MIME-Version' => '1.0', 43 | 'X-Mailer' => 'Nette Framework', 44 | ]; 45 | 46 | private array $attachments = []; 47 | private array $inlines = []; 48 | private string $htmlBody = ''; 49 | 50 | 51 | public function __construct() 52 | { 53 | foreach (static::$defaultHeaders as $name => $value) { 54 | $this->setHeader($name, $value); 55 | } 56 | 57 | $this->setHeader('Date', date('r')); 58 | } 59 | 60 | 61 | /** 62 | * Sets the sender of the message. Email or format "John Doe" <doe@example.com> 63 | */ 64 | public function setFrom(string $email, ?string $name = null): static 65 | { 66 | $this->setHeader('From', $this->formatEmail($email, $name)); 67 | return $this; 68 | } 69 | 70 | 71 | /** 72 | * Returns the sender of the message. 73 | */ 74 | public function getFrom(): ?array 75 | { 76 | return $this->getHeader('From'); 77 | } 78 | 79 | 80 | /** 81 | * Adds the reply-to address. Email or format "John Doe" <doe@example.com> 82 | */ 83 | public function addReplyTo(string $email, ?string $name = null): static 84 | { 85 | $this->setHeader('Reply-To', $this->formatEmail($email, $name), append: true); 86 | return $this; 87 | } 88 | 89 | 90 | /** 91 | * Sets the subject of the message. 92 | */ 93 | public function setSubject(string $subject): static 94 | { 95 | $this->setHeader('Subject', $subject); 96 | return $this; 97 | } 98 | 99 | 100 | /** 101 | * Returns the subject of the message. 102 | */ 103 | public function getSubject(): ?string 104 | { 105 | return $this->getHeader('Subject'); 106 | } 107 | 108 | 109 | /** 110 | * Adds email recipient. Email or format "John Doe" <doe@example.com> 111 | */ 112 | public function addTo(string $email, ?string $name = null): static // addRecipient() 113 | { 114 | $this->setHeader('To', $this->formatEmail($email, $name), append: true); 115 | return $this; 116 | } 117 | 118 | 119 | /** 120 | * Adds carbon copy email recipient. Email or format "John Doe" <doe@example.com> 121 | */ 122 | public function addCc(string $email, ?string $name = null): static 123 | { 124 | $this->setHeader('Cc', $this->formatEmail($email, $name), append: true); 125 | return $this; 126 | } 127 | 128 | 129 | /** 130 | * Adds blind carbon copy email recipient. Email or format "John Doe" <doe@example.com> 131 | */ 132 | public function addBcc(string $email, ?string $name = null): static 133 | { 134 | $this->setHeader('Bcc', $this->formatEmail($email, $name), append: true); 135 | return $this; 136 | } 137 | 138 | 139 | /** 140 | * Formats recipient email. 141 | */ 142 | private function formatEmail(string $email, ?string $name = null): array 143 | { 144 | if (!$name && preg_match('#^(.+) +<(.*)>$#D', $email, $matches)) { 145 | [, $name, $email] = $matches; 146 | $name = stripslashes($name); 147 | $tmp = substr($name, 1, -1); 148 | if ($name === '"' . $tmp . '"') { 149 | $name = $tmp; 150 | } 151 | } 152 | 153 | return [$email => $name]; 154 | } 155 | 156 | 157 | /** 158 | * Sets the Return-Path header of the message. 159 | */ 160 | public function setReturnPath(string $email): static 161 | { 162 | $this->setHeader('Return-Path', $email); 163 | return $this; 164 | } 165 | 166 | 167 | /** 168 | * Returns the Return-Path header. 169 | */ 170 | public function getReturnPath(): ?string 171 | { 172 | return $this->getHeader('Return-Path'); 173 | } 174 | 175 | 176 | /** 177 | * Sets email priority. 178 | */ 179 | public function setPriority(int $priority): static 180 | { 181 | $this->setHeader('X-Priority', (string) $priority); 182 | return $this; 183 | } 184 | 185 | 186 | /** 187 | * Returns email priority. 188 | */ 189 | public function getPriority(): ?int 190 | { 191 | $priority = $this->getHeader('X-Priority'); 192 | return is_numeric($priority) ? (int) $priority : null; 193 | } 194 | 195 | 196 | /** 197 | * Sets HTML body. 198 | */ 199 | public function setHtmlBody(string $html, ?string $basePath = null): static 200 | { 201 | if ($basePath) { 202 | $cids = []; 203 | $matches = Strings::matchAll( 204 | $html, 205 | '# 206 | (<img[^<>]*\s src\s*=\s* 207 | |<body[^<>]*\s background\s*=\s* 208 | |<[^<>]+\s style\s*=\s* ["\'][^"\'>]+[:\s] url\( 209 | |<style[^>]*>[^<]+ [:\s] url\() 210 | (["\']?)(?![a-z]+:|[/\#])([^"\'>)\s]+) 211 | |\[\[ ([\w()+./@~-]+) \]\] 212 | #ix', 213 | captureOffset: true, 214 | ); 215 | foreach (array_reverse($matches) as $m) { 216 | $file = rtrim($basePath, '/\\') . '/' . (isset($m[4]) ? $m[4][0] : urldecode($m[3][0])); 217 | if (!isset($cids[$file])) { 218 | $cids[$file] = substr($this->addEmbeddedFile($file)->getHeader('Content-ID'), 1, -1); 219 | } 220 | 221 | $html = substr_replace( 222 | $html, 223 | "{$m[1][0]}{$m[2][0]}cid:{$cids[$file]}", 224 | $m[0][1], 225 | strlen($m[0][0]), 226 | ); 227 | } 228 | } 229 | 230 | if ($this->getSubject() == null) { // intentionally == 231 | $html = Strings::replace($html, '#<title>(.+?)#is', function (array $m): void { 232 | $this->setSubject(Nette\Utils\Html::htmlToText($m[1])); 233 | }); 234 | } 235 | 236 | $this->htmlBody = ltrim(str_replace("\r", '', $html), "\n"); 237 | 238 | if ($this->getBody() === '' && $html !== '') { 239 | $this->setBody($this->buildText($html)); 240 | } 241 | 242 | return $this; 243 | } 244 | 245 | 246 | /** 247 | * Gets HTML body. 248 | */ 249 | public function getHtmlBody(): string 250 | { 251 | return $this->htmlBody; 252 | } 253 | 254 | 255 | /** 256 | * Adds embedded file. 257 | */ 258 | public function addEmbeddedFile(string $file, ?string $content = null, ?string $contentType = null): MimePart 259 | { 260 | return $this->inlines[$file] = $this->createAttachment($file, $content, $contentType, 'inline') 261 | ->setHeader('Content-ID', $this->getRandomId()); 262 | } 263 | 264 | 265 | /** 266 | * Adds inlined Mime Part. 267 | */ 268 | public function addInlinePart(MimePart $part): static 269 | { 270 | $this->inlines[] = $part; 271 | return $this; 272 | } 273 | 274 | 275 | /** 276 | * Adds attachment. 277 | */ 278 | public function addAttachment(string $file, ?string $content = null, ?string $contentType = null): MimePart 279 | { 280 | return $this->attachments[] = $this->createAttachment($file, $content, $contentType, 'attachment'); 281 | } 282 | 283 | 284 | /** 285 | * Gets all email attachments. 286 | * @return MimePart[] 287 | */ 288 | public function getAttachments(): array 289 | { 290 | return $this->attachments; 291 | } 292 | 293 | 294 | /** 295 | * Creates file MIME part. 296 | */ 297 | private function createAttachment( 298 | string $file, 299 | ?string $content, 300 | ?string $contentType, 301 | string $disposition, 302 | ): MimePart 303 | { 304 | $part = new MimePart; 305 | if ($content === null) { 306 | $content = Nette\Utils\FileSystem::read($file); 307 | $file = Strings::fixEncoding(basename($file)); 308 | } 309 | 310 | if (!$contentType) { 311 | $contentType = finfo_buffer(finfo_open(FILEINFO_MIME_TYPE), $content); 312 | } 313 | 314 | if (!strcasecmp($contentType, 'message/rfc822')) { // not allowed for attached files 315 | $contentType = 'application/octet-stream'; 316 | } elseif (!strcasecmp($contentType, 'image/svg')) { // Troublesome for some mailers... 317 | $contentType = 'image/svg+xml'; 318 | } 319 | 320 | $part->setBody($content); 321 | $part->setContentType($contentType); 322 | $part->setEncoding(preg_match('#(multipart|message)/#A', $contentType) ? self::Encoding8Bit : self::EncodingBase64); 323 | $part->setHeader('Content-Disposition', $disposition . '; filename="' . addcslashes($file, '"\\') . '"'); 324 | return $part; 325 | } 326 | 327 | 328 | /********************* building and sending ****************d*g**/ 329 | 330 | 331 | /** 332 | * Returns encoded message. 333 | */ 334 | public function generateMessage(): string 335 | { 336 | return $this->build()->getEncodedMessage(); 337 | } 338 | 339 | 340 | /** 341 | * Builds email. Does not modify itself, but returns a new object. 342 | */ 343 | public function build(): static 344 | { 345 | $mail = clone $this; 346 | $mail->setHeader('Message-ID', $mail->getHeader('Message-ID') ?? $this->getRandomId()); 347 | 348 | $cursor = $mail; 349 | if ($mail->attachments) { 350 | $tmp = $cursor->setContentType('multipart/mixed'); 351 | $cursor = $cursor->addPart(); 352 | foreach ($mail->attachments as $value) { 353 | $tmp->addPart($value); 354 | } 355 | } 356 | 357 | if ($mail->htmlBody !== '') { 358 | $tmp = $cursor->setContentType('multipart/alternative'); 359 | $cursor = $cursor->addPart(); 360 | $alt = $tmp->addPart(); 361 | if ($mail->inlines) { 362 | $tmp = $alt->setContentType('multipart/related'); 363 | $alt = $alt->addPart(); 364 | foreach ($mail->inlines as $value) { 365 | $tmp->addPart($value); 366 | } 367 | } 368 | 369 | $alt->setContentType('text/html', 'UTF-8') 370 | ->setEncoding(preg_match('#[^\n]{990}#', $mail->htmlBody) 371 | ? self::EncodingQuotedPrintable 372 | : (preg_match('#[\x80-\xFF]#', $mail->htmlBody) ? self::Encoding8Bit : self::Encoding7Bit)) 373 | ->setBody($mail->htmlBody); 374 | } 375 | 376 | $text = $mail->getBody(); 377 | $mail->setBody(''); 378 | $cursor->setContentType('text/plain', 'UTF-8') 379 | ->setEncoding(preg_match('#[^\n]{990}#', $text) 380 | ? self::EncodingQuotedPrintable 381 | : (preg_match('#[\x80-\xFF]#', $text) ? self::Encoding8Bit : self::Encoding7Bit)) 382 | ->setBody($text); 383 | 384 | return $mail; 385 | } 386 | 387 | 388 | /** 389 | * Builds text content. 390 | */ 391 | protected function buildText(string $html): string 392 | { 393 | $html = Strings::replace($html, [ 394 | '#<(style|script|head).*#Uis' => '', 395 | '#]#i' => ' $0', 396 | '#]*href=(?|"([^"]+)"|\'([^\']+)\')[^>]*>(.*?)#is' => '$2 <$1>', 397 | '#[\r\n]+#' => ' ', 398 | '#<(/?p|/?h\d|li|br|/tr)[ >/]#i' => "\n$0", 399 | ]); 400 | $text = Nette\Utils\Html::htmlToText($html); 401 | $text = Strings::replace($text, '#[ \t]+#', ' '); 402 | $text = implode("\n", array_map('trim', explode("\n", $text))); 403 | return trim($text); 404 | } 405 | 406 | 407 | private function getRandomId(): string 408 | { 409 | return '<' . Nette\Utils\Random::generate() . '@' 410 | . preg_replace('#[^\w.-]+#', '', $_SERVER['HTTP_HOST'] ?? php_uname('n')) 411 | . '>'; 412 | } 413 | } 414 | --------------------------------------------------------------------------------