メニュー

公開日:
10 min read
技術革新

Cloudflare workersでGmailを使ってメール送信を実装する 実装編

Cloudflare workersでGmailを使ってメール送信を実装する 実装編のイメージ

実装手順

1. wrangler.tomlを設定する

  • ダウンロードしたサービスアカウントキー(JSONファイル)の内容を、環境変数SERVICE_ACCOUNT_KEYとして保存します。

  • これは秘密情報なので、直接コード内にハードコーディングしないでください。

  • 汎用性を高めるために他の値も環境変数として設定します。

  • wrangler.tomlはローカル環境でしか使われないため、本番環境ではダッシュボードから環境変数を設定します。

       name = "otoiawase-form"
    pages_build_output_dir = "./dist"
    
    [vars]
    ENVIRONMENT = "development"
    BCC_EMAIL = "hoge@aqz.jp"
    SERVICE_ACCOUNT_EMAIL = "xxxxxxxxxxxxx.iam.gserviceaccount.com(jsonファイルのclient_emailの値)"
    SERVICE_ACCOUNT_KEY = "(jsonファイルのprivate_keyの値)"
    IMPERSONATED_USER = "hoge@aqz.jp"
    COMPANY_NAME = "合同会社アキューズ"
    COMPANY_EMAIL = "hoge@aqz.jp"
    COMPANY_WEBSITE = "https://aqz.jp"
    EMAIL_SUBJECT = "【合同会社アキューズ】お問い合わせ"

2. OAuth 2.0トークンの取得

Cloudflare Workers上で、サービスアカウントを使用してOAuth 2.0トークンを取得します。JWTを作成し、GoogleのOAuth 2.0トークンエンドポイントにリクエストします。

3. メールの送信

取得したアクセストークンを使用して、Gmail APIのusers.messages.sendエンドポイントにリクエストを送信します。


重要な注意点

  • 制限事項: Cloudflare Workersにはライブラリの制約があります。ネイティブなNode.jsモジュールは使用できないため、ブラウザ互換のコードを書く必要があります。
  • 権限の確認: サービスアカウントとドメイン全体の委任が正しく設定されていることを再確認してください。特に、なりすますユーザー(subクレームで指定)のメールアドレスが正しいことを確認してください。

以上の手順とコードで、Cloudflare Workers上からGmailを送信するプログラムを作成できます。必要に応じて、コードをカスタマイズし、セキュリティと信頼性を確保してください。

プログラム

と言うことで、下記が実際のコードotoiawase-form.tsだ。

   export interface Env {
  ENVIRONMENT: string;
  BCC_EMAIL: string;
  SERVICE_ACCOUNT_EMAIL: string;
  SERVICE_ACCOUNT_KEY: string;
  IMPERSONATED_USER: string;
  COMPANY_NAME: string;
  COMPANY_EMAIL: string;
  COMPANY_WEBSITE: string;
  EMAIL_SUBJECT: string;
}

function isLocalhost(env: Env) {
  return env.ENVIRONMENT == 'development';
}

// 環境に応じてログを出力する関数
function conditionalLog(env: Env, message: string, ...args: unknown[]): void {
  if (isLocalhost(env)) {
    console.log(message, ...args);
  }
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method === 'OPTIONS') {
      return handleOptionsRequest(env);
    }

    try {
      if (request.method !== 'POST') {
        return createResponse({ success: false, error: 'Only POST method is allowed' }, 405, env);
      }

      const formData = await request.formData();
      const validation = validateRequest(formData);
      if (!validation.isValid) {
        return createResponse({ success: false, error: validation.error }, 400, env);
      }

      const emailContent = createEmailContent(formData, env);
      const success = await sendEmail(formData, emailContent, env);

      if (success) {
        return createResponse({ success: true, message: 'Email sent successfully' }, 200, env);
      } else {
        throw new Error('Failed to send email');
      }
    } catch (error) {
      console.error('Error in fetch:', error);
      let errorMessage = 'An unexpected error occurred';
      if (error instanceof Error) {
        errorMessage = error.message;
      }
      return createResponse({ success: false, error: errorMessage }, 500, env);
    }
  },
};

function createResponse(body: any, status: number, env: Env): Response {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  };

  if (isLocalhost(env)) {
    headers['Access-Control-Allow-Origin'] = '*';
    headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS';
    headers['Access-Control-Allow-Headers'] = 'Content-Type';
  } else {
    headers['Access-Control-Allow-Origin'] = env.COMPANY_WEBSITE;
    headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS';
    headers['Access-Control-Allow-Headers'] = 'Content-Type';
  }

  return new Response(JSON.stringify(body), { status, headers });
}

