diff --git a/src/Utopia/Messaging/Adapter/Email/SMTP.php b/src/Utopia/Messaging/Adapter/Email/SMTP.php index 68707a71..28067fc8 100644 --- a/src/Utopia/Messaging/Adapter/Email/SMTP.php +++ b/src/Utopia/Messaging/Adapter/Email/SMTP.php @@ -20,6 +20,8 @@ class SMTP extends EmailAdapter * @param bool $smtpAutoTLS Enable/disable SMTP AutoTLS feature. Defaults to false. * @param string $xMailer The value to use for the X-Mailer header. * @param int $timeout SMTP timeout in seconds. + * @param bool $keepAlive Whether to reuse the SMTP connection across process() calls. + * @param int $timelimit SMTP command timelimit in seconds. */ public function __construct( private string $host, @@ -29,13 +31,17 @@ public function __construct( private string $smtpSecure = '', private bool $smtpAutoTLS = false, private string $xMailer = '', - private int $timeout = 30 + private int $timeout = 30, + private bool $keepAlive = false, + private int $timelimit = 30, ) { if (!\in_array($this->smtpSecure, ['', 'ssl', 'tls'])) { throw new \InvalidArgumentException('Invalid SMTP secure prefix. Must be "", "ssl" or "tls"'); } } + private ?PHPMailer $mail = null; + public function getName(): string { return static::NAME; @@ -52,18 +58,33 @@ public function getMaxMessagesPerRequest(): int protected function process(EmailMessage $message): array { $response = new Response($this->getType()); - $mail = new PHPMailer(); - $mail->isSMTP(); + + if ($this->keepAlive && $this->mail !== null) { + $mail = $this->mail; + $mail->clearAllRecipients(); + $mail->clearReplyTos(); + $mail->clearAttachments(); + } else { + $mail = new PHPMailer(); + $mail->isSMTP(); + $mail->Host = $this->host; + $mail->Port = $this->port; + $mail->SMTPAuth = !empty($this->username) && !empty($this->password); + $mail->Username = $this->username; + $mail->Password = $this->password; + $mail->SMTPSecure = $this->smtpSecure; + $mail->SMTPAutoTLS = $this->smtpAutoTLS; + $mail->Timeout = $this->timeout; + $mail->SMTPKeepAlive = $this->keepAlive; + + if ($this->keepAlive) { + $this->mail = $mail; + } + } + $mail->XMailer = $this->xMailer; - $mail->Host = $this->host; - $mail->Port = $this->port; - $mail->SMTPAuth = !empty($this->username) && !empty($this->password); - $mail->Username = $this->username; - $mail->Password = $this->password; - $mail->SMTPSecure = $this->smtpSecure; - $mail->SMTPAutoTLS = $this->smtpAutoTLS; - $mail->Timeout = $this->timeout; $mail->CharSet = 'UTF-8'; + $mail->getSMTPInstance()->Timelimit = $this->timelimit; $mail->Subject = $message->getSubject(); $mail->Body = $message->getContent(); $mail->setFrom($message->getFromEmail(), $message->getFromName()); @@ -95,7 +116,11 @@ protected function process(EmailMessage $message): array $size = 0; foreach ($message->getAttachments() as $attachment) { - $size += \filesize($attachment->getPath()); + if ($attachment->getContent() !== null) { + $size += \strlen($attachment->getContent()); + } else { + $size += \filesize($attachment->getPath()); + } } if ($size > self::MAX_ATTACHMENT_BYTES) { @@ -103,11 +128,21 @@ protected function process(EmailMessage $message): array } foreach ($message->getAttachments() as $attachment) { - $mail->addStringAttachment( - string: \file_get_contents($attachment->getPath()), - filename: $attachment->getName(), - type: $attachment->getType() - ); + if ($attachment->getContent() !== null) { + $mail->addStringAttachment( + string: $attachment->getContent(), + filename: $attachment->getName(), + encoding: PHPMailer::ENCODING_BASE64, + type: $attachment->getType() + ); + } else { + $mail->addStringAttachment( + string: \file_get_contents($attachment->getPath()), + filename: $attachment->getName(), + encoding: PHPMailer::ENCODING_BASE64, + type: $attachment->getType() + ); + } } } diff --git a/src/Utopia/Messaging/Messages/Email/Attachment.php b/src/Utopia/Messaging/Messages/Email/Attachment.php index 157d6060..3b8e0244 100644 --- a/src/Utopia/Messaging/Messages/Email/Attachment.php +++ b/src/Utopia/Messaging/Messages/Email/Attachment.php @@ -6,13 +6,15 @@ class Attachment { /** * @param string $name The name of the file. - * @param string $path The content of the file. + * @param string $path The path of the file. * @param string $type The MIME type of the file. + * @param ?string $content Raw string content of the file (used instead of path when non-null). */ public function __construct( private string $name, private string $path, private string $type, + private ?string $content = null, ) { } @@ -30,4 +32,9 @@ public function getType(): string { return $this->type; } + + public function getContent(): ?string + { + return $this->content; + } } diff --git a/tests/Messaging/Adapter/Email/SMTPTest.php b/tests/Messaging/Adapter/Email/SMTPTest.php index c4d6f37b..08037c8e 100644 --- a/tests/Messaging/Adapter/Email/SMTPTest.php +++ b/tests/Messaging/Adapter/Email/SMTPTest.php @@ -114,4 +114,128 @@ public function testSendEmailOnlyBCC(): void $this->assertEquals($subject, $lastEmail['subject']); $this->assertEquals($content, \trim($lastEmail['text'])); } + + public function testAttachmentWithStringContent(): void + { + $content = 'Hello, this is raw file content.'; + $attachment = new Attachment( + name: 'readme.txt', + path: '', + type: 'text/plain', + content: $content, + ); + + $this->assertEquals('readme.txt', $attachment->getName()); + $this->assertEquals('', $attachment->getPath()); + $this->assertEquals('text/plain', $attachment->getType()); + $this->assertEquals($content, $attachment->getContent()); + } + + public function testAttachmentWithoutStringContentDefaultsToNull(): void + { + $attachment = new Attachment( + name: 'image.png', + path: '/tmp/image.png', + type: 'image/png', + ); + + $this->assertNull($attachment->getContent()); + } + + public function testSMTPConstructorWithKeepAliveAndTimelimit(): void + { + $sender = new SMTP( + host: 'maildev', + port: 1025, + keepAlive: true, + timelimit: 60, + ); + + $this->assertInstanceOf(SMTP::class, $sender); + $this->assertEquals('SMTP', $sender->getName()); + } + + public function testSMTPConstructorDefaultsAreBackwardsCompatible(): void + { + // Existing call signature still works without new params + $sender = new SMTP( + host: 'maildev', + port: 1025, + ); + + $this->assertInstanceOf(SMTP::class, $sender); + } + + public function testSendEmailWithStringAttachment(): void + { + $sender = new SMTP( + host: 'maildev', + port: 1025, + ); + + $to = 'tester@localhost.test'; + $subject = 'String Attachment Test'; + $content = 'Test with string attachment'; + $fromName = 'Test Sender'; + $fromEmail = 'sender@localhost.test'; + + $message = new Email( + to: [$to], + subject: $subject, + content: $content, + fromName: $fromName, + fromEmail: $fromEmail, + attachments: [new Attachment( + name: 'note.txt', + path: '', + type: 'text/plain', + content: 'This is inline content', + )], + ); + + $response = $sender->send($message); + + $lastEmail = $this->getLastEmail(); + + $this->assertResponse($response); + $this->assertEquals($to, $lastEmail['to'][0]['address']); + $this->assertEquals($subject, $lastEmail['subject']); + } + + public function testSendEmailWithKeepAlive(): void + { + $sender = new SMTP( + host: 'maildev', + port: 1025, + keepAlive: true, + timelimit: 15, + ); + + $to = 'tester@localhost.test'; + $fromEmail = 'sender@localhost.test'; + + // Send first message + $message1 = new Email( + to: [$to], + subject: 'KeepAlive Test 1', + content: 'First message', + fromName: 'Test', + fromEmail: $fromEmail, + ); + + $response1 = $sender->send($message1); + $this->assertResponse($response1); + + // Send second message — should reuse the PHPMailer instance + $message2 = new Email( + to: [$to], + subject: 'KeepAlive Test 2', + content: 'Second message', + fromName: 'Test', + fromEmail: $fromEmail, + ); + + $response2 = $sender->send($message2); + $this->assertResponse($response2); + } }