Catching incoming emails in your Laravel application with Laravel Mailbox

Published on

While working on a client project over the last couple of weeks, one of the requested features was the ability to fetch incoming emails in the Laravel application. Using services such as Mailgun or Sendgrid, this is perfectly possible, since they offer support for webhooks where they send you either the raw email content, or a parsed version of it so that you can process the message.

With this specific feature request in mind, I could've just built a simple controller that would receive the webhook payload from one of those services and be done with it. But with every feature that I implement in one of my projects, I like to step back for a minute and think about if this specific feature can be seen as a reusable package.

Developing packages has a lot of benefits. For our clients at my company BeyondCode it means that they have to pay less and get well tested, well documented software that they can use in their own applications. And the applications can focus on what they really do as their core features. And parsing inbound emails is definitely not one of the core features of this specific client application.

So with that in mind, thinking about parsing inbound emails should actually not be bound to the service that you are using. If you want to switch at one point from (for example) Mailgun to Sendgrid, you would need to modify your code accordingly.

Or what about testing this locally? This is currently impossible - without using tools like Ngrok and accepting the webhooks locally.

PHP Package Development Video Course

If you are interested in learning more about PHP and Laravel package design, be sure to sign up and get notified when my PHP Package Development course launches, as well as receive a launch discount code.

The package structure

The main feature of Laravel Mailbox is to be able to parse inbound emails in a Laravel familiar way. This is what the syntax looks like:

Mailbox::from('sender@domain.com', function (InboundEmail $email) {
	// Access email information, like:
	$subject = $email->subject();
});

Mailbox::subject('Feedback form', function (InboundEmail $email) { });

The mailbox definition should feel like it's a simple routing and could easily be in a routes/mailbox.php file.

So here's how it all works.

Mailbox facade

In order to use the Mailbox:: syntax in any part of the Laravel application that uses this package, I register the Router as a singleton. This way we always receive the same instance of the Router object when we pull it out of the container in our application:

// MailboxServiceProvider.php

$this->app->singleton('mailbox', function () {
    return new Router($this->app);
});

Now to make use of the Mailbox facade, I simply create a new class that extends Laravel's facade and tell Laravel that the facade accessor - so the way the facade can pull our service out of the container - is also called mailbox.

Router

As I mentioned, I'm using a class called "Router". This class is responsible of storing all mailbox "routes" as well as calling the stored mailbox routes when the application receives an incoming email.

I decided to call it a "Router" because it stores the route between the inbound email and the logic that you want to perform with this email.

This class holds all methods that determine how you can register mailbox routes:

These are: from, to, cc, subject, fallback and catchAll.

When you add a route, for example using the Mailbox::from('sender@domain.com', $action); method, this is what happens internally:

public function from(string $pattern, $action) : Route
{
    return $this->addRoute(Route::FROM, $pattern, $action);
}

So this method adds a new route with a given pattern and action and returns it.

Let's see what the addRoute method looks like:

protected function addRoute(string $subject, string $pattern, $action) : Route
{
    $route = $this->createRoute($subject, $pattern, $action);

    $this->routes->add($route);

    return $route;
}

Pretty simple. We create a new route for a given "subject". Subjects are used to determine which parts of the inbound email should be used to match the given pattern.

For example: Mailbox::from('sender@domain.com', $action); uses the emails From email address, while Mailbox::subject('Feedback', $action); uses the email Subject. The subjects are all constants on the Route class that gets created in here.

After the route was creates, it will be added to $this->routes which is an instance of a RouteCollection class.

Right now, the only relevant method is the add method, which simply adds the route to an array:

protected $routes = [];

public function add(Route $route)
{
    $this->routes[] = $route;
}

Alright. And last but not least there's the createRoute method - which only creates a new Route instance with all the provided arguments and passes the IoC container to it:

protected function createRoute(string $subject, string $pattern, $action) : Route
{
    return (new Route($subject, $pattern, $action))
        ->setContainer($this->container);
}

This is all of the public API that the package consumer will get in touch with.

Now let's dig deeper to see how we can receive incoming emails from one of the supported services and how the matching mailboxes get called.

Consuming Webhooks

As I said, to receive an incoming email from a service like Mailgun, our application needs to provide an API endpoint that can be registered as the webhook URL.

With Laravel Mailbox, every driver needs to take care of registering their own URL endpoints. For this, every driver has a register method that gets called when the driver specified in the configuration file gets registered.

This is what the Mailgun driver looks like:

<?php

namespace BeyondCode\Mailbox\Drivers;

use Illuminate\Support\Facades\Route;
use BeyondCode\Mailbox\Http\Controllers\MailgunController;

