Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Improve file attachment for mail and notifications #42563

Merged
merged 2 commits into from
Jun 7, 2022

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented May 30, 2022

Purpose

This PR aims to bring the concept of an "Attachable" for re-use of attaching files to a mail or other notification-like resources. It also standardises the attachable features across:

  • Illuminate/Mail/Mailable
  • Illuminate/Mail/Message
  • Illuminate/Notifications/Messages/MailMessage

which is currently inconsistent, with Mailable supporting attaching from storage, while the others do not.

Why this feature is useful

Often in applications you have a model or POPO that represents a "file" that you attach to a notification. The best of example of this is the ever classic example of an Invoice. You may have in invoice model in your system that has an associated PDF that you attach to notifications.

There may be several emails that you want the invoice attached to, and what's more is you might have some extra logic involved in retrieving the invoice before attaching to an email.

Currently we can attach files to an email in the following ways...

class InvoiceCreated extends Mailable
{
    public function __construct(private Invoice $invoice)
    {
        //
    }

    public function build()
    {
        // From a file on the local filesystem...

        $this->attach($this->invoice->path);

        // As above, but with a nice filename and specifying the mime type...

        $this->attach($this->invoice->path, [
            'as' => "Acme Invoice {$this->invoice->number}.pdf",
            'mime' => 'application/pdf',
        ]);

        // From in-memory data...

        $this->attachData(
            $this->invoice->content,
            "Acme Invoice {$this->invoice->number}.pdf",
            ['mime' => 'application/pdf']
        );

        // Getting the contents on the fly...

        $data = Http::withHeaders(['Accept' => 'application/pdf'])
            ->get("https://api.xero.com/api.xro/2.0/Invoices/{$this->invoice->xero_id}")
            ->throw()
            ->body();

        $this->attachData(
            $data,
            "Acme Invoice {$this->invoice->number}.pdf",
            ['mime' => 'application/pdf']
        );


        /*
         * The following are only available on the `Mailable` class, making the API inconsistent...
         */

        // From the default storage disk...

        $this->attachFromStorage(
            $this->invoice->path,
            "Acme Invoice {$this->invoice->number}.pdf",
            ['mime' => 'application/pdf']
        );

        // From a specific storage disk...

        $this->attachFromStorageDisk(
            'backblaze',
            $this->invoice->file_path,
            "Acme Invoice {$this->invoice->number}.pdf",
            ['mime' => 'application/pdf']
        );
    }
}

When you then implement another email that sends this same file, you may then find yourself duplicating this code or reaching for the docs to remember how the APIs work, etc.

This PR aims to improve experience of attaching files to an email or other notification system.

Usage

There is now an new contract called Illumiante\Contracts\Mail\Attachable that is meant for your model and a new Illuminate\Mail\Attachment class that you return from the contract method.

You can specify how you want the attachment to be attached to you mail / notification...

class Invoice extends Model implements Attachable
{
    public function toMailAttachment()
    {
        // From a file on the local filesystem...

        return Attachment::fromPath($this->path);

        // As above, but with a nice filename and specifying the mime type...

        return Attachment::fromPath($this->path)
            ->as("Acme Invoice {$this->number}.pdf")
            ->withMime('application/pdf');

        // From in-memory data...

        return Attachment::fromData(fn () => $this->content)
            ->as("Acme Invoice {$this->number}.pdf")
            ->withMime('application/pdf');

        // Getting the contents on the fly...

        return Attachment::fromData(fn () => Http::withHeaders(['Accept' => 'application/pdf'])
            ->get("https://api.xero.com/api.xro/2.0/Invoices/{$this->xero_id}")
            ->throw()
            ->body()
        )->as("Acme Invoice {$this->invoice->number}.pdf")->withMime('application/pdf');
 

        /*
         * The following are now available on the `Mailable` and mail messages, both mail and notifications,
         * making the API consistent 🎉
         */


        // From the default storage disk...

        return Attachment::fromStorage($this->path)
            ->as("Acme Invoice {$this->number}.pdf")
            ->withMime('application/pdf');

        // From a specific storage disk...

        return Attachment::fromStorageDisk('backblaze', $this->path)
            ->as("Acme Invoice {$this->number}.pdf")
            ->withMime('application/pdf');
    }
}

Then to attach the invoice to a built in mailable resource the following API is used...

class InvoiceCreated extends Mailable
{
    public function __construct(private Invoice $invoice)
    {
        //
    }

    public function build()
    {
        $this->attach($this->invoice);
    }
}

It is also possible to set the filename or mine from within the mailable itself, if that is wanted...

class InvoiceCreated extends Mailable
{
    public function __construct(private Invoice $invoice)
    {
        //
    }

    public function build()
    {
        $this->attach(
            $this->invoice->toMailAttachment()->as('Pay Me.pdf')
        );
    }
}

Mutiple attachments

Sometimes your model may several attachments. No troubles...

class Invoice extends Model implements Attachable
{
    public function overdueAttachment()
    {
        return Attachment::fromData(/* ... */);
    }

    public function renewalAttachment()
    {
        return Attachment::fromData(/* ... */);
    }

    public function toMailAttachment()
    {
        return Attachment::fromData(/* ... */);
    }
}


// in the build function of your mailable....


$this->attach($this->invoice);

$this->attach($this->invoice->renewalAttachment());

$this->attach($this->invoice->overdueAttachment());

it is also possible to just use the attachment class right inline while attaching to a Mail\Mailable, Mail\Message, and Notification\Messages\MailMessage, with the latter two now being able to utilise the storage machanism.

// in the build function…

