简介

发送邮件不必过于复杂。Laravel 提供了由广泛使用的 Symfony Mailer 组件驱动的简洁、简单的邮件 API。Laravel 和 Symfony Mailer 提供了通过 SMTP、Cloudflare、Mailgun、Postmark、Resend、Amazon SES 以及 sendmail 发送邮件的驱动,让你能够快速开始通过自己选择的本地或基于云的服务发送邮件。

配置

Laravel 的邮件服务可通过应用程序的 config/mail.php 配置文件进行配置。此文件中配置的每个邮件器都可能有其自身独特的配置,甚至拥有自己独特的“传输方式”,允许你的应用程序使用不同的邮件服务来发送特定邮件。例如,你的应用程序可使用 Postmark 发送事务性邮件,同时使用 Amazon SES 发送批量邮件。

在 mail 配置文件内,你会找到一个 mailers 配置数组。此数组包含 Laravel 支持的每个主要邮件驱动/传输方式的示例配置条目,而 default 配置值决定了应用程序在需要发送邮件时默认使用的邮件器。

驱动/传输方式前置条件

基于 API 的驱动(如 Mailgun、Postmark 和 Resend)通常比通过 SMTP 服务器发送邮件更简单、更快捷。只要有可能,我们建议你使用这些驱动之一。

Cloudflare 驱动

要使用 Cloudflare 驱动,请通过 Composer 安装 Symfony 的 HTTPClient:

composer require symfony/http-client

接下来,你需要在应用程序的 config/mail.php 配置文件中进行两处修改。首先,将默认邮件器设置为 cloudflare:

'default' => env('MAIL_MAILER', 'cloudflare'),

其次,将以下配置数组添加到你的 mailers 数组中:

'cloudflare' => [
    'transport' => 'cloudflare',
],

配置完应用程序的默认邮件器后,将以下选项添加到你的 config/services.php 配置文件中:

'cloudflare' => [
    'account_id' => env('CLOUDFLARE_ACCOUNT_ID'),
    'key' => env('CLOUDFLARE_KEY'),
],

Mailgun 驱动

要使用 Mailgun 驱动,请通过 Composer 安装 Symfony 的 Mailgun Mailer 传输方式:

composer require symfony/mailgun-mailer symfony/http-client

接下来,你需要在应用程序的 config/mail.php 配置文件中进行两处修改。首先,将默认邮件器设置为 mailgun:

'default' => env('MAIL_MAILER', 'mailgun'),

其次,将以下配置数组添加到你的 mailers 数组中:

'mailgun' => [
    'transport' => 'mailgun',
    // 'client' => [
    //     'timeout' => 5,
    // ],
],

配置完应用程序的默认邮件器后,将以下选项添加到你的 config/services.php 配置文件中:

'mailgun' => [
    'domain' => env('MAILGUN_DOMAIN'),
    'secret' => env('MAILGUN_SECRET'),
    'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
    'scheme' => 'https',
],

如果你使用的不是美国 Mailgun 区域,你可以在 services 配置文件中定义你所在区域的端点:

'mailgun' => [
    'domain' => env('MAILGUN_DOMAIN'),
    'secret' => env('MAILGUN_SECRET'),
    'endpoint' => env('MAILGUN_ENDPOINT', 'api.eu.mailgun.net'),
    'scheme' => 'https',
],

Postmark 驱动

要使用 Postmark 驱动,请通过 Composer 安装 Symfony 的 Postmark Mailer 传输方式:

composer require symfony/postmark-mailer symfony/http-client

接下来,将应用程序 config/mail.php 配置文件中的 default 选项设置为 postmark。配置完应用程序的默认邮件器后,确保你的 config/services.php 配置文件包含以下选项:

'postmark' => [
    'key' => env('POSTMARK_API_KEY'),
],

如果你想要指定特定邮件器应使用的 Postmark 消息流,可将 message_stream_id 配置选项添加到该邮件器的配置数组中。该配置数组可在应用程序的 config/mail.php 配置文件中找到:

'postmark' => [
    'transport' => 'postmark',
    'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
    // 'client' => [
    //     'timeout' => 5,
    // ],
],

通过这种方式,你还能设置多个具有不同消息流的 Postmark 邮件器。

Resend 驱动

要使用 Resend 驱动,请通过 Composer 安装 Resend 的 PHP SDK:

composer require resend/resend-php

接下来,将应用程序 config/mail.php 配置文件中的 default 选项设置为 resend。配置完应用程序的默认邮件器后,确保你的 config/services.php 配置文件包含以下选项:

'resend' => [
    'key' => env('RESEND_API_KEY'),
],

SES 驱动

要使用 Amazon SES 驱动,你必须先安装适用于 PHP 的 Amazon AWS SDK。你可以通过 Composer 包管理器安装该库:

composer require aws/aws-sdk-php

接下来,将你的 config/mail.php 配置文件中的 default 选项设置为 ses,并确认你的 config/services.php 配置文件包含以下选项:

'ses' => [
    'key' => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],

若要通过会话令牌使用 AWS 临时凭证,你可以在应用程序的 SES 配置中添加一个 token 键:

'ses' => [
    'key' => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
    'token' => env('AWS_SESSION_TOKEN'),
],

若要使用 SES 的订阅管理功能,你可以在邮件消息的 headers 方法返回的数组中返回 X-Ses-List-Management-Options 头部:

/**
 * Get the message headers.
 */
public function headers(): Headers
{
    return new Headers(
        text: [
            'X-Ses-List-Management-Options' => 'contactListName=MyContactList;topicName=MyTopic',
        ],
    );
}

若要通过 SES 租户发送邮件,你可以从 headers 方法中返回 X-Ses-Tenant-Name 头部。Laravel 在发送邮件时会将该头部值作为 TenantName 选项传递给 SES:

public function headers(): Headers
{
    return new Headers(
        text: [
            'X-Ses-Tenant-Name' => 'tenant-id',
        ],
    );
}

