邮件

本指南介绍如何从 AdonisJS 应用发送邮件。你将学习如何:

  • 为 SMTP、Resend、Postmark、Mailgun、SparkPost、Brevo 和 SES 等服务配置邮件传输方式
  • 使用流畅的 Message API 发送邮件
  • 将邮件加入队列以进行后台投递
  • 将邮件组织为可复用的邮件类
  • 添加附件、内嵌图片以及日历邀请
  • 使用 fake mailer 测试邮件功能

概述

@adonisjs/mail 包提供了一个统一的 API,用于通过各种提供者发送邮件。它构建于 Nodemailer 之上,增加了流畅的配置 API、将邮件组织为类的支持、丰富的测试 API,以及通过 messenger 进行后台邮件投递。

该包引入了两个关键概念。传输方式(transport) 是底层的投递机制(SMTP 服务器、Resend 或 Mailgun 之类的 API 服务)。mailer 是某个传输方式的已配置实例,你用它来发送邮件。你可以在应用中配置多个 mailer,每个 mailer 可以使用不同的传输方式,或相同的传输方式配合不同的设置,并可在运行时切换。

安装

使用以下命令安装并配置该包:

node ace add @adonisjs/mail

你可以在安装时预先选择传输方式:

node ace add @adonisjs/mail --transports=resend --transports=smtp
查看 add 命令执行的步骤
  1. 使用检测到的包管理器安装 @adonisjs/mail 包。

  2. adonisrc.ts 文件中注册以下服务提供者与命令。

    adonisrc.ts
    {
      commands: [
        // ...other commands
        () => import('@adonisjs/mail/commands')
      ],
      providers: [
        // ...other providers
        () => import('@adonisjs/mail/mail_provider')
      ]
    }
  3. 创建 config/mail.ts 文件。

  4. 为所选邮件服务定义环境变量及其校验。

配置

邮件配置位于 config/mail.ts。该文件定义了你的 mailer、默认发件人地址以及传输方式设置。

另见: 配置桩文件

config/mail.ts
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'

const mailConfig = defineConfig({
  /**
   * 未指定 mailer 时使用的 mailer
   */
  default: 'smtp',

  /**
   * 未在各封邮件上设置时使用的全局「from」地址
   */
  from: {
    address: 'hello@example.com',
    name: 'My App',
  },

  /**
   * 未在各封邮件上设置时使用的全局「reply-to」地址
   */
  replyTo: {
    address: 'support@example.com',
    name: 'My App Support',
  },

  /**
   * 配置一个或多个 mailer。每个 mailer 使用一种传输方式,
   * 并可以有自己的设置。
   */
  mailers: {
    smtp: transports.smtp({
      host: env.get('SMTP_HOST'),
      port: env.get('SMTP_PORT'),
    }),

    resend: transports.resend({
      key: env.get('RESEND_API_KEY'),
      baseUrl: 'https://api.resend.com',
    }),
  },
})

export default mailConfig
选项说明
default在未指定 mailer 而调用 mail.send() 时使用的 mailer。
from全局发件人地址。除非在各封邮件上覆盖,否则都会使用它。
replyTo全局回复地址。除非在各封邮件上覆盖,否则都会使用它。
mailers一个包含你已配置 mailer 的对象。每个键是 mailer 名称,每个值是一个传输方式配置。

传输方式配置

每种传输方式都接受提供者特定的选项。请在下方切换器中选择一个提供者以进行配置。

SMTP transports.smtp

任何标准 SMTP 服务器。

安装带有 SMTP 传输方式的包。

node ace add @adonisjs/mail --transports=smtp

或在已有的配置中注册 mailer 与环境变量。

config/mail.ts
{
  mailers: {
    smtp: transports.smtp({
      host: env.get('SMTP_HOST'),
      port: env.get('SMTP_PORT'),
      secure: false,

      auth: {
        type: 'login',
        user: env.get('SMTP_USERNAME'),
        pass: env.get('SMTP_PASSWORD'),
      },

      tls: {},
      ignoreTLS: false,
      requireTLS: false,
      pool: false,
      maxConnections: 5,
      maxMessages: 100,
    }),
  },
}
.env
SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=

