Skip to content

Commit 4301825

Browse files
authored
Webhooks handling (#42)
1 parent 9952959 commit 4301825

File tree

15 files changed

+570
-1
lines changed

15 files changed

+570
-1
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ Design global layouts, compose your template, preview your emails and send them
1818
- 🔐 **Secure by default**: Both authentication and API endpoint are always secure: use one of the pre-built auth system or bring your own.
1919
- 📎 **Attachments**: Upload or retrieve attachments from a remote source such *S3*, *Spaces* etc.
2020
- 🪄 **Hackable**: MailCarrier relies on [Laravel](https://laravel.com/) and [Filament](https://filamentphp.com/), that means that over 30K packages are available to customise your MailCarrier instance.
21-
-**Queues**: You can choose whether or not to send emails in a enqueued, background jobs, to not block the user experience.
21+
-**Queues**: You can choose whether or not to send emails in a enqueued, background jobs, to not block the user experience.
22+
- 🪝 **Webhooks**: Track and receive events from your provider directly in MailCarrier.
2223

2324
## Quick start
2425

config/mailcarrier.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,18 @@
196196
*/
197197
'connection' => null,
198198
],
199+
200+
'webhooks' => [
201+
'strategies' => [
202+
// \MailCarrier\Webhooks\Strategies\MailgunStrategy::class,
203+
],
204+
205+
'providers' => [
206+
'mailgun' => [
207+
'secret' => env('MAILGUN_WEBHOOK_SECRET'),
208+
'verbose' => env('MAILGUN_WEBHOOK_VERBOSE', false),
209+
'fatal' => env('MAILGUN_WEBHOOK_FATAL', false),
210+
],
211+
],
212+
],
199213
];

routes/web.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use MailCarrier\Http\Controllers\LogController;
66
use MailCarrier\Http\Controllers\MailCarrierController;
77
use MailCarrier\Http\Controllers\SocialAuthController;
8+
use MailCarrier\Http\Controllers\WebhookController;
89
use MailCarrier\Livewire\PreviewTemplate;
910

