Magento 2 3/29/2021

Fooman E-Mail-Attachments for Magento 2

This blog post assumes that you are familiar with and have a working knowledge of:

  • how to write plugins and observers
  • how to set up integration tests (not mandatory, if you want to implement it without tests)
  • Basic understanding of how you can distinguish email types, e.g. using the Magento 2 Module's senders
  • How to write and organize your own Magento 2 Modules
  • How to install composer packages in your projects

as these topics will not be explained in depth.

Dear reader,
this technical blog post covers an approach of how to integrate email attachments using a lightweight plugin + observer implementation, for which a very solid base is gracefully provided by Fooman. The module called Fooman Email Attachments (Magento 2) already comes with the means to attach files to several types of emails:

  • shipment
  • invoice
  • creditmemo
  • order

If your requirement is covered among these, this is great news: You can skip the custom implementation on email type identification and use the event, named according to the scheme: fooman_emailattachments_before_send_(shipment|invoice|credimemo|order) and only write an event observer to attach the files. Evidently, this leads to a very lightweight solution without the need to override the email transport.

However, this is not always enough. The original type list must be extended for some scenarios. Therefore, we will show you how the type-identification mechanism works and demonstrate how you can tailor the extension to your needs with new email types. It is still lightweight, but it requires a deeper intrusion into the core of Fooman's email module.

A short note on why this is a great scenario to use integration tests

Sending emails is a task that requires a lot of preconditions to work before it can be manually tested. We could only confirm that our implementation works after we have written most of it, if we do not use testing. This is because we are forced to pave most of the roads before we can get any meaningful feedback - from creating the email with an attach­ment until being able to look at it in an email client. This is due to the fact that our target of modi­fi­cation is several layers deep within the application's infrastructure and can hardly be called a standalone feature, which makes it hard to debug without building the path to reach it (which can be considerable effort). Using integration tests however, we are able to reverse this relation and focus on a working implementation of the core feature, then implement our business logic on top of a secured state, which is the extension of the Fooman module to fit our specific email types. On top of that, we are able to extend the Fooman common test class to secure crucial parts of our business logic, providing us with some additional value because we can secure it's intactness.


Scenario and greater advantages of the approach of contact form attachments instead of public contact email addresses

Our use case for email attachments is a manufacturer of customer specified goods that wants to provide an accessible way for customers to send specifications to them. It is required to provide a secure and user-friendly channel for customers to send PDF files along with their request when contacting the company. Formulating an email and attaching an arbitrary file would be less user-friendly and less secure than providing a contact form with attachments, for which a server can govern and filter out invalid transmission attempts before they could cause damage.

With this approach it is possible to implement an array of desirable characteristics for many files-from-public-to-company-contacts scenarios:

  • the contact email address is obfuscated and does not have to be known publicly
  • the shop server does not have to store the files persistently, it delegates this task to the email server
  • the shop server can nonetheless check the files for e.g. mime-type authenticity and filter malicious content
  • the company has a way to enforce rules to specify what documents they want to receive in the process and let the user know, even before dispatch, e.g. by using a JavaScript validator for the upload field


Conceptional


Trivia