SMTP 选项会直接转发给 Nodemailer。另见: Nodemailer SMTP 文档

发送你的第一封邮件

导入 mail 服务并调用 send 方法。该回调会接收一个 Message 实例用于配置邮件。

app/controllers/users_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import mail from '@adonisjs/mail/services/main'
import User from '#models/user'

export default class UsersController {
  async store({ request }: HttpContext) {
    const user = await User.create(request.all())

    /**
     * 创建用户后发送一封欢迎邮件。
     * 回调负责配置邮件属性。
     */
    await mail.send((message) => {
      message
        .to(user.email)
        .from('welcome@example.com')
        .subject('Welcome to our app!')
        .htmlView('emails/welcome', { user })
    })

    return user
  }
}

mail.send 方法会立即使用默认 mailer 投递邮件。对于高流量的生产应用,你应该将邮件加入队列以进行后台投递。

配置邮件

Message 类提供了一个流畅的 API 来构建邮件。在传入 mail.sendmail.sendLater 的回调中,你会收到一个该类的实例。

主题与发件人

app/controllers/orders_controller.ts
await mail.send((message) => {
  message
    .subject('Your order has shipped')
    .from('orders@example.com')
})

from 方法接受字符串,或包含 address 与 name 的对象:

app/controllers/orders_controller.ts
await mail.send((message) => {
  message.from({
    address: 'orders@example.com',
    name: 'Acme Store'
  })
})

收件人

使用 toccbcc 来设置收件人。每个方法都接受字符串、对象或数组。

app/controllers/reports_controller.ts
await mail.send((message) => {
  message
    .to(user.email)
    .cc(user.manager.email)
    .bcc('audit@example.com')
})
app/controllers/reports_controller.ts
await mail.send((message) => {
  message
    .to({
      address: user.email,
      name: user.fullName,
    })
    .cc([
      { address: 'team-lead@example.com', name: 'Team Lead' },
      { address: 'pm@example.com', name: 'Project Manager' }
    ])
})

使用 replyTo 方法设置回复地址:

app/controllers/support_controller.ts
await mail.send((message) => {
  message
    .from('noreply@example.com')
    .replyTo('support@example.com')
})

邮件内容

使用 htmltext 方法定义内联的 HTML 与纯文本内容,或使用 htmlViewtextView 渲染 Edge 模板。

app/controllers/auth_controller.ts
await mail.send((message) => {
  /**
   * 内联内容适用于简单邮件
   */
  message.html(`
    <h1>Reset your password</h1>
    <p>Click <a href="${resetUrl}">here</a> to reset your password.</p>
  `)

  message.text(`
    Reset your password
    Visit ${resetUrl} to reset your password.
  `)
})

对于大多数邮件,Edge 模板能提供更好的组织方式:

node ace make:view emails/password_reset_html
node ace make:view emails/password_reset_text
app/controllers/auth_controller.ts
await mail.send((message) => {
  /**
   * 渲染 Edge 模板,将数据作为第二个参数传入
   */
  message.htmlView('emails/password_reset_html', { user, resetUrl })
  message.textView('emails/password_reset_text', { user, resetUrl })
})

另见: 配置 Edge

使用 MJML 实现响应式邮件

MJML 是一种可编译为响应式 HTML 邮件标记的语言。安装该包并使用 @mjml Edge 标签。

npm i mjml
resources/views/emails/welcome_html.edge
@mjml()
  <mjml>
    <mj-body>
      <mj-section>
        <mj-column>
          <mj-text font-size="20px" color="#333">
            Welcome, {{ user.name }}!
          </mj-text>
          <mj-button href="{{ verifyUrl }}">
            Verify Email
          </mj-button>
        </mj-column>
      </mj-section>
    </mj-body>
  </mjml>
@end

MJML 选项 作为 props 传入标签:

resources/views/emails/welcome_html.edge
@mjml({
  keepComments: false,
  fonts: {
    Lato: 'https://fonts.googleapis.com/css?family=Lato:400,500,700'
  }
})
  {{-- MJML 内容 --}}
