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 $to @param list $cc @param list $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 $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 $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; } } } }