メールガイド
Guren ではメールの送信を Fluent API で書け、配信に使うトランスポートを複数使い分けられます。キューと組み合わせれば非同期に送れ、HTML テンプレートや添付ファイルも扱えます。
メールガイド
Guren ではメールの送信を Fluent API で書け、配信に使うトランスポートを複数使い分けられます。キューと組み合わせれば非同期に送れ、HTML テンプレートや添付ファイルも扱えます。
標準的な書き方では、mail の API を @guren/core からインポートし、mail manager の設定は config/mail.ts にまとめます。コントローラーでは、メールを組み立てて送ることだけを書きます。
コアコンセプト
- MailManager: メールトランスポートを設定し、取り出すための中央レジストリです。
- Mail: メールを組み立てて送信するための Fluent ビルダーです。
- Transport: メールを配信するバックエンドです。Guren には SMTP、Resend、Log(開発用)、Memory(テスト用)のトランスポートが付属しています。
基本的な使い方
コンテナバインディングファサードを使用
アプリケーションコンテナからファサードを作っておけば、MailManager を引数で持ち回らなくてもメールを送れます。
import { createFacades } from '@guren/core'
const { Mail } = createFacades(app.container)
await Mail.to('user@example.com')
.subject('Hello!')
.text('Hello World!')
.send()
直接インスタンス化
MailManager を直接作ることもできます。
import { MailManager, mail } from '@guren/core'
const mailManager = new MailManager({
default: 'smtp',
from: { email: 'noreply@example.com', name: 'MyApp' },
transports: {
smtp: {
driver: 'smtp',
host: 'smtp.example.com',
port: 587,
auth: { user: 'user', pass: 'password' },
},
},
})
// シンプルなメールを送信
await mail(mailManager)
.to('user@example.com')
.subject('Hello!')
.text('Hello World!')
.send()
Fluent API
const builder = mail(mailManager)
// 宛先
builder.to('user@example.com') // 受信者を追加
builder.to({ email: 'user@example.com', name: 'John' }) // 名前付き
builder.toMany(['a@example.com', 'b@example.com']) // 複数の受信者
builder.cc('copy@example.com') // CC受信者
builder.bcc('blind@example.com') // BCC受信者
// 送信者と返信先
builder.from('sender@example.com') // デフォルトの送信元を上書き
builder.replyTo('support@example.com') // 返信先アドレス
// コンテンツ
builder.subject('Welcome!') // メール件名
builder.text('Plain text body') // プレーンテキスト
builder.html('<h1>HTML body</h1>') // HTMLコンテンツ
// 添付ファイル
builder.attach({
filename: 'report.pdf',
path: './storage/report.pdf',
})
builder.attach({
filename: 'data.json',
content: JSON.stringify(data),
contentType: 'application/json',
})
// ヘッダー
builder.header('X-Custom-Header', 'value')
// 送信
await builder.send()
設定
複数のトランスポート
送信に使うトランスポートは config/mail.ts にすべて宣言しておき、既定で使うものを環境変数で選びます。bunx guren add mail を実行すると、log、memory、smtp の 3 つを宣言したこのファイルが生成されます。下の例の resend は、手で追加したトランスポートです。
// config/mail.ts
import { defineMailConfig } from '@guren/core'
export default defineMailConfig((env) => {
const transports = {
log: { driver: 'log' },
memory: { driver: 'memory' },
smtp: {
driver: 'smtp',
host: env.SMTP_HOST,
port: env.SMTP_PORT,
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS ?? '' } : undefined,
},
resend: { driver: 'resend', apiKey: env.RESEND_API_KEY ?? '' },
}
// manager はどんな名前も受け付け、最初の送信で例外を投げる。それがキュージョブの中のこともある。
if (!Object.hasOwn(transports, env.MAIL_MAILER)) {
throw new Error(
`MAIL_MAILER="${env.MAIL_MAILER}" is not a declared transport. Declare it in config/mail.ts or use one of: ${Object.keys(transports).join(', ')}.`,
)
}
return {
default: env.MAIL_MAILER,
from: { email: env.MAIL_FROM_ADDRESS, name: env.MAIL_FROM_NAME },
transports,
}
})
// src/app.ts
import { createApp } from '@guren/core'
import env from '../config/env.js'
import mail from '../config/mail.js'
const app = createApp({ env, config: [mail] })
コールバックが読むキーは、すべて config/env.ts で宣言しておく必要があります。guren add mail が宣言するのは MAIL_MAILER、MAIL_FROM_ADDRESS、MAIL_FROM_NAME と SMTP_* です。上の resend のように自分で足したトランスポートのキーは、手で宣言してください(RESEND_API_KEY: Env.string().secret().optional())。変数の宣言方法は設定ガイドで説明しています。
MAIL_MAILER=log にすると、送ったメールがサーバーの出力に書き出されるので、開発中はこれで足ります。本番ではコードを変えずに、MAIL_MAILER を smtp や resend に切り替えてください。メールごとにトランスポートを指定することもできます。
// デフォルトトランスポートを使用
await mail(mailManager).to('user@example.com').subject('Test').text('Hello').send()
// 特定のトランスポートを使用
await mail(mailManager)
.via('resend')
.to('user@example.com')
.subject('Via Resend')
.text('Hello')
.send()
メールをサービスプロバイダで設定しているアプリも、そのまま動きます。詳しくはサービスプロバイダを使うアプリを参照してください。
トランスポートオプション
SMTP Transport:
| オプション | デフォルト | 説明 |
|---|---|---|
host |
必須 | SMTP サーバーのホスト名 |
port |
587 |
SMTP サーバーのポート |
secure |
false |
TLS を使う(通常はポート 465 と組み合わせる) |
auth.user |
- | SMTP ユーザー名 |
auth.pass |
- | SMTP パスワード |
pool |
true |
コネクションプーリングを使う |
maxConnections |
5 |
プールの最大接続数 |
Resend Transport:
| オプション | デフォルト | 説明 |
|---|---|---|
apiKey |
必須 | Resend API キー |
Log Transport(開発用):
| オプション | デフォルト | 説明 |
|---|---|---|
logger |
console.log |
送信の代わりに、整形したメッセージを受け取る関数 |
Memory Transport(テスト用):
| オプション | デフォルト | 説明 |
|---|---|---|
simulateFailure |
false |
送信の失敗を再現する |
failureMessage |
- | 失敗させたときのエラーメッセージ |
HTMLテンプレート
React Emailの使用
型安全なメールテンプレートを書きたい場合は、React Email と組み合わせて使えます。
bun add @react-email/render react
// app/Mail/WelcomeEmail.tsx
import * as React from 'react'
import { Html, Head, Body, Container, Text, Button } from '@react-email/components'
interface WelcomeEmailProps {
name: string
loginUrl: string
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Body style={{ fontFamily: 'sans-serif' }}>
<Container>
<Text>こんにちは {name}さん!</Text>
<Text>アプリケーションへようこそ。</Text>
<Button href={loginUrl}>始める</Button>
</Container>
</Body>
</Html>
)
}
import { mail } from '@guren/core'
import { WelcomeEmail } from '@/app/Mail/WelcomeEmail'
await mail(mailManager)
.to('user@example.com')
.subject('ようこそ!')
.template(WelcomeEmail, { name: 'John', loginUrl: 'https://example.com/login' })
.send()
プレーンHTMLの使用
await mail(mailManager)
.to('user@example.com')
.subject('ようこそ!')
.html(`
<h1>ようこそ、${user.name}さん!</h1>
<p>ご登録ありがとうございます。</p>
<a href="${loginUrl}">始める</a>
`)
.send()
添付ファイル
// ファイル添付
await mail(mailManager)
.to('user@example.com')
.subject('レポート')
.text('レポートを添付しました。')
.attach({
filename: 'report.pdf',
path: './storage/reports/monthly.pdf',
})
.send()
// インラインコンテンツ
await mail(mailManager)
.to('user@example.com')
.subject('データエクスポート')
.text('データエクスポートの準備ができました。')
.attach({
filename: 'data.json',
content: JSON.stringify(exportData, null, 2),
contentType: 'application/json',
})
.send()
// インライン画像(CID)
await mail(mailManager)
.to('user@example.com')
.subject('ニュースレター')
.html('<img src="cid:logo" alt="ロゴ" /><p>ようこそ!</p>')
.attach({
filename: 'logo.png',
path: './public/logo.png',
cid: 'logo',
})
.send()
キューによるメール送信
キューを使うと、メールを非同期に送れます。キューに入ったジョブは、ワーカーが動かしているアプリのコンテナから mail manager(mail)を取り出します。queue() は、同じコンテナにバインドされた queue manager を通してジョブをディスパッチします。必要な配線は、createApp() に 2 つの定義を並べることだけです。
// src/app.ts
import { createApp } from '@guren/core'
import env from '../config/env.js'
import mail from '../config/mail.js'
import queue from '../config/queue.js'
const app = createApp({ env, config: [mail, queue] })
defineMailConfig は、manager を解決するコンテナを渡して manager を作ります。そのため、manager は自分がどのアプリに属しているかを知っています。
config/queue.ts の書き方はキューガイドを参照してください。QUEUE_CONNECTION=sync ではジョブがその場で実行されるので、送信をリクエストの処理から切り離したい場合は、ワーカーが処理するドライバを選んでください。
コンテナを渡さずに createMailManager(config) で作った mail manager は、既定のアプリケーションの queue バインディングにジョブを入れます。mail バインディングが見つからないジョブは setMailManager() で設定した値を使いますが、この setter は 2.23.0 で非推奨になりました。config/mail.ts と同じように、manager はアプリのコンテナに束縛してください。
// 即座に送信せずキューに入れる
await mail(mailManager)
.to('user@example.com')
.subject('週次レポート')
.html(reportHtml)
.queue('emails') // キュー名
// メールはワーカーによって処理される
// bunx guren queue:work --queue=emails
Mailableクラス
何度も使うメールは、Mailable クラスとして生成しておけます。
bunx guren make:mail WelcomeMail
// app/Mail/WelcomeMail.ts
import { Mail, MailManager } from '@guren/core'
interface WelcomeMailData {
user: { name: string; email: string }
loginUrl: string
}
export class WelcomeMail {
constructor(
private readonly manager: MailManager,
private readonly data: WelcomeMailData
) {}
async send(): Promise<void> {
const { user, loginUrl } = this.data
await new Mail(this.manager)
.to(user.email)
.subject(`ようこそ、${user.name}さん!`)
.html(`
<h1>MyAppへようこそ!</h1>
<p>こんにちは ${user.name}さん、</p>
<p>ご参加ありがとうございます。</p>
<a href="${loginUrl}">始める</a>
`)
.send()
}
async queue(queueName: string = 'emails'): Promise<string> {
const { user, loginUrl } = this.data
return new Mail(this.manager)
.to(user.email)
.subject(`ようこそ、${user.name}さん!`)
.html(`
<h1>MyAppへようこそ!</h1>
<p>こんにちは ${user.name}さん、</p>
<p>ご参加ありがとうございます。</p>
<a href="${loginUrl}">始める</a>
`)
.queue(queueName)
}
}
// 使用方法
const welcomeMail = new WelcomeMail(mailManager, {
user: { name: 'John', email: 'john@example.com' },
loginUrl: 'https://example.com/login',
})
await welcomeMail.send()
// または
await welcomeMail.queue('emails')
コンテナとの統合
config/mail.ts は、mail manager を mail という名前のシングルトンとしてバインドします。そのため、コンテナから解決して使えます。
// app.container、または provider 内の this.container から取得
const mailManager = container.make('mail') // MailManager
container.fake() を使ったテスト
テストで mail manager を差し替えると、実際には送信せずに、送ろうとしたメッセージを捕まえられます。
// app.container、または provider 内の this.container から取得
import { MailManager, MemoryTransport } from '@guren/core'
test('sends welcome email on registration', async () => {
const memoryTransport = new MemoryTransport()
const fakeMail = new MailManager({
default: 'memory',
from: { email: 'test@example.com' },
})
fakeMail.registerTransport('memory', () => memoryTransport)
using _ = container.fake('mail', fakeMail)
// テスト対象のコードを実行する。ファサードやコンテナ経由で送ったメールは
// すべて memoryTransport に捕捉される
await registerUser({ email: 'new@example.com' })
const sent = memoryTransport.getSentMessages()
expect(sent).toHaveLength(1)
expect(sent[0].to[0].email).toBe('new@example.com')
})
テスト
テストでは Memory トランスポートを使います。
import { describe, test, expect, beforeEach } from 'bun:test'
import { MailManager, mail, MemoryTransport } from '@guren/core'
describe('Email', () => {
let mailManager: MailManager
let memoryTransport: MemoryTransport
beforeEach(() => {
memoryTransport = new MemoryTransport()
mailManager = new MailManager({
default: 'memory',
from: { email: 'test@example.com' },
})
mailManager.registerTransport('memory', () => memoryTransport)
})
test('ウェルカムメールを送信する', async () => {
await mail(mailManager)
.to('user@example.com')
.subject('ようこそ!')
.text('Hello World!')
.send()
const sent = memoryTransport.getSentMessages()
expect(sent).toHaveLength(1)
expect(sent[0].to[0].email).toBe('user@example.com')
expect(sent[0].subject).toBe('ようこそ!')
})
test('添付ファイル付きメールを送信する', async () => {
await mail(mailManager)
.to('user@example.com')
.subject('レポート')
.text('添付をご確認ください。')
.attach({ filename: 'data.txt', content: 'test data' })
.send()
const sent = memoryTransport.getSentMessages()
expect(sent[0].attachments).toHaveLength(1)
expect(sent[0].attachments![0].filename).toBe('data.txt')
})
})
ベストプラクティス
環境変数を使う。 SMTP の認証情報や API キーはハードコードせず、
config/env.tsで宣言してconfig/mail.tsで読みます。デフォルトの送信元を設定する。 送信者を既定値として持たせておけば、毎回書かずに済みます。
大量のメールはキューで送る。 同期で送ると、送信が終わるまでリクエストが止まってしまいます。
複雑なテンプレートには React Email を使う。 型安全なテンプレートは保守が楽です。
Memory トランスポートでテストする。 テストから実際のメールを送らないようにします。
送信の失敗を処理する。
SendResultを確認し、重要なメールにはリトライを組み込みます。件名は具体的に書く。 何のメールかがわかる件名は、配信率にもユーザー体験にも効きます。