🔽

Cloudflare Email Workers でメールの添付ファイルをR2へ保存する

2023/10/21に公開

はじめに

この記事はCloudflare Email Workers に関する記事です。

  • 受信メールの添付ファイルを別途保管しておきたい
  • Email Workers から R2 を利用したい

上記に当てはまる方のお役に立てるかと思います。

参照

https://zenn.dev/takashikakizoe/articles/cloudflare-email-workers-notify-chatwork

https://c-limber.co.jp/blog/5174

Cloudflare R2

Cloudflare R2は、Amazon S3と互換性のあるクラウド型のオブジェクトストレージサービスで、
Cloudflare Workersと完全に統合されたサーバーレスランタイムです。
ストレージバケットに書き込まれたり、読み取られたりするオブジェクトを動的に変換することを可能にします。

R2

エグレス料金ゼロ、グローバルに分散されたオブジェクトストレージです、素敵です。

無料枠

期限のない無料枠があるので試しやすいかと思います。

ストレージ: 10GB/月
クラスA操作(状態を変更する): 100万/月
クラスB操作(既存の状態を読み取る): 1,000万/月

Workers プロジェクトを作成

コマンドにて作成

以下のコマンドで作成しました

npm create cloudflare@2 -- email-workers-save-attachment-r2

後ほど全て置き換えるので "Hello World" Worker を選択し、今回は TypeScript Yes を選択しました。

using create-cloudflare version 2.6.1

╭ Create an application with Cloudflare Step 1 of 3
│
├ In which directory do you want to create your application?
│ dir ./email-workers-save-attachment-r2
│
├ What type of application do you want to create?
│ type "Hello World" Worker
│
├ Do you want to use TypeScript?
│ yes typescript
│
├ Copying files from "hello-world" template
│
├ Retrieving current workerd compatibility date
│ compatibility date 2023-10-16
│
├ Do you want to use git for version control?
│ yes git
│
╰ Application created

╭ Installing dependencies Step 2 of 3
│
├ Installing dependencies
│ installed via `npm install`
│
├ Committing new files
│ git commit
│
╰ Dependencies Installed

╭ Deploy with Cloudflare Step 3 of 3
│
├ Do you want to deploy your application?
│ no deploy via `npm run deploy`
│
├  APPLICATION CREATED  Deploy your application with npm run deploy
│
│ Navigate to the new directory cd email-workers-save-attachment-r2
│ Run the development server npm run start
│ Deploy your application npm run deploy
│ Read the documentation https://developers.cloudflare.com/workers
│ Stuck? Join us at https://discord.gg/cloudflaredev
│
╰ See you again soon!

プロジェクトディレクトリへ移動

cd email-workers-save-attachment-r2

postal-mime の追加

npm install postal-mime

R2のバケットを作成しておく

wrangler r2 bucket create mail-attachment
 ⛅️ wrangler 3.14.0
-------------------
Creating bucket mail-attachment.
Created bucket mail-attachment.

wrangler.toml の編集

環境変数を設定するために wrangler.toml を編集します

以下の情報へ変更して下さい
今回はMAIL_WORKER_BUCKET という名前で作成したバケットを指定しています

  • CHATWORK_TOKEN は Chatwork API のトークン
  • CHATWORK_ROOM は Chatwork のルームID
  • RELAY_EMAILS は 転送先のメールアドレスの配列
  • MAIL_WORKER_BUCKET は R2のバケット名
