feca-mailshots-plugin/feca_mailshots_plugin/src/Infrastructure/BasicSmtpSender.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;
}
}
}
}