Log emails as *.eml in Laravel Mailer

December 3rd, 2021 by Philip Iezzi 4 min read
cover image

How to log full email messages into *.eml files on filesystem in your Laravel project? We would like to log full message bodies for all messages, no matter if they are sent out directly using Laravel's Mail facade or as Mail Notifications. That can actually be implemented in a super easy way, but it was so hard to find any good documentation or tutorial about this.

NOTE: Swift Mailer is no longer maintained since Nov 2021. In Laravel 9 LTS, which is going to be released on Jan 25th 2022, Swift Mailer is going to be swapped out with Symfony Mailer, see PR #38481. Below presented solution seems to be compatible with the new Mailer, though. I'll keep this post updated, if any changes are needed for Laravel 9.

Simple log channel implementation (DON'T)

In this Stackoverflow post, I have presented a simple way of logging email messages.

Create logdir logs/emails

Add an extra channel configuration to config/logging.php:

    'channels' => [
        // ...
        'emails' => [
            'driver' => 'single',
            'path' => storage_path('logs/emails/' . \Illuminate\Support\Str::uuid() . '.eml'),
            'level' => 'debug',
        ],

And then simply use the following in your .env:

MAIL_MAILER=log
MAIL_LOG_CHANNEL=emails

That way, you get wonderful logs/emails/<UUID>.eml files. But this solution has several drawbacks:

  • There is no easy way of sending out emails both through MAIL_MAILER=log and some other mailer, e.g. the standard MAIL_MAILER=smtp
  • Writing more than one message to the email log channel during the same run would not trigger an updated UUID, so the second message would get appended to the same logfile. This happens e.g. if you trigger a notification in Tinker.
  • The first line (before Message-ID: ...) of the email header is prefixed with log formatting prefix [_TIMESTAMP_] local.DEBUG: . As a non-standard email header is ignored by all/most email clients, this should be no issue in development. But definitely not cool!
  • The *.eml message body is scrambled, somehow. The MIME multipart content type boundary gets lost, so the email client has no idea where the HTML part of the message body starts and which part to treat as plaintext.

So, this solution is rejected! We need some slightly more sophisticated logging!

LogSentMessage event listener

Let's forget about the emails log channel from above solution and create the following disk configuration instead, config/filesystems.php:

return [
    // ...
    'disks' => [
        // ...
        'emails' => [
            'driver'     => 'local',
            'root'       => storage_path('logs/emails'),
        ],

Instead of changing the Mailer in .env (keep whatever MAIL_MAILER you are used to!), we build our LogSentMessage event listener and use Laravel's MessageSent event.

Create app/Listeners/LogSentMessage.php, which is going to write the full message:

<?php

namespace App\Listeners;

use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class LogSentMessage
{
    public function handle(MessageSent $event)
    {
        $messageId = $event->data['__laravel_notification_id'] ?? Str::uuid();
        Storage::disk('emails')->put(
            sprintf('%s_%s.eml', now()->format('YmdHis'), $messageId),
            $event->message->toString()
        );
    }
}

The LogSentMessage event listener can now be hooked into app/Providers/EventServiceProvider.php

<?php

namespace App\Providers;

use App\Listeners\LogSentMessage;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Mail\Events\MessageSent;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        MessageSent::class => [
            LogSentMessage::class,
        ],
    ];
}

To generate the listener class, you could simply add these lines to EventServiceProvider.php:

    protected $listen = [
+        MessageSent::class => [
+            'App\Listeners\LogSentMessage',
+        ],
    ];

... and then fire:

$ php artisan event:generate

This works great! Email notifications/messages are now getting sent out normally and at the same time a copy of the full message is stored here:

storage/logs/emails
├── 20211203131816_385ce6e8-437c-427e-828c-7464a4a48ca2.eml
└── 20211203132656_757c62b6-b6fa-45d0-99e3-6caf5be6a9f3.eml

You can open or even import them with any mail client and they are just 100% valid emails, including all headers (also Bcc). Just make sure this storage path is never publicly exposed.

Thanks @davewood for this simple trick.