snicco/better-wp-mail

在WordPress中处理邮件时保持你的理智

v2.0.0-beta.9 2024-09-07 14:27 UTC

README

codecov Psalm Type-Coverage Psalm level PhpMetrics - Static Analysis PHP-Versions

BetterWPMail 是一个小型库,它提供了一组关于 wp_mail 函数的表述性、面向对象的 API。

BetterWPMail 不是一个 SMTP 插件!

它支持(可选)许多邮件传输方式,但默认使用 WPMailTransport,以便在分布式 WordPress 代码中使用。

目录

  1. 动机
  2. 安装
  3. 创建邮件发送器
  4. 创建和发送邮件
    1. 不可变性
    2. 发送邮件
    3. 添加地址
    4. 设置邮件内容
    5. 向模板添加上下文
    6. 添加附件
    7. 嵌入图像
    8. 添加自定义头信息
    9. 全局配置邮件
    10. 扩展邮件类
    11. 使用邮件事件
    12. 用 markdown 编写邮件 / 使用自定义渲染器
    13. 处理异常
  5. 测试
  6. 贡献
  7. 问题和 PR
  8. 安全

动机

列出 wp_mail 函数的所有问题需要很长时间。最常见的问题是

  • ❌ 发送 HTML 主体时没有支持纯文本版本。
  • ❌ 没有支持内联附件。
  • ❌ 没有支持复杂的多部分邮件。
  • ❌ 你不能为附件选择自定义文件名。
  • ❌ 你不能发送已经在内存中的附件(例如生成的 PDF)。你总是必须首先写入临时文件。
  • ❌ 零错误处理。
  • ❌ 没有支持模板邮件。
  • ...
  • ...

许多插件使用大量漏洞来规避这些问题

这就是你可能在大多数 WordPress 插件代码中找到的内容

function my_plugin_send_mail(string $to, string $html_message) {
    
    add_filter('phpmailer_init', 'add_plain_text');
    
    /* Add ten other filters */
    wp_mail($to, $html_message);
    
    remove_filter('phpmailer_init', 'add_plain_text')
    
    /* Remove ten other filters */
}

function add_plain_text(\PHPMailer\PHPMailer\PHPMailer $mailer) {
    $mailer->AltBody = strip_tags($mailer->Body);
}

为什么这么糟糕?

除了你为每个发送的邮件运行了大量的不必要钩子之外,如果 wp_mail 抛出了在其他地方恢复的异常会发生什么?

你现在有十个遗留的钩子回调在同一个 PHP 进程中修改每个发出的邮件。根据你添加的过滤器的类型,现在有极大的潜在漏洞,这些漏洞几乎无法调试。

这个例子可以在 WooCommerce 代码库中看到。(这里不是在抨击 WooCommerce,目前没有其他替代方案,因为 wp_mail 的工作方式。)

在内部,WordPress 使用捆绑的 PHPMailer,这是一个值得信赖和稳定的库。PHPMailer 对上面列出的大多数问题都有原生支持,但 wp_mail 没有使用它们。

这就是 BetterWPMail 发挥作用的地方。

安装

composer require snicco/better-wp-mail

创建一个 Mailer

直接使用 wp_mail,而是使用能够发送 Email 对象的 Mailer 类。

快速入门

use Snicco\Component\BetterWPMail\Mailer;

$mailer = new Mailer();

Mailer::__construct 的完整签名是

  public function __construct(
        ?Transport $transport = null,
        ?MailRenderer $mail_renderer = null,
        ?MailEvents $event_dispatcher = null,
        ?MailDefaults $default_config = null
    )
  • Transport 是一个接口,默认将使用 WPMailTransport,所有邮件最终将通过 wp_mail 函数发送。

    如果您在受控环境中使用 BetterWPMail,您可以提供自己的 Transport 接口实现。如果您正在分发代码,则应始终使用默认传输,因为您无法控制用户将安装的 SMTP 插件。

    将来,我们将创建一个 symfony/mailer 传输,这将允许您使用 Symfony 的邮件器集成的数十个提供商之一发送电子邮件。

  • MailRenderer 接口负责将邮件模板转换为 html/plain-text 内容。默认情况下,将使用 FileSystemRenderer,该渲染器将搜索与模板名称匹配的文件。

  • MailEvents 接口负责在发送邮件之前和之后触发事件。默认情况下,将使用 NullEvents 实例,它不会触发任何事件。

  • MailDefaults 负责提供发送者名称、回复地址等设置的回退配置。