function handleOptionsRequest(env: Env): Response {
  return createResponse(null, 204, env);
}

function validateRequest(formData: FormData): { isValid: boolean; error?: string } {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const company = formData.get('company') as string;
  const message = formData.get('message') as string;

  if (!name || !email || !company || !message) {
    return { isValid: false, error: 'Missing required fields' };
  }

  if (!validateEmail(email)) {
    return { isValid: false, error: 'Invalid email address' };
  }

  return { isValid: true };
}

function validateEmail(email: string): boolean {
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailPattern.test(email);
}

function createEmailContent(formData: FormData, env: Env): string {
  const name = formData.get('name') as string;
  const company = formData.get('company') as string;
  const message = formData.get('message') as string;

  return `
${company}
${name}

このたびは${env.COMPANY_NAME}へのお問合せいただきありがとうございます。

●お問合せ内容:

${message}

---------

すべてのお問合せにお返事できませんが、いただいたメッセージには必ず目を通します。

今後ともよろしくお願いいたします。

${env.COMPANY_NAME}
`;
}

// ヘッダーを配列に変換するユーティリティ関数
function headersToArray(headers: Headers): [string, string][] {
  const result: [string, string][] = [];
  headers.forEach((value, key) => {
    result.push([key, value]);
  });
  return result;
}

async function sendEmail(formData: FormData, content: string, env: Env): Promise<boolean> {
  try {
    const accessToken = await getAccessToken(env);

    const to = formData.get('email') as string;
    if (!to || !validateEmail(to)) {
      throw new Error('Invalid email address');
    }

    const subject = `=?UTF-8?B?${base64Encode(env.EMAIL_SUBJECT)}?=`;
    const from = `=?UTF-8?B?${base64Encode(env.COMPANY_NAME)}?= <${env.COMPANY_EMAIL}>`;
    const bcc = env.BCC_EMAIL; // BCC emailを環境変数から取得

    const emailParts = [
      `From: ${from}`,
      `To: ${to}`,
      `Subject: ${subject}`,
      `Bcc: ${bcc}`, // BCCヘッダーを追加
      'MIME-Version: 1.0',
      'Content-Type: text/plain; charset=UTF-8',
      'Content-Transfer-Encoding: base64',
      '',
      base64Encode(content),
    ];

    const email = emailParts.join('\r\n');

    const response = await fetch('https://www.googleapis.com/gmail/v1/users/me/messages/send', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ raw: base64UrlEncode(email) }),
    });

    conditionalLog(env, `Gmail API Response Status: ${response.status} ${response.statusText}`);
    conditionalLog(
      env,
      'Gmail API Response Headers:',
      Object.fromEntries(headersToArray(response.headers)),
    );

    const responseBody = await response.text();
    conditionalLog(env, 'Gmail API Response Body:', responseBody);

    if (!response.ok) {
      throw new Error(
        `Failed to send email: ${response.status} ${response.statusText}. Response: ${responseBody}`,
      );
    }

    return true;
  } catch (error) {
    console.error('Error in sendEmail:', error);
    throw error;
  }
}

// 通常のBase64エンコード
function base64Encode(str: string): string {
  return btoa(unescape(encodeURIComponent(str)));
}

// URL安全なBase64エンコード
function base64UrlEncode(str: string): string {
  return base64Encode(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function mimeEncode(str: string): string {
  return '=?UTF-8?B?' + base64EncodeUnicode(str) + '?=';
}

function base64EncodeUnicode(str: string): string {
  return arrayBufferToBase64(str2ab(str));
}

function arrayBufferToBase64(buffer: ArrayBuffer): string {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

function str2ab(str: string): ArrayBuffer {
  const buf = new ArrayBuffer(str.length);
  const bufView = new Uint8Array(buf);
  for (let i = 0, strLen = str.length; i < strLen; i++) {
    bufView[i] = str.charCodeAt(i);
  }
  return buf;
}

interface TokenError {
  error: string;
  error_description?: string;
}

async function getAccessToken(env: Env): Promise<string> {
  const jwt = await createJWT(env);

  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion: jwt,
    }),
  });

  conditionalLog(env, `Token Response Status: ${tokenResponse.status} ${tokenResponse.statusText}`);
  conditionalLog(
    env,
    'Token Response Headers:',
    Object.fromEntries(headersToArray(tokenResponse.headers)),
  );

  const responseBody = await tokenResponse.text();
  conditionalLog(env, 'Token Response Body:', responseBody);

  if (!tokenResponse.ok) {
    let errorInfo: TokenError;
    try {
      errorInfo = JSON.parse(responseBody);
    } catch {
      errorInfo = { error: 'Unknown error', error_description: responseBody };
    }
    const errorMessage = `Failed to obtain access token: ${tokenResponse.status} ${
      tokenResponse.statusText
    }. Error: ${errorInfo.error}. Description: ${
      errorInfo.error_description || 'No description provided'
    }`;
    console.error(errorMessage);
    throw new Error(errorMessage);
  }

  let tokenData: { access_token: string };
  try {
    tokenData = JSON.parse(responseBody);
  } catch (error) {
    console.error('Failed to parse token response:', error);
    throw new Error(
      `Failed to parse token response: ${
        error instanceof Error ? error.message : String(error)
      }. Response body: ${responseBody}`,
    );
  }

  return tokenData.access_token;
}