class Mailgun implements DriverInterface
{
    public function register()
    {
        Route::prefix(config('mailbox.path'))->group(function () {
            Route::post('/mailgun/mime', MailgunController::class);
        });
    }

So all the Mailgun driver does, is it registers a new POST endpoint at a configurable path (the default is /laravel-mailbox) using the path /mailgun/mime. This route gets handles by the MailgunController and this is the route that you would specify when setting up Mailgun with Laravel Mailbox.

Webhook Controller

Let's take a look at the MailgunController class:

<?php

namespace BeyondCode\Mailbox\Http\Controllers;

use BeyondCode\Mailbox\Facades\Mailbox;
use BeyondCode\Mailbox\Http\Requests\MailgunRequest;

class MailgunController
{
    public function __invoke(MailgunRequest $request)
    {
        Mailbox::callMailboxes($request->email());
    }
}

Very straight forward. We have a custom MailgunRequest that handles all of the validation logic and we then use the Mailbox::callMailboxes method and give it the inbound email from the incoming Mailgun request.

So let's take a look at the MailgunRequest class too:


class MailgunRequest extends FormRequest
{
    public function validator()
    {
        $validator = Validator::make($this->all(), [
            'body-mime' => 'required',
            'timestamp' => 'required',
            'token' => 'required',
            'signature' => 'required',
        ]);
        
        $validator->after(function () {
            $this->verifySignature();
        });
        
        return $validator;
    }
 	
 	// ...   
}

This is the important part. So for Mailgun we setup a custom validator that specifies which fields we need to receive from this webhook. Then we use the validator->after method to perform another validation and verify the signature. This is required so that we can ensure that the incoming request actually was sent from Mailgun - and not from someone else.

If you want, you can take a look at the verification process for Mailgun on GitHub. We validate the incoming signature using the Mailgun API key and only allow emails that are not older than 2 minutes.

Last but not least we have the email() method:

public function email()
{
    return InboundEmail::fromMessage($this->get('body-mime'));
}

This method returns a new InboundEmail class that get's created from Mailguns raw MIME mail and returns it. You can check out the full InboundEmail class on GitHub. It contains all the helper methods that allow you to retrieve email information such as the from() email, the subject(), etc.

Now we have an InboundEmail from the incoming webhook and pass it to our Router to actually call the mailboxes that match any of our defined routes.

Calling Mailboxes

With out inbound email, we now need to figure out if there are mailbox routes that match the criteria of the inbound email. This happens in the callMailboxes method.

public function callMailboxes(InboundEmail $email)
{
    if ($email->isValid()) {
        $matchedRoutes = $this->routes->match($email)->map(function (Route $route) use ($email) {
            $route->run($email);
        });

        // ....
    }
}

Alright, let's go through it step by step.

First, we make sure that the inbound email is actually valid. That means: we have a from email address and either a text or html body.

Next we use the match method on our route collection to return all routes that match the incoming email - for all of those routes we try to run them with the inbound email. This will call the closure or invokable class that you defined as your Mailbox action.

First, let's see what the matching looks like.

This is the match method in the RouteCollection:

public function match(InboundEmail $message): Collection
{
    return Collection::make($this->routes)->filter(function ($route) use ($message) {
        return $route->matches($message);
    });
}

We filter all defined routes and only return those where $route->matches($message) returns true. So we delegate the actual matching to the Route class.

This is how we match the inbound email in the actual route:

public function matches(InboundEmail $message): bool
{
    $subjects = $this->gatherMatchSubjectsFromMessage($message);

    return Collection::make($subjects)->first(function (string $subject) {
        return $this->matchesRegularExpression($subject);
    }) !== null;
}

Since one route can match multiple "subjects" (remember: from email, subject, to email, etc.) the gatherMatchSubjectsFromMessage method returns an array with all these subjects. Depending on the route subject this could be the from email, the to emails, the cc emails or the email subject itself:

protected function gatherMatchSubjectsFromMessage(InboundEmail $message)
{
    switch ($this->subject) {
        case self::FROM:
            return [$message->from()];
        break;
        case self::TO:
            return $this->convertMessageAddresses($message->to());
        break;
        case self::CC:
            return $this->convertMessageAddresses($message->cc());
        break;
        case self::SUBJECT:
            return [$message->subject()];
        break;
    }
}

Alright - now all we have to do is try and see if any of these subjects matches the pattern of our mailbox route - since they also support wildcards and parameters, like Mailbox::from('{username}@domain.com'.

The whole regular expression matching takes place in the HandlesRegularExpressions trait - I'm not going into detail about it.

Running Routes

Now if at least one of your mailbox routes matches the given email and matching subject, this route gets executed. Which means that either the closure or the invokable class will be called. For this I use Laravel's built in RouteDependencyResolverTrait ,since the behavior is so similar, to create the dependencies for the closure or the invokable class and actuall go and call this method.

At this point, your mailbox code will be executed and you can perform the logic that you want.

Documentation

Phew - and that's the majority of what this package does. It abstracts all of the logic for handling incoming emails, so that the package consumer can fully concentrate on writing the application logic iself.

If you want to know more about the package, check out the official documentation as well as the GitHub repository.

If you are interested in learning more about PHP and Laravel package design, be sure to sign up and get notified when the course launches, as well as receive a launch discount code.