创建和发送邮件

不可变性

Email 类是一个 不可变 值对象。创建电子邮件后,您无法更改它。Email 类的所有公共方法都返回该对象的 新修改版本

不可变性在 PHP 社区中并不常见,但实际上很容易理解。

use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = new Email();

❌ // This is incorrect.
$email->addTo('calvin@snicco.io');

✅ // This is correct
$email = $email->addTo('calvin@snicco.io');

BetterWPMail 的基本约定是

  • add 开头的方法将合并属性并返回一个新的对象。
  • with 开头的方法将替换属性并返回一个新的对象。
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = new Email();

$email = $email->addTo('calvin@snicco.io');
// The email has one recipient now.

$email = $email->addTo('marlon@snicco.io');
// The email has two recipients now.

$email = $email->withTo('jondoe@snicco.io');
// The email has one recipient "jondoe@snicco.io"

发送邮件

使用 Mailer 类发送电子邮件。

至少需要一个收件人和正文(html/text/附件)才能发送电子邮件。

use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io')
                      ->withHtmlBody('<h1>BetterWPMail is awesome</h1>');

$mailer->send($email);

添加地址

所有需要电子邮件地址的方法(from()、to() 等)接受 stringsarraysWP_User 实例或 MailBox 实例。

use Snicco\Component\BetterWPMail\ValueObject\Email;
use Snicco\Component\BetterWPMail\ValueObject\Mailbox;

$email = new Email();

$admin = new WP_User(1);

$email = $email
    
    // email address is a simple string
    ->addTo('calvin@snicco.io')
    
    // with an explicit display name
    ->addCc('Marlon <marlon@snicco.io>')
    
    // as an array, where the first argument is the email
    ->addBcc(['Jon Doe', 'jon@snicco.io'])        
    
    // as an array with a "name" + "email" key        
    ->addFrom(['name' => 'Jane Doe', 'email' => 'jane@snicco.io'])

    // with an instance of WP_USER
    ->addFrom($admin)        
    
    // with an instance of MailBox
    ->addReplyTo(Mailbox::create('no-reply@snicco.io'));

设置邮件内容

您有两种方式设置电子邮件的内容

  1. 通过明确将其设置为字符串。
  2. 通过在电子邮件对象上设置模板,该模板将在发送前被渲染为 html/plain-text。
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io');

$email = $email
    ->withHtmlBody('<h1>BetterWPMail is awesome</h1>')
    ->withTextBody('BetterWPMail supports plain text.')

$templated_email = $email
    ->withHtmlTemplate('/path/to/template-html.php')
    ->withTextBody('/path/to/template-plain.txt')

如果电子邮件具有 html 内容但没有明确指定的文本内容,则 html 内容将通过 strip_tags 传递,并用作纯文本版本。

向模板添加上下文

假设我们想向多个用户发送以下模板的欢迎邮件

<?php
// path/to/email-templates/welcome.php
?>
<h1>Hi <?= esc_html($first_name) ?></h1>,

<p>Thanks for signing up to <?= esc_html($site_name) ?></p>

我们可以利用电子邮件是不可变的这一事实来重用基础电子邮件实例

use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())

    ->withHtmlTemplate('path/to/email-templates/welcome.php')
    
    ->withContext(['site_name' => 'snicco.io']);

// Important: don't use withContext here or site_name is gone.
$email1 = $email->addContext('first_name', 'Calvin')
                ->addTo('calvin@snicco.io');
                
$mailer->send($email1);

$email2 = $email->addContext('first_name', 'Marlon');
                ->addTo('marlon@snicco.io');
                
$mailer->send($email2);

这将导致发送以下两个电子邮件

<h1>Hi Calvin</h1>,

<p>Thanks for signing up to snicco.io</p>
<h1>Hi Marlon</h1>,

<p>Thanks for signing up to snicco.io</p>

添加附件

