239 lines
9.1 KiB
PHP
239 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace FecaMailshots\Infrastructure;
|
|
|
|
use FecaMailshots\Application\SmtpSender;
|
|
|
|
final class BasicSmtpSender implements SmtpSender
|
|
{
|
|
public function send(array $credentials, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo = null, array $attachments = []): array
|
|
{
|
|
$host = (string) ($credentials['smtp_host'] ?? '');
|
|
$port = (int) ($credentials['smtp_port'] ?? 0);
|
|
$user = (string) ($credentials['smtp_user'] ?? '');
|
|
$pass = (string) ($credentials['smtp_password'] ?? '');
|
|
$from = (string) ($credentials['smtp_from_email'] ?? '');
|
|
$fromName = (string) ($credentials['smtp_from_name'] ?? '');
|
|
|
|
if ($host === '' || $port <= 0 || $user === '' || $pass === '' || $from === '') {
|
|
throw new \RuntimeException('Missing SMTP credentials.');
|
|
}
|
|
|
|
$transport = !empty($credentials['smtp_require_tls']) ? 'tls://' : '';
|
|
$fp = @stream_socket_client($transport . $host . ':' . $port, $errno, $errstr, 20);
|
|
if (!is_resource($fp)) {
|
|
throw new \RuntimeException('SMTP connect failed: ' . $errstr);
|
|
}
|
|
|
|
try {
|
|
$this->expect($fp, [220]);
|
|
$this->cmd($fp, 'EHLO localhost', [250]);
|
|
if (empty($credentials['smtp_require_tls'])) {
|
|
// try opportunistic STARTTLS
|
|
$line = $this->cmd($fp, 'STARTTLS', [220], false);
|
|
if ($line !== null) {
|
|
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
throw new \RuntimeException('Failed to enable STARTTLS crypto.');
|
|
}
|
|
$this->cmd($fp, 'EHLO localhost', [250]);
|
|
}
|
|
}
|
|
|
|
$this->cmd($fp, 'AUTH LOGIN', [334]);
|
|
$this->cmd($fp, base64_encode($user), [334]);
|
|
$this->cmd($fp, base64_encode($pass), [235]);
|
|
$this->cmd($fp, 'MAIL FROM:<' . $from . '>', [250]);
|
|
|
|
$allRecipients = array_values(array_unique(array_merge($to, $cc, $bcc)));
|
|
foreach ($allRecipients as $recipient) {
|
|
$this->cmd($fp, 'RCPT TO:<' . trim($recipient) . '>', [250, 251]);
|
|
}
|
|
|
|
$this->cmd($fp, 'DATA', [354]);
|
|
|
|
$raw = $this->buildMime($from, $fromName, $to, $cc, $bcc, $subject, $htmlBody, $replyTo, $attachments);
|
|
fwrite($fp, $this->dotStuff($raw));
|
|
fwrite($fp, "\r\n.\r\n");
|
|
$this->expect($fp, [250]);
|
|
$this->cmd($fp, 'QUIT', [221], false);
|
|
|
|
return ['raw_mime' => $raw];
|
|
} finally {
|
|
fclose($fp);
|
|
}
|
|
}
|
|
|
|
/** @param list<string> $to @param list<string> $cc @param list<string> $bcc */
|
|
private function buildMime(string $from, string $fromName, array $to, array $cc, array $bcc, string $subject, string $htmlBody, ?string $replyTo, array $attachments): string
|
|
{
|
|
$headers = [];
|
|
$fromHeader = $fromName !== '' ? sprintf('%s <%s>', $this->encodeHeaderValue($fromName), $from) : $from;
|
|
$headers[] = 'From: ' . $fromHeader;
|
|
$headers[] = 'To: ' . implode(', ', $to);
|
|
if ($cc !== []) {
|
|
$headers[] = 'Cc: ' . implode(', ', $cc);
|
|
}
|
|
if ($bcc !== []) {
|
|
$headers[] = 'Bcc: ' . implode(', ', $bcc);
|
|
}
|
|
if ($replyTo !== null && trim($replyTo) !== '') {
|
|
$headers[] = 'Reply-To: ' . trim($replyTo);
|
|
}
|
|
$headers[] = 'Subject: ' . $this->encodeHeaderValue($subject);
|
|
$headers[] = 'MIME-Version: 1.0';
|
|
if ($attachments === []) {
|
|
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
|
$headers[] = 'Content-Transfer-Encoding: quoted-printable';
|
|
return implode("\r\n", $headers) . "\r\n\r\n" . $this->encodeQuotedPrintable($htmlBody);
|
|
}
|
|
|
|
$boundary = 'feca_mailshots_' . bin2hex(random_bytes(12));
|
|
$mime = implode("\r\n", array_merge($headers, ['Content-Type: multipart/mixed; boundary="' . $boundary . '"'])) . "\r\n\r\n";
|
|
$mime .= '--' . $boundary . "\r\n";
|
|
$mime .= 'Content-Type: text/html; charset=UTF-8' . "\r\n";
|
|
$mime .= 'Content-Transfer-Encoding: quoted-printable' . "\r\n\r\n";
|
|
$mime .= $this->encodeQuotedPrintable($htmlBody) . "\r\n";
|
|
|
|
foreach ($attachments as $attachment) {
|
|
$filename = trim((string) ($attachment['filename'] ?? 'attachment.bin'));
|
|
$mimeType = trim((string) ($attachment['mime_type'] ?? 'application/octet-stream'));
|
|
$bytes = (string) ($attachment['content_bytes'] ?? '');
|
|
if ($filename === '' || $bytes === '') {
|
|
continue;
|
|
}
|
|
|
|
$escapedFilename = addcslashes($filename, '"\\');
|
|
$mime .= '--' . $boundary . "\r\n";
|
|
$mime .= 'Content-Type: ' . $mimeType . '; name="' . $escapedFilename . '"' . "\r\n";
|
|
$mime .= 'Content-Transfer-Encoding: base64' . "\r\n";
|
|
$mime .= 'Content-Disposition: attachment; filename="' . $escapedFilename . '"' . "\r\n\r\n";
|
|
$mime .= $this->encodeBase64Chunked($bytes) . "\r\n";
|
|
}
|
|
|
|
$mime .= '--' . $boundary . '--' . "\r\n";
|
|
return $mime;
|
|
}
|
|
|
|
private function encodeHeaderValue(string $value): string
|
|
{
|
|
$value = preg_replace('/[\r\n]+/', ' ', $value);
|
|
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', is_string($value) ? $value : '');
|
|
$value = trim(preg_replace('/[ \t]+/', ' ', is_string($value) ? $value : '') ?? '');
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$chars = [];
|
|
if (preg_match_all('/./us', $value, $matches) === 1) {
|
|
$chars = $matches[0];
|
|
} else {
|
|
$chars = str_split($value);
|
|
}
|
|
|
|
$chunks = [];
|
|
$chunk = '';
|
|
foreach ($chars as $char) {
|
|
if ($chunk !== '' && strlen($chunk . $char) > 45) {
|
|
$chunks[] = $chunk;
|
|
$chunk = '';
|
|
}
|
|
$chunk .= $char;
|
|
}
|
|
if ($chunk !== '') {
|
|
$chunks[] = $chunk;
|
|
}
|
|
|
|
$encoded = array_map(static fn(string $chunk): string => '=?UTF-8?B?' . base64_encode($chunk) . '?=', $chunks);
|
|
return implode("\r\n ", $encoded);
|
|
}
|
|
|
|
private function encodeBase64Chunked(string $bytes): string
|
|
{
|
|
$stream = fopen('php://temp', 'w+b');
|
|
if (!is_resource($stream)) {
|
|
throw new \RuntimeException('Unable to allocate temp stream for attachment encoding.');
|
|
}
|
|
|
|
try {
|
|
if (fwrite($stream, $bytes) === false) {
|
|
throw new \RuntimeException('Failed writing attachment bytes to temp stream.');
|
|
}
|
|
rewind($stream);
|
|
$filter = stream_filter_append(
|
|
$stream,
|
|
'convert.base64-encode',
|
|
STREAM_FILTER_READ,
|
|
['line-length' => 76, 'line-break-chars' => "\r\n"]
|
|
);
|
|
if ($filter === false) {
|
|
throw new \RuntimeException('Failed to initialize base64 stream filter.');
|
|
}
|
|
|
|
$encoded = stream_get_contents($stream);
|
|
if (!is_string($encoded)) {
|
|
throw new \RuntimeException('Failed to read encoded attachment bytes.');
|
|
}
|
|
return rtrim($encoded, "\r\n");
|
|
} finally {
|
|
fclose($stream);
|
|
}
|
|
}
|
|
|
|
private function encodeQuotedPrintable(string $body): string
|
|
{
|
|
$body = preg_replace("/\r\n|\r|\n/", "\r\n", $body) ?? $body;
|
|
return quoted_printable_encode($body);
|
|
}
|
|
|
|
private function dotStuff(string $data): string
|
|
{
|
|
$data = preg_replace("/\r\n|\r|\n/", "\r\n", $data) ?? $data;
|
|
return preg_replace('/(?m)^\./', '..', $data) ?? $data;
|
|
}
|
|
|
|
/** @param list<int> $codes */
|
|
private function cmd($fp, string $cmd, array $codes, bool $throwOnMismatch = true): ?string
|
|
{
|
|
fwrite($fp, $cmd . "\r\n");
|
|
return $this->expect($fp, $codes, $throwOnMismatch);
|
|
}
|
|
|
|
/** @param list<int> $codes */
|
|
private function expect($fp, array $codes, bool $throwOnMismatch = true): ?string
|
|
{
|
|
$lastLine = null;
|
|
while (true) {
|
|
$line = fgets($fp, 4096);
|
|
if ($line === false) {
|
|
if ($throwOnMismatch) {
|
|
throw new \RuntimeException('SMTP read failed.');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
$lastLine = $line;
|
|
if (!preg_match('/^(\d{3})([\s-])/', $line, $m)) {
|
|
if ($throwOnMismatch) {
|
|
throw new \RuntimeException('SMTP malformed response: ' . trim($line));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
$code = (int) $m[1];
|
|
if (!in_array($code, $codes, true)) {
|
|
if ($throwOnMismatch) {
|
|
throw new \RuntimeException('SMTP unexpected response: ' . trim($line));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
$continuation = $m[2] === '-';
|
|
if (!$continuation) {
|
|
return $lastLine;
|
|
}
|
|
}
|
|
}
|
|
}
|