Cloudflare Workers 上で Hono を使った Email送信用のエンドポイントを設置する
はじめに
この記事はCloudflare Email Workers & Hono に関する記事です。
- Cloudflare Workers 上で Hono を使いたい
- (私と同じく) Hono を使ったことがない これから使っていきたい!
- Workers で Email送信用のエンドポイントを設置したい
上記に当てはまる方のお役に立てるかと思います。
コードは一旦こちらに置いておきます。
hono
2023年10月末に hono v3.9.0 がリリースされました。
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 dev
で http://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
を配置。
私にはまだ使いこなせないので、まずは形だけ...!
bearerの認証を使うために di.ts
に hono/bearer-auth
を追加、
interface.tsも追加しました。
.
├── node_modules
├── src
│ ├── di.ts # 追加
│ ├── index.ts
│ └── interface.ts # 追加
├── .gitignore
├── package.json
├── README.md
├── tsconfig.json
└── wrangler.toml
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;
}
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 変更
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が固定なので後日なおします。。。
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 追加
こちらも改良の余地がありありですが、
とりあえず動くものを配置しました。
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
としてルーティングを追加しました。
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レコードのセレクター |
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 を使って行きたいと思います!
イベント
東京に住んでいたら...!と強く思いました、とても素敵なイベントですね!
いつか参加できたらと思っております!
最後まで読んでいただき、ありがとうございました!
Discussion