diff --git a/.gitignore b/.gitignore index 24265e89..3640a16f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,97 @@ .idea vendor +Makefile +.envrc +.env +*.p8 + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### PHPUnit ### +# Covers PHPUnit +# Reference: https://phpunit.de/ + +# Generated files .phpunit.result.cache +.phpunit.cache + +# PHPUnit +/app/phpunit.xml +/phpunit.xml + +# Build data +/build/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,linux,windows,phpunit \ No newline at end of file diff --git a/composer.json b/composer.json index 0e9de463..7dfe178e 100644 --- a/composer.json +++ b/composer.json @@ -26,8 +26,8 @@ "ext-curl": "*" }, "require-dev": { - "phpunit/phpunit": "9.5.*", - "phpmailer/phpmailer": "6.6.*", + "phpunit/phpunit": "^9.6", + "phpmailer/phpmailer": "^6.8", "laravel/pint": "^1.2" }, "config": { diff --git a/composer.lock b/composer.lock index c2118454..9f6b978b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1ca9da311c804e40032e90c09ad04d76", + "content-hash": "5ecbd865cbd7f14e7819fb79643573be", "packages": [], "packages-dev": [ { @@ -371,16 +371,16 @@ }, { "name": "phpmailer/phpmailer", - "version": "v6.6.4", + "version": "v6.8.0", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "a94fdebaea6bd17f51be0c2373ab80d3d681269b" + "reference": "df16b615e371d81fb79e506277faea67a1be18f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/a94fdebaea6bd17f51be0c2373ab80d3d681269b", - "reference": "a94fdebaea6bd17f51be0c2373ab80d3d681269b", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/df16b615e371d81fb79e506277faea67a1be18f1", + "reference": "df16b615e371d81fb79e506277faea67a1be18f1", "shasum": "" }, "require": { @@ -390,22 +390,24 @@ "php": ">=5.5.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", - "doctrine/annotations": "^1.2", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2", + "doctrine/annotations": "^1.2.6 || ^1.13.3", "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", "phpcompatibility/php-compatibility": "^9.3.5", "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.6.2", - "yoast/phpunit-polyfills": "^1.0.0" + "squizlabs/php_codesniffer": "^3.7.1", + "yoast/phpunit-polyfills": "^1.0.4" }, "suggest": { "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", "league/oauth2-google": "Needed for Google XOAUTH2 authentication", "psr/log": "For optional PSR-3 debug logging", - "stevenmaguire/oauth2-microsoft": "Needed for Microsoft XOAUTH2 authentication", - "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)" + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" }, "type": "library", "autoload": { @@ -437,7 +439,7 @@ "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.6.4" + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.8.0" }, "funding": [ { @@ -445,7 +447,7 @@ "type": "github" } ], - "time": "2022-08-22T09:22:00+00:00" + "time": "2023-03-06T14:43:22+00:00" }, { "name": "phpunit/php-code-coverage", @@ -767,20 +769,20 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.25", + "version": "9.6.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d" + "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d", - "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a6d351645c3fe5a30f5e86be6577d946af65a328", + "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1", + "doctrine/instantiator": "^1.3.1 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", @@ -809,8 +811,8 @@ "sebastian/version": "^3.0.2" }, "suggest": { - "ext-soap": "*", - "ext-xdebug": "*" + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "bin": [ "phpunit" @@ -818,7 +820,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.5-dev" + "dev-master": "9.6-dev" } }, "autoload": { @@ -849,7 +851,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.25" + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.10" }, "funding": [ { @@ -865,7 +868,7 @@ "type": "tidelift" } ], - "time": "2022-09-25T03:44:45+00:00" + "time": "2023-07-10T04:04:23+00:00" }, { "name": "sebastian/cli-parser", diff --git a/docker-compose.yml b/docker-compose.yml index 96054433..238cd4c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,30 @@ version: '3.9' services: tests: + environment: + - MAILGUN_API_KEY + - MAILGUN_DOMAIN + - SENDGRID_API_KEY + - FCM_SERVER_KEY + - TWILIO_ACCOUNT_SID + - TWILIO_AUTH_TOKEN + - TELNYX_API_KEY + - TELNYX_PUBLIC_KEY + - APNS_AUTHKEY_8KVVCLA3HL + - APNS_AUTH_ID + - APNS_TEAM_ID + - APNS_BUNDLE_ID + - MSG_91_SENDER_ID + - MSG_91_AUTH_KEY + - TEST_EMAIL + - TEST_FROM_EMAIL build: context: . volumes: - ./src:/usr/local/src/src - ./tests:/usr/local/src/tests - ./phpunit.xml:/usr/local/src/phpunit.xml - + maildev: image: appwrite/mailcatcher:1.0.0 ports: diff --git a/src/Utopia/Messaging/Adapters/Email/Mailgun.php b/src/Utopia/Messaging/Adapters/Email/Mailgun.php index ba64c906..21c70118 100644 --- a/src/Utopia/Messaging/Adapters/Email/Mailgun.php +++ b/src/Utopia/Messaging/Adapters/Email/Mailgun.php @@ -14,14 +14,25 @@ class Mailgun extends EmailAdapter public function __construct( private string $apiKey, private string $domain, + private bool $isUS = true ) { } + /** + * Get adapter name. + * + * @return string + */ public function getName(): string { return 'Mailgun'; } + /** + * Get adapter description. + * + * @return int + */ public function getMaxMessagesPerRequest(): int { return 1000; @@ -34,19 +45,26 @@ public function getMaxMessagesPerRequest(): int */ protected function process(Email $message): string { - return $this->request( + $usDomain = 'api.mailgun.net'; + $euDomain = 'api.eu.mailgun.net'; + + $domain = $this->isUS ? $usDomain : $euDomain; + + $response = $this->request( method: 'POST', - url: "https://api.mailgun.net/v3/{$this->domain}/messages", + url: "https://$domain/v3/{$this->domain}/messages", headers: [ 'Authorization: Basic '.base64_encode('api:'.$this->apiKey), ], body: \http_build_query([ - 'from' => $message->getFrom(), 'to' => \implode(',', $message->getTo()), + 'from' => $message->getFrom(), 'subject' => $message->getSubject(), 'text' => $message->isHtml() ? null : $message->getContent(), 'html' => $message->isHtml() ? $message->getContent() : null, ]), ); + + return $response; } } diff --git a/src/Utopia/Messaging/Adapters/Email/Sendgrid.php b/src/Utopia/Messaging/Adapters/Email/Sendgrid.php index 17745c48..28dc661b 100644 --- a/src/Utopia/Messaging/Adapters/Email/Sendgrid.php +++ b/src/Utopia/Messaging/Adapters/Email/Sendgrid.php @@ -2,26 +2,48 @@ namespace Utopia\Messaging\Adapters\Email; +use Exception; use Utopia\Messaging\Adapters\Email as EmailAdapter; use Utopia\Messaging\Messages\Email; class Sendgrid extends EmailAdapter { - public function __construct( - private string $apiKey, - ) { + /** + * @param string $apiKey Your Sendgrid API key to authenticate with the API. + * @return void + */ + public function __construct(private string $apiKey) + { } + /** + * Get adapter name. + * + * @return string + */ public function getName(): string { return 'Sendgrid'; } + /** + * Get max messages per request. + * + * @return int + */ public function getMaxMessagesPerRequest(): int { return 1000; } + /** + * {@inheritdoc} + * + * @param Email $message + * @return string + * + * @throws Exception + */ protected function process(Email $message): string { return $this->request( diff --git a/src/Utopia/Messaging/Adapters/Push/APNS.php b/src/Utopia/Messaging/Adapters/Push/APNS.php new file mode 100644 index 00000000..4f8dda83 --- /dev/null +++ b/src/Utopia/Messaging/Adapters/Push/APNS.php @@ -0,0 +1,175 @@ + [ + 'alert' => [ + 'title' => $message->getTitle(), + 'body' => $message->getBody(), + ], + 'badge' => $message->getBadge(), + 'sound' => $message->getSound(), + 'data' => $message->getData(), + ], + ]; + + // Assuming the 'to' array contains device tokens for the push notification recipients. + + // $url = $this->endpoint.'/3/device'; + // $response = $this->request('POST', $url, $headers, \json_encode($payloads)); + // // This example simply returns the last response, adjust as needed + // return $response; + + return \json_encode($this->notify($message->getTo(), $payload)); + } + + private function notify(array $to, array $payload): array + { + $headers = [ + 'authorization: bearer '.$this->generateJwt(), + 'apns-topic: '.$this->bundleId, + ]; + + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0); + + curl_setopt_array($ch, [ + CURLOPT_PORT => 443, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => \json_encode($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HEADER => true, + ]); + + $response = ''; + + foreach ($to as $token) { + curl_setopt($ch, CURLOPT_URL, $this->endpoint.'/3/device/'.$token); + + $response = curl_exec($ch); + } + + curl_close($ch); + + return $this->formatResponse($response); + } + + private function formatResponse(string $response): array + { + $filtered = array_filter( + explode("\r\n", $response), + function ($value) { + return ! empty($value); + } + ); + + $result = []; + + foreach ($filtered as $value) { + if (str_contains($value, 'HTTP')) { + $result['status'] = trim(str_replace('HTTP/2 ', '', $value)); + + continue; + } + + $parts = explode(':', trim($value)); + + $result[$parts[0]] = $parts[1]; + } + + return $result; + } + + /** + * Generate JWT. + * + * @return string + * + * @throws Exception + */ + private function generateJwt(): string + { + $header = json_encode(['alg' => 'ES256', 'kid' => $this->authKeyId]); + $claims = json_encode([ + 'iss' => $this->teamId, + 'iat' => time(), + ]); + + // Replaces URL sensitive characters that could be the result of base64 encoding. + // Replace to _ to avoid any special handling. + $base64UrlHeader = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($header)); + $base64UrlClaims = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($claims)); + + if (! $this->authKey) { + throw new \Exception('Invalid private key'); + } + + $signature = ''; + $success = openssl_sign("$base64UrlHeader.$base64UrlClaims", $signature, $this->authKey, OPENSSL_ALGO_SHA256); + + if (! $success) { + throw new \Exception('Failed to sign JWT'); + } + + $base64UrlSignature = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($signature)); + + return "$base64UrlHeader.$base64UrlClaims.$base64UrlSignature"; + } +} diff --git a/src/Utopia/Messaging/Adapters/Push/FCM.php b/src/Utopia/Messaging/Adapters/Push/FCM.php index 3734b16d..8a4d8ac2 100644 --- a/src/Utopia/Messaging/Adapters/Push/FCM.php +++ b/src/Utopia/Messaging/Adapters/Push/FCM.php @@ -15,11 +15,21 @@ public function __construct( ) { } + /** + * Get adapter name. + * + * @return string + */ public function getName(): string { return 'FCM'; } + /** + * Get max messages per request. + * + * @return int + */ public function getMaxMessagesPerRequest(): int { return 1000; diff --git a/src/Utopia/Messaging/Adapters/SMS/Telesign.php b/src/Utopia/Messaging/Adapters/SMS/Telesign.php index d19d5876..6e77903e 100644 --- a/src/Utopia/Messaging/Adapters/SMS/Telesign.php +++ b/src/Utopia/Messaging/Adapters/SMS/Telesign.php @@ -37,10 +37,10 @@ public function getMaxMessagesPerRequest(): int */ protected function process(SMS $message): string { - $to = \array_map( - fn ($to) => \ltrim($to, '+'), + $to = $this->formatNumbers(\array_map( + fn ($to) => $to, $message->getTo() - ); + )); return $this->request( method: 'POST', @@ -50,8 +50,18 @@ protected function process(SMS $message): string ], body: \http_build_query([ 'template' => $message->getContent(), - 'recipients' => \implode(',', $to), + 'recipients' => $to, ]), ); } + + private function formatNumbers(array $numbers): string + { + $formatted = \array_map( + fn ($number) => $number.':'.\uniqid(), + $numbers + ); + + return implode(',', $formatted); + } } diff --git a/src/Utopia/Messaging/Adapters/SMS/TwilioNotify.php b/src/Utopia/Messaging/Adapters/SMS/TwilioNotify.php deleted file mode 100644 index a977ed79..00000000 --- a/src/Utopia/Messaging/Adapters/SMS/TwilioNotify.php +++ /dev/null @@ -1,53 +0,0 @@ -request( - method: 'POST', - url: "https://notify.twilio.com/v1/Services/{$this->serviceSid}/Notifications", - headers: [ - 'Authorization: Basic '.base64_encode("{$this->accountSid}:{$this->authToken}"), - ], - body: \http_build_query([ - 'Body' => $message->getContent(), - 'ToBinding' => \json_encode(\array_map( - fn ($to) => ['binding_type' => 'sms', 'address' => $to], - $message->getTo() - )), - ]), - ); - } -} diff --git a/src/Utopia/Messaging/Adapters/SMS/Vonage.php b/src/Utopia/Messaging/Adapters/SMS/Vonage.php index 2643a6b0..7ed16714 100644 --- a/src/Utopia/Messaging/Adapters/SMS/Vonage.php +++ b/src/Utopia/Messaging/Adapters/SMS/Vonage.php @@ -48,7 +48,7 @@ protected function process(SMS $message): string body: \http_build_query([ 'text' => $message->getContent(), 'from' => $message->getFrom(), - 'to' => \implode(',', $to), + 'to' => $to[0], //\implode(',', $to), 'api_key' => $this->apiKey, 'api_secret' => $this->apiSecret, ]), diff --git a/src/Utopia/Messaging/Message.php b/src/Utopia/Messaging/Message.php index 3b5e21ae..5c335d17 100644 --- a/src/Utopia/Messaging/Message.php +++ b/src/Utopia/Messaging/Message.php @@ -7,4 +7,7 @@ */ interface Message { + public function getTo(): array; + + public function getFrom(): ?string; } diff --git a/src/Utopia/Messaging/Messages/Push.php b/src/Utopia/Messaging/Messages/Push.php index d4a4333b..93b8f5d7 100644 --- a/src/Utopia/Messaging/Messages/Push.php +++ b/src/Utopia/Messaging/Messages/Push.php @@ -40,6 +40,11 @@ public function getTo(): array return $this->to; } + public function getFrom(): ?string + { + return null; + } + /** * @return string */ diff --git a/tests/e2e/Email/MailgunTest.php b/tests/e2e/Email/MailgunTest.php new file mode 100644 index 00000000..81ae6497 --- /dev/null +++ b/tests/e2e/Email/MailgunTest.php @@ -0,0 +1,42 @@ +send($message)); + + $this->assertArrayHasKey('id', $result); + $this->assertArrayHasKey('message', $result); + $this->assertTrue(str_contains(strtolower($result['message']), 'queued')); + } +} diff --git a/tests/e2e/Email/SendgridTest.php b/tests/e2e/Email/SendgridTest.php new file mode 100644 index 00000000..05bcc3ab --- /dev/null +++ b/tests/e2e/Email/SendgridTest.php @@ -0,0 +1,34 @@ +send($message); + + $this->assertEquals(true, true); + } +} diff --git a/tests/e2e/Push/APNSTest.php b/tests/e2e/Push/APNSTest.php new file mode 100644 index 00000000..1f7b860f --- /dev/null +++ b/tests/e2e/Push/APNSTest.php @@ -0,0 +1,39 @@ +send($message)); + + $this->assertEquals('200', $response['status']); + $this->assertArrayHasKey('apns-id', $response); + $this->assertArrayHasKey('apns-unique-id', $response); + } +} diff --git a/tests/e2e/Push/FCMTest.php b/tests/e2e/Push/FCMTest.php new file mode 100644 index 00000000..71128e45 --- /dev/null +++ b/tests/e2e/Push/FCMTest.php @@ -0,0 +1,35 @@ +send($message)); + + $this->assertNotEmpty($response); + $this->assertEquals(1, $response->success); + $this->assertEquals(0, $response->failure); + } +} diff --git a/tests/e2e/SMS/Msg91Test.php b/tests/e2e/SMS/Msg91Test.php new file mode 100644 index 00000000..07df9b1c --- /dev/null +++ b/tests/e2e/SMS/Msg91Test.php @@ -0,0 +1,27 @@ +send($message), true); + + $this->assertEquals('success', $result['type']); + } +} diff --git a/tests/e2e/SMS/TelesignTest.php b/tests/e2e/SMS/TelesignTest.php new file mode 100644 index 00000000..30d2054a --- /dev/null +++ b/tests/e2e/SMS/TelesignTest.php @@ -0,0 +1,14 @@ +markTestSkipped('Telesign requires support/sales call in order to enable bulk SMS'); + } +} diff --git a/tests/e2e/SMS/TelnyxTest.php b/tests/e2e/SMS/TelnyxTest.php new file mode 100644 index 00000000..dc711498 --- /dev/null +++ b/tests/e2e/SMS/TelnyxTest.php @@ -0,0 +1,29 @@ +send($message), true); + + // $this->assertEquals('success', $result["type"]); + + $this->markTestSkipped('Telnyx had no testing numbers available at this time.'); + } +} diff --git a/tests/e2e/SMS/TwilioTest.php b/tests/e2e/SMS/TwilioTest.php new file mode 100644 index 00000000..c3b86760 --- /dev/null +++ b/tests/e2e/SMS/TwilioTest.php @@ -0,0 +1,32 @@ +send($message)); + + $this->assertNotEmpty($result); + $this->assertEquals($to[0], $result->to); + $this->assertEquals($from, $result->from); + $this->assertNull($result->error_message); + } +} diff --git a/tests/e2e/SMS/VonageTest.php b/tests/e2e/SMS/VonageTest.php new file mode 100644 index 00000000..7f812759 --- /dev/null +++ b/tests/e2e/SMS/VonageTest.php @@ -0,0 +1,32 @@ +send($message), true); + + $this->assertArrayHasKey('messages', $result); + $this->assertEquals(1, count($result['messages'])); + $this->assertEquals('1', $result['message-count']); + } +}