可以通过两种方式向 Email 实例添加附件

  1. 通过挂载文件系统上的本地路径。
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io');

$email = $email

    ->addAttachment('/path/to/documents/terms-of-use.pdf')
    
    // optionally with a custom display name
    ->addAttachment('/path/to/documents/privacy.pdf', 'Privacy Policy')
    
    // optionally with an explicit content-type,
    ->addAttachment('/path/to/documents/contract.doc', 'Contract', 'application/msword');
  1. 通过挂载一个二进制字符串或流,该字符串或流您已经在内存中有了(例如生成的 PDF)。
use Snicco\Component\BetterWPMail\ValueObject\Email;

$pdf = /* generate pdf */

$email = (new Email())->addTo('calvin@snicco.io');

$email = $email

    ->addBinaryAttachment($pdf, 'Your PDF', 'application/pdf')

BetterWPMail依赖于其Transport接口来执行邮件的实际发送。因此,如果您没有为附件传递显式的MIME类型,则不会进行MIME类型检测。这将被委派给具体的传输实现。

WPMailTransport会将此任务委派给wp_mail/PHPMailerPHPMailer的行为如下:

  1. 如果您传递了显式的MIME类型,则使用该类型。
  2. 尝试从文件名中猜测MIME类型。
  3. 如果2不可能,则默认为application/octet-stream,其定义为“任意二进制数据”。

嵌入图像

如果您想在邮件中显示图像,则必须嵌入它们,而不是将它们作为附件添加。

然后,您可以在邮件内容中使用以下语法引用嵌入的图像:cid: + 图像嵌入名称

<?php
// path/to/email-templates/welcome-with-image.php
?>
<h1>Hi <?= esc_html($first_name) ?></h1>,

<img src="cid:logo">
  use Snicco\Component\BetterWPMail\ValueObject\Email;
  
  $email = (new Email())
    ->addTo('calvin@snicco.io')
    ->addContext('first_name', 'Calvin');
  
  $email1 = $email
      ->addEmbed('/path/to/images/logo.png', 'logo', 'image/png')
      ->withHtmlTemplate('path/to/email-templates/welcome-with-image.php');
  
  // or with inline html
  $email2 = $email
      ->addEmbed('/path/to/images/logo.png', 'logo', 'image/png')
      ->withHtmlBody('<img src="cid:logo">');  

添加自定义头信息

  use Snicco\Component\BetterWPMail\ValueObject\Email;
  
  $email = (new Email())
    ->addTo('calvin@snicco.io')
    // custom headers are string, string key value pairs.
    // These are not validated in any form.
    ->addCustomHeaders(['X-Auto-Response-Suppress'=> 'OOF, DR, RN, NRN, AutoReply'])

全局配置邮件

所有邮件的默认配置由传递给Mailer类的MailDefaults类确定。

如果您在创建Mailer时没有显式传递MailDefaults实例,它们将根据全局的WordPress设置创建。

记住:您始终可以在每个邮件的基础上覆盖这些设置。

use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\ValueObject\MailDefaults;

$from_name = 'My Plugin';
$from_email = 'myplugin@site.com';

$reply_to_name = 'My Plugin Reply-To'
$reply_to_email = 'myplugin-reply-to@site.com';

$mail_defaults = new MailDefaults(
    $from_name,
     $from_email, 
     $reply_to_name, 
     $reply_to_email
);

// Other arguments set to default for brevity.
$mailer = new Mailer(null, null, null, $mail_defaults);

扩展Email

如果您在多个地方发送相同的邮件,您可能希望扩展Email类以在单个位置预配置共享设置。

创建您自定义的邮件类与邮件事件有很多协同作用。

自定义欢迎邮件的示例

use Snicco\Component\BetterWPMail\ValueObject\Email;
use Snicco\Component\BetterWPMail\ValueObject\Mailbox;

class WelcomeEmail extends Email {
        
    // You can configure the protected
    // priorities of the Email class    
    protected ?int $priority = 5;
    
    protected string $text = 'We would like to welcome you to snicco.io';
    
    protected ?string $html_template = '/path/to/templates/welcome.php';
    