如果你想定义其他选项,以便 Laravel 在发送邮件时将这些选项传递给 AWS SDK 的 SendEmail 方法,你可以在 ses 配置中定义一个 options 数组:

'ses' => [
    'key' => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
    'options' => [
        'ConfigurationSetName' => 'MyConfigurationSet',
        'EmailTags' => [
            ['Name' => 'foo', 'Value' => 'bar'],
        ],
    ],
],

故障转移配置

有时,你配置用于发送应用程序邮件的外部服务可能会出现故障。在这些情况下,定义一个或多个备用邮件投递配置会很有用,以便在主要投递驱动不可用时使用。

要实现这一点,你应该在应用程序的 mail 配置文件中定义一个使用 failover 传输方式的邮件器。应用程序的 failover 邮件器配置数组应包含一个 mailers 数组,该数组指定了在投递时应按什么顺序选择已配置的邮件器:

'mailers' => [
    'failover' => [
        'transport' => 'failover',
        'mailers' => [
            'postmark',
            'mailgun',
            'sendmail',
        ],
        'retry_after' => 60,
    ],

    // ...
],

配置好使用 failover 传输方式的邮件发送器后,需要将该故障转移发送器在应用程序的 .env 文件中设为默认发送器,方可使用故障转移功能:

MAIL_MAILER=failover

轮询(Round Robin)配置

roundrobin 传输方式可让您将邮件任务分发至多个发送器。要使用该功能,需在应用程序的 mail 配置文件中定义一个使用 roundrobin 传输方式的发送器。应用程序 roundrobin 发送器的配置数组应包含一个 mailers 数组,用于指定应使用哪些已配置的发送器来投递邮件:

'mailers' => [
    'roundrobin' => [
        'transport' => 'roundrobin',
        'mailers' => [
            'ses',
            'postmark',
        ],
        'retry_after' => 60,
    ],

    // ...
],

定义好轮询发送器后,应在应用程序的 mail 配置文件中,将其名称指定为 default 配置键的值,从而将该发送器设为应用程序使用的默认发送器:

'default' => env('MAIL_MAILER', 'roundrobin'),

轮询传输方式会从已配置的发送器列表中随机选择一个发送器,并在后续的每封邮件中依次切换至下一个可用发送器。与助力实现高可用性的 failover 传输方式不同,roundrobin 传输方式提供的是负载均衡。

生成 Mailable

开发 Laravel 应用时,应用发送的每类邮件都会用一个“mailable”类来表示。这些类存放于 app/Mail 目录中。若应用中未显示该目录,不必担心,使用 make:mail Artisan 命令创建首个 mailable 类时,系统会自动生成该目录:

php artisan make:mail OrderShipped

编写 Mailable

生成 mailable 类后,打开该类以了解其内容。mailable 类的配置通过多个方法完成,包括 envelope、content 和 attachments 方法。

envelope 方法返回一个 Illuminate\Mail\Mailables\Envelope 对象,用于定义邮件主题,有时也会定义收件人。content 方法返回一个 Illuminate\Mail\Mailables\Content 对象,用于定义生成邮件内容时所用的Blade 模板。

配置发件人

使用 Envelope

首先,我们来看如何配置邮件的发件人,也就是邮件的“发件人”信息。配置发件人有两种方式,第一种是在邮件的 envelope 中指定“from”地址:

use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;

/**
 * 获取邮件信封。
 */
public function envelope(): Envelope
{
    return new Envelope(
        from: new Address('[email protected]', 'Jeffrey Way'),
        subject: 'Order Shipped',
    );
}

如有需要,也可指定 replyTo 地址:

return new Envelope(
    from: new Address('[email protected]', 'Jeffrey Way'),
    replyTo: [
        new Address('[email protected]', 'Taylor Otwell'),
    ],
    subject: 'Order Shipped',
);

使用全局 from 地址

不过,如果应用的所有邮件都使用相同的“from”地址,每次生成 mailable 类时都要添加该地址,操作会十分繁琐。此时,可在 config/mail.php 配置文件中指定全局“from”地址。若 mailable 类中未指定其他“from”地址,系统会使用该全局地址:

'from' => [
    'address' => env('MAIL_FROM_ADDRESS', '[email protected]'),
    'name' => env('MAIL_FROM_NAME', 'Example'),
],

此外,还可在 config/mail.php 配置文件中定义全局“reply_to”地址:

'reply_to' => [
    'address' => '[email protected]',
    'name' => 'App Name',
],

配置视图

在 mailable 类的 content 方法中,可定义 view,即渲染邮件内容时使用的模板。由于每封邮件通常使用Blade 模板渲染内容,因此在构建邮件 HTML 时,能充分运用 Blade 模板引擎的所有功能与便捷性:

/**
 * 获取邮件内容定义。
 */
public function content(): Content
{
    return new Content(
        view: 'mail.orders.shipped',
    );
}

建议创建 resources/views/mail 目录来存放所有邮件模板,但也可将模板放在 resources/views 目录下的任何位置。

纯文本邮件

若想定义邮件的纯文本版本,可在创建邮件的 Content 定义时指定纯文本模板。与 view 参数类似,text 参数应为模板名称,用于渲染邮件内容。可同时定义邮件的 HTML 版本和纯文本版本:

/**
 * 获取邮件内容定义。
 */
public function content(): Content
{
    return new Content(
        view: 'mail.orders.shipped',
        text: 'mail.orders.shipped-text'
    );
}

为便于理解,html 参数可作为 view 参数的别名使用:

return new Content(
    html: 'mail.orders.shipped',
    text: 'mail.orders.shipped-text'
);

视图数据

通过公共属性传递

通常需要向视图传递一些数据,用于渲染邮件的 HTML。有两种方式可让数据在视图中可用。第一种,mailable 类中定义的所有公共属性,会自动在视图中生效。例如,可向 mailable 类的构造函数传递数据,并将数据赋值给类中定义的公共属性:

<?php

namespace App\Mail;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Queue\SerializesModels;

class OrderShipped extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * 创建新的消息实例。
     */
    public function __construct(
        public Order $order,
    ) {}

    /**
     * 获取邮件内容定义。
     */
    public function content(): Content
    {
        return new Content(
            view: 'mail.orders.shipped',
        );
    }
}

数据设为公共属性后,会自动在视图中可用,因此在 Blade 模板中可像访问其他数据一样访问该数据:

<div>
    Price: {{ $order->price }}
</div>

通过 with 参数传递:

若想在数据发送到模板前自定义其格式,可通过 Content 定义的 with 参数,手动将数据传递给视图。通常会通过 mailable 类的构造函数传递数据,但需将数据设为 protected 或 private 属性,避免数据自动在模板中可用:

<?php

namespace App\Mail;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Queue\SerializesModels;

class OrderShipped extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * 创建新的消息实例。
     */
    public function __construct(
        protected Order $order,
    ) {}

    /**
     * 获取邮件内容定义。
     */
    public function content(): Content
    {
        return new Content(
            view: 'mail.orders.shipped',
            with: [
                'orderName' => $this->order->name,
                'orderPrice' => $this->order->price,
            ],
        );
    }
}

一旦数据通过 with 参数传入,它将自动在视图中可用,因此您可以像访问 Blade 模板中的任何其他数据一样访问它:

<div>
    Price: {{ $orderPrice }}
</div>

附件

若要为电子邮件添加附件,需将附件添加到邮件的 attachments 方法返回的数组中。首先,您可以通过向 Attachment 类提供的 fromPath 方法传入文件路径来添加附件:

use Illuminate\Mail\Mailables\Attachment;

/**
 * Get the attachments for the message.
 *
 * @return array<int, \Illuminate\Mail\Mailables\Attachment>
 */
public function attachments(): array
{
    return [
        Attachment::fromPath('/path/to/file'),
    ];
}

为邮件添加文件时,您也可以使用 as 和 withMime 方法指定附件的显示名称和/或 MIME 类型:

/**
 * Get the attachments for the message.
 *
 * @return array<int, \Illuminate\Mail\Mailables\Attachment>
 */
public function attachments(): array
{
    return [
        Attachment::fromPath('/path/to/file')
            ->as('name.pdf')
            ->withMime('application/pdf'),
    ];
}

从磁盘附加文件

如果您将文件存储在文件系统磁盘之一上,可以使用 fromStorage 附件方法将其附加到电子邮件中:

/**
 * Get the attachments for the message.
 *
 * @return array<int, \Illuminate\Mail\Mailables\Attachment>
 */
public function attachments(): array
{
    return [
        Attachment::fromStorage('/path/to/file'),
    ];
}

当然,您也可以指定附件的名称和 MIME 类型:

/**
 * Get the attachments for the message.
 *
 * @return array<int, \Illuminate\Mail\Mailables\Attachment>
 */
public function attachments(): array
{
    return [
        Attachment::fromStorage('/path/to/file')
            ->as('name.pdf')
            ->withMime('application/pdf'),
    ];
}

如果您需要指定非默认磁盘的存储磁盘,可以使用 fromStorageDisk 方法:

/**
 * Get the attachments for the message.
 *
 * @return array<int, \Illuminate\Mail\Mailables\Attachment>
 */
public function attachments(): array
{
    return [
        Attachment::fromStorageDisk('s3', '/path/to/file')
            ->as('name.pdf')
            ->withMime('application/pdf'),
    ];
}

原始数据附件

fromData 附件方法可用于将原始字节字符串作为附件添加。例如,如果您在内存中生成了 PDF,并且希望将其附加到电子邮件中而无需将其写入磁盘,就可以使用此方法。fromData 方法接受一个闭包,该闭包用于解析原始数据字节,同时还要提供附件应分配的名称:

/**
 * Get the attachments for the message.
 *
 * @return array<int, \Illuminate\Mail\Mailables\Attachment>
 */
public function attachments(): array
{
    return [
        Attachment::fromData(fn () => $this->pdf, 'Report.pdf')
            ->withMime('application/pdf'),
    ];
}

内联附件

在电子邮件中嵌入内联图像通常比较麻烦,不过 Laravel 提供了一种便捷的方式将图像附加到电子邮件中。若要嵌入内联图像,可在电子邮件模板中使用 $message 变量上的 embed 方法。Laravel 会自动将所有电子邮件模板中的 $message 变量设置为可用,因此您无需担心手动传入它:

<body>
    Here is an image:

    <img src="{{ $message->embed($pathToImage) }}">
</body>

由于纯文本邮件不使用内联附件,因此在纯文本邮件模板中无法使用 $message 变量。

嵌入原始数据附件

如果您已经拥有想要嵌入电子邮件模板的原始图像数据字符串,可以在 $message 变量上调用 embedData 方法。调用 embedData 方法时,需要提供应分配给嵌入图像的文件名:

<body>
    Here is an image from raw data:

    <img src="{{ $message->embedData($data, 'example-image.jpg') }}">
</body>

可附加对象

虽然通过简单的字符串路径向邮件添加文件通常已足够,但在许多情况下,应用程序中的可附加实体是由类表示的。例如,如果您的应用程序要将照片附加到邮件中,应用程序中可能还有一个代表该照片的 Photo 模型。遇到这种情况时,若只需将 Photo 模型传递给 attach 方法,岂不是很方便?可附加对象就允许您实现这一点。

要开始使用,请在可附加到邮件的对象上实现 Illuminate\Contracts\Mail\Attachable 接口。该接口要求您的类定义一个 toMailAttachment 方法,该方法返回一个 Illuminate\Mail\Attachment 实例:

<?php

namespace App\Models;

use Illuminate\Contracts\Mail\Attachable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Mail\Attachment;