@end

将邮件加入队列

同步发送邮件会阻塞请求处理,直到等待邮件提供者的响应返回。为了获得更好的性能,请使用 mail.sendLater 将邮件加入队列以进行后台投递。

app/controllers/users_controller.ts
import mail from '@adonisjs/mail/services/main'

export default class UsersController {
  async store({ request }: HttpContext) {
    const user = await User.create(request.all())

    /**
     * 将邮件加入队列,而非立即发送。
     * 请求无需等待投递即可完成。
     */
    await mail.sendLater((message) => {
      message
        .to(user.email)
        .from('welcome@example.com')
        .subject('Welcome!')
        .htmlView('emails/welcome', { user })
    })

    return user
  }
}

默认情况下,邮件 messenger 使用内存队列。这意味着如果在进程终止前邮件尚未发送,它们就会丢失。

Warning

内存 messenger 适用于开发环境,但不推荐用于生产环境。如果你的应用在仍有待发邮件的情况下崩溃或重启,这些邮件将永远无法送达。请为生产部署使用像 BullMQ 这样的持久化队列。

使用 BullMQ 实现持久化队列

对于生产应用,请使用 BullMQ 和 Redis 配置一个持久化队列。这能确保邮件在进程重启后不会丢失。

npm i bullmq

创建一个自定义 messenger,将邮件任务存储到 Redis 中:

start/mail.ts
import { Queue } from 'bullmq'
import mail from '@adonisjs/mail/services/main'

const emailsQueue = new Queue('emails')

mail.setMessenger((mailer) => {
  return {
    async queue(mailMessage, config) {
      /**
       * 存储已编译的消息、配置和 mailer 名称。
       * 一个 worker 进程会取走它并发送。
       */
      await emailsQueue.add('send_email', {
        mailMessage,
        config,
        mailerName: mailer.name,
      })
    }
  }
})

创建一个 worker 来处理队列。在单独的进程中运行它:

workers/mail_worker.ts
import { Worker } from 'bullmq'
import mail from '@adonisjs/mail/services/main'

new Worker('emails', async (job) => {
  if (job.name === 'send_email') {
    const { mailMessage, config, mailerName } = job.data

    /**
     * 使用指定的 mailer 发送预编译的消息
     */
    await mail.use(mailerName).sendCompiled(mailMessage, config)
  }
})

在 mailer 之间切换

使用 mail.use() 通过特定的 mailer(而非默认 mailer)发送邮件。

app/controllers/notifications_controller.ts
import mail from '@adonisjs/mail/services/main'

/**
 * 通过默认 mailer 发送事务性邮件
 */
await mail.send((message) => {
  message.subject('Order confirmed')
})

/**
 * 通过 Mailgun 发送营销邮件
 */
await mail.use('mailgun').send((message) => {
  message.subject('Weekly newsletter')
})

/**
 * 通过特定 mailer 将邮件加入队列
 */
await mail.use('resend').sendLater((message) => {
  message.subject('Your report is ready')
})

mailer 实例会在进程生命周期内被缓存。要关闭连接并将其从缓存中移除,请使用 mail.close()

app/services/mail_service.ts
import mail from '@adonisjs/mail/services/main'

/**
 * 关闭 mailgun 连接并从缓存中移除
 */
await mail.close('mailgun')

/**
 * 下一次调用会创建全新的实例
 */
mail.use('mailgun')

附件

文件附件

使用 attach 方法并传入绝对文件路径来附加文件:

app/controllers/invoices_controller.ts
import app from '@adonisjs/core/services/app'

await mail.send((message) => {
  message.attach(app.makePath('storage/invoices/inv-001.pdf'))
})

自定义附件文件名与其他选项:

app/controllers/invoices_controller.ts
await mail.send((message) => {
  message.attach(app.makePath('storage/invoices/inv-001.pdf'), {
    filename: 'invoice-october-2024.pdf',
    contentType: 'application/pdf',
  })
})
选项说明
filename附件的显示名称。默认为文件的基本名。
contentTypeMIME 类型。如果未设置,则从扩展名推断。
contentDisposition可以是 attachment(默认)或 inline
headers作为键值对的自定义请求头。