We are going to create a module to solve the task, meaning the implantation is vastly independent form the rest of the code base. Our only dependencies will be the modules Magento_Contact (depending on one's use case) and Fooman_Email_Attachments. We will use integration testing to start our implementation independently from how the function is going to be embedded in the application for previously stated reasons.


Brief introduction: How does the Fooman Module work?

In essence, Fooman's email attachment module fires events, onto which we can implement observers to attach files from arbitrary sources. Fooman provides us with its own abstract observer implementation, that has several"addAttachment" methods, specific for the mime type we intend to attach.

Code Reference:

fooman/emailattachments-implementation-m2/src/Observer/AbstractObserver

Currently implemented attachment methods of AbstractObserver:

  • attachContent (mimeType)
  • attachPdf(deprecated, use contentAttacher->addPdf instead)
  • attachTxt(deprecated, use contentAttacher->addTxt instead)
  • attachHtml(deprecated, use contentAttacher->addHtml instead)
  • attachTermsAndConditions

The AbstractObserver contains a contentAttacher, the deprecated attachXyz Methods are only wrapper functions.

    * @deprecated see \Fooman\EmailAttachments\Model\ContentAttacher::addPdf()
    */
    public function attachPdf($pdfString, $pdfFilename, ContainerInterface $attachmentContainer)
    {
        $this->contentAttacher->addPdf($pdfString, $pdfFilename, $attachmentContainer);
    }

The contentAttacher uses an AttachmentContainer accessible from the observer, using

$observer→getAttachmentContainer()

The attachment container stores files with a hash and is able to check attachments for duplicates. \Fooman\EmailAttachments\Model\AttachmentContainer:

Code:

 
    /**
     * @param Api\AttachmentInterface $attachment
     */
    public function addAttachment(Api\AttachmentInterface $attachment)
    {
        $dedupId = hash('sha256', $attachment->getFilename());
        if(!isset($this->dedupIds[$dedupId])) {
            $this->attachments[] = $attachment;
            $this->dedupIds[$dedupId] = true;
        }        
    }

Fooman recommends using direct calls to the ContentAttacher's methods for specific mime types. The generic method is suitable to attach files of other than pre-specified mime-types without much additional effort.

Code:

In the AbstractObserver:

    public function attachContent($content, $pdfFilename, $mimeType, ContainerInterface $attachmentContainer)
    {
        $this->contentAttacher->addGeneric($content, $pdfFilename, $mimeType, $attachmentContainer);
    }

creates a new Attachment via AttachmentFactory and directs it to the attachmentContainer, which then will check and add as cited above:

    public function addGeneric($content, $filename, $mimeType, ContainerInterface $attachmentContainer)
    {
        $attachment = $this->attachmentFactory->create(
            [
                'content' => $content,
                'mimeType' => $mimeType,
                'fileName' => $filename
            ]
        );
        $attachmentContainer->addAttachment($attachment);
    }

The AttachmentContainer itself is introduced through an aroundCreate Plugin, see
\Fooman\EmailAttachments\Plugin\MimeMessageFactory::aroundCreate,
running on the core's \Fooman\EmailAttachments\Plugin\MimeMessageFactory.

    public function aroundCreate(
        \Magento\Framework\Mail\MimeMessageInterfaceFactory $subject,
        \Closure $proceed,
        array $data = []
    ) {
        if (isset($data['parts'])) {
            $attachmentContainer = $this->attachmentContainerFactory->create();
            $this->emailEventDispatcher->dispatch($attachmentContainer);
            $data['parts'] = $this->attachIfNeeded($data['parts'], $attachmentContainer);
        }
        return $proceed($data);
    }


How to add new email type distinctions

We will extend the AbstractObserver in order to actually attach the file. Since it is important to add these attachments selectively to “email types”, it is implied that there must be a mechanism to identify and distinctively process such email types. As well, there must be ways to extend on this distinction mechanism for our own cases. Fooman: How to add new email type distinctions


Email type identification in detail

In order to identify the email, we will use two plugins.

The first plugin will be responsible for enriching the core's contact form email template variables with an identification flag, named “is_contact_form_email”. This flag is further on used to identify the email's type.

Code:

    src/app/code/Project/ContactAttachment/Model/IdentifyContactFormEmailService.php

    private const IS_CONTACT_FORM_MAIL = 'is_contact_form_email';
...
    public function enrichEmailTemplateVariables(array $variables): array
    {
        $variables[self::IS_CONTACT_FORM_MAIL] = true;
        return $variables;
    }

called in:

    \Project\ContactAttachment\Plugin\ContactMailPlugin
    public function beforeSend(MailInterface $subject, string $replyTo, array 	$variables): array
    {
        $variables = $this
		->contactFormEmailService
		->enrichEmailTemplateVariables($variables);
        return [$replyTo, $variables];
    }

This is specific because it can be set to run selectively before the send method for any of the core's or custom modules' MailInterface's send methods. The beforeSend plugin not only gives us the possibility to be specific regarding the email's context from its sending class, but also to evaluate template variable contents. Adding this plugin strictly speaking is not always necessary, e.g. if there is another distinct feature one could use to determine the email type from. However, it can be considered better practice to introduce distinct properties for the purpose because we would not rely on possibly changing circumstances. In case of our contact form, the MailInterface class itself provides us with a sufficient distinction, which we process into the is_contact_form_email flag.

The second plugin is technically essential for any extension on the available email types in \Fooman\EmailAttachments\Model\EmailIdentifier::getMainEmailType and will run after Fooman's internal Fooman\EmailAttachments\Model\EmailIdentifier's getType method. Together with these predefined types, plugins on afterGetType will add up to a list of available identification procedures.


Let's take a look on how Fooman does it:

    /**
     * If you want to identify additional email types add an afterGetType plugin to this method.
     *
     * The below class will then return your custom event fooman_emailattachments_before_send_YOURTYPE
     * @see EmailEventDispatcher::determineEmailAndDispatch()
     *
     * @param NextEmailInfo $nextEmailInfo
     *
     * @return EmailType
     */
    public function getType(NextEmailInfo $nextEmailInfo)
    {
        $type = false;
        $templateVars = $nextEmailInfo->getTemplateVars();

        $varCode = $this→getMainEmailType($templateVars); //for a custom type this will be false
        if ($varCode) {
            $method = 'get' . ucfirst($varCode) . 'Email';
            $type = $this->$method(
                $nextEmailInfo->getTemplateIdentifier(),
                $templateVars[$varCode]->getStoreId()
            );
        }

        return $this->emailTypeFactory->create(['type' => $type, 'varCode' => $varCode]);
    }

    private function getMainEmailType($templateVars)
    {
        if (isset($templateVars['shipment']) && method_exists($templateVars['shipment'], 'getStoreId')) {
            return 'shipment';
        }

//... other type checks

        //Not an email we can identify
        return false;
    }

We can find a reference to custom events in the comment, indicating that an extension of this section via plugin is intended.

It is responsible for looking for type-identifying features such as the previously set flag and must return a
\Fooman\EmailAttachments\Model\EmailType. The email type will define the event signature we can observe to finally attach files via its type attribute, which you will set in the constructor.

The getTypeMethod is critical for the observer to follow. Fooman has implemented some magic in it, that formulates the event's name. The scheme of this magic is simple to understand and can be found in
\Fooman\EmailAttachments\Model\EmailEventDispatcher:determineEmailAndDispatch.

In essence, it will read the type-value (in our case “contact”) given to the EmailType constructor in our afterGetType Plugin and append it to fooman_emailattachments_before_send_(contact)

Code:

    public function determineEmailAndDispatch(Api\AttachmentContainerInterface $attachmentContainer)
    {
        $emailType = $this->emailIdentifier->getType($this->nextEmailInfo);
        if ($emailType->getType()) {
            $this->eventManager->dispatch(
                'fooman_emailattachments_before_send_' . $emailType->getType(),
                [

                    'attachment_container' => $attachmentContainer,
                    $emailType->getVarCode() => $this->nextEmailInfo->getTemplateVars()[$emailType->getVarCode()]
                ]
            );
        }
    }

Our extension via plugin is implemented as follows and effectively appends an additional custom type to the list:

Code:

    public function afterGetType(EmailIdentifier $subject, EmailType $result, 	NextEmailInfo $nextEmailInfo)
    {
        if ($this->contactFormEmailService->isValidContactFormEmail($nextEmailInfo)) {
            return new EmailType('contact', 'data');
        }
        return $result;
    }

which calls upon our model's

src/app/code/Project/ContactAttachment/Model
    public function isValidContactFormEmail(NextEmailInfo $nextEmailInfo): bool
    {
        return $nextEmailInfo->getTemplateVars()[self::IS_CONTACT_FORM_MAIL] ?? false
            && $nextEmailInfo->getTemplateVars()['data'] ?? null !== null;
    }

Two conditions are checked to decide if the event is triggered/if the email is considered a part of Fooman's “attach something to this” list. They are equivalent to the if-statements in getMainEmailType. At this stage, we can implement our observer using this event.


What are we testing?

After these initial steps we are able to introduce test coverage into our implementation. This test coverage secures the core of our implementation which is the mechanism to fire an event we can hook into with an “attachmentAdderObserver” implementation of our own, containing or pointing to our business logic.

In order to get test coverage, we need to set up integration tests and a dedicated test suite for our module. These tests will be of two categories:

  1. Independent integration tests focusing only on the just explained core of infrastructure extension, which is the event observer mechanism.
  2. A second group of tests that cover functional behavior and test important parts of our business logic.

    Let's take a look at the test cases by function name:

Event observer tests: Project/ContactAttachment/Test/Integration/ContactEmailEventTest.php contains two test cases, described by their names as

  • testIsFiredBeforeContactEmailSent
  • testIsNotFiredBeforeOtherEmailSent

These tests ensure that the distinction mechanism is working and the event is fired

In later stages we will test the specific behavior of our implementation:

Project\ContactAttachment\Test\Integration\BeforeSendCustomerAttachmentObserverTest contains behavioural tests on

  • testWithoutAttachment
  • testWithAttachment
  • testWithInvalidAttachment
  • testWithError

How are we testing it?

Test coverage for our observer test is depicted in the scheme below as grey

Fooman: Test coverage for behavior test

In practice, the implementation of the tests for event dispatching looks like this:

/**
 * @magentoAppIsolation enabled
 * @magentoAppArea frontend
 */
class ContactEmailEventTest extends TestCase
{
    /**
     * @var MailInterface
     */
    private $mailInterface;
    /**
     * @var Event\Manager|\PHPUnit\Framework\MockObject\MockObject
     */
    private $eventManagerMock;
    /**
     * @var ObjectManager
     */
    private $objectManager;
    /**
     * @var TransportBuilder
     */
    private $transportBuilder;

    protected function setUp(): void
    {
        /** @var ObjectManager $objectManager */
        $this->objectManager = Bootstrap::getObjectManager();
        $this->createEventManagerMock();
        $this->mailInterface = $this->objectManager->create(MailInterface::class);
        $this->transportBuilder = $this->objectManager->create(TransportBuilder::class);
        parent::setUp();
    }

    public function testIsFiredBeforeContactEmailSent()
    {
        $this->eventManagerMock->expects($this->once())->method('dispatch')->with(
            'fooman_emailattachments_before_send_contact'
        );
        $this->mailInterface->send('test@example.com', ['data' => ['name' => 'Hans Wurst']]);
    }

    public function testIsNotFiredBeforeOtherEmailSent()
    {
        $this->eventManagerMock->expects($this->never())->method('dispatch')->with(
            'fooman_emailattachments_before_send_contact'
        );
        $this->transportBuilder
            ->addTo('to@example.com')
            ->setFrom('sales')
            // existence of "data" alone should not determine contact email type
            ->setTemplateVars(['data' => ['something' => 'but not a contact form!']])
            ->setTemplateIdentifier('customer_account_information_change_email_template')
            ->setTemplateOptions([
                'area' => Area::AREA_FRONTEND,
                'store' => 0
            ])
            ->getTransport()->sendMessage();
    }

    /**
     * Event Manager is used as proxy, we need to replace the instance there
     */
    private function createEventManagerMock(): void
    {
        $this->eventManagerMock = $this->createMock(Event\ManagerInterface::class);
        $this->objectManager->addSharedInstance($this->eventManagerMock, Event\Manager\Proxy::class);
    }
}

Test coverage for our behavior test is depicted in grey below

Fooman: Test coverage for behavior test

Noteworthy: While our event dispatcher can be tested separately, we should include the testing library of the Fooman module in order to intercept the sent email and to determine if the behavioral outcome matches our expectations. The Fooman common testing class provides us with methods prepared to intercept emails and looking into their content without needing an actual email client. This way, we are able to determine if our attachment would have been sent as desired, if an exception is thrown in the process with an invalid file as desired and if emails without attachments also work as desired. This allows us to add not only test coverage for our infrastructure, but as well to test the crucial part of the business logic, that provides protection for the company contact and readable error hints for the user.

In order to achieve this, we need to include "fooman/magento2-phpunit-bridge": "^0.9.6" in our require dev section, as the base test class Common from Fooman's module requires it to run with phpUnit. We can then use and/or extend the \Fooman\EmailAttachments\Observer\Common test cases. In our scenario we needed to introduce a check on if mailHog was available to avoid tests failing in environments where mailHog was missing.

Side note on Mailhog:
Mailhog is a universal open source email testing tool. It is designed to test a system's email sending capability. If you are looking to replace testing emails using an actual SMTP client, we recommend taking a look at the github repository for more information: https://github.com/mailhog/mhsendmail

Since it might be a prevalent case, the logic to check on mailHog is:

    private function isMailhogAvailable() {
        return gethostbyname('mailhog') !== 'mailhog';
    }


Let's take a look at the essential part of our eventObserver implementation


It will follow a scheme looking like this:
    /**
     * @param \Magento\Framework\Event\Observer $observer
     * @throws MissingAttachmentException
     * @throws PdfException // any custom exception you might want to attach an understandable error message to
     */
    public function execute(\Magento\Framework\Event\Observer $observer)
    {
            $this->attachContent(
          ,
          ,
          ,
                $observer->getAttachmentContainer()
            );
        }
    }