class Photo extends Model implements Attachable
{
    /**
     * Get the attachable representation of the model.
     */
    public function toMailAttachment(): Attachment
    {
        return Attachment::fromPath('/path/to/file');
    }
}

定义好可附加对象后,在构建电子邮件消息时,可以从 attachments 方法返回该对象的实例:

/**
 * Get the attachments for the message.
 *
 * @return array<int, \Illuminate\Mail\Mailables\Attachment>
 */
public function attachments(): array
{
    return [$this->photo];
}

当然,附件数据可以存储在远程文件存储服务(如 Amazon S3)中。因此,Laravel 还允许您从应用程序文件系统磁盘之一中存储的数据生成附件实例:

// Create an attachment from a file on your default disk...
return Attachment::fromStorage($this->path);

// Create an attachment from a file on a specific disk...
return Attachment::fromStorageDisk('backblaze', $this->path);

此外,您还可以通过内存中的数据创建附件实例。为此,请向 fromData 方法提供一个闭包。该闭包应返回代表附件的原始数据:

return Attachment::fromData(fn () => $this->content, 'Photo Name');

Laravel 还提供了一些额外的方法,您可以使用这些方法来自定义附件。例如,您可以使用 as 和 withMime 方法自定义文件的名称和 MIME 类型:

return Attachment::fromPath('/path/to/file')
    ->as('Photo Name')
    ->withMime('image/jpeg');

标头

有时您可能需要为发出的邮件添加额外的标头。例如,您可能需要设置自定义的 Message-Id 或其他任意文本标头。

要实现这一点,请在可邮寄对象上定义一个 headers 方法。headers 方法应返回一个 Illuminate\Mail\Mailables\Headers 实例。该类接受 messageId、references 和 text 参数。当然,您可以只提供特定邮件所需的参数:

use Illuminate\Mail\Mailables\Headers;

/**
 * Get the message headers.
 */
public function headers(): Headers
{
    return new Headers(
        messageId: '[email protected]',
        references: ['[email protected]'],
        text: [
            'X-Custom-Header' => 'Custom Value',
        ],
    );
}

标签和元数据

一些第三方电子邮件服务提供商(如 Mailgun 和 Postmark)支持邮件的“标签”和“元数据”,可用于对应用程序发送的邮件进行分组和跟踪。您可以通过 Envelope 定义向电子邮件添加标签和元数据:

use Illuminate\Mail\Mailables\Envelope;

/**
 * Get the message envelope.
 *
 * @return \Illuminate\Mail\Mailables\Envelope
 */
public function envelope(): Envelope
{
    return new Envelope(
        subject: 'Order Shipped',
        tags: ['shipment'],
        metadata: [
            'order_id' => $this->order->id,
        ],
    );
}

如果你的应用使用了 Mailgun 驱动,你可以参阅 Mailgun 的文档,了解更多关于标签(tags)和元数据(metadata)的信息。同样,你也可以参阅 Postmark 的文档,了解其对标签(tags)和元数据(metadata)的支持情况。

如果你的应用使用 Amazon SES 来发送邮件,你应该使用 metadata 方法将SES “标签(tags)”附加到邮件上。

自定义 Symfony 邮件消息

Laravel 的邮件功能基于 Symfony Mailer 实现。Laravel 允许你注册自定义回调,在发送邮件之前,会使用 Symfony 的 Message 实例来调用这些回调。这让你有机会在邮件发送前对其进行深度定制。要实现这一点,请在你的 Envelope 定义中添加一个 using 参数:

use Illuminate\Mail\Mailables\Envelope;
use Symfony\Component\Mime\Email;

/**
 * Get the message envelope.
 */
public function envelope(): Envelope
{
    return new Envelope(
        subject: 'Order Shipped',
        using: [
            function (Email $message) {
                // ...
            },
        ]
    );
}

Markdown Mailables

Markdown mailable 邮件让你能够在 mailable 中利用邮件通知里预先构建好的模板和组件。由于这些邮件使用 Markdown 编写,Laravel 能够为邮件渲染出美观且响应式的 HTML 模板,同时还会自动生成纯文本版本。

生成 Markdown Mailables

若要生成带有对应 Markdown 模板的 mailable,你可以使用 make:mail Artisan 命令的 --markdown 选项:

php artisan make:mail OrderShipped --markdown=mail.orders.shipped

然后,在 mailable 的 content 方法中配置 Content 定义时,请使用 markdown 参数,而不是 view 参数:

use Illuminate\Mail\Mailables\Content;

/**
 * Get the message content definition.
 */
public function content(): Content
{
    return new Content(
        markdown: 'mail.orders.shipped',
        with: [
            'url' => $this->orderUrl,
        ],
    );
}

编写 Markdown 邮件

Markdown mailable 结合了 Blade 组件和 Markdown 语法,让你能够轻松构建邮件内容,同时还能利用 Laravel 预先构建好的邮件 UI 组件:

<x-mail::message>
# Order Shipped

Your order has been shipped!

<x-mail::button :url="$url">
View Order
</x-mail::button>

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

编写 Markdown 邮件时,不要使用过度缩进。按照 Markdown 标准,Markdown 解析器会将缩进的内容渲染成代码块。

按钮组件

按钮组件会渲染一个居中的按钮链接。该组件接收两个参数:一个 url 和一个可选的 color。支持的颜色有 primary、success 和 error。你可以在一封邮件中添加任意数量的按钮组件:

<x-mail::button :url="$url" color="success">
View Order
</x-mail::button>

面板组件

面板组件会将给定的文本块渲染在一个面板中,该面板的背景色与邮件其余部分稍有不同。这让你可以突出显示某个文本块:

<x-mail::panel>
This is the panel content.
</x-mail::panel>

表格组件

表格组件可以将 Markdown 表格转换成 HTML 表格。该组件接收 Markdown 表格作为其内容。支持使用默认的 Markdown 表格对齐语法来设置表格列的对齐方式:

<x-mail::table>
| Laravel       | Table         | Example       |
| ------------- | :-----------: | ------------: |
| Col 2 is      | Centered      | $10           |
| Col 3 is      | Right-Aligned | $20           |
</x-mail::table>

自定义组件

你可以将所有的 Markdown 邮件组件导出到自己的应用中进行自定义。要导出这些组件,请使用 vendor:publish Artisan 命令,发布 laravel-mail 资源标签:

php artisan vendor:publish --tag=laravel-mail

该命令会将 Markdown 邮件组件发布到 resources/views/vendor/mail 目录下。mail 目录会包含 html 目录和 text 目录,每个目录都包含了各个可用组件对应的表现形式。你可以根据自己的需求自由地对这些组件进行自定义。

自定义 CSS

导出组件之后,resources/views/vendor/mail/html/themes 目录中会有一个 default.css 文件。你可以对这个文件中的 CSS 进行自定义,你的样式会自动转换为 Markdown 邮件 HTML 表现形式中的内联 CSS 样式。

如果你想为 Laravel 的 Markdown 组件打造一个全新的主题,可以在 html/themes 目录中放入一个 CSS 文件。为你的新 CSS 文件命名并保存后,更新应用的 config/mail.php 配置文件中的 theme 选项,使其与你新主题的名称保持一致。

如果要为单个 mailable 自定义主题,可以将 mailable 类的 $theme 属性设置为发送该 mailable 时要使用的主题名称。

发送邮件

要发送邮件,请在 Mail 门面(facade)上使用 to 方法。to 方法接收一个邮箱地址、一个用户实例或一个用户集合。如果你传入一个对象或对象集合,邮件发送器在确定邮件收件人时会自动使用它们的 email 和 name 属性,所以请确保这些对象上有这些属性。指定好收件人后,你可以将 mailable 类的实例传给 send 方法:

<?php

namespace App\Http\Controllers;

use App\Mail\OrderShipped;
use App\Models\Order;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;

class OrderShipmentController extends Controller
{
    /**
     * Ship the given order.
     */
    public function store(Request $request): RedirectResponse
    {
        $order = Order::findOrFail($request->order_id);

        // Ship the order...

        Mail::to($request->user())->send(new OrderShipped($order));

        return redirect('/orders');
    }
}

发送邮件时,你不必只指定“收件人(to)”。你可以将这些方法串联起来,自由设置“收件人(to)”“抄送(cc)”和“密送(bcc)”:

Mail::to($request->user())
    ->cc($moreUsers)
    ->bcc($evenMoreUsers)
    ->send(new OrderShipped($order));

循环遍历收件人

有时,你可能需要通过遍历收件人数组或邮箱地址列表,将一封 mailable 发送给多个收件人。然而,由于 to 方法会将邮箱地址追加到 mailable 的收件人列表中,每次循环迭代都会向之前的所有收件人再发送一封邮件。因此,你应该始终为每个收件人重新创建 mailable 实例:

foreach (['[email protected]', '[email protected]'] as $recipient) {
    Mail::to($recipient)->send(new OrderShipped($order));
}

通过指定邮件发送器发送邮件

默认情况下,Laravel 会使用应用 mail 配置文件中设为 default 的邮件发送器来发送邮件。不过,你也可以使用 mailer 方法,通过特定的邮件发送器配置来发送邮件:

Mail::mailer('postmark')
    ->to($request->user())
    ->send(new OrderShipped($order));

队列邮件

将邮件加入队列

由于发送电子邮件可能会对应用的响应时间产生负面影响,许多开发者会选择将电子邮件加入队列,以便在后台发送。Laravel 借助内置的统一队列 API让这个过程变得十分简单。要将邮件加入队列,在指定邮件收件人之后,在 Mail 门面上使用 queue 方法即可:

Mail::to($request->user())
    ->cc($moreUsers)
    ->bcc($evenMoreUsers)
    ->queue(new OrderShipped($order));

该方法会自动处理将任务推送到队列的操作,从而在后台发送邮件。在使用此功能前,你需要配置队列。

延迟队列消息

若你希望延迟发送队列中的邮件消息,可使用 later 方法。作为其第一个参数,later 方法接收一个 DateTime 实例,用于指定消息应发送的时间:

Mail::to($request->user())
    ->cc($moreUsers)
    ->bcc($evenMoreUsers)
    ->later(now()->plus(minutes: 10), new OrderShipped($order));

推送到指定队列

由于通过 make:mail 命令生成的所有 Mailable 类都使用了 Illuminate\Bus\Queueable 特性,你可以在任意 Mailable 类实例上调用 onQueue 和 onConnection 方法,以此指定消息使用的连接与队列名称:

$message = (new OrderShipped($order))
    ->onConnection('sqs')
    ->onQueue('emails');

Mail::to($request->user())
    ->cc($moreUsers)
    ->bcc($evenMoreUsers)
    ->queue($message);

或者,你也可以使用 Mailable 类上的 Connection 和 Queue 特性来指定连接和队列:

use Illuminate\Queue\Attributes\Connection;
use Illuminate\Queue\Attributes\Queue;

#[Connection('sqs')]
#[Queue('emails')]
class OrderShipped extends Mailable
{
    // ...
}

默认入队

如果你希望某些 Mailable 类始终进入队列,可以在该类上实现 ShouldQueue 契约。这样一来,即便你在发送邮件时调用 send 方法,由于实现了该契约,该 Mailable 依然会被加入队列:

use Illuminate\Contracts\Queue\ShouldQueue;

class OrderShipped extends Mailable implements ShouldQueue
{
    // ...
}

队列中的 Mailable 与数据库事务

当队列中的 Mailable 在数据库事务内被分发时,它们可能会在数据库事务提交之前就被队列处理。此时,你在数据库事务期间对模型或数据库记录所做的任何更改,可能尚未反映到数据库中。此外,在事务中创建的任何模型或数据库记录,在数据库中也可能尚不存在。如果你的 Mailable 依赖这些模型,那么处理发送队列 Mailable 的任务时,就可能出现意外错误。

