From 0880fa23a517007e0a4b3ac93ef16aef4c39190e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 26 May 2026 21:15:41 +1200 Subject: [PATCH] fix: retain mails worker action --- app/worker.php | 3 +- src/Appwrite/Platform/Services/Workers.php | 2 + src/Appwrite/Platform/Workers/Mails.php | 212 ++++++++++++++++++ tests/unit/Platform/Workers/MailsTest.php | 94 ++++++++ .../Platform/Workers/RegistrationTest.php | 28 +++ 5 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Workers/Mails.php create mode 100644 tests/unit/Platform/Workers/MailsTest.php create mode 100644 tests/unit/Platform/Workers/RegistrationTest.php diff --git a/app/worker.php b/app/worker.php index b9e5cbd2a7..169b8e9770 100644 --- a/app/worker.php +++ b/app/worker.php @@ -73,7 +73,6 @@ if (! isset($args[1])) { \array_shift($args); $workerName = $args[0]; -$actionName = $workerName === 'mails' ? 'notifications' : $workerName; if (\str_starts_with($workerName, 'databases')) { $queueName = System::getEnv('_APP_QUEUE_NAME', 'database_db_main'); @@ -110,7 +109,7 @@ try { $platform->setWorker($worker); $platform->init(Service::TYPE_WORKER, [ - 'workerName' => strtolower($actionName), + 'workerName' => strtolower($workerName), ]); } catch (\Throwable $e) { Console::error($e->getMessage() . ', File: ' . $e->getFile() . ', Line: ' . $e->getLine()); diff --git a/src/Appwrite/Platform/Services/Workers.php b/src/Appwrite/Platform/Services/Workers.php index 9ab26a41b7..5af3d078de 100644 --- a/src/Appwrite/Platform/Services/Workers.php +++ b/src/Appwrite/Platform/Services/Workers.php @@ -7,6 +7,7 @@ use Appwrite\Platform\Workers\Certificates; use Appwrite\Platform\Workers\Deletes; use Appwrite\Platform\Workers\Executions; use Appwrite\Platform\Workers\Functions; +use Appwrite\Platform\Workers\Mails; use Appwrite\Platform\Workers\Messaging; use Appwrite\Platform\Workers\Migrations; use Appwrite\Platform\Workers\Notifications; @@ -26,6 +27,7 @@ class Workers extends Service ->addAction(Deletes::getName(), new Deletes()) ->addAction(Executions::getName(), new Executions()) ->addAction(Functions::getName(), new Functions()) + ->addAction(Mails::getName(), new Mails()) ->addAction(Messaging::getName(), new Messaging()) ->addAction(Notifications::getName(), new Notifications()) ->addAction(Webhooks::getName(), new Webhooks()) diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php new file mode 100644 index 0000000000..74a007720b --- /dev/null +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -0,0 +1,212 @@ + + */ + protected array $richTextParams = [ + 'b' => '', + '/b' => '', + ]; + + public static function getName(): string + { + return 'mails'; + } + + public function __construct() + { + $this + ->desc('Mails worker') + ->inject('message') + ->inject('project') + ->inject('register') + ->inject('log') + ->callback($this->action(...)); + } + + public function action(Message $message, Document $project, Registry $register, Log $log): void + { + if (\class_exists(Runtime::class)) { + Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP); + } + + $payload = $message->getPayload(); + if (empty($payload)) { + throw new Exception('Missing payload'); + } + + $smtp = $payload['smtp'] ?? []; + if (!\is_array($smtp)) { + $smtp = []; + } + + if (empty($smtp) && empty(System::getEnv('_APP_SMTP_HOST'))) { + throw new Exception('Skipped mail processing. No SMTP configuration has been set.'); + } + + $type = empty($smtp) ? 'cloud' : 'smtp'; + $log->addTag('type', $type); + + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS', 'disabled') === 'disabled' ? 'http' : 'https'; + $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', 'localhost')); + + $recipient = (string) ($payload['recipient'] ?? ''); + $subject = (string) ($payload['subject'] ?? ''); + $variables = $payload['variables'] ?? []; + if (!\is_array($variables)) { + $variables = []; + } + $variables['host'] = $protocol . '://' . $hostname; + $name = (string) ($payload['name'] ?? ''); + $body = (string) ($payload['body'] ?? ''); + $preview = (string) ($payload['preview'] ?? ''); + + $variables['subject'] = $subject; + $variables['heading'] = $variables['heading'] ?? $subject; + $variables['year'] = \date('Y'); + + $attachment = $payload['attachment'] ?? []; + if (!\is_array($attachment)) { + $attachment = []; + } + + $bodyTemplate = (string) ($payload['bodyTemplate'] ?? ''); + if (empty($bodyTemplate)) { + $bodyTemplate = __DIR__ . '/../../../../app/config/locale/templates/email-base.tpl'; + } + + $bodyTemplate = Template::fromFile($bodyTemplate); + $bodyTemplate->setParam('{{body}}', $body, escapeHtml: false); + foreach ($variables as $key => $value) { + $bodyTemplate->setParam('{{' . $key . '}}', $value, escapeHtml: $key !== 'redirect'); + } + foreach ($this->richTextParams as $key => $value) { + $bodyTemplate->setParam('{{' . $key . '}}', $value, escapeHtml: false); + } + + $previewWhitespace = ''; + if (!empty($preview)) { + $previewTemplate = Template::fromString($preview); + foreach ($variables as $key => $value) { + $previewTemplate->setParam('{{' . $key . '}}', $value); + } + $preview = \strip_tags($previewTemplate->render()); + + $previewLen = \strlen($preview); + if ($previewLen < $this->previewMaxLen) { + $previewWhitespace = \str_repeat($this->whitespaceCodes, $this->previewMaxLen - $previewLen); + } + } + + $bodyTemplate->setParam('{{preview}}', $preview); + $bodyTemplate->setParam('{{previewWhitespace}}', $previewWhitespace, false); + + $body = $bodyTemplate->render(); + + $subjectTemplate = Template::fromString($subject); + foreach ($variables as $key => $value) { + $subjectTemplate->setParam('{{' . $key . '}}', $value); + } + $subject = \strip_tags($subjectTemplate->render()); + + /** @var EmailAdapter $adapter */ + $adapter = empty($smtp) + ? $register->get('smtp') + : new SMTP( + host: $smtp['host'], + port: (int) $smtp['port'], + username: $smtp['username'] ?? '', + password: $smtp['password'] ?? '', + smtpSecure: $smtp['secure'] ?? '', + smtpAutoTLS: false, + xMailer: 'Appwrite Mailer', + timeout: 10, + keepAlive: true, + timelimit: 30, + ); + + $defaultFromEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); + $defaultFromName = \urldecode(System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); + + $fromEmail = !empty($smtp) ? ($smtp['senderEmail'] ?? $defaultFromEmail) : $defaultFromEmail; + $fromName = !empty($smtp) ? ($smtp['senderName'] ?? $defaultFromName) : $defaultFromName; + $replyTo = $defaultFromEmail; + $replyToName = $defaultFromName; + + $customMailOptions = $payload['customMailOptions'] ?? []; + if (!\is_array($customMailOptions)) { + $customMailOptions = []; + } + + if (!empty($customMailOptions['senderEmail'])) { + $fromEmail = (string) $customMailOptions['senderEmail']; + } + if (!empty($customMailOptions['senderName'])) { + $fromName = (string) $customMailOptions['senderName']; + } + + if (!empty($customMailOptions['replyToEmail']) || !empty($customMailOptions['replyToName'])) { + $replyTo = (string) ($customMailOptions['replyToEmail'] ?? $replyTo); + $replyToName = (string) ($customMailOptions['replyToName'] ?? $replyToName); + } elseif (!empty($smtp)) { + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + $replyTo = !empty($smtpReplyToEmail) ? $smtpReplyToEmail : ($smtp['senderEmail'] ?? $replyTo); + $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); + } + + $attachments = null; + if (!empty($attachment['content'] ?? '')) { + $attachments = [ + new Attachment( + name: $attachment['filename'] ?? 'unknown.file', + path: '', + type: $attachment['type'] ?? 'plain/text', + content: \base64_decode($attachment['content']), + ), + ]; + } + + $emailMessage = new EmailMessage( + to: [['email' => $recipient, 'name' => $name]], + subject: $subject, + content: $body, + fromName: $fromName, + fromEmail: $fromEmail, + replyToName: $replyToName, + replyToEmail: $replyTo, + attachments: $attachments, + html: true, + ); + + try { + $adapter->send($emailMessage); + } catch (Throwable $error) { + if ($type === 'smtp') { + throw new Exception('Error sending mail: ' . $error->getMessage(), 401); + } + throw new Exception('Error sending mail: ' . $error->getMessage(), 500); + } + } +} diff --git a/tests/unit/Platform/Workers/MailsTest.php b/tests/unit/Platform/Workers/MailsTest.php new file mode 100644 index 0000000000..08a695429b --- /dev/null +++ b/tests/unit/Platform/Workers/MailsTest.php @@ -0,0 +1,94 @@ +sendCount++; + $this->captured = $message; + + return [ + 'deliveredTo' => 1, + 'type' => $this->getType(), + 'results' => [['recipient' => $message->getTo()[0]['email'] ?? '', 'status' => 'sent']], + ]; + } +} + +class MailsTest extends TestCase +{ + public function testLegacyMailPayloadIsSentByMailsWorker(): void + { + $adapter = new SpyMailAdapter(); + $registry = new Registry(); + $registry->set('smtp', static fn () => $adapter); + + $previousSmtpHost = \getenv('_APP_SMTP_HOST'); + \putenv('_APP_SMTP_HOST=spy.smtp.test'); + + try { + $worker = new Mails(); + $worker->action( + new Message([ + 'pid' => 'pid', + 'queue' => 'v1-mails', + 'timestamp' => \time(), + 'payload' => [ + 'recipient' => 'legacy@example.test', + 'name' => 'Legacy User', + 'subject' => 'Hello {{name}}', + 'body' => 'Body {{name}}', + 'variables' => ['name' => 'Legacy'], + 'customMailOptions' => [ + 'senderEmail' => 'sender@example.test', + 'senderName' => 'Custom Sender', + 'replyToEmail' => 'reply@example.test', + 'replyToName' => 'Custom Reply', + ], + ], + ]), + new Document(['$id' => 'project-x']), + $registry, + new Log(), + ); + } finally { + \putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost); + } + + $this->assertSame(1, $adapter->sendCount); + $this->assertNotNull($adapter->captured); + + $message = $adapter->captured; + $this->assertSame('legacy@example.test', $message->getTo()[0]['email'] ?? ''); + $this->assertSame('Legacy User', $message->getTo()[0]['name'] ?? ''); + $this->assertSame('Hello Legacy', $message->getSubject()); + $this->assertSame('sender@example.test', $message->getFromEmail()); + $this->assertSame('Custom Sender', $message->getFromName()); + $this->assertSame('reply@example.test', $message->getReplyToEmail()); + $this->assertSame('Custom Reply', $message->getReplyToName()); + } +} diff --git a/tests/unit/Platform/Workers/RegistrationTest.php b/tests/unit/Platform/Workers/RegistrationTest.php new file mode 100644 index 0000000000..e1d92a3028 --- /dev/null +++ b/tests/unit/Platform/Workers/RegistrationTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(Mails::class, $service->getAction('mails')); + $this->assertInstanceOf(Notifications::class, $service->getAction('notifications')); + } + + public function testEntrypointDoesNotAliasMailsToNotifications(): void + { + $contents = \file_get_contents(__DIR__ . '/../../../../app/worker.php'); + + $this->assertIsString($contents); + $this->assertStringNotContainsString("mails' ? 'notifications'", $contents); + $this->assertStringContainsString('\'workerName\' => strtolower($workerName)', $contents); + } +}