wrangler.toml
name = "email-workers-save-attachment-r2"
main = "src/index.ts"
compatibility_date = "2023-10-16"
node_compat = true
workers_dev = true
vars = { CHATWORK_TOKEN = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", CHATWORK_ROOM = "XXXXXXXXX", RELAY_EMAILS = '["email1@example.com", "email2@example.com"]' }

[[r2_buckets]]
binding = "MAIL_WORKER_BUCKET"
bucket_name = "mail-attachment"

index.ts の編集

前回からの主な変更点

前回の index.js から index.ts へ変更し、添付ファイルへの対応を追加しました。
parsedEmailのattachmentを受け取り、await env.MAIL_WORKER_BUCKET.put() にてR2のバケットに保存しています。

const processAttachments = async (attachments: any[], env: Environment, messageId: string): Promise<string> => {
	if (attachments.length === 0) {
		console.log('No attachments');
		return 'No attachments';
	}
	let attachmentInfo = '';
	for (const att of attachments) {
		console.log('Attachment: ', att.filename);
		console.log('Attachment disposition: ', att.disposition);
		console.log('Attachment mime type: ', att.mimeType);
		console.log('Attachment content: ', att.content);
		let attachmentKey = `${messageId}_${att.filename}`;
		await env.MAIL_WORKER_BUCKET.put(attachmentKey, att.content);
		attachmentInfo += attachmentKey + '\n';
	}
	return attachmentInfo;
};

index.ts の全文

以下の機能を持つように実装しております。

  • メール受信をトリガーに実行
    • 添付ファイルを R2 へ保存
    • 通知文を作成し Chatwork へ通知
    • 転送先のメールアドレスへ転送
index.ts
import PostalMime from 'postal-mime';

const CW_API_ENDPOINT = 'https://api.chatwork.com/v2/rooms/';

interface Environment {
	CHATWORK_TOKEN: string;
	CHATWORK_ROOM: string;
	RELAY_EMAILS: string;
	MAIL_WORKER_BUCKET: R2Bucket;
}

interface MessageContext {
	waitUntil: (promise: Promise<void>) => void;
}

interface Message {
	from: string;
	to: string;
	headers: Map<string, string>;
	raw: ReadableStream<Uint8Array>;
	rawSize: number;
	forward: (email: string) => Promise<void>;
}

export default {
	async email(message: Message, env: Environment, ctx: MessageContext): Promise<void> {
		ctx.waitUntil(notifyMessage(message, env));
	}
};

const streamToArrayBuffer = async (stream: ReadableStream<Uint8Array>, streamSize: number): Promise<Uint8Array> => {
	let result = new Uint8Array(streamSize);
	let bytesRead = 0;
	const reader = stream.getReader();
	while (true) {
		const { done, value } = await reader.read();
		if (done) {
			break;
		}
		result.set(value, bytesRead);
		bytesRead += value.length;
	}
	return result;
};

const processAttachments = async (attachments: any[], env: Environment, messageId: string): Promise<string> => {
	if (attachments.length === 0) {
		console.log('No attachments');
		return 'No attachments';
	}
	let attachmentInfo = '';
	for (const att of attachments) {
		console.log('Attachment: ', att.filename);
		console.log('Attachment disposition: ', att.disposition);
		console.log('Attachment mime type: ', att.mimeType);
		console.log('Attachment content: ', att.content);
		let attachmentKey = `${messageId}_${att.filename}`;
		await env.MAIL_WORKER_BUCKET.put(attachmentKey, att.content);
		attachmentInfo += attachmentKey + '\n';
	}
	return attachmentInfo;
};

const buildNotifyMessage = async (
	message: Message,
	parsedEmail: any,
	relayEmails: string[],
	env: Environment
): Promise<string> => {

	let messageId = message.headers.get('message-id');
	messageId = messageId ? messageId.replace(/^<|>$/g, '') : '';
	const attachments = await processAttachments(parsedEmail.attachments, env, messageId);
	let emailText = parsedEmail.text;
	if (emailText.length > 600) {
		emailText = emailText.slice(0, 600) + '...';
	}

	return `
[info]
[title]Notifications from Cloudflare Mail Email Workers[/title]
From: ${message.from}
To: ${message.to}
[info]
Title: ${message.headers.get('subject')}
Body:
${emailText}
[/info]
[code]
Attachment:
${attachments}
[/code]
[code]
Message-id: ${message.headers.get('message-id')}
Received: ${message.headers.get('received')}
Date: ${message.headers.get('date')}
[/code]
[code]
# Forwarding Email Addresses
${relayEmails.join(', ')}
[/code]
[/info]`;
};

const sendChatwork = async (notifyMessage: string, env: Environment): Promise<void> => {
	try {
		const cwBody = new URLSearchParams({
			body: notifyMessage
		});
		const cwHeaders = new Headers();
		cwHeaders.append('Content-Type', 'application/x-www-form-urlencoded');
		cwHeaders.append('X-ChatWorkToken', env.CHATWORK_TOKEN);
		const cwRequest = new Request(`${CW_API_ENDPOINT}${env.CHATWORK_ROOM}/messages/?${cwBody.toString()}`, {
			headers: cwHeaders,
			method: 'POST'
		});
		let cwResponse = await fetch(cwRequest);
		console.log(cwResponse);
	} catch (e) {
		console.error(e);
	}
};

const notifyMessage = async function(message: Message, env: Environment): Promise<void> {
	try {
		const relayEmails: string[] = JSON.parse(env.RELAY_EMAILS);
		const rawEmail = await streamToArrayBuffer(message.raw, message.rawSize);
		const parser = new PostalMime();
		const parsedEmail = await parser.parse(rawEmail);
		const notifyMessage = await buildNotifyMessage(message, parsedEmail, relayEmails, env);
		console.log('parsedEmail: ', parsedEmail);
		await Promise.all(relayEmails.map(email => message.forward(email)));
		await sendChatwork(notifyMessage, env);
	} catch (e) {
		console.error(e);
	}
};

Cloudflare Workers へデプロイ

以下のコマンドでログイン中のアカウントへデプロイが可能です

wrangler deploy

メール本文のPDF化は?

業務でも非常によく利用しているPuppeteerが使えると思ったのですが、
waitlist に登録して待ちが必要のようです。
(登録したので楽しみに待っております!)

https://developers.cloudflare.com/browser-rendering/platform/puppeteer/
https://www.cloudflare.com/ja-jp/lp/workers-browser-rendering-api/

その他 ライブラリは重さなどの制約に引っかかりそうなので少しトライしてやめておきました。

Lambda での PDF はこちらから

最後に

前回に引き続き Cloudflare Email Workers のお話でした。
これでメールを受信したら R2へ添付ファイルを保存することができます。
Cloudflare D1 等も利用して電子請求書への対応などができたら良いなと思っています。

最後まで読んでいただき、ありがとうございました!

Discussion