来自流与缓冲区的附件

使用 attachData 附加来自流或缓冲区的动态内容:

app/controllers/reports_controller.ts
import fs from 'node:fs'

await mail.send((message) => {
  /**
   * 从可读流附加
   */
  message.attachData(fs.createReadStream('./report.pdf'), {
    filename: 'report.pdf'
  })

  /**
   * 从缓冲区附加
   */
  message.attachData(Buffer.from('Hello world'), {
    filename: 'greeting.txt',
    encoding: 'utf-8',
  })
})
Warning

不要将 attachDatamail.sendLater 一起使用。队列中的邮件会被序列化为 JSON,因此流无法存储,而缓冲区会显著增加存储体积。试图将带有流附件的邮件加入队列会失败。相反,请先将文件保存到磁盘,然后使用 attach 配合文件路径。

内嵌图片

在你的 Edge 模板中使用 embedImage 辅助函数将图片直接内嵌到 HTML 内容中。它使用 CID(Content-ID)附件,能在各种邮件客户端中可靠显示。

resources/views/emails/newsletter.edge
<img src="{{ embedImage(app.makePath('assets/logo.png')) }}" alt="Logo" />

该辅助函数返回一个 cid: URL,并会自动将图片作为附件添加。

对于动态的图片数据,请使用 embedImageData

resources/views/emails/report.edge
<img src="{{ embedImageData(chartBuffer, { filename: 'chart.png' }) }}" />

日历邀请

使用 icalEvent 方法附加日历事件。你可以提供原始的 iCalendar 内容,或使用流畅的 API。

app/controllers/meetings_controller.ts
import { DateTime } from 'luxon'

await mail.send((message) => {
  message.icalEvent((calendar) => {
    calendar.createEvent({
      summary: 'Project kickoff meeting',
      start: DateTime.now().plus({ days: 1 }).set({ hour: 10 }),
      end: DateTime.now().plus({ days: 1 }).set({ hour: 11 }),
      location: 'Conference Room A',
    })
  }, {
    method: 'REQUEST',
    filename: 'meeting.ics',
  })
})

日历对象是一个 ical-generator 的实例。

从文件或 URL 加载邀请内容:

app/controllers/meetings_controller.ts
import app from '@adonisjs/core/services/app'

await mail.send((message) => {
  /**
   * 来自本地文件
   */
  message.icalEventFromFile(
    app.resourcesPath('invites/standup.ics'),
    { method: 'REQUEST', filename: 'standup.ics' }
  )

  /**
   * 来自 URL
   */
  message.icalEventFromUrl(
    'https://example.com/calendar/event-123.ics',
    { method: 'REQUEST', filename: 'event.ics' }
  )
})

自定义请求头

使用 header 方法添加自定义请求头:

app/controllers/notifications_controller.ts
await mail.send((message) => {
  message.header('X-Entity-Ref', 'order-12345')
  message.header('X-Priority', '1')
})

对于不应被编码或折行的请求头,请使用 preparedHeader

app/controllers/notifications_controller.ts
await mail.send((message) => {
  message.preparedHeader(
    'X-Custom-Data',
    'value with special chars or very long content'
  )
})

列表请求头

辅助方法简化了用于邮件列表功能的常见 List-* 请求头:

app/controllers/newsletters_controller.ts
await mail.send((message) => {
  message.listUnsubscribe({
    url: 'https://example.com/unsubscribe?token=abc',
    comment: 'Unsubscribe from this list'
  })

  message.listHelp('support@example.com?subject=help')

  /**
   * 对于其他 List-* 请求头
   */
  message.addListHeader('post', 'https://example.com/list/post')
})

Gmail 和 Yahoo 等邮箱提供者要求批量发件人支持 一键退订(RFC 8058) 。它允许提供者在收到单个 HTTP POST 请求发往你的退订 URL 时退订收件人,因此收件人无需离开收件箱或加载确认页面。

