From c370c2129828b4c280daa54796251b13c932a363 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 9 Sep 2024 08:19:24 +0600 Subject: [PATCH] sender --- src/Email/Emailer.php | 171 +++++++++++---- src/Email/EmailerX.phpx | 336 ------------------------------ src/Email/System/EmailBuilder.php | 83 +++++++- 3 files changed, 210 insertions(+), 380 deletions(-) delete mode 100644 src/Email/EmailerX.phpx diff --git a/src/Email/Emailer.php b/src/Email/Emailer.php index 9f73b6a..d90c82c 100644 --- a/src/Email/Emailer.php +++ b/src/Email/Emailer.php @@ -6,19 +6,56 @@ use Infocyph\TakingBytes\Email\System\GenericSender; use Infocyph\TakingBytes\Email\System\SMTPSender; -class Emailer +final class Emailer { - private $to = []; - private $cc = []; - private $bcc = []; - private $from = []; - private $replyTo; - private $subject; - private $plainText; - private $htmlContent; - private $attachments = []; - private $smtpConfigured = false; - private $smtpConfig = []; + // Recipients grouped (to, cc, bcc) + private array $recipients = [ + 'to' => [], + 'cc' => [], + 'bcc' => [] + ]; + + // Message details grouped + private array $messageDetails = [ + 'messageId' => '', + 'inReplyTo' => '', + 'references' => [] + ]; + + // General headers grouped + private array $generalHeaders = [ + 'language' => '', + 'priority' => null, + 'mailer' => '' + ]; + + // List headers grouped + private array $listHeaders = [ + 'listId' => '', + 'unsubscribe' => '', + 'subscribe' => '', + 'archive' => '' + ]; + + // Miscellaneous headers grouped + private array $miscHeaders = [ + 'confirmedOptIn' => null, + 'spamStatus' => '', + 'organization' => '', + 'dispositionNotificationTo' => '' + ]; + + private string $subject; + private string $htmlContent; + private string $plainText; + private string $replyTo; + private array $attachments = []; + private array $smtpConfig = []; + + /** + * @var bool + */ + private bool $smtpConfigured = false; public function __construct($fromEmail, $fromName) { @@ -29,7 +66,7 @@ public function __construct($fromEmail, $fromName) $this->replyTo = $this->from['email']; // Default Reply-To } - public function setSMTP(array $smtpConfig) + public function setSMTP(array $smtpConfig): self { $this->smtpConfig = array_merge([ 'host' => '', @@ -43,49 +80,79 @@ public function setSMTP(array $smtpConfig) return $this; } - public function to($email) + // Set recipients (to, cc, bcc) + public function setRecipients(array $to, array $cc = [], array $bcc = []): self { - $this->to[] = $this->encodeNonAscii($email); + $this->recipients['to'] = array_map([$this, 'encodeNonAscii'], $to); + $this->recipients['cc'] = array_map([$this, 'encodeNonAscii'], $cc); + $this->recipients['bcc'] = array_map([$this, 'encodeNonAscii'], $bcc); return $this; } - public function cc($email) + // Set message details (subject, content) + public function setMessage(string $subject, string $htmlContent, string $plainText = ''): self { - $this->cc[] = $this->encodeNonAscii($email); + $this->subject = $this->encodeMimeHeader($subject); + $this->htmlContent = $htmlContent; + $this->plainText = $plainText; return $this; } - public function bcc($email) + // Set reply-to email + public function setReplyTo(string $email): self { - $this->bcc[] = $this->encodeNonAscii($email); + $this->replyTo = $this->encodeNonAscii($email); return $this; } - public function subject($subject) + // Set message-specific headers + public function setMessageDetails(string $messageId = '', string $inReplyTo = '', array $references = []): self { - $this->subject = $this->encodeMimeHeader($subject); + $this->messageDetails['messageId'] = $messageId; + $this->messageDetails['inReplyTo'] = $inReplyTo; + $this->messageDetails['references'] = $references; return $this; } - public function plainText($text) + // Set general headers + public function setGeneralHeaders(string $language = '', int $priority = null, string $mailer = ''): self { - $this->plainText = $text; + $this->generalHeaders['language'] = $language; + $this->generalHeaders['priority'] = $priority; + $this->generalHeaders['mailer'] = $mailer; return $this; } - public function htmlContent($html) - { - $this->htmlContent = $html; + // Set list headers + public function setListHeaders( + string $listId = '', + string $unsubscribe = '', + string $subscribe = '', + string $archive = '' + ): self { + $this->listHeaders['listId'] = $listId; + $this->listHeaders['unsubscribe'] = $unsubscribe; + $this->listHeaders['subscribe'] = $subscribe; + $this->listHeaders['archive'] = $archive; return $this; } - public function replyTo($email) - { - $this->replyTo = $this->encodeNonAscii($email); + // Set miscellaneous headers + public function setMiscHeaders( + bool $confirmedOptIn = null, + string $spamStatus = '', + string $organization = '', + string $dispositionNotificationTo = '' + ): self { + $this->miscHeaders['confirmedOptIn'] = $confirmedOptIn; + $this->miscHeaders['spamStatus'] = $spamStatus; + $this->miscHeaders['organization'] = $organization; + $this->miscHeaders['dispositionNotificationTo'] = $dispositionNotificationTo; return $this; } - public function attachment($filePath, $filename = null) + // Add attachment + public function attachment($filePath, $filename = null): self { if (file_exists($filePath)) { $this->attachments[] = ['path' => $filePath, 'name' => $filename ?: basename($filePath)]; @@ -93,29 +160,60 @@ public function attachment($filePath, $filename = null) return $this; } + // Send the email public function send() { $builder = new EmailBuilder($this->from); - $builder->setCommonHeaders($this->to, $this->subject, $this->cc, $this->bcc, $this->replyTo); -// $builder->setIdHeaders(); - // Choose the appropriate sender method (SMTP or generic) + // Set common headers + $builder->setCommonHeaders( + $this->recipients['to'], + $this->subject, + $this->recipients['cc'], + $this->recipients['bcc'], + $this->replyTo + ) + ->setIdHeaders( + $this->messageDetails['messageId'], + $this->messageDetails['inReplyTo'], + $this->messageDetails['references'] + ) + ->setGeneralHeaders( + $this->generalHeaders['language'], + $this->generalHeaders['priority'], + $this->generalHeaders['mailer'] + ) + ->setListHeaders( + $this->listHeaders['listId'], + $this->listHeaders['unsubscribe'], + $this->listHeaders['subscribe'], + $this->listHeaders['archive'] + ) + ->setMiscHeaders( + $this->miscHeaders['confirmedOptIn'], + $this->miscHeaders['spamStatus'], + $this->miscHeaders['organization'], + $this->miscHeaders['dispositionNotificationTo'] + ); + + // Choose the appropriate sending method (SMTP or generic) if ($this->smtpConfigured) { return (new SMTPSender($this->from, $this->smtpConfig))->send( - $this->to, + $this->recipients['to'], $builder->setBody($this->htmlContent, $this->plainText, $this->attachments), $builder->getHeaders() ); } + return (new GenericSender())->send( - $this->to, + $this->recipients['to'], $this->subject, $builder->setBody($this->htmlContent, $this->plainText, $this->attachments), $builder->getHeaders() ); } - + // Helper to encode non-ASCII characters private function encodeNonAscii($string) { return preg_replace_callback('/[^\x20-\x7E]/', function ($matches) { @@ -123,6 +221,7 @@ private function encodeNonAscii($string) }, $string); } + // Helper to encode MIME headers private function encodeMimeHeader($text) { return '=?UTF-8?B?' . base64_encode($text) . '?='; diff --git a/src/Email/EmailerX.phpx b/src/Email/EmailerX.phpx deleted file mode 100644 index 81e5f09..0000000 --- a/src/Email/EmailerX.phpx +++ /dev/null @@ -1,336 +0,0 @@ - false, - 'error' => '', - 'details' => [] - ]; - - public function __construct($fromEmail, $fromName) - { - $this->from = [ - 'email' => $this->encodeNonAscii($fromEmail), - 'name' => $this->encodeNonAscii($fromName) - ]; - $this->boundaryAlternative = sha1(uniqid(time(), true)); - $this->replyTo = $this->from['email']; // Default Reply-To if not provided - } - - public function setSMTP(array $smtpConfig) - { - $this->smtpConfig = array_merge([ - 'host' => '', - 'auth' => false, - 'username' => '', - 'password' => '', - 'port' => 25, - 'secure' => null // TLS or SSL - ], $smtpConfig); - - $this->smtpConfigured = true; - return $this; - } - - public function to($email) - { - $this->to[] = $this->encodeNonAscii($email); - return $this; - } - - public function cc($email) - { - $this->cc[] = $this->encodeNonAscii($email); - return $this; - } - - public function bcc($email) - { - $this->bcc[] = $this->encodeNonAscii($email); - return $this; - } - - public function subject($subject) - { - $this->subject = $this->encodeMimeHeader($subject); - return $this; - } - - public function plainText($text) - { - $this->plainText = $text; - return $this; - } - - public function htmlContent($html) - { - $this->htmlContent = $html; - return $this; - } - - public function replyTo($email) - { - $this->replyTo = $this->encodeNonAscii($email); - return $this; - } - - public function attachment($filePath, $filename = null) - { - if (file_exists($filePath)) { - $this->attachments[] = ['path' => $filePath, 'name' => $filename ?: basename($filePath)]; - $this->boundaryMixed = sha1(uniqid(time(), true)); - } else { - $this->status['error'] = "File not found: $filePath"; - } - return $this; - } - - public function send() - { - if (!$this->validateRequiredFields()) { - return false; - } - - $this->generatePlainTextFromHtml(); - - $headers = $this->buildHeaders(); - $message = $this->buildBody(); - - if ($this->smtpConfigured) { - return $this->sendViaSMTP($message, $headers); - } else { - return $this->sendViaMailFunction($message, $headers); - } - } - - private function validateRequiredFields() - { - if (empty($this->to) || empty($this->from['email']) || empty($this->subject)) { - $this->status['error'] = 'Missing essential email data (to, from, subject)'; - return false; - } - - return true; - } - - private function generatePlainTextFromHtml() - { - if (empty($this->plainText) && !empty($this->htmlContent)) { - $this->plainText = strip_tags($this->htmlContent); // Automatically set plain text if not provided - } - } - - private function buildHeaders() - { - $headers = "From: =?UTF-8?B?" . base64_encode($this->from['name']) . "?= <{$this->from['email']}>\r\n" - . "Reply-To: {$this->replyTo}\r\n"; - - if (!empty($this->cc)) { - $headers .= "Cc: " . implode(',', $this->cc) . "\r\n"; - } - - if (!empty($this->bcc)) { - $headers .= "Bcc: " . implode(',', $this->bcc) . "\r\n"; - } - - $headers .= "MIME-Version: 1.0\r\n"; - - if (!empty($this->attachments)) { - $headers .= "Content-Type: multipart/mixed; boundary=\"{$this->boundaryMixed}\"\r\n"; - } else { - $headers .= "Content-Type: multipart/alternative; boundary=\"{$this->boundaryAlternative}\"\r\n"; - } - - return $headers; - } - - private function buildBody() - { - $message = ""; - - if (!empty($this->attachments)) { - $message .= "--{$this->boundaryMixed}\r\n"; - $message .= "Content-Type: multipart/alternative; boundary=\"{$this->boundaryAlternative}\"\r\n\r\n"; - } - - $message .= $this->buildPlainTextPart(); - $message .= $this->buildHtmlPart(); - - $message .= "--{$this->boundaryAlternative}--\r\n"; - - if (!empty($this->attachments)) { - $message .= $this->buildAttachmentsPart(); - $message .= "--{$this->boundaryMixed}--\r\n"; - } - - return $message; - } - - private function buildPlainTextPart() - { - if (empty($this->plainText)) { - return ''; - } - - return "--{$this->boundaryAlternative}\r\n" - . "Content-Type: text/plain; charset=UTF-8\r\n" - . "Content-Transfer-Encoding: 7bit\r\n\r\n" - . "{$this->plainText}\r\n\r\n"; - } - - private function buildHtmlPart() - { - if (empty($this->htmlContent)) { - return ''; - } - - return "--{$this->boundaryAlternative}\r\n" - . "Content-Type: text/html; charset=UTF-8\r\n" - . "Content-Transfer-Encoding: quoted-printable\r\n\r\n" - . "{$this->encodeQuotedPrintable($this->htmlContent)}\r\n\r\n"; - } - - private function buildAttachmentsPart() - { - $attachmentsPart = ""; - foreach ($this->attachments as $attachment) { - $filePath = $attachment['path']; - $fileName = $attachment['name']; - $fileType = mime_content_type($filePath); - $fileContent = chunk_split(base64_encode(file_get_contents($filePath))); - - $attachmentsPart .= "--{$this->boundaryMixed}\r\n" - . "Content-Type: $fileType; name*=\"UTF-8''" . rawurlencode($fileName) . "\"\r\n" - . "Content-Disposition: attachment; filename*=\"UTF-8''" . rawurlencode($fileName) . "\"\r\n" - . "Content-Transfer-Encoding: base64\r\n\r\n" - . "$fileContent\r\n\r\n"; - } - return $attachmentsPart; - } - - private function sendViaMailFunction($message, $headers) - { - $toRecipients = implode(',', $this->to); - - if (mail($toRecipients, $this->subject, $message, $headers)) { - $this->status['sent'] = true; - $this->status['details'] = [ - 'to' => $this->to, - 'cc' => $this->cc, - 'bcc' => $this->bcc, - 'subject' => $this->subject, - 'attachments' => $this->attachments, - 'reply_to' => $this->replyTo - ]; - return true; - } else { - $this->status['error'] = 'Failed to send email'; - return false; - } - } - - private function sendViaSMTP($message, $headers) - { - $smtpHost = $this->smtpConfig['host']; - $smtpPort = $this->smtpConfig['port']; - $smtpUser = $this->smtpConfig['username']; - $smtpPass = $this->smtpConfig['password']; - $smtpSecure = $this->smtpConfig['secure']; - - $connection = fsockopen($smtpHost, $smtpPort, $errno, $errstr, 30); - - if (!$connection) { - $this->status['error'] = "Failed to connect to SMTP server: $errstr ($errno)"; - return false; - } - - $this->getServerResponse($connection); - - if ($smtpSecure === 'tls') { - fwrite($connection, "EHLO $smtpHost\r\n"); - $this->getServerResponse($connection); - - fwrite($connection, "STARTTLS\r\n"); - $this->getServerResponse($connection); - - stream_socket_enable_crypto($connection, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); - } elseif ($smtpSecure === 'ssl') { - stream_socket_enable_crypto($connection, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT); - fwrite($connection, "EHLO $smtpHost\r\n"); - $this->getServerResponse($connection); - } else { - fwrite($connection, "EHLO $smtpHost\r\n"); - $this->getServerResponse($connection); - } - - fwrite($connection, "AUTH LOGIN\r\n"); - $this->getServerResponse($connection); - - fwrite($connection, base64_encode($smtpUser) . "\r\n"); - $this->getServerResponse($connection); - - fwrite($connection, base64_encode($smtpPass) . "\r\n"); - $this->getServerResponse($connection); - - fwrite($connection, "MAIL FROM: <{$this->from['email']}>\r\n"); - $this->getServerResponse($connection); - - foreach ($this->to as $recipient) { - fwrite($connection, "RCPT TO: <$recipient>\r\n"); - $this->getServerResponse($connection); - } - - fwrite($connection, "DATA\r\n"); - $this->getServerResponse($connection); - - fwrite($connection, "$headers\r\n$message\r\n.\r\n"); - $this->getServerResponse($connection); - - fwrite($connection, "QUIT\r\n"); - fclose($connection); - - $this->status['sent'] = true; - return true; - } - - private function getServerResponse($connection) - { - $response = fgets($connection, 512); - if (strpos($response, '250') === false && strpos($response, '354') === false) { - $this->status['error'] = "SMTP Error: $response"; - } - } - - private function encodeNonAscii($string) - { - return preg_replace_callback('/[^\x20-\x7E]/', function ($matches) { - return '=?UTF-8?B?' . base64_encode($matches[0]) . '?='; - }, $string); - } - - private function encodeMimeHeader($text) - { - return '=?UTF-8?B?' . base64_encode($text) . '?='; - } - - private function encodeQuotedPrintable($string) - { - return quoted_printable_encode($string); - } -} diff --git a/src/Email/System/EmailBuilder.php b/src/Email/System/EmailBuilder.php index dd87f7b..2d921f6 100644 --- a/src/Email/System/EmailBuilder.php +++ b/src/Email/System/EmailBuilder.php @@ -2,16 +2,15 @@ namespace Infocyph\TakingBytes\Email\System; -class EmailBuilder +final class EmailBuilder { private string $boundaryAlternative; private ?string $boundaryMixed = null; - private array $headers = []; public function __construct(private array $from) { - $this->boundaryAlternative = sha1(uniqid(time(), true)); + $this->boundaryAlternative = bin2hex(random_bytes(16)); } public function setCommonHeaders(array $to, string $subject, array $cc = [], $bcc = [], $replyTo = '') @@ -29,13 +28,13 @@ public function setCommonHeaders(array $to, string $subject, array $cc = [], $bc if (!empty($bcc)) { $this->headers[] = "Bcc: " . implode(',', $bcc); } + $this->headers['XM'] = 'X-Mailer: PHP/' . phpversion(); return $this; } public function setIdHeaders(string $messageId, string $inReplyTo = '', array $references = []) { $domain = substr(strrchr($this->from['email'], "@"), 1); - $this->headers[] = "Message-ID: <$messageId>"; if (!empty($inReplyTo)) { @@ -52,6 +51,71 @@ public function setIdHeaders(string $messageId, string $inReplyTo = '', array $r return $this; } + // General headers method with direct parameters + public function setGeneralHeaders( + string $language = '', + int $priority = null, + string $mailer = '' + ) { + if (!empty($language)) { + $this->headers[] = "Content-Language: $language"; + } + if (!is_null($priority)) { + if (!in_array($priority, [1, 2, 3])) { + throw new \InvalidArgumentException("Invalid X-Priority value. It must be 1, 2, or 3."); + } + $this->headers[] = "X-Priority: $priority"; + } + if (!empty($mailer)) { + $this->headers['XM'] = "X-Mailer: $mailer"; + } + return $this; + } + + // List headers method with direct parameters + public function setListHeaders( + string $listId = '', + string $unsubscribe = '', + string $subscribe = '', + string $archive = '' + ) { + if (!empty($listId)) { + $this->headers[] = "List-Id: <$listId>"; + } + if (!empty($unsubscribe)) { + $this->headers[] = "List-Unsubscribe: <$unsubscribe>"; + } + if (!empty($subscribe)) { + $this->headers[] = "List-Subscribe: <$subscribe>"; + } + if (!empty($archive)) { + $this->headers[] = "List-Archive: <$archive>"; + } + return $this; + } + + // Misc headers method with direct parameters + public function setMiscHeaders( + bool $confirmedOptIn = null, + string $spamStatus = '', + string $organization = '', + string $dispositionNotificationTo = '' + ) { + if ($confirmedOptIn !== null) { + $this->headers[] = "X-Confirmed-OptIn: " . ($confirmedOptIn ? 'Yes' : 'No'); + } + if (!empty($spamStatus)) { + $this->headers[] = "X-Spam-Status: $spamStatus"; + } + if (!empty($organization)) { + $this->headers[] = "Organization: $organization"; + } + if (!empty($dispositionNotificationTo)) { + $this->headers[] = "Disposition-Notification-To: $dispositionNotificationTo"; + } + return $this; + } + public function setBody($htmlContent, $plainText = '', $attachments = []) { if (empty($plainText) && !empty($htmlContent)) { @@ -59,12 +123,14 @@ public function setBody($htmlContent, $plainText = '', $attachments = []) } $this->headers[] = 'MIME-Version: 1.0'; $message = $this->buildAlternativeBody($plainText, $htmlContent); + $this->headers['CT'] = "Content-Type: multipart/alternative; boundary=\"$this->boundaryAlternative\""; if (!empty($attachments)) { $this->boundaryMixed = sha1(uniqid(time(), true)); - $this->headers[] = "Content-Type: multipart/mixed; boundary=\"$this->boundaryMixed\""; - return $this->wrapWithMixedBoundary($message, $attachments); + $this->headers['CT'] = "Content-Type: multipart/mixed; boundary=\"$this->boundaryMixed\""; + $message = $this->wrapWithMixedBoundary($message, $attachments); } - $this->headers[] = "Content-Type: multipart/alternative; boundary=\"$this->boundaryAlternative\""; + $this->headers[] = 'Content-Length: ' . strlen($message); + return $message; } @@ -73,6 +139,7 @@ public function getHeaders() return implode("\r\n", $this->headers); } + // Private helper methods for body building private function buildAlternativeBody($plainText, $htmlContent) { $message = $this->buildPlainTextPart($plainText); @@ -114,7 +181,7 @@ private function buildAttachmentsPart($attachments) foreach ($attachments as $attachment) { $filePath = $attachment['path']; $fileName = rawurlencode($attachment['name']); - $fileType = mime_content_type($filePath); + $fileType = mime_content_type($filePath) ?: 'application/octet-stream'; $fileContent = chunk_split(base64_encode(file_get_contents($filePath))); $attachmentsPart .= "--$this->boundaryMixed\r\n"