1011
Route::middleware(['web', 'auth:' . Config::get('filament.auth.guard')])->group(function () {
@@ -22,3 +23,5 @@
2223
Route::middleware('web')->group(function () {
2324
Route::get('templates/preview', PreviewTemplate::class)->name('templates.preview');
2425
});
26+
27+
Route::post('webhook', WebhookController::class)->name('webhook.process');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace MailCarrier\Http\Controllers;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Http\Response;
7+
use Illuminate\Support\Collection;
8+
use MailCarrier\Webhooks\Actions\ProcessWebhook;
9+
use MailCarrier\Webhooks\Dto\IncomingWebhook;
10+
11+
class WebhookController extends Controller
12+
{
13+
/**
14+
* Handle incoming webhooks from email providers.
15+
*/
16+
public function __invoke(Request $request, ProcessWebhook $processWebhook): Response
17+
{
18+
$webhook = new IncomingWebhook(
19+
headers: (new Collection($request->headers->all()))
20+
->map(fn (array $values) => $values[0]),
21+
body: $request->all(),
22+
);
23+
24+
$processWebhook->run($webhook);
25+
26+
return new Response;
27+
}
28+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace MailCarrier\Webhooks\Actions;
4+
5+
use Illuminate\Support\Facades\App;
6+
use Illuminate\Support\Facades\Config;
7+
use Illuminate\Support\Facades\Log;
8+
use Illuminate\Support\Str;
9+
use MailCarrier\Actions\Action;
10+
use MailCarrier\Models\Log as LogModel;
11+
use MailCarrier\Webhooks\Dto\IncomingWebhook;
12+
use MailCarrier\Webhooks\Dto\WebhookData;
13+
use MailCarrier\Webhooks\Exceptions\WebhookValidationException;
14+
15+
class ProcessWebhook extends Action
16+
{
17+
/**
18+
* Process the incoming webhook using all configured strategies.
19+
*
20+
* @throws \MailCarrier\Webhooks\Exceptions\WebhookValidationException
21+
*/
22+
public function run(IncomingWebhook $webhook): void
23+
{
24+
/** @var array<class-string<\MailCarrier\Webhooks\Strategies\Contracts\Strategy>> $strategies */
25+
$strategies = Config::get('mailcarrier.webhooks.strategies', []);
26+
27+
foreach ($strategies as $strategyClass) {
28+
/** @var \MailCarrier\Webhooks\Strategies\Contracts\Strategy $strategy */
29+
$strategy = App::make($strategyClass);
30+
31+
if (!$strategy->validate($webhook)) {
32+
if ($strategy->isVerbose()) {
33+
Log::warning('Webhook validation failed', [
34+
'strategy' => Str::of($strategyClass)
35+
->afterLast('\\')
36+
->remove('::class')
37+
->toString(),
38+
]);
39+
}
40+
41+
if ($strategy->isFatal()) {
42+
throw new WebhookValidationException;
43+
}
44+
45+
continue;
46+
}
47+
48+
$data = $strategy->extract($webhook->body);
49+
50+
$this->updateLog($data);
51+
52+
// If we found a valid strategy and updated the log, we can stop
53+
break;
54+
}
55+
}
56+
57+
/**
58+
* Create a new log event with webhook data.
59+
*/
60+
private function updateLog(WebhookData $data): void
61+
{
62+
LogModel::query()
63+
->firstWhere('message_id', $data->messageId)
64+
?->events()
65+
?->create([
66+
'name' => $data->eventName,
67+
'createdAt' => $data->date,
68+
]);
69+
}
70+
}

src/Webhooks/Dto/IncomingWebhook.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace MailCarrier\Webhooks\Dto;
4+
5+
use Illuminate\Support\Arr;
6+
use Illuminate\Support\Collection;
7+
8+
class IncomingWebhook
9+
{
10+
/**
11+
* @param Collection<string, string|null> $headers
12+
* @param array<string, mixed> $body
13+
*/
14+
public function __construct(
15+
public readonly Collection $headers,
16+
public readonly array $body,
17+
) {}
18+
19+
/**
20+
* Get a header value by name.
21+
*/
22+
public function getHeader(string $name, ?string $default = null): ?string
23+
{
24+
return $this->headers->get($name, $default);
25+
}
26+
27+
/**
28+
* Get a body value by key using dot notation.
29+
*/
30+
public function getBodyValue(string $key, mixed $default = null): mixed
31+
{
32+
return Arr::get($this->body, $key, $default);
33+
}
34+
}

src/Webhooks/Dto/WebhookData.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace MailCarrier\Webhooks\Dto;
4+
5+
use Carbon\CarbonImmutable;
6+
7+
class WebhookData
8+
{
9+
public function __construct(
10+
public readonly string $messageId,
11+
public readonly string $eventName,
12+
public readonly CarbonImmutable $date,
13+
) {}
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace MailCarrier\Webhooks\Exceptions;
4+
5+
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
6+
7+
class WebhookValidationException extends UnprocessableEntityHttpException
8+
{
9+
public function __construct()
10+
{
11+
parent::__construct('Webhook validation failed.');
12+
}
13+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace MailCarrier\Webhooks\Strategies\Contracts;
4+
5+
use MailCarrier\Webhooks\Dto\IncomingWebhook;
6+
use MailCarrier\Webhooks\Dto\WebhookData;
7+
8+
interface Strategy
9+
{
10+
/**
11+
* Whether to log validation failures.
12+
*/
13+
public function isVerbose(): bool;
14+
15+
/**
16+
* Whether to throw an exception on validation failure instead of continuing.
17+
*/
18+
public function isFatal(): bool;
19+
20+
/**
21+
* Validate the incoming webhook.
22+
*/
23+
public function validate(IncomingWebhook $webhook): bool;
24+
25+
/**
26+
* Extract structured data from the webhook payload.
27+
*/
28+
public function extract(array $payload): WebhookData;
29+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace MailCarrier\Webhooks\Strategies;
4+
5+
use Carbon\CarbonImmutable;
6+
use Illuminate\Support\Facades\Config;
7+
use MailCarrier\Webhooks\Dto\IncomingWebhook;
8+
use MailCarrier\Webhooks\Dto\WebhookData;
9+
use MailCarrier\Webhooks\Strategies\Contracts\Strategy;
10+
11+
class MailgunStrategy implements Strategy
12+
{
13+
/**
14+
* Whether to log validation failures.
15+
*/
16+
public function isVerbose(): bool
17+
{
18+
return Config::get('mailcarrier.webhooks.providers.mailgun.verbose', false);
19+
}
20+
21+
/**
22+
* Whether to throw an exception on validation failure instead of continuing.
23+
*/
24+
public function isFatal(): bool
25+
{
26+
return Config::get('mailcarrier.webhooks.providers.mailgun.fatal', false);
27+
}
28+
29+
/**
30+
* Get the Mailgun webhook secret from config.
31+
*/
32+
private function getSecret(): string
33+
{
34+
$secret = Config::get('mailcarrier.webhooks.providers.mailgun.secret');
35+
36+
if (empty($secret)) {
37+
throw new \RuntimeException('Mailgun webhook secret is not configured. Please set MAILGUN_WEBHOOK_SECRET in your .env file.');
38+
}
39+
40+
return $secret;
41+
}
42+
43+
/**
44+
* Validate the webhook signature using Mailgun's algorithm.
45+
*
46+
* @see https://documentation.mailgun.com/docs/mailgun/user-manual/tracking-messages/#securing-webhooks
47+
*/
48+
public function validate(IncomingWebhook $webhook): bool
49+
{
50+
$signature = $webhook->getBodyValue('signature');
51+
52+
if (!isset($signature['timestamp'], $signature['token'], $signature['signature'])) {
53+
return false;
54+
}
55+
56+
// Concatenate timestamp and token
57+
$data = $signature['timestamp'] . $signature['token'];
58+
59+
// Calculate HMAC using SHA256 with secret from config
60+
$calculatedSignature = hash_hmac('sha256', $data, $this->getSecret());
61+
62+
// Compare signatures
63+
return hash_equals($calculatedSignature, $signature['signature']);
64+
}
65+
66+
/**
67+
* Extract structured data from Mailgun's webhook payload.
68+
*
69+
* @param array $payload The raw webhook payload from Mailgun
70+
*/
71+
public function extract(array $payload): WebhookData
72+
{
73+
if (!isset($payload['event-data'])) {
74+
throw new \InvalidArgumentException('Invalid Mailgun webhook payload: missing event-data');
75+
}
76+
77+
$eventData = $payload['event-data'];
78+
79+
if (!isset($eventData['id'], $eventData['event'], $eventData['timestamp'])) {
80+
throw new \InvalidArgumentException('Invalid Mailgun webhook payload: missing required fields');
81+
}
82+
83+
return new WebhookData(
84+
messageId: $eventData['id'],
85+
eventName: $eventData['event'],
86+
date: CarbonImmutable::createFromTimestamp($eventData['timestamp'])
87+
);
88+
}
89+
}

0 commit comments

Comments
 (0)