通过向 listUnsubscribe 传入 { oneClick: true } 作为第二个参数来启用它。除了你的 List-Unsubscribe URL,AdonisJS 还会发出 List-Unsubscribe-Post: List-Unsubscribe=One-Click 请求头,以表明支持一键退订。你传入的 URL 必须是一个接受 POST 请求并立即退订收件人的 https 端点。

app/controllers/newsletters_controller.ts
await mail.send((message) => {
  message.listUnsubscribe('https://example.com/unsubscribe?token=abc', {
    oneClick: true
  })
})

另见: Nodemailer 列表请求头文档

基于类的邮件

对于复杂的应用,请将邮件组织为专门的类,而非使用内联回调。这能提升可测试性,并使控制器专注于请求处理。

node ace make:mail verify_email
app/mails/verify_email.ts
import User from '#models/user'
import router from '@adonisjs/core/services/router'
import { BaseMail } from '@adonisjs/mail'

export default class VerifyEmailNotification extends BaseMail {
  /**
   * 为常见邮件选项设置类属性
   */
  from = 'noreply@example.com'
  subject = 'Please verify your email address'

  constructor(private user: User) {
    super()
  }

  /**
   * 在 prepare 方法中配置邮件。
   * 会在发送前自动调用。
   */
  prepare() {
    const verifyUrl = router.makeUrl('email.verify', {
      token: this.user.verificationToken
    })

    this.message
      .to(this.user.email)
      .htmlView('emails/verify_email', {
        user: this.user,
        verifyUrl,
      })
  }
}

通过将一个实例传入 mail.sendmail.sendLater 来发送邮件:

app/controllers/auth_controller.ts
import mail from '@adonisjs/mail/services/main'
import VerifyEmailNotification from '#mails/verify_email'

export default class AuthController {
  async register({ request }: HttpContext) {
    const user = await User.create(request.all())

    await mail.sendLater(new VerifyEmailNotification(user))

    return { message: 'Check your email to verify your account' }
  }
}
属性/方法说明
from默认发件人地址。在 prepare 中用 message.from() 覆盖。
subject默认主题行。在 prepare 中用 message.subject() 覆盖。
replyTo默认回复地址。
prepare()配置邮件。在发送前自动调用。
build()继承自 BaseMail。调用 prepare 并编译邮件。

另见: Make mail 命令

测试

mail 包提供了一个 fake mailer,用于在不实际发送邮件的情况下测试邮件功能。

使用 fake mailer

调用 mail.fake() 来拦截所有邮件。返回的对象包含一个 mails 属性用于断言。

tests/functional/users/register.spec.ts
import { test } from '@japa/runner'
import mail from '@adonisjs/mail/services/main'
import VerifyEmailNotification from '#mails/verify_email'

test.group('Users | register', () => {
  test('sends verification email on registration', async ({ client }) => {
    /**
     * Fake 该 mailer。`using` 关键字会在测试结束时
     * 自动恢复真实的 mailer。
     */
    using fake = mail.fake()

    await client
      .post('/register')
      .form({ email: 'user@example.com', password: 'secret123' })

    /**
     * 断言邮件已被发送
     */
    fake.mails.assertSent(VerifyEmailNotification, ({ message }) => {
      return message
        .hasTo('user@example.com')
        .hasSubject('Please verify your email address')
    })
  })

  test('does not send password reset when user not found', async ({ client }) => {
    using fake = mail.fake()

    await client
      .post('/forgot-password')
      .form({ email: 'unknown@example.com' })

    fake.mails.assertNotSent(PasswordResetNotification)
  })
})

如果你需要更精细地控制真实 mailer 何时恢复,也可以手动调用 mail.restore()

断言方法

mails 对象提供了以下断言方法:

方法说明
assertSent(Mail, finder?)断言某个邮件类已被发送。可选的 finder 回调用于额外检查。
assertNotSent(Mail, finder?)断言某个邮件类未被发送。
assertSentCount(count)断言发送的邮件总数。
assertSentCount(Mail, count)断言某个特定类发送的邮件数量。
assertNoneSent()断言没有发送任何邮件。
assertQueued(Mail, finder?)断言某个邮件已通过 sendLater 加入队列。
assertNotQueued(Mail, finder?)断言某个邮件未被加入队列。
assertQueuedCount(count)断言加入队列的邮件总数。
assertQueuedCount(Mail, count)断言某个特定类加入队列的邮件数量。
assertNoneQueued()断言没有邮件被加入队列。