如果队列连接的 after_commit 配置选项被设置为 false,你仍然可以在发送邮件时通过调用 afterCommit 方法,指定某个入队的 Mailable 要在所有打开的数据库事务提交之后再进行分发:

Mail::to($request->user())->send(
    (new OrderShipped($order))->afterCommit()
);

或者,你也可以在 Mailable 的构造函数中调用 afterCommit 方法:

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;

class OrderShipped extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    /**
     * 创建一个新的消息实例。
     */
    public function __construct()
    {
        $this->afterCommit();
    }
}

若要了解解决这些问题的更多信息,请查阅有关队列任务与数据库事务的文档。

队列邮件失败处理

当队列邮件发送失败时,若在队列 Mailable 类中定义了 failed 方法,该方法会被调用。导致队列邮件失败的 Throwable 实例将作为参数传递给 failed 方法:

<?php

namespace App\Mail;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Throwable;

class OrderDelayed extends Mailable implements ShouldQueue
{
    use SerializesModels;

    /**
     * 处理队列邮件的失败情况。
     */
    public function failed(Throwable $exception): void
    {
        // ...
    }
}

渲染 Mailable

有时,你可能希望在不发送 Mailable 的情况下获取其 HTML 内容。为此,你可以调用 Mailable 的 render 方法。该方法会将 Mailable 的 HTML 内容求值后,以字符串形式返回:

use App\Mail\InvoicePaid;
use App\Models\Invoice;

$invoice = Invoice::find(1);

return (new InvoicePaid($invoice))->render();

在浏览器中预览 Mailable

在设计 Mailable 的模板时,若能像普通的 Blade 模板一样,在浏览器中快速预览渲染后的 Mailable,将会十分方便。为此,Laravel 允许你直接从路由闭包或控制器中返回任意 Mailable。当返回一个 Mailable 时,它会在浏览器中被渲染并显示,让你无需将其发送至真实邮箱地址,就能快速预览其样式:

Route::get('/mailable', function () {
    $invoice = App\Models\Invoice::find(1);

    return new App\Mail\InvoicePaid($invoice);
});

Mailable 本地化

Laravel 允许你使用与当前请求语言环境不同的语言环境发送 Mailable,若邮件被加入队列,系统还会记住该语言环境。

为实现这一点,Mail 门面提供了一个 locale 方法来设置所需的语言。当 Mailable 的模板被求值时,应用程序会切换至该语言环境,而在求值完成后,又会恢复为之前的语言环境:

Mail::to($request->user())->locale('es')->send(
    new OrderShipped($order)
);

用户偏好的语言环境

有时,应用程序会存储每个用户偏好的语言环境。通过在一个或多个模型上实现 HasLocalePreference 契约,你可以指示 Laravel 在发送邮件时使用存储的语言环境:

use Illuminate\Contracts\Translation\HasLocalePreference;

class User extends Model implements HasLocalePreference
{
    /**
     * 获取用户偏好的语言环境。
     */
    public function preferredLocale(): string
    {
        return $this->locale;
    }
}

一旦实现了该接口,Laravel 在向模型发送 Mailable 和通知时,会自动使用其偏好的语言环境。因此,在使用该接口时,无需再调用 locale 方法:

Mail::to($request->user())->send(new OrderShipped($order));

测试

测试 Mailable 内容

Laravel 提供了多种方法,可用于检查 Mailable 的结构。此外,Laravel 还提供了若干便捷方法,用于验证 Mailable 是否包含你预期的内容:

use App\Mail\InvoicePaid;
use App\Models\User;

test('mailable content', function () {
    $user = User::factory()->create();

    $mailable = new InvoicePaid($user);

    $mailable->assertFrom('[email protected]');
    $mailable->assertTo('[email protected]');
    $mailable->assertHasCc('[email protected]');
    $mailable->assertHasBcc('[email protected]');
    $mailable->assertHasReplyTo('[email protected]');
    $mailable->assertHasSubject('Invoice Paid');
    $mailable->assertHasTag('example-tag');
    $mailable->assertHasMetadata('key', 'value');

    $mailable->assertSeeInHtml($user->email);
    $mailable->assertDontSeeInHtml('Invoice Not Paid');
    $mailable->assertSeeInOrderInHtml(['Invoice Paid', 'Thanks']);

    $mailable->assertSeeInText($user->email);
    $mailable->assertDontSeeInText('Invoice Not Paid');
    $mailable->assertSeeInOrderInText(['Invoice Paid', 'Thanks']);

    $mailable->assertHasAttachment('/path/to/file');
    $mailable->assertHasAttachment(Attachment::fromPath('/path/to/file'));
    $mailable->assertHasAttachedData($pdfData, 'name.pdf', ['mime' => 'application/pdf']);
    $mailable->assertHasAttachmentFromStorage('/path/to/file', 'name.pdf', ['mime' => 'application/pdf']);
    $mailable->assertHasAttachmentFromStorageDisk('s3', '/path/to/file', 'name.pdf', ['mime' => 'application/pdf']);
});
use App\Mail\InvoicePaid;
use App\Models\User;

public function test_mailable_content(): void
{
    $user = User::factory()->create();

    $mailable = new InvoicePaid($user);

    $mailable->assertFrom('[email protected]');
    $mailable->assertTo('[email protected]');
    $mailable->assertHasCc('[email protected]');
    $mailable->assertHasBcc('[email protected]');
    $mailable->assertHasReplyTo('[email protected]');
    $mailable->assertHasSubject('Invoice Paid');
    $mailable->assertHasTag('example-tag');
    $mailable->assertHasMetadata('key', 'value');

    $mailable->assertSeeInHtml($user->email);
    $mailable->assertDontSeeInHtml('Invoice Not Paid');
    $mailable->assertSeeInOrderInHtml(['Invoice Paid', 'Thanks']);

    $mailable->assertSeeInText($user->email);
    $mailable->assertDontSeeInText('Invoice Not Paid');
    $mailable->assertSeeInOrderInText(['Invoice Paid', 'Thanks']);

    $mailable->assertHasAttachment('/path/to/file');
    $mailable->assertHasAttachment(Attachment::fromPath('/path/to/file'));
    $mailable->assertHasAttachedData($pdfData, 'name.pdf', ['mime' => 'application/pdf']);
    $mailable->assertHasAttachmentFromStorage('/path/to/file', 'name.pdf', ['mime' => 'application/pdf']);
    $mailable->assertHasAttachmentFromStorageDisk('s3', '/path/to/file', 'name.pdf', ['mime' => 'application/pdf']);
}

