PDFメール送信で3時間ハマった話(Puppeteer + AWS SES)
添付ファイルが開けない
「PDFを生成してメール送信するだけ」——そう思って実装を始めた。相続診断AIのWebアプリで、診断結果のPDFをメールに添付して送る機能。やることはシンプルなはず。
添付ファイルが壊れて開けない。3時間ハマった。
実際に動作するコードとハマりポイントをまとめます。
結論から
PDF添付メールで失敗する原因は、だいたいこのどっちか:
-
Puppeteerの
page.pdf()はUint8Arrayを返す →Buffer.from()で変換が必須 -
nodemailerには
@aws-sdk/client-sesv2が必要 → レガシーなclient-sesでは動かない
この2点さえ押さえれば動く。nodemailerがMIME構築もBase64エンコードも自動でやってくれるので、コードもシンプルに収まります。
環境構築
必要なパッケージ
npm install puppeteer nodemailer @aws-sdk/client-sesv2
実装コード
index.js として保存すれば、そのまま動く。
import puppeteer from 'puppeteer';
import nodemailer from 'nodemailer';
import {SESv2Client, SendEmailCommand} from '@aws-sdk/client-sesv2';
// AWS SES認証情報(AWS IAMコンソールで取得、実際の値に置き換えてください)
const AWS_SES_REGION = 'ap-northeast-1'; // 東京リージョン
const AWS_SES_ACCESS_KEY_ID = 'AKIA******************';
const AWS_SES_SECRET_ACCESS_KEY = '****************************************';
// メール送信設定(実際の値に置き換えてください)
const FROM_EMAIL = 'sender@example.com'; // 送信元メールアドレス(SESで検証済み)
const TO_EMAIL = 'recipient@example.com'; // 送信先メールアドレス
/**
* HTMLからPDFを生成
*
* ポイント: page.pdf()は Uint8Array を返すため、Buffer.from()で変換が必須
*/
async function generatePdf() {
let browser;
try {
const html = `
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>サンプルレポート</title>
<style>
body { font-family: sans-serif; padding: 20px; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>サンプルレポート</h1>
<p>これはPuppeteerで生成したPDFのサンプルです。</p>
</body>
</html>
`;
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox', // サンドボックス無効化(Docker/CI環境で必須)
'--disable-setuid-sandbox', // setuidサンドボックス無効化(権限関連の問題回避)
'--disable-dev-shm-usage', // /dev/shmの使用を無効化(メモリ不足対策)
'--disable-gpu', // GPU無効化(サーバー環境では不要)
'--no-first-run', // 初回起動時の処理をスキップ(高速化)
'--no-zygote', // Zygoteプロセス無効化(メモリ削減)
'--single-process', // シングルプロセスモード(メモリ削減、安定性向上)
'--disable-web-security', // 同一オリジンポリシー無効化(ローカルHTMLの読み込み許可)
'--disable-features=VizDisplayCompositor' // 描画最適化を無効化(安定性優先)
]
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'domcontentloaded' });
const pdfBuffer = await page.pdf({
format: 'A4', // 用紙サイズ(A4, Letter, Legal等)
printBackground: true, // 背景色・背景画像を印刷
margin: { // 余白設定
top: '10mm',
right: '10mm',
bottom: '10mm',
left: '10mm'
},
preferCSSPageSize: false, // CSS @page サイズを優先しない
displayHeaderFooter: false // ヘッダー・フッターを非表示
});
// ハマりポイント①: Uint8Array → Buffer変換
// NG: return pdfBuffer; // Uint8Arrayのまま
// OK: Buffer.from()で変換してから返す(nodemailerが確実に処理できる)
return Buffer.from(pdfBuffer);
} finally {
if (browser) await browser.close();
}
}
/**
* AWS SESでメール送信(PDF添付)
*
* ポイント: nodemailerを使えばMIME構築とBase64エンコードが自動化される
*/
async function sendEmail({ from, to, subject, html, attachments }) {
// AWS SESv2クライアント作成
const sesClient = new SESv2Client({
region: AWS_SES_REGION,
credentials: {
accessKeyId: AWS_SES_ACCESS_KEY_ID,
secretAccessKey: AWS_SES_SECRET_ACCESS_KEY,
}
});
// nodemailer transporter作成
const transporter = nodemailer.createTransport({
SES: {
sesClient,
SendEmailCommand
}
});
// メール送信(添付ファイルは自動でBase64エンコードされる)
await transporter.sendMail({
from,
to,
subject,
html,
attachments: attachments || []
});
console.log('メール送信完了');
}
/**
* メイン処理
*/
async function main() {
try {
console.log('PDF生成中...');
const pdfBuffer = await generatePdf();
console.log(`PDF生成完了 - サイズ: ${pdfBuffer.length} bytes`);
console.log('メール送信中...');
await sendEmail({
from: FROM_EMAIL,
to: TO_EMAIL,
subject: 'レポートを送付します',
html: '<p>レポートを添付しました。</p>',
attachments: [
{ filename: 'report.pdf', content: pdfBuffer }
]
});
console.log('完了');
} catch (error) {
console.error('エラー:', error);
}
}
main();
実行方法
定数(AWS_SES_ACCESS_KEY_ID、AWS_SES_SECRET_ACCESS_KEY、FROM_EMAIL、TO_EMAIL)を実際の値に置き換えて実行するだけ。
node index.js
ハマりポイントを詳しく
ハマりポイント①: Uint8Array vs Buffer
Puppeteerのpage.pdf()はUint8Arrayを返す。nodemailerにそのまま渡すと、PDFが壊れて開けない。
// NG: Uint8Arrayのままではnodemailerでエラーになる可能性
const pdfBuffer = await page.pdf({ ... });
// pdfBufferはUint8Array
// OK: Buffer.from()で変換してから使う
const pdfBuffer = await page.pdf({ ... });
return Buffer.from(pdfBuffer); // これで確実にBufferとして扱える
nodemailerは内部でBase64エンコードするんだけど、Uint8Arrayだと正しくエンコードされないことがある。Buffer.from()で変換しておけば確実。
ハマりポイント②: @aws-sdk/client-sesv2が必要
nodemailerの公式ドキュメントを見ると@aws-sdk/client-sesで良さそうに見える。でも最新版のnodemailerは@aws-sdk/client-sesv2(SES API v2)を要求する。
// NG: レガシーなclient-sesを使うとエラー
import {SESClient} from '@aws-sdk/client-ses'; // 動作しない
// OK: client-sesv2を使う
import {SESv2Client, SendEmailCommand} from '@aws-sdk/client-sesv2'; // 正しい
レガシーなclient-sesを使うと、こんなエラーが出る:
Using legacy SES configuration, expecting @aws-sdk/client-sesv2
パッケージを入れ替えれば解決:
npm uninstall @aws-sdk/client-ses
npm install @aws-sdk/client-sesv2
オプション設定
PDF生成オプション(page.pdf)
| オプション | 値 | 説明 |
|---|---|---|
format |
'A4' |
用紙サイズ(A4, A3, Letter, Legal, Tabloid等) |
printBackground |
true |
背景色・背景画像を印刷に含める |
margin.top |
'10mm' |
上余白(mm, cm, in, px指定可) |
margin.right |
'10mm' |
右余白 |
margin.bottom |
'10mm' |
下余白 |
margin.left |
'10mm' |
左余白 |
preferCSSPageSize |
false |
CSS @page サイズを優先しない(format優先) |
displayHeaderFooter |
false |
ヘッダー・フッターを非表示 |
ヘッダー・フッターやページ番号を入れたい場合:
{
format: 'A4',
printBackground: true,
margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
preferCSSPageSize: false,
displayHeaderFooter: true, // ヘッダー・フッター表示
headerTemplate: '<div style="font-size:10px; text-align:center; width:100%;">ヘッダー</div>',
footerTemplate: '<div style="font-size:10px; text-align:center; width:100%;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>',
landscape: false, // false=縦向き, true=横向き
scale: 1 // 拡大縮小(0.1〜2.0)
}
Puppeteer起動オプション(launch)
本番環境(Docker、EC2、Lambda等)で必要になるオプション一覧。ローカル開発ならheadless: trueだけで動く。
| オプション | 説明 | 必要性 |
|---|---|---|
--no-sandbox |
Chromeのサンドボックスを無効化 | Docker/CI環境では必須(権限不足エラー回避) |
--disable-setuid-sandbox |
setuidサンドボックスを無効化 | 権限関連の問題を回避 |
--disable-dev-shm-usage |
/dev/shm共有メモリの使用を無効化 |
メモリ不足エラー対策(特にDocker) |
--disable-gpu |
GPU処理を無効化 | サーバー環境ではGPU不要、安定性向上 |
--no-first-run |
初回起動時の処理をスキップ | 起動時間短縮 |
--no-zygote |
Zygoteプロセス生成を無効化 | メモリ使用量削減 |
--single-process |
シングルプロセスモードで動作 | メモリ削減、クラッシュ時の復旧が容易 |
--disable-web-security |
同一オリジンポリシーを無効化 | ローカルHTMLファイルの読み込みを許可 |
--disable-features=VizDisplayCompositor |
描画最適化機能を無効化 | 描画バグ回避、安定性優先 |
おまけ: Puppeteerのメモリ不足
Target closedで落ちる場合は、起動オプションの見直しが先。--disable-dev-shm-usageと--single-processが効く。
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // 重要
'--disable-gpu',
'--single-process' // 重要
]
});
Docker環境なら--shm-sizeで共有メモリを増やすのも手:
docker run --shm-size=2gb your-image
ハマるのは決まって同じところ
Uint8Arrayをそのまま渡す。レガシーなSDKを使う。3時間の原因は結局この2つだった。
手動でMIME組んでBase64エンコードして……みたいな苦行をしなくていいのがnodemailerの良さ。ハマりどころさえ避ければ、PDF添付メールは意外とあっさり動く。
Discussion