直接测试邮件类

通过构建邮件类(而不发送)来单独测试它们:

tests/unit/mails/verify_email.spec.ts
import { test } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'
import VerifyEmailNotification from '#mails/verify_email'

test.group('VerifyEmailNotification', () => {
  test('builds correct message', async () => {
    const user = await UserFactory.create()
    const email = new VerifyEmailNotification(user)

    /**
     * 构建邮件并渲染模板
     */
    await email.buildWithContents()

    /**
     * 断言邮件属性
     */
    email.message.assertTo(user.email)
    email.message.assertFrom('noreply@example.com')
    email.message.assertSubject('Please verify your email address')

    /**
     * 断言渲染后的内容
     */
    email.message.assertHtmlIncludes(`Hello ${user.name}`)
    email.message.assertHtmlIncludes('/verify/')
  })
})

访问已发送的邮件

检索已发送或已加入队列的邮件列表以进行自定义断言:

tests/functional/notifications.spec.ts
using fake = mail.fake()

/**
 * 获取所有已发送的邮件
 */
const sentEmails = fake.mails.sent()

/**
 * 获取所有已加入队列的邮件
 */
const queuedEmails = fake.mails.queued()

/**
 * 查找特定的邮件
 */
const verifyEmail = sentEmails.find((email) => {
  return email instanceof VerifyEmailNotification
})

if (verifyEmail) {
  verifyEmail.message.assertHtmlIncludes('Verify your email')
}

自定义传输方式

创建自定义传输方式来集成包中未包含的邮件提供者。传输方式会封装一个 Nodemailer 传输并对其响应进行归一化。

app/mail/transports/mandrill.ts
import nodemailer from 'nodemailer'
import mandrillTransport from 'nodemailer-mandrill-transport'
import { MailResponse } from '@adonisjs/mail'
import type { NodeMailerMessage, MailTransportContract } from '@adonisjs/mail/types'

export type MandrillConfig = {
  auth: {
    apiKey: string
  }
}

export class MandrillTransport implements MailTransportContract {
  #config: MandrillConfig

  constructor(config: MandrillConfig) {
    this.#config = config
  }

  async send(
    message: NodeMailerMessage,
    config?: MandrillConfig
  ): Promise<MailResponse> {
    /**
     * 使用合并后的配置创建 nodemailer 传输
     */
    const transporter = nodemailer.createTransport(
      mandrillTransport({ ...this.#config, ...config })
    )

    const response = await transporter.sendMail(message)

    /**
     * 返回归一化后的响应
     */
    return new MailResponse(response.messageId, response.envelope, response)
  }
}

为在配置文件中使用而创建一个工厂函数:

app/mail/transports/mandrill.ts
import type { MailManagerTransportFactory } from '@adonisjs/mail/types'

export function mandrillTransport(
  config: MandrillConfig
): MailManagerTransportFactory {
  return () => new MandrillTransport(config)
}

在配置中注册传输方式:

config/mail.ts
import env from '#start/env'
import { defineConfig } from '@adonisjs/mail'
import { mandrillTransport } from '#app/mail/transports/mandrill'

const mailConfig = defineConfig({
  mailers: {
    mandrill: mandrillTransport({
      auth: {
        apiKey: env.get('MANDRILL_API_KEY'),
      },
    }),
  },
})

自定义模板引擎

默认情况下,mail 包使用 Edge 来渲染邮件模板。要使用不同的模板引擎,请覆盖 Message 类上的静态 templateEngine 属性。

start/mail.ts
import { Message } from '@adonisjs/mail'

Message.templateEngine = {
  async render(templatePath, data) {
    /**
     * 使用你偏好的模板引擎
     */
    return myTemplateEngine.render(templatePath, data)
  }
}

事件

mail 包在邮件生命周期中会发出事件。

另见: 事件参考