跳转到内容
getnextpdf.com

将生成的 PDF 作为邮件附件发送

把一份发票、收据或报表用邮件发出去,是你对一份生成的 PDF 所做的最常见的事情之一。干净的做法是构建文档、取它的原始字节,并把那些字节直接交给你的 mailer 的附件 API。你在任何环节都不需要一个磁盘上的临时文件。

这是一篇 how-to。它假设你已经知道如何在你的框架中发送邮件。NextPDF 这一侧只是一次调用:Document::getPdfData() 把原始的可移植文档格式(PDF)字节作为一个字符串返回。附件这一侧完全属于你的 mailer——本指南使用 Laravel 的 Attachment::fromData() 与 Symfony Mailer 的 Email::attach()

NextPDF 出货任何邮件辅助工具。文档上没有“email this PDF”方法,而且你应当对任何展示这种方法的示例保持怀疑。附件 API 始终是你框架的。

本页是 把文件嵌入 PDF 内部 的外发对照面。那份指南把文件作为嵌入流附加 PDF;本指南把完成的 PDF 附加到一封邮件上。它们是不同的操作——不要把两者混为一谈。

无论你还做什么,NextPDF 这一步都相同:产出字节。

<?php
declare(strict_types=1);
use NextPDF\Core\Document;
// Standalone entrypoint: the static factory wires the default dependencies.
// (The bare `new Document(...)` constructor requires injected collaborators.)
$document = Document::createStandalone();
$document->addPage();
$document->cell(0, 10, 'Invoice #1042', newLine: true);
// Raw PDF bytes, built in memory. No file is written.
$bytes = $document->getPdfData();

getPdfData() 构建文档并把它的字节作为一个字符串返回。它不向磁盘写入任何东西,也不发送任何超文本传输协议(HTTP)头,这正是你对一个附件所想要的。

如果你只通过 NextPDF\Contracts\PdfDocumentInterface 类型持有文档(例如,框架集成交给你的一个值),请改用契约级的等价方式:

use NextPDF\Contracts\OutputDestination;
$bytes = $document->output(dest: OutputDestination::String);

output(dest: OutputDestination::String) 声明在 PdfDocumentInterface 上,并返回相同的原始字节而不发出任何头。当你持有一个具体的 NextPDF\Core\Document 时使用 getPdfData(),当你只有接口时使用 output(...) 形式。

如果你使用 Laravel 或 Symfony 集成,请从容器解析一份全新文档,而不要直接构造一份——各框架中的解析路径参见 从控制器返回生成的 PDF。无论你以何种方式取得文档,下文一切都一样有效。

Laravel:把内存中的字节附加到一个 Mailable

标题为“Laravel:把内存中的字节附加到一个 Mailable”的章节

Laravel 的 Attachment::fromData() 接受一个返回原始字节的回调,外加一个文件名。没有临时文件。在你的 Mailable 上实现 attachments() 并返回一个 Attachment

app/Mail/InvoiceMail.php
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Invoice;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use NextPDF\Core\Document;
final class InvoiceMail extends Mailable
{
public function __construct(private readonly Invoice $invoice) {}
public function envelope(): Envelope
{
return new Envelope(subject: "Invoice #{$this->invoice->number}");
}
public function content(): Content
{
return new Content(markdown: 'mail.invoice');
}
/** @return array<int, Attachment> */
public function attachments(): array
{
return [
Attachment::fromData(fn (): string => $this->buildPdf(), "invoice-{$this->invoice->number}.pdf")
->withMime('application/pdf'),
];
}
private function buildPdf(): string
{
// Standalone document: the static factory wires the default
// dependencies, so this example is self-contained. If you use the
// nextpdf/laravel integration, resolve a document via its documented
// binding instead — see the integration page linked below.
$document = Document::createStandalone();
$document->addPage();
$document->cell(0, 10, "Invoice #{$this->invoice->number}", newLine: true);
return $document->getPdfData();
}
}

照常发送它:

use App\Mail\InvoiceMail;
use Illuminate\Support\Facades\Mail;
Mail::to($invoice->customerEmail)->send(new InvoiceMail($invoice));

fromData() 回调会在消息被构建时惰性调用,因此 PDF 是在发送时生成的,而非在构造时。请设置 ->withMime('application/pdf'),让收件人的客户端把这一部分当作 PDF,而不是从扩展名去猜测。在控制器内部,你也可以在一个原始消息上调用 $message->attachData($bytes, $name, ['mime' => 'application/pdf']),但在 Mailable 上使用 Attachment::fromData() 才是惯用的现代形式。

Symfony Mailer 的 Email::attach() 接受内存中字符串形式的主体,外加一个显式的文件名与内容类型。同样地,没有临时文件。

