fix: retain mails worker action

This commit is contained in:
Jake Barnby
2026-05-26 21:15:41 +12:00
parent 16bfbce2e9
commit 0880fa23a5
5 changed files with 337 additions and 2 deletions
+1 -2
View File
@@ -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());
@@ -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())
+212
View File
@@ -0,0 +1,212 @@
<?php
namespace Appwrite\Platform\Workers;
use Appwrite\Template\Template;
use Exception;
use Swoole\Runtime;
use Throwable;
use Utopia\Database\Document;
use Utopia\Logger\Log;
use Utopia\Messaging\Adapter\Email as EmailAdapter;
use Utopia\Messaging\Adapter\Email\SMTP;
use Utopia\Messaging\Messages\Email as EmailMessage;
use Utopia\Messaging\Messages\Email\Attachment;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
use Utopia\Registry\Registry;
use Utopia\System\System;
class Mails extends Action
{
protected int $previewMaxLen = 150;
protected string $whitespaceCodes = '&#xa0;&#x200C;&#x200B;&#x200D;&#x200E;&#x200F;&#xFEFF;';
/**
* @var array<string, string>
*/
protected array $richTextParams = [
'b' => '<strong>',
'/b' => '</strong>',
];
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);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace Tests\Unit\Platform\Workers;
use Appwrite\Platform\Workers\Mails;
use PHPUnit\Framework\TestCase;
use Utopia\Database\Document;
use Utopia\Logger\Log;
use Utopia\Messaging\Adapter\Email as EmailAdapter;
use Utopia\Messaging\Messages\Email as EmailMessage;
use Utopia\Queue\Message;
use Utopia\Registry\Registry;
class SpyMailAdapter extends EmailAdapter
{
public ?EmailMessage $captured = null;
public int $sendCount = 0;
public function getName(): string
{
return 'SpySMTP';
}
public function getMaxMessagesPerRequest(): int
{
return 1000;
}
protected function process(EmailMessage $message): array
{
$this->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());
}
}
@@ -0,0 +1,28 @@
<?php
namespace Tests\Unit\Platform\Workers;
use Appwrite\Platform\Services\Workers;
use Appwrite\Platform\Workers\Mails;
use Appwrite\Platform\Workers\Notifications;
use PHPUnit\Framework\TestCase;
class RegistrationTest extends TestCase
{
public function testMailsAndNotificationsWorkersAreRegisteredSeparately(): void
{
$service = new Workers();
$this->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);
}
}