    public function __construct(WP_User $user) {
    
        // configure dynamic properties in the constructor.
        $this->subject = sprintf('Welcome to snicco.io %s', $user->display_name);
        
        $this->to[] = Mailbox::create($user);
        
        $this->context['first_name'] = $user->first_name;
        
    }

}

$user = new WP_User(1);

$mailer->send(new WelcomeEmail($user));

使用邮件事件

当您调用Mailer::send时,会触发两种类型的事件。

在将Email实例传递给配置的Transport之前,将触发SendingEmail事件。此事件包含当前Email作为公共属性,这为您在发送之前更改其设置提供了机会。

邮件发送后,将触发EmailWasSent事件。此事件主要用于日志记录目的。

要使用邮件事件,您必须在创建邮件器实例时传递MailEvents实例。

默认情况下,BetterWPMail附带了一个实现此接口的实现,它使用WordPress钩子系统

use Snicco\Component\BetterWPMail\Event\MailEventsUsingWPHooks;
use Snicco\Component\BetterWPMail\Event\SendingEmail;
use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\Transport\WPMailTransport;

$mailer = new Mailer(
    null,
    null,
    new MailEventsUsingWPHooks()
);

add_filter(Email::class, function (SendingEmail $event) {
    // This will add 'admin@site.com' to every email that is being sent.
    $event->email = $event->email->addBcc('admin@site.com');
});

add_filter(WelcomeEmail::class, function (SendingEmail $event) {
    // This will add 'welcome@site.com' to every welcome email that is sent.
    $event->email = $event->email->addBcc('welcome@site.com');
});

邮件事件的常见用例是允许用户自定义特定邮件

// In your code
$user = new WP_User(1);
$mailer->send(new MyPluginWelcomeMail($user));

// Third-party code:
add_filter(MyPluginWelcomeMail::class, function (SendingEmail $event) {
    // This will overwrite your default template for the "MyPluginWelcomeEmail" only
    $event->email = $event->email->withHtmlTemplate('path/to/custom/welcome.php');
});

使用markdown编写邮件/使用自定义MailRenderer

如果您在创建邮件器实例时没有传递任何参数,则将使用默认的渲染器,它是以下组合:

  • 《AggregateRenderer》(将渲染委托给多个《MailRenderer》实例之间)
  • 《FilesystemRenderer》(在设置在《Email》上的模板名称匹配的文件中查找)

现在让我们创建一个自定义配置

  • 我们想要渲染 Markdown 电子邮件,并
  • 将《FilesystemRenderer》用作后备。

首先,我们需要一种将 Markdown 转换为 HTML 的方法。

我们将为此任务使用《erusev/parsedown》。

composer require erusev/parsedown

现在让我们创建一个自定义的《MarkdownMailRenderer》

use Snicco\Component\BetterWPMail\Renderer\MailRenderer;

class MarkdownEmailRenderer implements MailRenderer {
    
    // This renderer should only render .md files that exist.
    public function supports(string $template_name,?string $extension = null) : bool{
        
        return 'md' === $extension && is_file($template_name);
            
    }
    
    public function render(string $template_name,array $context = []) : string{
        
        // First, we get the string contents of the template.
        $contents = file_get_contents($template_name);
        
        // To allow basic templating, replace placeholders inside {{ }} 
        foreach ($context as $name => $value ) {
            $contents = str_replace('{{'.$name'.}}', $value);
        }
        
        // Convert the markdown to HTML and return it.
        return (new Parsedown())->text($contents);
                        
    }
    
}

现在我们已经准备好渲染 Markdown 电子邮件,我们可以像这样创建我们的《Mailer》

use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\Renderer\AggregateRenderer;
use Snicco\Component\BetterWPMail\Renderer\FilesystemRenderer;
use Snicco\Component\BetterWPMail\ValueObject\Email;

// This mail renderer will use our new markdown renderer (if possible) and default the filesystem renderer.
$mail_renderer = new AggregateRenderer(
    new MarkdownMailRenderer(),
    new FilesystemRenderer(),
);

$mailer = new Mailer(null, $mail_renderer);

$email = new Email();
$email = $email->addTo('calvin@snicco.io');

// This email will be renderer with the default renderer
$email_html = $email->withHtmlTemplate('/path/to/templates/welcome.php');
$mailer->send($email_html);

