Introduction
Adding attachments like PDF invoices, shipments, or custom files to Magento 2 sales emails can enhance customer experience. This guide walks you through implementing email attachments using a custom module, with custom transport builder code examples from the Vendor\Module\Model\Email\TransportBuilder
class.
In Magento 2.4.8 (and newer), the laminas-mime
library has been removed.
To send email attachments now, you should use Symfony’s Mime via Magento’s native Mail framework.
Here's a concise guide combining best practices and our refined implementation.
Steps to Add Email Attachments
-
Create di.xml
Create a
di.xml
file in theVendor/Module/etc/
directory.<?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> <preference for="Magento\Framework\Mail\Template\TransportBuilder" type="Vendor\Module\Model\Email\TransportBuilder" /> </config> -
Define Custom Email TransportBuilder
Create or customize a
TransportBuilder
class to support email attachments in Magento 2.4.8+, replacing the legacy Laminas MIME system. Below is a basic structure of the class with a samplegetTransport()
method.<?php namespace Vendor\Module\Model\Email; use Magento\Framework\HTTP\Mime; use Magento\Framework\Mail\Template\FactoryInterface; use Magento\Framework\Mail\Template\SenderResolverInterface; use Magento\Framework\Mail\Template\TransportBuilder as CoreTransportBuilder; use Magento\Framework\App\TemplateTypesInterface; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Mail\AddressConverter; use Magento\Framework\Mail\Exception\InvalidArgumentException; use Magento\Framework\Exception\MailException; use Magento\Framework\Mail\MessageInterface; use Magento\Framework\Mail\MimeInterface; use Magento\Framework\Mail\EmailMessageInterfaceFactory; use Magento\Framework\Mail\MimeMessageInterfaceFactory; use Magento\Framework\Mail\MimePartInterfaceFactory; use Magento\Framework\Mail\TransportInterfaceFactory; use Magento\Framework\ObjectManagerInterface; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\Multipart\MixedPart; class TransportBuilder extends CoreTransportBuilder { /** @var array */ private $messageData = []; /** * @var EmailMessageInterfaceFactory */ private $emailMessageInterfaceFactory; /** * @var MimeMessageInterfaceFactory */ private $mimeMessageInterfaceFactory; /** * @var MimePartInterfaceFactory */ private $mimePartInterfaceFactory; /** * @var AddressConverter */ private $addressConverter; /** * @var string */ private $messageBodyParts = []; /** * @param FactoryInterface $templateFactory * @param MessageInterface $message * @param SenderResolverInterface $senderResolver * @param ObjectManagerInterface $objectManager * @param TransportInterfaceFactory $mailTransportFactory * @param EmailMessageInterfaceFactory $emailMessageInterfaceFactory * @param MimeMessageInterfaceFactory $mimeMessageInterfaceFactory * @param MimePartInterfaceFactory $mimePartInterfaceFactory * @param AddressConverter $addressConverter */ public function __construct( FactoryInterface $templateFactory, MessageInterface $message, SenderResolverInterface $senderResolver, ObjectManagerInterface $objectManager, TransportInterfaceFactory $mailTransportFactory, EmailMessageInterfaceFactory $emailMessageInterfaceFactory, MimeMessageInterfaceFactory $mimeMessageInterfaceFactory, MimePartInterfaceFactory $mimePartInterfaceFactory, AddressConverter $addressConverter ) { parent::__construct( $templateFactory, $message, $senderResolver, $objectManager, $mailTransportFactory ); $this->emailMessageInterfaceFactory = $emailMessageInterfaceFactory; $this->mimeMessageInterfaceFactory = $mimeMessageInterfaceFactory; $this->mimePartInterfaceFactory = $mimePartInterfaceFactory; $this->addressConverter = $addressConverter; } /** * Add cc address * * @param array|string $address * @param string $name * * @return $this */ public function addCc($address, $name = '') { $this->addAddressByType('cc', $address, $name); return $this; } /** * Add to address * * @param array|string $address * @param string $name * * @return $this * @throws InvalidArgumentException */ public function addTo($address, $name = '') { $this->addAddressByType('to', $address, $name); return $this; } /** * Add bcc address * * @param array|string $address * * @return $this * @throws InvalidArgumentException */ public function addBcc($address) { $this->addAddressByType('bcc', $address); return $this; } /** * Set Reply-To Header * * @param string $email * @param string|null $name * * @return $this * @throws InvalidArgumentException */ public function setReplyTo($email, $name = null) { $this->addAddressByType('replyTo', $email, $name); return $this; } /** * Set mail from address * * @param string|array $from * * @return $this * @throws InvalidArgumentException * @see setFromByScope() * * @deprecated 102.0.1 This function sets the from address but does not provide * a way of setting the correct from addresses based on the scope. */ public function setFrom($from) { return $this->setFromByScope($from); } /** * Set mail from address by scopeId * * @param string|array $from * @param string|int $scopeId * * @return $this * @throws InvalidArgumentException * @throws MailException * @since 102.0.1 */ public function setFromByScope($from, $scopeId = null) { $result = $this->_senderResolver->resolve($from, $scopeId); $this->addAddressByType('from', $result['email'], $result['name']); return $this; } /** * Reset object state * * @return $this */ protected function reset() { $this->messageData = []; $this->templateIdentifier = null; $this->templateVars = null; $this->templateOptions = null; $this->messageBodyParts = []; return $this; } /** * Prepare message. * * @return $this * @throws LocalizedException if template type is unknown */ protected function prepareMessage() { $template = $this->getTemplate(); $content = $template->processTemplate(); switch ($template->getType()) { case TemplateTypesInterface::TYPE_TEXT: $partType = MimeInterface::TYPE_TEXT; break; case TemplateTypesInterface::TYPE_HTML: $partType = MimeInterface::TYPE_HTML; break; default: throw new LocalizedException( new Phrase('Unknown template type') ); } /** @var \Magento\Framework\Mail\MimePartInterface $mimePart */ $mimePart = $this->mimePartInterfaceFactory->create( [ 'content' => $content, 'type' => $partType ] ); $this->messageData['encoding'] = $mimePart->getCharset(); $this->messageData['body'] = $this->mimeMessageInterfaceFactory->create( ['parts' => [$mimePart]] ); $this->messageData['subject'] = html_entity_decode( (string)$template->getSubject(), ENT_QUOTES ); $this->message = $this->emailMessageInterfaceFactory->create($this->messageData); if (count($this->messageBodyParts)) { $this->messageData['body']->getMimeMessage()->setBody( new MixedPart($this->messageData['body']->getMimeMessage()->getBody(), ...$this->messageBodyParts) ); } return $this; } /** * Handles possible incoming types of email (string or array) * * @param string $addressType * @param string|array $email * @param string|null $name * * @return void * @throws InvalidArgumentException */ private function addAddressByType(string $addressType, $email, ?string $name = null): void { if (is_string($email)) { $this->messageData[$addressType][] = $this->addressConverter->convert($email, $name); return; } $convertedAddressArray = $this->addressConverter->convertMany($email); if (isset($this->messageData[$addressType])) { $this->messageData[$addressType] = array_merge( $this->messageData[$addressType], $convertedAddressArray ); } else { $this->messageData[$addressType] = $convertedAddressArray; } } /** * @inheritdoc */ public function addAttachment( $body, $filename = null, $mimeType = Mime::TYPE_OCTETSTREAM ) { $this->messageBodyParts[] = new DataPart( $body, $filename, $mimeType, Mime::ENCODING_BASE64 ); return $this; } }
-
Configure and Send Email with Custom TransportBuilder
Use the custom
TransportBuilder
(defined indi.xml
) to send emails with attachments. Inject the custom class into your service and configure it with template variables and attachments. Below is an example using thesendEmail
method:customTransportBuilder // Use custom TransportBuilder ->setTemplateIdentifier($this->scopeConfig->getValue(self::EMAIL_SEND_TO_SUPPLIER_TEMPLATE)) ->setTemplateOptions( [ 'area' => \Magento\Framework\App\Area::AREA_FRONTEND, 'store' => \Magento\Store\Model\Store::DEFAULT_STORE_ID, ] ) ->setTemplateVars([ 'purchase_order' => $purchaseOrder, 'supplier' => $supplier, 'logo_url' => $logo_src ]) ->setFrom($sender) ->addTo(trim($supplier->getContactEmail())) ->addAttachment($fileContent, 'PurchaseOrder-' . $poCode . '.pdf'); ?>
Note: Ensure the custom
TransportBuilder
class (Vendor\Module\Model\Email\TransportBuilder
) extendsMagento\Framework\Mail\Template\TransportBuilder
and implements theaddAttachment
method to handle file attachments correctly. Avoid circular dependencies by not injecting the service back into the custom builder. -
Configure Email Template
Create an email template in the Magento admin panel under Content > Email Templates. Assign it a unique
templateId
for use in thesendEmail
method. -
Test the Implementation
Test the email functionality by triggering an order email or using a custom script to call the
sendEmail
method. Verify that attachments (e.g., PDF invoices) are included and that the email renders correctly across clients like Gmail and Outlook. Use web-safe fonts (e.g., Arial) and keep attachment sizes below 100KB to avoid issues like Gmail clipping. Additionally, configure the MagePlaza SMTP module for reliable email delivery: navigate to Stores > Configuration > MagePlaza Extensions > SMTP Configuration in the Magento admin panel, enable the SMTP extension, and set up your email provider credentials (e.g., Gmail SMTP server: smtp.gmail.com, port 587, with TLS). Test the connection and save the configuration to ensure emails are sent through your preferred SMTP server.