src/Mailer/InvoiceMailer.php
<?php
declare(strict_types=1);
namespace App\Mailer;
use NextPDF\Core\DocumentFactory;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
final class InvoiceMailer
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly DocumentFactory $documents,
) {}
public function sendInvoice(string $to, int $invoiceId): void
{
// Build a fresh document from the factory, which wires the default
// dependencies for you. (Use Document::createStandalone() if you do
// not have the factory injected.)
$document = $this->documents->create();
$document->addPage();
$document->cell(0, 10, "Invoice #{$invoiceId}", newLine: true);
$email = (new Email())
->from('billing@example.com')
->to($to)
->subject("Invoice #{$invoiceId}")
->text('Your invoice is attached.')
->attach(
$document->getPdfData(),
"invoice-{$invoiceId}.pdf",
'application/pdf',
);
$this->mailer->send($email);
}
}

attach(string $body, ?string $name, ?string $contentType) 直接接受字节。请把 'application/pdf' 作为第三个参数传入,使该部分被正确地类型化。如果你更愿意从一个流附加,存在 attachFromPath(),但对于生成的内容,内存中的 attach() 形式避免了一次无谓的磁盘往返。

构建一份多页 PDF 与发送邮件都足够慢,以致你不应在请求线程上做它们。请把这项工作排入队列。其模式是派发一个 job(在 Laravel 中,也可以把 Mailable 本身排入队列),并在 worker 上构建 PDF。

最简单的 Laravel 形式:让 Mailable 实现 ShouldQueue。由于 Attachment::fromData() 回调会在队列消息被构建时运行,PDF 是在 worker 上生成的,而非在派发时。

app/Mail/InvoiceMail.php (queued)
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
final class InvoiceMail extends Mailable implements ShouldQueue
{
// ... same envelope(), content(), attachments() as above ...
}
// Dispatched to the queue; the worker builds the PDF and sends the mail.
Mail::to($invoice->customerEmail)->queue(new InvoiceMail($invoice));

对于 Symfony,请派发一个携带标识符(而非字节)的 Messenger 消息,并让 handler 在 worker 上构建 PDF 并发送邮件。传入一个 invoice id,在 handler 中查出该记录,并调用上文所示的 InvoiceMailer。当为 SendEmailMessage 配置了一个 Messenger transport 时,Symfony 的 mailer 已经是异步的,因此即便是一个同步调用的 send() 也能被传输到一个 worker 上。

如果你在一个专门的生成 job 中生成 PDF 然后再用邮件发送它,关于该集成的 GeneratePdfJob / GeneratePdfMessage 接口及其 worker 安全规则,参见 在队列工作中生成 PDF。一种常见形态是:一个 job 生成并保存 PDF,第二个 job(或成功回调)把它读回并用邮件发送。当你在单个 job 内把字节保留在内存中时,你完全跳过了文件。

  • 附件,而非内联。 一份生成的发票或报表几乎总是一个独立的可下载文件,因此请把它作为附件。请把内联内容(带 cid: 引用的 Content-Disposition: inline)保留给你嵌入 HTML 主体中的图像——一份 PDF 不是主体内容。
  • 留意体积。 邮件附件在传输中以 base64 编码,这会让载荷膨胀约三分之一。许多接收服务器把一封消息的上限设在编码之后约 10–25 MB。对于一份大型报表,请附上一封带签名下载链接的简短通知邮件,而不是文件本身,并通过 HTTP 提供该 PDF——参见 从控制器返回生成的 PDF
  • 构建一次,附加一次。 把返回的字节一次性捕获进一个变量,并把那个字符串复用于该消息。不要为一封邮件反复调用最终的输出方法。
  • worker 上的内存。 对于典型的发票与收据,把整份 PDF 保留在内存中没有问题。对于受限 worker 上非常大的文档,请用 save() 保存到一个临时路径、通过 Laravel attach($path) / Symfony Email::attachFromPath($path) 附加,然后删除该文件——以一次磁盘往返换取一个更低的内存峰值。(对于本页其余各处使用的内存内路径,基于字节的形式是 Laravel Attachment::fromData() / Symfony Email::attach($bytes, 'name.pdf', 'application/pdf')。)
  • 绝不要把未经验证的用户输入插入附件文件名。请传入一个你可控的值(一个由你生成的发票号),使收件人的客户端无法被一个精心构造的名称所操纵。
  • 只向每个客户发送其本人的文档。请在 job 内部从已认证主体的记录构建 PDF,而不是从一个轻信请求所取的 id 构建。
  • 在队列路径中,失败时记录异常类与一个关联 id,绝不记录异常消息或堆栈跟踪。绝不要在构建并发送的外围写一个空的 catch 块。