Our implementation example:
    /**
     * @param \Magento\Framework\Event\Observer $observer
     * @throws MissingAttachmentException
     * @throws PdfException // any custom exception you might want to attach an understandable error message to
     */
    public function execute(\Magento\Framework\Event\Observer $observer)
    {
        if ($this->attachmentService->requestHasAttachment($this->request)) {
            $this->attachPdf(
                $this->attachmentService->getPdfContentStringFromRequest($this->request),
                __('customer_file') . '.pdf',
                $observer->getAttachmentContainer()
            );
        }
    }

In our attachment service, we implemented details for extracting the file from the request (arrives in a request instance via Dependency Injection) and a routine to check the true mime type, convert it to an object and back using a PDF processor. We can set up the PDF processor to strip unwanted elements from the document (such as JavaScript) or to compress it further. In addition, it would not let potentially malicious sequences get into the re-rendered document.

Of course, this structure must be reflected in our email template. The containing template variable is called "data", which we inject into our HTML email template and which we introduced before in our definition of EmailType:

 {{/depend}}
    {{depend data.attachment}}

We can now add integration tests to ensure the business logic is running correctly as we develop it.

As mentioned, this test class makes use of the Fooman common test class and looks at the dispatched email message:

/**
 * @magentoAppArea       frontend
 * @magentoAppIsolation  enabled
 */
