├── LICENSE ├── composer.json ├── config └── resend.php ├── routes └── web.php └── src ├── Events ├── ContactCreated.php ├── ContactDeleted.php ├── ContactUpdated.php ├── DomainCreated.php ├── DomainDeleted.php ├── DomainUpdated.php ├── EmailBounced.php ├── EmailClicked.php ├── EmailComplained.php ├── EmailDelivered.php ├── EmailDeliveryDelayed.php ├── EmailOpened.php └── EmailSent.php ├── Exceptions └── ApiKeyIsMissing.php ├── Facades └── Resend.php ├── Http ├── Controllers │ └── WebhookController.php └── Middleware │ └── VerifyWebhookSignature.php ├── ResendServiceProvider.php └── Transport └── ResendTransportFactory.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jayan Ratna 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "resend/resend-laravel", 3 | "description": "Resend for Laravel", 4 | "keywords": ["php", "resend", "laravel", "sdk", "api", "client"], 5 | "homepage": "https://resend.com/", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Resend and contributors", 10 | "homepage": "https://github.com/resend/resend-laravel/contributors" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.1", 15 | "illuminate/http": "^10.0|^11.0|^12.0", 16 | "illuminate/support": "^10.0|^11.0|^12.0", 17 | "resend/resend-php": "^0.18.0", 18 | "symfony/mailer": "^6.2|^7.0" 19 | }, 20 | "require-dev": { 21 | "friendsofphp/php-cs-fixer": "^3.14", 22 | "mockery/mockery": "^1.5", 23 | "orchestra/testbench": "^8.17|^9.0|^10.0", 24 | "pestphp/pest": "^2.0|^3.7" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Resend\\Laravel\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Resend\\Laravel\\Tests\\": "tests/" 34 | } 35 | }, 36 | "extra": { 37 | "branch-alias": { 38 | "dev-main": "1.x-dev" 39 | }, 40 | "laravel": { 41 | "providers": [ 42 | "Resend\\Laravel\\ResendServiceProvider" 43 | ] 44 | } 45 | }, 46 | "config": { 47 | "sort-packages": true, 48 | "preferred-install": "dist", 49 | "allow-plugins": { 50 | "pestphp/pest-plugin": true 51 | } 52 | }, 53 | "minimum-stability": "dev", 54 | "prefer-stable": true 55 | } 56 | -------------------------------------------------------------------------------- /config/resend.php: -------------------------------------------------------------------------------- 1 | env('RESEND_API_KEY'), 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Resend Domain 20 | |-------------------------------------------------------------------------- 21 | | 22 | | This is the subdomain where the package routes will be accessible from. 23 | | If the setting is null, Resend will reside under the same domain as your 24 | | application. Otherwise, this value will be used as the subdomain. 25 | | 26 | */ 27 | 28 | 'domain' => env('RESEND_DOMAIN', null), 29 | 30 | /* 31 | |-------------------------------------------------------------------------- 32 | | Resend Path 33 | |-------------------------------------------------------------------------- 34 | | 35 | | This is the base URI path where the package routes, such as the webhook 36 | | handler, will be available from. You are free to tweak the path to your 37 | | preference and application design. 38 | | 39 | */ 40 | 41 | 'path' => env('RESEND_PATH', 'resend'), 42 | 43 | /* 44 | |-------------------------------------------------------------------------- 45 | | Resend Webhooks 46 | |-------------------------------------------------------------------------- 47 | | 48 | | Your Resend webhook secret is used to prevent unauthorized requestes to 49 | | your Resend webhook handling controllers. The tolerance setting will 50 | | check the drift between the current time and the signed request's. 51 | | 52 | */ 53 | 54 | 'webhook' => [ 55 | 'secret' => env('RESEND_WEBHOOK_SECRET'), 56 | 'tolerance' => env('RESEND_WEBHOOK_TOLERANCE', 300), 57 | ], 58 | 59 | ]; 60 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | name('webhook'); 6 | -------------------------------------------------------------------------------- /src/Events/ContactCreated.php: -------------------------------------------------------------------------------- 1 | apiKeys; 15 | } 16 | 17 | public static function domains(): Domain 18 | { 19 | return static::getFacadeRoot()->domains; 20 | } 21 | 22 | public static function emails(): Email 23 | { 24 | return static::getFacadeRoot()->emails; 25 | } 26 | 27 | /** 28 | * Get the registered name of the component. 29 | */ 30 | protected static function getFacadeAccessor(): string 31 | { 32 | return 'resend'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Http/Controllers/WebhookController.php: -------------------------------------------------------------------------------- 1 | middleware(VerifyWebhookSignature::class); 33 | } 34 | } 35 | 36 | /** 37 | * Handle a Resend webhook call. 38 | */ 39 | public function handleWebhook(Request $request): Response 40 | { 41 | $payload = json_decode($request->getContent(), true); 42 | 43 | if (is_null($payload) || ! isset($payload['type'])) { 44 | return new Response('Invalid payload', 400); 45 | } 46 | 47 | $method = 'handle' . Str::studly(str_replace('.', '_', $payload['type'])); 48 | 49 | if (method_exists($this, $method)) { 50 | $response = $this->{$method}($payload); 51 | 52 | return $response; 53 | } 54 | 55 | return $this->missingMethod($payload); 56 | } 57 | 58 | /** 59 | * Handle contact created event. 60 | */ 61 | protected function handleContactCreated(array $payload): Response 62 | { 63 | ContactCreated::dispatch($payload); 64 | 65 | return $this->successMethod(); 66 | } 67 | 68 | /** 69 | * Handle contact deleted event. 70 | */ 71 | protected function handleContactDeleted(array $payload): Response 72 | { 73 | ContactDeleted::dispatch($payload); 74 | 75 | return $this->successMethod(); 76 | } 77 | 78 | /** 79 | * Handle contact updated event. 80 | */ 81 | protected function handleContactUpdated(array $payload): Response 82 | { 83 | ContactUpdated::dispatch($payload); 84 | 85 | return $this->successMethod(); 86 | } 87 | 88 | /** 89 | * Handle domain created event. 90 | */ 91 | protected function handleDomainCreated(array $payload): Response 92 | { 93 | DomainCreated::dispatch($payload); 94 | 95 | return $this->successMethod(); 96 | } 97 | 98 | /** 99 | * Handle domain deleted event. 100 | */ 101 | protected function handleDomainDeleted(array $payload): Response 102 | { 103 | DomainDeleted::dispatch($payload); 104 | 105 | return $this->successMethod(); 106 | } 107 | 108 | /** 109 | * Handle domain updated event. 110 | */ 111 | protected function handleDomainUpdated(array $payload): Response 112 | { 113 | DomainUpdated::dispatch($payload); 114 | 115 | return $this->successMethod(); 116 | } 117 | 118 | /** 119 | * Handle email bounced event. 120 | */ 121 | protected function handleEmailBounced(array $payload): Response 122 | { 123 | EmailBounced::dispatch($payload); 124 | 125 | return $this->successMethod(); 126 | } 127 | 128 | /** 129 | * Handle email clicked event. 130 | */ 131 | protected function handleEmailClicked(array $payload): Response 132 | { 133 | EmailClicked::dispatch($payload); 134 | 135 | return $this->successMethod(); 136 | } 137 | 138 | /** 139 | * Handle email complained event. 140 | */ 141 | protected function handleEmailComplained(array $payload): Response 142 | { 143 | EmailComplained::dispatch($payload); 144 | 145 | return $this->successMethod(); 146 | } 147 | 148 | /** 149 | * Handle email delivered event. 150 | */ 151 | protected function handleEmailDelivered(array $payload): Response 152 | { 153 | EmailDelivered::dispatch($payload); 154 | 155 | return $this->successMethod(); 156 | } 157 | 158 | /** 159 | * Handle email delivery delayed event. 160 | */ 161 | protected function handleEmailDeliveryDelayed(array $payload): Response 162 | { 163 | EmailDeliveryDelayed::dispatch($payload); 164 | 165 | return $this->successMethod(); 166 | } 167 | 168 | /** 169 | * Handle email opened event. 170 | */ 171 | protected function handleEmailOpened(array $payload): Response 172 | { 173 | EmailOpened::dispatch($payload); 174 | 175 | return $this->successMethod(); 176 | } 177 | 178 | /** 179 | * Handle email sent event. 180 | */ 181 | protected function handleEmailSent(array $payload): Response 182 | { 183 | EmailSent::dispatch($payload); 184 | 185 | return $this->successMethod(); 186 | } 187 | 188 | /** 189 | * Handle successful calls on the controller. 190 | */ 191 | protected function successMethod($parameters = []): Response 192 | { 193 | return new Response('Webhook handled', 200); 194 | } 195 | 196 | /** 197 | * Handle calls to missing methods on the controller. 198 | */ 199 | protected function missingMethod($parameters = []): Response 200 | { 201 | return new Response; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/Http/Middleware/VerifyWebhookSignature.php: -------------------------------------------------------------------------------- 1 | getTransformedHeaders($request); 22 | 23 | WebhookSignature::verify( 24 | $request->getContent(), 25 | $headers, 26 | config('resend.webhook.secret'), 27 | config('resend.webhook.tolerance') 28 | ); 29 | } catch (Exception $exception) { 30 | throw new AccessDeniedHttpException($exception->getMessage(), $exception); 31 | } 32 | 33 | return $next($request); 34 | } 35 | 36 | /** 37 | * Transform headers to a simple associative array. 38 | * This method extracts the first value from each header and returns an array where each key is the header name and the associated value is that first header value 39 | */ 40 | protected function getTransformedHeaders(Request $request): array 41 | { 42 | $headers = []; 43 | foreach ($request->headers->all() as $key => $value) { 44 | $headers[$key] = $value[0]; 45 | } 46 | 47 | return $headers; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ResendServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerRoutes(); 22 | $this->registerPublishing(); 23 | 24 | Mail::extend('resend', function (array $config = []) { 25 | return new ResendTransportFactory($this->app['resend'], $config['options'] ?? []); 26 | }); 27 | } 28 | 29 | /** 30 | * Register any application services. 31 | */ 32 | public function register(): void 33 | { 34 | $this->configure(); 35 | $this->bindResendClient(); 36 | } 37 | 38 | /** 39 | * Setup the configuration for Resend. 40 | */ 41 | protected function configure(): void 42 | { 43 | $this->mergeConfigFrom( 44 | __DIR__ . '/../config/resend.php', 'resend' 45 | ); 46 | } 47 | 48 | /** 49 | * Bind the Resend Client. 50 | */ 51 | protected function bindResendClient(): void 52 | { 53 | $this->app->singleton(ClientContract::class, static function (): Client { 54 | $apiKey = config('resend.api_key') ?? config('services.resend.key'); 55 | 56 | if (! is_string($apiKey)) { 57 | throw ApiKeyIsMissing::create(); 58 | } 59 | 60 | return Resend::client($apiKey); 61 | }); 62 | 63 | $this->app->alias(ClientContract::class, 'resend'); 64 | $this->app->alias(ClientContract::class, Client::class); 65 | } 66 | 67 | /** 68 | * Register the package routes. 69 | */ 70 | protected function registerRoutes(): void 71 | { 72 | Route::group([ 73 | 'domain' => config('resend.domain', null), 74 | 'namespace' => 'Resend\Laravel\Http\Controllers', 75 | 'prefix' => config('resend.path'), 76 | 'as' => 'resend.', 77 | ], function () { 78 | $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); 79 | }); 80 | } 81 | 82 | /** 83 | * Register the package's publishable assets. 84 | */ 85 | protected function registerPublishing(): void 86 | { 87 | if ($this->app->runningInConsole()) { 88 | $this->publishes([ 89 | __DIR__ . '/../config/resend.php' => $this->app->configPath('resend.php'), 90 | ]); 91 | } 92 | } 93 | 94 | /** 95 | * Get the services provided by the provider. 96 | */ 97 | public function provides(): array 98 | { 99 | return [ 100 | Client::class, 101 | ]; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Transport/ResendTransportFactory.php: -------------------------------------------------------------------------------- 1 | getOriginalMessage()); 33 | $envelope = $message->getEnvelope(); 34 | 35 | $headers = []; 36 | $headersToBypass = ['from', 'to', 'cc', 'bcc', 'subject', 'content-type', 'sender', 'reply-to']; 37 | foreach ($email->getHeaders()->all() as $name => $header) { 38 | if (in_array($name, $headersToBypass, true)) { 39 | continue; 40 | } 41 | 42 | $headers[$header->getName()] = $header->getBodyAsString(); 43 | } 44 | 45 | $attachments = []; 46 | if ($email->getAttachments()) { 47 | foreach ($email->getAttachments() as $attachment) { 48 | $attachmentHeaders = $attachment->getPreparedHeaders(); 49 | $filename = $attachmentHeaders->getHeaderParameter('Content-Disposition', 'filename'); 50 | 51 | $item = [ 52 | 'content' => str_replace("\r\n", '', $attachment->bodyToString()), 53 | 'filename' => $filename, 54 | 'content_type' => $attachmentHeaders->get('Content-Type')->getBody(), 55 | ]; 56 | 57 | $attachments[] = $item; 58 | } 59 | } 60 | 61 | try { 62 | $result = $this->resend->emails->send([ 63 | 'bcc' => $this->stringifyAddresses($email->getBcc()), 64 | 'cc' => $this->stringifyAddresses($email->getCc()), 65 | 'from' => $envelope->getSender()->toString(), 66 | 'headers' => $headers, 67 | 'html' => $email->getHtmlBody(), 68 | 'reply_to' => $this->stringifyAddresses($email->getReplyTo()), 69 | 'subject' => $email->getSubject(), 70 | 'text' => $email->getTextBody(), 71 | 'to' => $this->stringifyAddresses($this->getRecipients($email, $envelope)), 72 | 'attachments' => $attachments, 73 | ]); 74 | } catch (Exception $exception) { 75 | throw new TransportException( 76 | sprintf('Request to the Resend API failed. Reason: %s', $exception->getMessage()), 77 | is_int($exception->getCode()) ? $exception->getCode() : 0, 78 | $exception 79 | ); 80 | } 81 | 82 | $messageId = $result->id; 83 | 84 | $email->getHeaders()->addHeader('X-Resend-Email-ID', $messageId); 85 | } 86 | 87 | /** 88 | * Get the recipients without CC or BCC. 89 | */ 90 | protected function getRecipients(Email $email, Envelope $envelope): array 91 | { 92 | return array_filter($envelope->getRecipients(), function (Address $address) use ($email) { 93 | return in_array($address, array_merge($email->getCc(), $email->getBcc()), true) === false; 94 | }); 95 | } 96 | 97 | /** 98 | * Get the string representation of the transport. 99 | */ 100 | public function __toString(): string 101 | { 102 | return 'resend'; 103 | } 104 | } 105 | --------------------------------------------------------------------------------