📧

PDFメール送信で3時間ハマった話(Puppeteer + AWS SES)

に公開
2026/02/19

添付ファイルが開けない

「PDFを生成してメール送信するだけ」——そう思って実装を始めた。相続診断AIのWebアプリで、診断結果のPDFをメールに添付して送る機能。やることはシンプルなはず。

添付ファイルが壊れて開けない。3時間ハマった。

実際に動作するコードとハマりポイントをまとめます。

結論から

PDF添付メールで失敗する原因は、だいたいこのどっちか:

  1. Puppeteerのpage.pdf()はUint8Arrayを返す → Buffer.from()で変換が必須
  2. 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