正如你所预料的,“HTML”断言用于断言邮件类的 HTML 版本包含指定字符串,而“text”断言则用于断言邮件类的纯文本版本包含指定字符串。

测试邮件类的发送

我们建议将邮件类内容的测试与断言邮件类已“发送”给特定用户的测试分开进行。通常情况下,邮件类的内容与你正在测试的代码无关,仅断言已指示 Laravel 发送指定邮件类就已足够。

你可以使用 Mail 门面的 fake 方法来阻止邮件发送。调用 Mail 门面的 fake 方法后,你就可以断言是否已指示将邮件类发送给用户,甚至可以检查邮件类接收到的数据:

<?php

use App\Mail\OrderShipped;
use Illuminate\Support\Facades\Mail;

test('orders can be shipped', function () {
    Mail::fake();

    // 执行订单发货...

    // 断言没有任何邮件类被发送...
    Mail::assertNothingSent();

    // 断言某个邮件类已被发送...
    Mail::assertSent(OrderShipped::class);

    // 断言某个邮件类被发送了两次...
    Mail::assertSent(OrderShipped::class, 2);

    // 断言邮件类已发送至某个邮箱地址...
    Mail::assertSent(OrderShipped::class, '[email protected]');

    // 断言邮件类已发送至多个邮箱地址...
    Mail::assertSent(OrderShipped::class, ['[email protected]', '...']);

    // 断言某个邮件类未被发送...
    Mail::assertNotSent(AnotherMailable::class);

    // 断言某个邮件类被发送了两次...
    Mail::assertSentTimes(OrderShipped::class, 2);

    // 断言某个邮件类恰好被发送了一次...
    Mail::assertSentOnce(OrderShipped::class);

    // 断言总共发送了 3 个邮件类...
    Mail::assertSentCount(3);
});
<?php

namespace Tests\Feature;

use App\Mail\OrderShipped;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_orders_can_be_shipped(): void
    {
        Mail::fake();

        // 执行订单发货...

        // 断言没有任何邮件类被发送...
        Mail::assertNothingSent();

        // 断言某个邮件类已被发送...
        Mail::assertSent(OrderShipped::class);

        // 断言某个邮件类被发送了两次...
        Mail::assertSent(OrderShipped::class, 2);

        // 断言邮件类已发送至某个邮箱地址...
        Mail::assertSent(OrderShipped::class, '[email protected]');

        // 断言邮件类已发送至多个邮箱地址...
        Mail::assertSent(OrderShipped::class, ['[email protected]', '...']);

        // 断言某个邮件类未被发送...
        Mail::assertNotSent(AnotherMailable::class);

        // 断言某个邮件类被发送了两次...
        Mail::assertSentTimes(OrderShipped::class, 2);

        // 断言某个邮件类恰好被发送了一次...
        Mail::assertSentOnce(OrderShipped::class);

        // 断言总共发送了 3 个邮件类...
        Mail::assertSentCount(3);
    }
}

如果你要将邮件类加入队列以便在后台投递,应当使用 assertQueued 方法,而非 assertSent:

Mail::assertQueued(OrderShipped::class);
Mail::assertQueuedOnce(OrderShipped::class);
Mail::assertNotQueued(OrderShipped::class);
Mail::assertNothingQueued();
Mail::assertQueuedCount(3);

你还可以使用 assertOutgoingCount 方法来断言已发送或已加入队列的邮件类总数:

Mail::assertOutgoingCount(3);

你可以向 assertSent、assertNotSent、assertQueued 或 assertNotQueued 方法传入一个闭包,以断言某个满足指定“真值测试”的邮件类已被发送。只要有至少一个满足该真值测试的邮件类被发送,断言就会成功:

Mail::assertSent(function (OrderShipped $mail) use ($order) {
    return $mail->order->id === $order->id;
});

调用 Mail 门面的断言方法时,闭包接收的邮件类实例提供了一些实用方法,可用于检查邮件类:

Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) use ($user) {
    return $mail->hasTo($user->email) &&
           $mail->hasCc('...') &&
           $mail->hasBcc('...') &&
           $mail->hasReplyTo('...') &&
           $mail->hasFrom('...') &&
           $mail->hasSubject('...') &&
           $mail->hasMetadata('order_id', $mail->order->id) &&
           $mail->usesMailer('ses');
});

邮件类实例还包含多个实用方法,可用于检查邮件类上的附件:

use Illuminate\Mail\Mailables\Attachment;

Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) {
    return $mail->hasAttachment(
        Attachment::fromPath('/path/to/file')
            ->as('name.pdf')
            ->withMime('application/pdf')
    );
});

Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) {
    return $mail->hasAttachment(
        Attachment::fromStorageDisk('s3', '/path/to/file')
    );
});

Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) use ($pdfData) {
    return $mail->hasAttachment(
        Attachment::fromData(fn () => $pdfData, 'name.pdf')
    );
});

你可能注意到了,有两种方法可用于断言邮件未被发送:assertNotSent 和 assertNotQueued。有时你可能希望断言既没有邮件被发送,也没有加入队列。为此,你可以使用 assertNothingOutgoing 和 assertNotOutgoing 方法:

Mail::assertNothingOutgoing();

Mail::assertNotOutgoing(function (OrderShipped $mail) use ($order) {
    return $mail->order->id === $order->id;
});