class BeforeSendCustomerAttachmentObserverTest extends FoomanCommon
{
    public function testWithoutAttachment()
    {
        $this->whenContactMailSent();
        $this->thenEmailShouldHaveNoPdfAttachment();
    }

    public function testWithAttachment(): void
    {
        $this->givenRequestHasUploadedFile('dummy.pdf');
        $this->whenContactMailSent();
        $this->compareWithReceivedPdf(Zend_Pdf::load($this->getTestFilepath('dummy.pdf')));
    }

    public function testWithInvalidAttachment(): void
    {
        $this->givenRequestHasUploadedFile('invalid.pdf');
        $this->expectException(PdfException::class);
        $this->whenContactMailSent();
    }

    public function testWithError(): void {
        $this->givenRequestHasUploadedFileAndError('any.pdf');
        $this->expectException(MissingAttachmentException::class);
        $this->whenContactMailSent();
    }

    protected function whenContactMailSent(): void
    {
        $contactMailSender = $this->objectManager->create(\Magento\Contact\Model\Mail::class);
        $replyTo = 'test@example.com';
        $contactMailSender->send($replyTo, $this->data());
    }

    private function givenRequestHasUploadedFile(string $fileName): void
    {
        $request = $this->objectManager->get(RequestInterface::class);
        $files = new Parameters();
        $files->set(
            RequestAttachmentService::ATTACHED_FILE_KEY,
            $this->createValidFileRequestRepresentation($fileName)
        );
        $request->setFiles($files);
    }