$this->attach(Attachment::fromStorageDisk('s3', 'logo.png'));

General API for 3rd parties

This new class has two ways of attaching data. By referencing a path or by supplying a Cloure that resolves a string of data. All APIs are just wrappers around these two features.

Third party packages that want to integrate with this class may specify how they want to handle an attachment. They must specify both a handler for a path based resource and a data based resource.

class NotificationFrom3rdParty
{
    public function attach(Attachment $attachment): void
    {
        $attachment->attachWith(
            fn (string $path) => $this->attachPath($path, $attachment->as, $attachment->mime),
            fn (Closure $data) => $this->attachData($data, $attachment->as, $attachment->mime),
        );
    }

    public function attachData(Closure $data, string $filename, string $mimeType): void
    {
        //
    }

    public function attachPath(string $path, string $filename, string $mimeType): void
    {
        //
    }
}

$notificationFrom3rdPartyInstance->attach($invoiceModel);

You can see that the $data is not eagerly resolved, allowing the integration to decide when they want to resolve the data - which is useful for delaying the resolution from storage etc, which is how the current mail system works.

Extending the dev facing API

The attachment class is also Macroable, so it is possible to create nice named constructors from 3rd party libraries...

// Service provider...

Attachment::macro('fromXeroInvoice', function ($id) {
    return Attachment::fromData(fn () => Http::withHeaders(['Accept' => 'application/pdf'])
        ->get("https://api.xero.com/api.xro/2.0/Invoices/{$id}")
        ->throw()
        ->body());
});

// Attachable...

public function toMailAttachment()
{
    return Attachment::fromXeroInvoice($this->xero_id)
        ->as("Acme Invoice {$this->number}.pdf")
        ->withMime('application/pdf');;
}

Or even just a more generic way of retrieving from a public API...

Attachment::macro('fromUrl', function ($url) {
    return Attachment::fromData(fn () => Http::get($url)->throw()->body();
});

Notes

  • I've put the new contract and the new Attachment class in the Mail namespace, but as shown above it is kinda just generic "Notification". I'm happy to move it to the Notifications namespace if we feel that is a better fit.
  • This new implementation does not dedupe "fromStorage" attachments in the same way that the current Mailable implementation does, as it has to resolve the Data the know how to dedupe.
  • The current mail implementation does eagerly load the data during the "build" function call.
  • It would be possible to consolidate both the fromPath and fromData APIs so that everything at the end of the day results in a fromData. This would simplify the integration as well, as you no longer have to handle both the "Path" and "Data" based attachments. The reason I didn't opt for a simple file_get_contents is that the Symfony mailer is doing a heap under the hood that I didn't want to have to replicate, so we continue to have two APIs - just like we currently do.
  • This PR does not support inline attachments, as I wanted to make sure it was wanted before doing the work. Will do a follow up PR to ensure they work with inline attachments if this is merged / wanted. I'll also bring the embed feature to Mailable and Notifications/Messages/MailMessage which do not currently support inline embeds as a first class feature.

@timacdonald
Copy link
Member Author

Looks like there are some tests failing on prefer-lowest as it isn't detecting the fallback mime type in the same way. Marking as draft until I can look at that tomorrow.

@timacdonald timacdonald marked this pull request as draft May 30, 2022 07:32
Copy link
Member

@driesvints driesvints left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a great addition to the mail component 👍

I think the Attachment class can definitely live in the Mail namespace.

@timacdonald timacdonald marked this pull request as ready for review May 31, 2022 03:25
@taylorotwell
Copy link
Member

@timacdonald I think this looks good so far! Feel free to continue with embeds. Marking back to draft for now while that is being worked on.

@taylorotwell taylorotwell marked this pull request as draft June 1, 2022 15:23
@timacdonald
Copy link
Member Author

timacdonald commented Jun 2, 2022

This one is now ready to roll with embeds for all mail types.

@timacdonald timacdonald marked this pull request as ready for review June 2, 2022 05:55
@taylorotwell
Copy link
Member

taylorotwell commented Jun 2, 2022

@timacdonald how does this embed stuff actually work?

Normally I think of embeds as being in the message itself?

CleanShot 2022-06-02 at 10 21 37@2x

For instance, where is this actually "embedding" the attachment in the message?

CleanShot 2022-06-02 at 10 23 51@2x

@timacdonald timacdonald marked this pull request as draft June 2, 2022 22:56
@timacdonald timacdonald marked this pull request as ready for review June 3, 2022 01:04
@timacdonald
Copy link
Member Author

timacdonald commented Jun 3, 2022

@taylorotwell got that addressed. I misunderstood how the embed worked under the hood - the implementation was incorrect.

Got it sorted now. This email view...

<strong>BEFORE</strong>
<img src="{{
    $message->embed(
        Attachment::fromData(
            fn () => file_get_contents('https://pbs.twimg.com/profile_images/1495883253445959681/xLo3tosq_400x400.jpg'),
            'tim'
        )->withMime('image/png')
    )
}}" title="Expected title content" style="border-radius: 999px;">
<strong>AFTER</strong>

Renders inline as expected...

Screen Shot 2022-06-03 at 11 02 04 am

In an actual app, this would of course be written more like....

<img src="{{ $message->embed($user) }}">

assuming that $user is Attachable

@timacdonald timacdonald marked this pull request as draft June 3, 2022 01:28
@timacdonald timacdonald marked this pull request as ready for review June 3, 2022 02:07
@taylorotwell taylorotwell merged commit 32b4b7c into laravel:9.x Jun 7, 2022
@timacdonald timacdonald deleted the attachable branch June 13, 2022 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants