🔥

Cloudflare Workers 上で Hono を使った Email送信用のエンドポイントを設置する

2023/11/02に公開

はじめに

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

  • Cloudflare Workers 上で Hono を使いたい
  • (私と同じく) Hono を使ったことがない これから使っていきたい!
  • Workers で Email送信用のエンドポイントを設置したい

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


コードは一旦こちらに置いておきます。
https://github.com/TakashiKakizoe1109/cloudflare-workers-hono-email-endpoint

hono

2023年10月末に hono v3.9.0 がリリースされました。
https://twitter.com/honojs/status/1717816452827533355

Cloudflare Workers & Cloudflare Pages で 爆速で利用できる Hono を使わない手はない!
私はまだ利用したことがないので、今回は Email送信用のエンドポイントを設置しようと思います!

hono プロジェクトを作成

コマンド一発でまずはプロジェクトを作成。
hono v3.9.1 でのスタートです。

npm create hono@latest cloudflare-workers-hono-email-endpoint

create-hono version 0.3.2
✔ Using target directory … cloudflare-workers-hono-email-endpoint
✔ Which template do you want to use? › cloudflare-workers
cloned honojs/starter#main to /Users/takashikakizoe/projects/php7/cloudflare-workers-hono-email-endpoint
✔ Copied project files
cd cloudflare-workers-hono-email-endpoint
npm install

初期構成

.
├── node_modules
├── src
│   └── index.ts
├── .gitignore
├── package.json
├── README.md
├── tsconfig.json
└── wrangler.toml

初期index.ts

import {Hono} from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export default app

初期動作確認

npm run devhttp://localhost:8787 で確認できました!素敵!

npm run dev

> dev
> wrangler dev src/index.ts

 ⛅️ wrangler 3.15.0
-------------------
⎔ Starting local server...
[mf:inf] Ready on http://0.0.0.0:8787
[mf:inf] - http://127.0.0.1:8787
[mf:inf] - http://192.168.10.10:8787
[mf:inf] GET / 200 OK (15ms)
[mf:inf] GET /favicon.ico 404 Not Found (1ms)

di.ts 配置

codehex さまの記事を参考に後々使うために di.ts を配置。
https://zenn.dev/notahotel/articles/bb15e25cde6bf9

私にはまだ使いこなせないので、まずは形だけ...!
bearerの認証を使うために di.tshono/bearer-auth を追加、
interface.tsも追加しました。

.
├── node_modules
├── src
│   ├── di.ts # 追加
│   ├── index.ts
│   └── interface.ts # 追加
├── .gitignore
├── package.json
├── README.md
├── tsconfig.json
└── wrangler.toml
interface.ts
export interface Env {
  [key: string]: any;
  API_TOKEN: string;
  API_END_POINT_MAILCHANNELS: string;
  DKIM_PRIVATE_KEY: string;
  MC_FROM_EMAIL: string;
  MC_FROM_NAME: string;
  MC_DKIM_DOMAIN: string;
  MC_DKIM_SELECTOR: string;
}
di.ts
import {Hono} from 'hono';
import {bearerAuth} from "hono/bearer-auth";
import {type Env} from "./interface";

export class DIContainer {
  constructor(
    private readonly env: Env,
    private readonly req?: Request,
  ) {
  }

  async cleanup(): Promise<void> {
  }
}

type Variables = {
  di: DIContainer;
};
export type HonoTypes = {
  Bindings: Env;
  Variables: Variables
};

export const app = new Hono<HonoTypes>();
app.use('*', async (c, next) => {
  const di = new DIContainer(c.env, c.req.raw);
  c.set('di', di);
  const token = c.env.API_TOKEN;
  const auth = bearerAuth({token});
  await auth(c, next)
  if (c.error) {
    // di.logger.write('error', c.error);
  }
  c.executionCtx.waitUntil(di.cleanup());
});

api/mail/send.ts & utils/mailchannels.ts を追加

送信用の send.ts と MailChannels へ送信するための utils/mailchannels.ts を追加しました。
また interface.ts にも MailChannels へ送信するための interface を追加しました。

.
├── node_modules
├── src
│   ├── api
│   │   └── mail
│   │       └── send.ts # 追加
│   ├── utils
│   │   └── mailchannels.ts # 追加
│   ├── di.ts
│   ├── index.ts
│   └── interface.ts # 変更
├── .gitignore
├── package.json
├── README.md
├── tsconfig.json
└── wrangler.toml

interface.ts 変更

interface.ts
export interface Env {
	[key: string]: any;
	API_TOKEN: string;
	API_END_POINT_MAILCHANNELS: string;
	DKIM_PRIVATE_KEY: string;
	MC_FROM_EMAIL: string;
	MC_FROM_NAME: string;
	MC_DKIM_DOMAIN: string;
	MC_DKIM_SELECTOR: string;
}

export interface EmailAddress {
	email: string;
	name?: string;
}

export interface Personalization {
	//to: [EmailAddress, ...EmailAddress[]];
	to: EmailAddress[];
	from?: EmailAddress;
	dkim_domain?: string;
	dkim_private_key?: string;
	dkim_selector?: string;
	reply_to?: EmailAddress;
	cc?: EmailAddress[];
	bcc?: EmailAddress[];
	subject?: string;
	headers?: Record<string, string>;
}

export interface ContentItem {
	type: string;
	value: string;
}

export interface MailSendBody {
	personalizations: [Personalization, ...Personalization[]];
	from: EmailAddress;
	reply_to?: EmailAddress;
	subject: string;
	content: [ContentItem, ...ContentItem[]];
	headers?: Record<string, string>;
}
export interface ForApiPersonalization {
	to: EmailAddress[];
	cc?: EmailAddress[];
	bcc?: EmailAddress[];
}

export interface ForApiContent {
	type: string;
	value: string;
}

export interface ForApiMailRequest {
	personalizations: ForApiPersonalization[];
	subject: string;
	content: ForApiContent[];
	from: EmailAddress;
	reply_to?: EmailAddress;
}

mailchannels.ts 追加

改良の余地がありありですが、とりあえず動くものを配置しました。
fromが固定なので後日なおします。。。

mailchannels.ts
import {ContentItem, EmailAddress, Env, MailSendBody, ForApiMailRequest, Personalization} from '../interface';

export const sendTransferMail = async (
  request: ForApiMailRequest,
  env: Env
): Promise<Response> => {
  let toEmailAddresses = [
    {
      email: request.personalizations[0].to[0].email,
    }
  ];
  const fromEmailAddress: EmailAddress = {
    email: env.MC_FROM_EMAIL,
    name: env.MC_FROM_NAME
  };
  const personalization: Personalization = {
    to: toEmailAddresses,
    from: fromEmailAddress,
    dkim_domain: env.MC_DKIM_DOMAIN,
    dkim_selector: env.MC_DKIM_SELECTOR,
    dkim_private_key: env.DKIM_PRIVATE_KEY
  };
  const content: ContentItem = {
    type: request.content[0].type,
    value: request.content[0].value
  };
  const payload: MailSendBody = {
    personalizations: [personalization],
    from: fromEmailAddress,
    subject: request.subject,
    content: [content]
  };
  const response = await fetch(env.API_END_POINT_MAILCHANNELS, {
    method: 'POST',
    headers: {
      'content-type': 'application/json'
    },
    body: JSON.stringify(payload)
  });
  console.log('mailchannels:', response.status, response.statusText, await response.text());
  return response;
};

send.ts 追加

こちらも改良の余地がありありですが、
とりあえず動くものを配置しました。

send.ts
import {app} from '../../di';
import {validator} from 'hono/validator'
import {sendTransferMail} from '../../utils/mailchannels'
import {type ForApiMailRequest} from '../../interface'

const jsonValidator = validator('json', (value: ForApiMailRequest, c) => {
  const personalizations = value['personalizations'];
  const subject = value['subject'];
  const content = value['content'];
  const from = value['from'];

  // Validate personalizations
  if (!Array.isArray(personalizations) || !personalizations.length) {
    return c.text('Invalid personalizations', 400)
  }
  for (let personalization of personalizations) {
    if (!personalization.to || !Array.isArray(personalization.to)) {
      return c.text('Invalid personalizations', 400)
    }
  }

  // Validate subject
  if (!subject || subject.length > 78) {
    return c.text('Invalid subject', 400)
  }

  // Validate content
  if (!Array.isArray(content) || !content.length) {
    return c.text('Invalid content', 400)
  }
  for (let item of content) {
    if (!item.type || !item.value) {
      return c.text('Invalid content', 400)
    }
  }

  // Validate from
  if (!from || !from.email.includes('@')) {
    return c.text('Invalid from', 400)
  }

  return {
    personalizations: personalizations,
    subject: subject,
    content: content,
    from: from,
  }
});

// const apiMailSend = new Hono()
const apiMailSend = app.post(
  "/",
  jsonValidator,
  async (c) => {
    const response = await sendTransferMail(c.req.valid('json'), c.env);
    return c.json({status: response.status, statusText: response.statusText}, response.status);
  }
);

export {apiMailSend};

index.ts 変更

/api/mail/send としてルーティングを追加しました。

index.ts
import {app} from './di';
import {apiMailSend} from './api/mail/send'

app.route('/api/mail/send', apiMailSend)

export default app

wrangler.toml で workers の 変数を設定

送信に必要な変数を登録しておきます。
API_TOKEN と付けられていますが、 Bearer認証に使います。
API_END_POINT_MAILCHANNELS は固定で、そのほかは自身のドメインに合わせて設定しました。

変数名
API_TOKEN 認証に使う
API_END_POINT_MAILCHANNELS 固定値: https://api.mailchannels.net/tx/v1/send
MC_FROM_EMAIL From メールアドレス
MC_FROM_NAME From 名称
MC_DKIM_DOMAIN DKIM認証ドメイン
MC_DKIM_SELECTOR DKIMレコードのセレクター
wrangler.toml
name = "cloudflare-workers-hono-email-endpoint"
compatibility_date = "2023-01-01"
main = "src/index.ts"
node_compat = true
workers_dev = true

[vars]
API_TOKEN = "XXXXXXXXXXXXXXXX"
API_END_POINT_MAILCHANNELS = "https://api.mailchannels.net/tx/v1/send"
MC_FROM_EMAIL = "noreply@example.com"
MC_FROM_NAME = "Example"
MC_DKIM_DOMAIN = "example.com"
MC_DKIM_SELECTOR = "mailchannels"

DKIM設定

下記コマンドから record.txt を取得し、DNSレコードの設定まで済ませます。

openssl genrsa -out dkim_private.pem 2048
openssl genrsa 2048 | tee priv_key.pem | openssl rsa -outform der | openssl base64 -A | wrangler secret put DKIM_PRIVATE_KEY
echo -n "v=DKIM1;p=" > record.txt && openssl rsa -in priv_key.pem -pubout -outform der | openssl base64 -A >> record.txt

Cloudflare DNS 設定

SPFレコードの設定、mailchannelsが送信可能かの判断のためのレコード、DKIM認証用レコード、DMARCレコードを設定します。

Type Name Content note
TXT @ v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net -all SPF Record
TXT _mailchannels v=mc1 cfid=xxxxxxxxxx.workers.dev cfid=yourdomain.com mailchannelsが送信可能ドメインかどうかを判断するために使用
TXT mailchannels._domainkey record.txt value DKIM認証用レコード
TXT _dmarc v=DMARC1; p=reject; pct=100; rua=mailto:[notify mail address] 失敗したメールに対して実行したいアクションを宣言

いざデプロイ & メール送信

デプロイして、メール送信してみます!

wrangler deploy

まずはデプロイし、workersのURLを確認!
https://send-workers-email.xxxxxxxxxx.workers.dev
次に、curlでメール送信してみます!
https://send-workers-email.xxxxxxxxxx.workers.dev/api/mail/send

curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer XXXXXXXXXXXXXXXX" -d '{"personalizations": [{"to": [{"email": "sample@example.com"}]}],"from": {"email": "noreply@example.com"},"subject": "Hello, World!","content": [{"type": "text/plain", "value": "Hey!!"}]}' https://send-workers-email.xxxxxxxxxx.workers.dev/api/mail/send

某有名サービスに倣っております

今回は私のGmail宛に送信しましたが、しっかりとメール送信ができました!

最後に

前回に加えて 今回は Hono に触れることができました。
今後もCloudflare Pages 等で Hono を使って行きたいと思います!

イベント

https://notahotel-cf.peatix.com/

https://workers-tech.connpass.com/event/300546/

東京に住んでいたら...!と強く思いました、とても素敵なイベントですね!
いつか参加できたらと思っております!

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

Discussion