async function createJWT(env: Env): Promise<string> {
  const key = env.SERVICE_ACCOUNT_KEY.replace(/\\n/g, '\n');

  const header = {
    alg: 'RS256',
    typ: 'JWT',
  };

  const now = Math.floor(Date.now() / 1000);
  const claims = {
    iss: env.SERVICE_ACCOUNT_EMAIL,
    scope: 'https://www.googleapis.com/auth/gmail.send',
    aud: 'https://oauth2.googleapis.com/token',
    exp: now + 3600,
    iat: now,
    sub: env.IMPERSONATED_USER,
  };

  const encoder = new TextEncoder();
  const input = [btoa(JSON.stringify(header)), btoa(JSON.stringify(claims))].join('.');

  const signature = await crypto.subtle.sign(
    {
      name: 'RSASSA-PKCS1-v1_5',
      hash: 'SHA-256',
    },
    await importPrivateKey(key),
    encoder.encode(input),
  );

  const jwt = input + '.' + arrayBufferToBase64(signature);
  return jwt;
}

async function importPrivateKey(pemKey: string): Promise<CryptoKey> {
  const pemHeader = '-----BEGIN PRIVATE KEY-----';
  const pemFooter = '-----END PRIVATE KEY-----';
  const pemContents = pemKey
    .substring(pemKey.indexOf(pemHeader) + pemHeader.length, pemKey.indexOf(pemFooter))
    .replace(/\s/g, '');
  const binaryDerString = atob(pemContents);
  const binaryDer = str2ab(binaryDerString);

  return crypto.subtle.importKey(
    'pkcs8',
    binaryDer,
    {
      name: 'RSASSA-PKCS1-v1_5',
      hash: 'SHA-256',
    },
    false,
    ['sign'],
  );
}

4. デプロイとテスト

  1. テスト: otoiawase-formにリクエストを送信し、メールが正しく送信されることを確認します。

    • ローカルでテストするために、以下のコマンドを実行します。
       npx wrangler pages dev
    • ブラウザでhttp://localhost:8787/にアクセスし、お問い合わせフォームから送信します。
    • 呼び出すURLは’/otoiawase-form’です。‘functions’は不要です。
    • TinaCMSサーバーを起動する必要はありません。
    • ‘npm run build’を忘れずに実行する。
  2. デプロイ

  • ダッシュボードから環境変数を設定します。 ※設定不要(後述)
  • リポジトリにコミットします

Cloudflare Pages Functionsの問題点

VSCodeのデバッガーが動作しない!(涙)

このプログラムはworkerで作ったものを、Pages Functionsに修正したものなので、デバッグの必要が無かったのだが、そうでなかったら地獄の作業が待っていたはずである。 workerはVSCodeでブレークポイントが使える。 AIにPages FunctionsをVSCodeでブレークポイントで停止する方法を聞いても、おかしなことしか言ってこないわけだ。 AIは知ったかぶりをするので、なんだかおかしいと思ったら、Google検索したほうがいい。 このときにPerplexityを使うのはダメだ。こいつもAIなので知ったかぶりをするのである!

また、環境変数もworkerならローカルと本番ともにtomlファイルで設定できるが、Pages Functionsはダッシュボードから設定するしかない。これもなかなか不便だ。

→実際にはwrangler.tomlの内容が自動的にダッシュボードに表示されるため、デプロイ後に環境変数ENVIRONMENTをproductionに変更する必要がある。