// This email will be renderer with our new markdown renderer.
$email_markdown= $email->withHtmlTemplate('/path/to/templates/markdown/welcome.md');
$mailer->send($email_markdown);

处理异常

与《wp_mail》相比,调用《Mailer::send()`》在失败时将抛出《CantSendEmail》异常。

use Snicco\Component\BetterWPMail\Exception\CantSendEmail;
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io')
                      ->withHtmlBody('<h1>BetterWPMail has awesome error handling</h1>');

try {
    $mailer->send($email);
} catch (CantSendEmail $e) {

   // You can catch this exception if you like,
   // or let it bubble up depending on your use case.
    error_log($e->getDebugData());
}

这比原生方式与《wp_mail》交互有众多优势。

function handleMailError(WP_Error $error) {
    // what now?
}

add_action('wp_mail_failed', 'handleMailError');

$success = wp_mail('calvin@snicco.io', 'wp_mail has bad error_handling');

remove_action('wp_mail_failed', 'handleMailError');

if($success === false) {
    // what now?
}

测试

《BetterWPMail》附带一个专门的测试包,该包提供了一个用于测试的《FakeTransport》类。

首先,将包作为 composer 《dev-dependency》安装

composer install --dev snicco/better-wp-mail-testing

在测试期间,如何将《FakeTransport》连接到《Mailer》实例,这很大程度上取决于您的整体代码库是如何设置的。您可能想在依赖注入容器内这样做。

《FakeTranport》有以下《phpunit》断言方法

use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\Testing\FakeTransport;
use Snicco\Component\BetterWPMail\ValueObject\Email;

$mailer = new Mailer($transport = new FakeTransport());

// This fill pass
$transport->assertNotSent(WelcomeEmail::class);

$mailer->send(new MyPluginWelcomeEmail());

// This will fail now.
$transport->assertNotSent(MyPluginWelcomeEmail::class);

// This will pass
$transport->assertSent(MyPluginWelcomeEmail::class);

// This will fail
$transport->assertSent(PurchaseEmail::class);

// This will fail
$transport->assertSentTimes(MyPluginWelcomeEmail:class, 2);

$mailer->send(new MyPluginWelcomeEmail());

// This will now pass.
$transport->assertSentTimes(MyPluginWelcomeEmail:class, 2);

$email = (new Email())->addTo('calvin@snicco.io');
$mailer->send($email);

// This will pass
$transport->assertSentTo('calvin@snicco.io');
// This will pass
$transport->assertNotSentTo('marlon@snicco.io');

$email = (new Email())->addTo('marlon@snicco.io');
$mailer->send($email);

// This will now fail.
$transport->assertNotSentTo('marlon@snicco.io');

// Using an assertion closure. This will pass.
$transport->assertSent(Email::class, function (Email $email) {
    return $email->to()->has('calvin@snicco.io')
});

拦截 WordPress 邮件

除了伪造由使用《Mailer》类的您自己的代码发送的电子邮件外,《FakeTransport》还允许您伪造所有通过使用《wp_mail》直接发送的电子邮件。

use Snicco\Component\BetterWPMail\Testing\FakeTransport;
use Snicco\Component\BetterWPMail\Testing\WPMail;

$transport = new FakeTransport()

$transport->interceptWordPressEmails();

// This will pass
$transport->assertNotSent(WPMail::class);

// No emails will be sent here.
wp_mail('calvin@snicco.io', 'Hi calvin', 'Testing WordPress emails was never this easy...');

// This will now fail.
$transport->assertNotSent(WPMail::class);

// This will pass
$transport->assertSent(WPMail::class);

// This will pass
$transport->assertSent(WPMail::class, function (WPMail $mail) {
    return 'Hi calvin' === $mail->subject();
});

// This will fail
$transport->assertSent(WPMail::class, function (WPMail $mail) {
    return 'Hi marlon' === $mail->subject();
});

贡献

此存储库是《Snicco》项目开发存储库的《read-only》分叉。

这就是您可以如何贡献.

报告问题和发送拉取请求

请在《Snicco》monorepo中报告问题。

安全

如果您在《BetterWPMail》中发现安全漏洞,请遵循我们的《disclosure procedure》。