20 | * @author Nicolas Grekas
21 | * @author Chris Corbyn
22 | *
23 | * @internal
24 | */
25 | abstract class AbstractStream
26 | {
27 | /** @var resource|null */
28 | protected $stream;
29 | /** @var resource|null */
30 | protected $in;
31 | /** @var resource|null */
32 | protected $out;
33 | protected $err;
34 |
35 | private string $debug = '';
36 |
37 | public function write(string $bytes, bool $debug = true): void
38 | {
39 | if ($debug) {
40 | $timestamp = (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.up');
41 | foreach (explode("\n", trim($bytes)) as $line) {
42 | $this->debug .= \sprintf("[%s] > %s\n", $timestamp, $line);
43 | }
44 | }
45 |
46 | $bytesToWrite = \strlen($bytes);
47 | $totalBytesWritten = 0;
48 | while ($totalBytesWritten < $bytesToWrite) {
49 | $bytesWritten = @fwrite($this->in, substr($bytes, $totalBytesWritten));
50 | if (false === $bytesWritten || 0 === $bytesWritten) {
51 | throw new TransportException('Unable to write bytes on the wire.');
52 | }
53 |
54 | $totalBytesWritten += $bytesWritten;
55 | }
56 | }
57 |
58 | /**
59 | * Flushes the contents of the stream (empty it) and set the internal pointer to the beginning.
60 | */
61 | public function flush(): void
62 | {
63 | fflush($this->in);
64 | }
65 |
66 | /**
67 | * Performs any initialization needed.
68 | */
69 | abstract public function initialize(): void;
70 |
71 | public function terminate(): void
72 | {
73 | $this->stream = $this->err = $this->out = $this->in = null;
74 | }
75 |
76 | public function readLine(): string
77 | {
78 | if (feof($this->out)) {
79 | return '';
80 | }
81 |
82 | $line = @fgets($this->out);
83 | if ('' === $line || false === $line) {
84 | if (stream_get_meta_data($this->out)['timed_out']) {
85 | throw new TransportException(\sprintf('Connection to "%s" timed out.', $this->getReadConnectionDescription()));
86 | }
87 | if (feof($this->out)) { // don't use "eof" metadata, it's not accurate on Windows
88 | throw new TransportException(\sprintf('Connection to "%s" has been closed unexpectedly.', $this->getReadConnectionDescription()));
89 | }
90 | if (false === $line) {
91 | throw new TransportException(\sprintf('Unable to read from connection to "%s": ', $this->getReadConnectionDescription().error_get_last()['message'] ?? ''));
92 | }
93 | }
94 |
95 | $this->debug .= \sprintf('[%s] < %s', (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.up'), $line);
96 |
97 | return $line;
98 | }
99 |
100 | public function getDebug(): string
101 | {
102 | $debug = $this->debug;
103 | $this->debug = '';
104 |
105 | return $debug;
106 | }
107 |
108 | public static function replace(string $from, string $to, iterable $chunks): \Generator
109 | {
110 | if ('' === $from) {
111 | yield from $chunks;
112 |
113 | return;
114 | }
115 |
116 | $carry = '';
117 | $fromLen = \strlen($from);
118 |
119 | foreach ($chunks as $chunk) {
120 | if ('' === $chunk = $carry.$chunk) {
121 | continue;
122 | }
123 |
124 | if (str_contains($chunk, $from)) {
125 | $chunk = explode($from, $chunk);
126 | $carry = array_pop($chunk);
127 |
128 | yield implode($to, $chunk).$to;
129 | } else {
130 | $carry = $chunk;
131 | }
132 |
133 | if (\strlen($carry) > $fromLen) {
134 | yield substr($carry, 0, -$fromLen);
135 | $carry = substr($carry, -$fromLen);
136 | }
137 | }
138 |
139 | if ('' !== $carry) {
140 | yield $carry;
141 | }
142 | }
143 |
144 | abstract protected function getReadConnectionDescription(): string;
145 | }
146 |
--------------------------------------------------------------------------------
/Transport/Smtp/Stream/ProcessStream.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Component\Mailer\Transport\Smtp\Stream;
13 |
14 | use Symfony\Component\Mailer\Exception\TransportException;
15 |
16 | /**
17 | * A stream supporting local processes.
18 | *
19 | * @author Fabien Potencier
20 | * @author Chris Corbyn
21 | *
22 | * @internal
23 | */
24 | final class ProcessStream extends AbstractStream
25 | {
26 | private string $command;
27 | private bool $interactive = false;
28 |
29 | public function setCommand(string $command): void
30 | {
31 | $this->command = $command;
32 | }
33 |
34 | public function setInteractive(bool $interactive): void
35 | {
36 | $this->interactive = $interactive;
37 | }
38 |
39 | public function initialize(): void
40 | {
41 | $descriptorSpec = [
42 | 0 => ['pipe', 'r'],
43 | 1 => ['pipe', 'w'],
44 | 2 => ['pipe', '\\' === \DIRECTORY_SEPARATOR ? 'a' : 'w'],
45 | ];
46 | $pipes = [];
47 | $this->stream = proc_open($this->command, $descriptorSpec, $pipes);
48 | stream_set_blocking($pipes[2], false);
49 | if ($err = stream_get_contents($pipes[2])) {
50 | throw new TransportException('Process could not be started: '.$err);
51 | }
52 | $this->in = &$pipes[0];
53 | $this->out = &$pipes[1];
54 | $this->err = &$pipes[2];
55 | }
56 |
57 | public function terminate(): void
58 | {
59 | if (null !== $this->stream) {
60 | fclose($this->in);
61 | $out = stream_get_contents($this->out);
62 | fclose($this->out);
63 | $err = stream_get_contents($this->err);
64 | fclose($this->err);
65 | if (0 !== $exitCode = proc_close($this->stream)) {
66 | $errorMessage = 'Process failed with exit code '.$exitCode.': '.$out.$err;
67 | }
68 | }
69 |
70 | parent::terminate();
71 |
72 | if (!$this->interactive && isset($errorMessage)) {
73 | throw new TransportException($errorMessage);
74 | }
75 | }
76 |
77 | protected function getReadConnectionDescription(): string
78 | {
79 | return 'process '.$this->command;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Transport/Smtp/Stream/SocketStream.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Component\Mailer\Transport\Smtp\Stream;
13 |
14 | use Symfony\Component\Mailer\Exception\TransportException;
15 |
16 | /**
17 | * A stream supporting remote sockets.
18 | *
19 | * @author Fabien Potencier
20 | * @author Chris Corbyn
21 | *
22 | * @internal
23 | */
24 | final class SocketStream extends AbstractStream
25 | {
26 | private string $url;
27 | private string $host = 'localhost';
28 | private int $port = 465;
29 | private float $timeout;
30 | private bool $tls = true;
31 | private ?string $sourceIp = null;
32 | private array $streamContextOptions = [];
33 |
34 | /**
35 | * @return $this
36 | */
37 | public function setTimeout(float $timeout): static
38 | {
39 | $this->timeout = $timeout;
40 |
41 | return $this;
42 | }
43 |
44 | public function getTimeout(): float
45 | {
46 | return $this->timeout ?? (float) \ini_get('default_socket_timeout');
47 | }
48 |
49 | /**
50 | * Literal IPv6 addresses should be wrapped in square brackets.
51 | *
52 | * @return $this
53 | */
54 | public function setHost(string $host): static
55 | {
56 | $this->host = $host;
57 |
58 | return $this;
59 | }
60 |
61 | public function getHost(): string
62 | {
63 | return $this->host;
64 | }
65 |
66 | /**
67 | * @return $this
68 | */
69 | public function setPort(int $port): static
70 | {
71 | $this->port = $port;
72 |
73 | return $this;
74 | }
75 |
76 | public function getPort(): int
77 | {
78 | return $this->port;
79 | }
80 |
81 | /**
82 | * Sets the TLS/SSL on the socket (disables STARTTLS).
83 | *
84 | * @return $this
85 | */
86 | public function disableTls(): static
87 | {
88 | $this->tls = false;
89 |
90 | return $this;
91 | }
92 |
93 | public function isTLS(): bool
94 | {
95 | return $this->tls;
96 | }
97 |
98 | /**
99 | * @return $this
100 | */
101 | public function setStreamOptions(array $options): static
102 | {
103 | $this->streamContextOptions = $options;
104 |
105 | return $this;
106 | }
107 |
108 | public function getStreamOptions(): array
109 | {
110 | return $this->streamContextOptions;
111 | }
112 |
113 | /**
114 | * Sets the source IP.
115 | *
116 | * IPv6 addresses should be wrapped in square brackets.
117 | *
118 | * @return $this
119 | */
120 | public function setSourceIp(string $ip): static
121 | {
122 | $this->sourceIp = $ip;
123 |
124 | return $this;
125 | }
126 |
127 | /**
128 | * Returns the IP used to connect to the destination.
129 | */
130 | public function getSourceIp(): ?string
131 | {
132 | return $this->sourceIp;
133 | }
134 |
135 | public function initialize(): void
136 | {
137 | $this->url = $this->host.':'.$this->port;
138 | if ($this->tls) {
139 | $this->url = 'ssl://'.$this->url;
140 | }
141 | $options = [];
142 | if ($this->sourceIp) {
143 | $options['socket']['bindto'] = $this->sourceIp.':0';
144 | }
145 | if ($this->streamContextOptions) {
146 | $options = array_merge($options, $this->streamContextOptions);
147 | }
148 | // do it unconditionally as it will be used by STARTTLS as well if supported
149 | $options['ssl']['crypto_method'] ??= \STREAM_CRYPTO_METHOD_TLS_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT;
150 | $streamContext = stream_context_create($options);
151 |
152 | $timeout = $this->getTimeout();
153 | set_error_handler(function ($type, $msg) {
154 | throw new TransportException(\sprintf('Connection could not be established with host "%s": ', $this->url).$msg);
155 | });
156 | try {
157 | $this->stream = stream_socket_client($this->url, $errno, $errstr, $timeout, \STREAM_CLIENT_CONNECT, $streamContext);
158 | } finally {
159 | restore_error_handler();
160 | }
161 |
162 | stream_set_blocking($this->stream, true);
163 | stream_set_timeout($this->stream, (int) $timeout, (int) (($timeout - (int) $timeout) * 1000000));
164 | $this->in = &$this->stream;
165 | $this->out = &$this->stream;
166 | }
167 |
168 | public function startTLS(): bool
169 | {
170 | set_error_handler(function ($type, $msg) {
171 | throw new TransportException('Unable to connect with STARTTLS: '.$msg);
172 | });
173 | try {
174 | return stream_socket_enable_crypto($this->stream, true);
175 | } finally {
176 | restore_error_handler();
177 | }
178 | }
179 |
180 | public function terminate(): void
181 | {
182 | if (null !== $this->stream) {
183 | fclose($this->stream);
184 | }
185 |
186 | parent::terminate();
187 | }
188 |
189 | protected function getReadConnectionDescription(): string
190 | {
191 | return $this->url;
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/Transport/TransportFactoryInterface.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Component\Mailer\Transport;
13 |
14 | use Symfony\Component\Mailer\Exception\IncompleteDsnException;
15 | use Symfony\Component\Mailer\Exception\UnsupportedSchemeException;
16 |
17 | /**
18 | * @author Konstantin Myakshin
19 | */
20 | interface TransportFactoryInterface
21 | {
22 | /**
23 | * @throws UnsupportedSchemeException
24 | * @throws IncompleteDsnException
25 | */
26 | public function create(Dsn $dsn): TransportInterface;
27 |
28 | public function supports(Dsn $dsn): bool;
29 | }
30 |
--------------------------------------------------------------------------------
/Transport/TransportInterface.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Component\Mailer\Transport;
13 |
14 | use Symfony\Component\Mailer\Envelope;
15 | use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
16 | use Symfony\Component\Mailer\SentMessage;
17 | use Symfony\Component\Mime\RawMessage;
18 |
19 | /**
20 | * Interface for all mailer transports.
21 | *
22 | * When sending emails, you should prefer MailerInterface implementations
23 | * as they allow asynchronous sending.
24 | *
25 | * @author Fabien Potencier
26 | */
27 | interface TransportInterface extends \Stringable
28 | {
29 | /**
30 | * @throws TransportExceptionInterface
31 | */
32 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage;
33 | }
34 |
--------------------------------------------------------------------------------
/Transport/Transports.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * For the full copyright and license information, please view the LICENSE
9 | * file that was distributed with this source code.
10 | */
11 |
12 | namespace Symfony\Component\Mailer\Transport;
13 |
14 | use Symfony\Component\Mailer\Envelope;
15 | use Symfony\Component\Mailer\Exception\InvalidArgumentException;
16 | use Symfony\Component\Mailer\Exception\LogicException;
17 | use Symfony\Component\Mailer\SentMessage;
18 | use Symfony\Component\Mime\Message;
19 | use Symfony\Component\Mime\RawMessage;
20 |
21 | /**
22 | * @author Fabien Potencier
23 | */
24 | final class Transports implements TransportInterface
25 | {
26 | /**
27 | * @var array
28 | */
29 | private array $transports = [];
30 | private TransportInterface $default;
31 |
32 | /**
33 | * @param iterable $transports
34 | */
35 | public function __construct(iterable $transports)
36 | {
37 | foreach ($transports as $name => $transport) {
38 | $this->default ??= $transport;
39 | $this->transports[$name] = $transport;
40 | }
41 |
42 | if (!$this->transports) {
43 | throw new LogicException(\sprintf('"%s" must have at least one transport configured.', __CLASS__));
44 | }
45 | }
46 |
47 | public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
48 | {
49 | /** @var Message $message */
50 | if (RawMessage::class === $message::class || !$message->getHeaders()->has('X-Transport')) {
51 | return $this->default->send($message, $envelope);
52 | }
53 |
54 | $headers = $message->getHeaders();
55 | $transport = $headers->get('X-Transport')->getBody();
56 | $headers->remove('X-Transport');
57 |
58 | if (!isset($this->transports[$transport])) {
59 | throw new InvalidArgumentException(\sprintf('The "%s" transport does not exist (available transports: "%s").', $transport, implode('", "', array_keys($this->transports))));
60 | }
61 |
62 | try {
63 | return $this->transports[$transport]->send($message, $envelope);
64 | } catch (\Throwable $e) {
65 | $headers->addTextHeader('X-Transport', $transport);
66 |
67 | throw $e;
68 | }
69 | }
70 |
71 | public function __toString(): string
72 | {
73 | return '['.implode(',', array_keys($this->transports)).']';
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "symfony/mailer",
3 | "type": "library",
4 | "description": "Helps sending emails",
5 | "keywords": [],
6 | "homepage": "https://symfony.com",
7 | "license": "MIT",
8 | "authors": [
9 | {
10 | "name": "Fabien Potencier",
11 | "email": "fabien@symfony.com"
12 | },
13 | {
14 | "name": "Symfony Community",
15 | "homepage": "https://symfony.com/contributors"
16 | }
17 | ],
18 | "require": {
19 | "php": ">=8.2",
20 | "egulias/email-validator": "^2.1.10|^3|^4",
21 | "psr/event-dispatcher": "^1",
22 | "psr/log": "^1|^2|^3",
23 | "symfony/event-dispatcher": "^6.4|^7.0",
24 | "symfony/mime": "^7.2",
25 | "symfony/service-contracts": "^2.5|^3"
26 | },
27 | "require-dev": {
28 | "symfony/console": "^6.4|^7.0",
29 | "symfony/http-client": "^6.4|^7.0",
30 | "symfony/messenger": "^6.4|^7.0",
31 | "symfony/twig-bridge": "^6.4|^7.0"
32 | },
33 | "conflict": {
34 | "symfony/http-client-contracts": "<2.5",
35 | "symfony/http-kernel": "<6.4",
36 | "symfony/messenger": "<6.4",
37 | "symfony/mime": "<6.4",
38 | "symfony/twig-bridge": "<6.4"
39 | },
40 | "autoload": {
41 | "psr-4": { "Symfony\\Component\\Mailer\\": "" },
42 | "exclude-from-classmap": [
43 | "/Tests/"
44 | ]
45 | },
46 | "minimum-stability": "dev"
47 | }
48 |
--------------------------------------------------------------------------------