    private function createValidFileRequestRepresentation(string $fileName): array
    {
        $filePath = $this->getTestFilepath($fileName);
        return [
            'name'     => $fileName,
            'type'     => 'application/pdf',
            'tmp_name' => $filePath,
            'error'    => 0,
            'size'     => 13264,
        ];
    }

    private function givenRequestHasUploadedFileAndError(string $fileName): void
    {
        $request = $this->objectManager->get(RequestInterface::class);
        $files = new Parameters();
        $files->set(
            RequestAttachmentService::ATTACHED_FILE_KEY,
            $this->createFileWithErrorRequestRepresentation($fileName)
        );
        $request->setFiles($files);
    }

    private function createFileWithErrorRequestRepresentation(string $fileName): array
    {
        return [
            'name'     => $fileName,
            'type'     => 'application/pdf',
            'tmp_name' => null,
            'error'    => 2,
            'size'     => null,
        ];
    }

    private function getTestFilepath(string $fileName): string
    {
        return __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . $fileName;
    }

    private function data()
    {
        return [
            'data' => [
                'name'      => "standard blue",
                'email'     => "mail@address.net",
                'telephone' => "123123123123",
                'company'   => "My Company Name",
                'address'   => "Address\r\nMultiline\r\n12345",
                'comment'   => "My Opinion",
            ],
        ];
    }

    private function thenEmailShouldHaveNoPdfAttachment(): void
    {
        $pdfAttachment = $this->getAttachmentOfType($this->getLastEmail(), 'application/pdf; charset=utf-8');
        self::assertFalse($pdfAttachment);
    }

}

In order for this test to run, we included two dummy-files in our test folder: One true PDF and one potentially malicious file, obfuscated as a PDF. This way we documented and saved our business logic and its core concerns.
We appreciate the great quality of Fooman's open source module. Its cunning approach is easy to extend and requires only a minimal implementation, meaning the code remains very focused, opposing the more classical approach of implementing own email transports. Not only is the approach itself smart, but the test classes provided by Fooman also allow a great test coverage for our case with minimal effort. For us as a company, this greatly matches our goal to write robust and update compatible implementations.

Thank you, Fooman!

We hope this post helps you with your implementation of similar scenarios and we would like to thank you for your attention. Happy coding and stay safe!