邮件与本地开发

在开发发送电子邮件的应用时,你可能不希望真的向线上邮箱地址发送邮件。Laravel 提供了多种方式,可在本地开发期间“禁用”邮件的实际发送。

Log 驱动

log 邮件驱动不会发送邮件,而是将所有邮件消息写入日志文件,供你检查。通常情况下,此驱动仅在本地开发期间使用。如需详细了解如何按环境配置应用,请参阅配置文档。

HELO / Mailtrap / Mailpit

此外,你也可以使用 HELO 或 Mailtrap 这类服务,配合 smtp 驱动将邮件消息发送到“虚拟”邮箱,这样你就可以在真实的邮件客户端中查看这些邮件。这种方式的优势在于,你可以在 Mailtrap 的邮件查看器中实际检查最终邮件。

如果你使用的是 Laravel Sail,则可使用 Mailpit 预览邮件。Sail 运行时,可通过以下地址访问 Mailpit 界面:http://localhost:8025。

使用全局 to 地址

最后,你可以通过调用 Mail 门面提供的 alwaysTo 方法,指定一个全局的“收件人”地址。通常情况下,该方法应在应用程序的某一个服务提供者的 boot 方法中调用:

use Illuminate\Support\Facades\Mail;

/**
 * 启动任何应用程序服务。
 */
public function boot(): void
{
    if ($this->app->environment('local')) {
        Mail::alwaysTo('[email protected]');
    }
}

当使用 alwaysTo 方法时,邮件消息中所有额外的“抄送”或“密送”地址都会被移除。

事件

Laravel 在发送邮件消息时会派发两个事件。MessageSending 事件会在消息发送之前派发,而 MessageSent 事件会在消息发送之后派发。请记住,这些事件是在邮件发送时派发的,而不是在邮件入队时派发的。你可以在应用程序中为这些事件创建事件监听器:

use Illuminate\Mail\Events\MessageSending;
// use Illuminate\Mail\Events\MessageSent;

class LogMessage
{
    /**
     * 处理该事件。
     */
    public function handle(MessageSending $event): void
    {
        // ...
    }
}

自定义传输

Laravel 内置了多种邮件传输方式;不过,你可能需要编写自定义的传输方式,以便通过 Laravel 开箱即用所不支持的其他服务来投递邮件。首先,定义一个继承自 Symfony\Component\Mailer\Transport\AbstractTransport 类的类。然后,在你的传输类中实现 doSend 和 __toString 方法:

<?php

namespace App\Mail;

use MailchimpTransactional\ApiClient;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\MessageConverter;

class MailchimpTransport extends AbstractTransport
{
    /**
     * 创建一个新的 Mailchimp 传输实例。
     */
    public function __construct(
        protected ApiClient $client,
    ) {
        parent::__construct();
    }

    /**
     * {@inheritDoc}
     */
    protected function doSend(SentMessage $message): void
    {
        $email = MessageConverter::toEmail($message->getOriginalMessage());

        $this->client->messages->send(['message' => [
            'from_email' => $email->getFrom(),
            'to' => collect($email->getTo())->map(function (Address $email) {
                return ['email' => $email->getAddress(), 'type' => 'to'];
            })->all(),
            'subject' => $email->getSubject(),
            'text' => $email->getTextBody(),
        ]]);
    }

    /**
     * 获取该传输的字符串表示形式。
     */
    public function __toString(): string
    {
        return 'mailchimp';
    }
}

在定义好自定义传输后,你可以通过 Mail 门面提供的 extend 方法来注册它。通常,这应在应用程序的 AppServiceProvider 的 boot 方法中完成。传递给 extend 方法的闭包会接收一个 $config 参数。该参数包含了在应用程序的 config/mail.php 配置文件中为该邮件发送器定义的配置数组:

use App\Mail\MailchimpTransport;
use Illuminate\Support\Facades\Mail;
use MailchimpTransactional\ApiClient;

/**
 * 启动任何应用程序服务。
 */
public function boot(): void
{
    Mail::extend('mailchimp', function (array $config = []) {
        $client = new ApiClient;

        $client->setApiKey($config['key']);

        return new MailchimpTransport($client);
    });
}

在自定义传输定义并注册完成后,你可以在应用程序的 config/mail.php 配置文件中创建一个邮件发送器定义,以使用新的传输:

'mailchimp' => [
    'transport' => 'mailchimp',
    'key' => env('MAILCHIMP_API_KEY'),
    // ...
],

额外的 Symfony 传输

Laravel 内置了对一些现有的、由 Symfony 维护的邮件传输的支持,例如 Mailgun 和 Postmark。不过,你可能希望为 Laravel 扩展支持其他由 Symfony 维护的传输。你可以通过 Composer 安装必要的 Symfony 邮件程序并在 Laravel 中注册该传输来实现这一点。例如,你可以安装并注册“Brevo”(前身为“Sendinblue”)的 Symfony 邮件程序:

composer require symfony/brevo-mailer symfony/http-client

安装 Brevo 邮件程序包后,你可以在应用程序的 services 配置文件中,添加一条用于 Brevo API 凭据的配置项:

'brevo' => [
    'key' => env('BREVO_API_KEY'),
],

接下来,你可以使用 Mail 门面的 extend 方法将该传输注册到 Laravel 中。通常,这应在服务提供者的 boot 方法中完成:

use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory;
use Symfony\Component\Mailer\Transport\Dsn;

/**
 * 启动任何应用程序服务。
 */
public function boot(): void
{
    Mail::extend('brevo', function () {
        return (new BrevoTransportFactory)->create(
            new Dsn(
                'brevo+api',
                'default',
                config('services.brevo.key')
            )
        );
    });
}

在你的传输注册完成后,你可以在应用程序的 config/mail.php 配置文件中创建一个邮件发送器定义,以使用新的传输:

'brevo' => [
    'transport' => 'brevo',
    // ...
],