社内ヘルプデスクに問い合わせが届いたら、担当者にメールで知らせたい。ところが、チケットを登録する処理の中でそのままメールを送ると、メール送信サービスが遅いだけで画面が固まり、止まっていれば登録そのものが失敗してしまいます。
「問い合わせが来たことに担当者がすぐ気づける仕組みがほしい。でも、メールが送れないせいで申請まで止まるのは困る」
結論から言うと、通知は「キュー(Queue=順番待ちの列)」に預けて、別の処理に任せるのが定石です。この回では Cloudflare Queues を使い、チケット登録の応答を待たせずに担当者へメールを送り、失敗したら間隔をあけて自動でやり直し、それでもだめなものは別の列(dead letter queue)に退避するところまで作ります。
前回は、スクリーンショットなどの添付ファイルを R2 に保存しました。まだの方は先にご覧ください。
詳しくは前回の記事で解説しています。


この回のコードは、Node.js と npm を新しくして(第0回の準備のとおり)、前回までのプロジェクトに足していきます。新しいライブラリのインストールは不要です(Queues は Cloudflare Workers の標準機能のため)。
通知メールを「その場で送らない」理由と、キューの仕組み
メールをチケット登録の処理の中で送ると、次の問題が起きます。
- メール送信サービスの応答が遅いと、申請した人の画面も遅くなる
- 送信サービスが一時的に止まっていると、申請自体がエラーになる
- 失敗したときにやり直す仕組みを、自分で作ることになる
キューを使うと、役割が次のように分かれます。
| 役割 | 呼び方 | この連載での担当 |
|---|---|---|
| 預ける側 | producer(プロデューサー) | チケット登録の API。「#12 の通知をお願い」と書いたメモをキューに入れるだけ |
| 受け取る側 | consumer(コンシューマー) | Worker の queue() 関数。メモを受け取り、メールを送る |
| 受け取りに失敗し続けたものの置き場 | dead letter queue(DLQ) | 上限まで失敗したメモが移される別のキュー |
Cloudflare Queues の大事な性質が2つあります。公式ドキュメントによると、配信は at-least-once(少なくとも1回) で、メッセージが失われない代わりに、まれに同じメッセージが2回届くことがあります(出典は記事の最後にまとめます)。そのため、同じ通知を2回受け取っても、メールが2通飛ばない作りにする必要があります。
もう1つは、再試行の仕組みです。max_retries の初期値は 3 で、上限を超えたメッセージは、DLQ を設定していればそこへ移されます。設定していなければ削除されます。
📰 出典:Cloudflare Docs「Batching, Retries and Delays」
📰 出典:Cloudflare Docs「Delivery guarantees」
この回で作るものの全体像
wrangler.jsoncにキュー(預ける側・受け取る側・DLQ)を設定する- 送信の記録テーブル
notification_logを作る(二重送信の防止用) - メール送信の部品
src/mail.tsを作る - 通知の処理
src/notify.tsを作る(預ける関数・受け取る関数・再試行の待ち時間) - チケット登録の API から、キューに預ける
- テストで、成功・失敗・二重配信を確かめる
1. キューを設定する
wrangler.jsonc に次の変更を加えます(+ が追加、- が削除です)。メール送信サービスの URL と送信元アドレスは、秘密ではないので vars に置きます。API キーは vars に書かず、後でシークレットとして渡します。
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -4,7 +4,23 @@
"main": "src/index.ts",
"compatibility_date": "2026-08-01",
"observability": { "enabled": true },
- "vars": { "APP_NAME": "社内ヘルプデスク" },
+ "vars": {
+ "APP_NAME": "社内ヘルプデスク",
+ "MAIL_FROM": "helpdesk@example.com",
+ "MAIL_API_URL": "https://mail-api.example.com/v1/send"
+ },
+ "queues": {
+ "producers": [{ "binding": "NOTIFY_QUEUE", "queue": "helpdesk-notifications" }],
+ "consumers": [
+ {
+ "queue": "helpdesk-notifications",
+ "max_batch_size": 10,
+ "max_batch_timeout": 5,
+ "max_retries": 3,
+ "dead_letter_queue": "helpdesk-notifications-dlq"
+ }
+ ]
+ },
"r2_buckets": [
{
"binding": "ATTACHMENTS",
producersのbindingは、コードからenv.NOTIFY_QUEUEとして使う名前ですmax_batch_sizeとmax_batch_timeoutは、メッセージを何件・何秒ためてから受け取り側に渡すかの設定です(初期値と同じ 10 件・5 秒)max_retries: 3は、最初の1回に加えて最大3回やり直す設定ですMAIL_FROMとMAIL_API_URLの値はダミーです。お使いのメール送信サービスに合わせて変えてください
設定を変えたら、型の定義を作り直します。
npx wrangler types
API キーの型は手で足します。src/env.d.ts に次の変更を加えます。
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -1,4 +1,5 @@
// wrangler secret / .dev.vars で渡す値の型(wrangler types では生成されないため手で足す)
interface Env {
SESSION_SECRET: string
+ MAIL_API_KEY: string
}
テスト側の型 test/env.d.ts にも同じ1行を足します。
--- a/test/env.d.ts
+++ b/test/env.d.ts
@@ -2,6 +2,7 @@
declare namespace Cloudflare {
interface Env {
SESSION_SECRET: string
+ MAIL_API_KEY: string
TEST_MIGRATIONS: import('cloudflare:test').D1Migration[]
}
}
ローカルで動かすときの値は .dev.vars(Git に入れないファイル)に書きます。.dev.vars.example に1行足しておきましょう。
SESSION_SECRET=ローカル開発用の長いランダム文字列に置き換える
MAIL_API_KEY=メール送信サービスのAPIキー(ローカルでは適当な値でよい)
2. 送信記録のテーブルを作る
同じ通知が2回届いても二重に送らないために、「誰に送ったか」を D1 に記録します。次のファイルを新しく作ります。
-- migrations/0005_notification_log.sql
-- 第5回: 通知メールの送信記録。キューは「同じ通知が2回届くことがある」ため、送った相手を覚えて二重送信を防ぐ
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES tickets (id),
kind TEXT NOT NULL,
recipient TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (ticket_id, kind, recipient)
);
UNIQUE (ticket_id, kind, recipient) は「同じチケット・同じ種類の通知・同じ宛先」の組を1行しか持てない制約です。最後の砦として働きます。適用は次のコマンドです。
npx wrangler d1 migrations apply helpdesk --local
3. メール送信の部品を作る
メール送信サービスは、会社ごとに選ぶものが違い、API の形も違います。そこで、送信の部分を1つのファイルに閉じ込めます。サービスを変えるときは、このファイルだけを直せば済みます。
// src/mail.ts
export type Mail = { to: string; subject: string; text: string }
// メール送信サービスの HTTP API を呼ぶ。サービスごとに形式が違うので、差し替える場所はここだけにする。
// ここでは「JSON を POST し、API キーは Authorization ヘッダーで渡す」最小の形にしている
export type SendMail = (env: Env, mail: Mail) => Promise<void>
export const sendMail: SendMail = async (env, mail) => {
const res = await fetch(env.MAIL_API_URL, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${env.MAIL_API_KEY}` },
body: JSON.stringify({ from: env.MAIL_FROM, to: mail.to, subject: mail.subject, text: mail.text }),
})
// 2xx 以外は失敗として投げる。呼び出し側(キューの処理)が、時間をおいて再試行する
if (!res.ok) throw new Error(`mail api failed: ${res.status}`)
}
SendMail という「型」を作っておくのがポイントです。通常は本物の sendMail を使い、テストでは偽物の関数を渡せます。
4. 通知の処理を書く
中心になるファイルです。長いので、全文は折りたたんでいます。先に3つの工夫を説明します。
- キューには番号だけを入れる:メッセージは
{ kind: 'ticket_created', ticketId: 12 }だけです。問い合わせの本文や個人情報をキューに残さず、送る直前に D1 から最新の内容を読みます - 宛先ごとに送信済みかを確かめる:二重配信されても、
notification_logにある相手には送りません。1人だけ送れなかったときの再試行でも、送れなかった相手にだけ送ります - メッセージごとに ack / retry を呼ぶ:成功したら
msg.ack()(処理済みの合図)、失敗したらmsg.retry()(待ってから再配信)です。公式ドキュメントによると、明示的な ack がないと、1件の失敗でバッチ全体が再試行されます。1件ずつ明示すれば、成功した分は巻き込まれません
<details> <summary>src/notify.ts の全文</summary>
// src/notify.ts
import { sendMail, type SendMail } from './mail'
// キューに入れるメッセージ。チケットの中身は入れず、番号だけにする
// (本文や個人情報をキューに残さない。送る直前に D1 から最新を読む)
export type NotificationMessage = { kind: 'ticket_created'; ticketId: number }
// 登録の流れ(API)から呼ぶ。キューに入れられなくても、チケットの登録自体は成功させる
export const enqueueNotification = async (env: Env, message: NotificationMessage) => {
try {
await env.NOTIFY_QUEUE.send(message)
} catch (e) {
console.error('通知をキューに入れられませんでした', message, e)
}
}
// 再試行までの待ち時間(秒)。1回目30秒、2回目60秒、3回目120秒…と倍々に増やし、上限は10分
export const retryDelaySeconds = (attempts: number) => Math.min(30 * 2 ** (attempts - 1), 600)
// ヘッダーの行を壊す改行を取り除く
const oneLine = (s: string) => s.replace(/[\r\n]+/g, ' ').trim()
type TicketRow = { id: number; title: string; category: string; priority: string; requester: string }
// 1件の通知を処理する。送れなかった宛先があれば、最後にエラーを投げて再試行にまわす
export const handleMessage = async (env: Env, message: NotificationMessage, send: SendMail) => {
const ticket = await env.DB.prepare('SELECT id, title, category, priority, requester FROM tickets WHERE id = ?')
.bind(message.ticketId)
.first<TicketRow>()
// チケットが無ければ、何度やっても送れない。再試行せず終わらせる
if (!ticket) return
const { results: agents } = await env.DB.prepare(
`SELECT email FROM users WHERE role IN ('agent', 'admin') AND active = 1 ORDER BY id`,
).all<{ email: string }>()
let failed = 0
for (const { email } of agents) {
// すでに送った相手には送らない(キューは同じ通知を2回渡すことがあるため)
const done = await env.DB.prepare(
'SELECT 1 AS ok FROM notification_log WHERE ticket_id = ? AND kind = ? AND recipient = ?',
)
.bind(ticket.id, message.kind, email)
.first()
if (done) continue
try {
await send(env, {
to: email,
subject: `[ヘルプデスク] 新しい問い合わせ #${ticket.id}: ${oneLine(ticket.title)}`,
text: `カテゴリ: ${ticket.category}\n優先度: ${ticket.priority}\n申請者: ${ticket.requester}\n\n詳細はヘルプデスクの画面で確認してください。`,
})
await env.DB.prepare('INSERT OR IGNORE INTO notification_log (ticket_id, kind, recipient) VALUES (?, ?, ?)')
.bind(ticket.id, message.kind, email)
.run()
} catch (e) {
failed++
console.error('通知メールを送れませんでした', { ticketId: ticket.id, kind: message.kind }, e)
}
}
if (failed > 0) throw new Error(`${failed} 件の宛先に送れませんでした`)
}
// キューから届いたメッセージの束を処理する。成功は ack(処理済みの合図)、失敗は retry(待ってから再配信)
export const processBatch = async (batch: MessageBatch<NotificationMessage>, env: Env, send: SendMail = sendMail) => {
for (const msg of batch.messages) {
try {
await handleMessage(env, msg.body, send)
msg.ack()
} catch {
// 上限(wrangler.jsonc の max_retries)を超えると、Cloudflare が dead_letter_queue へ移す
msg.retry({ delaySeconds: retryDelaySeconds(msg.attempts) })
}
}
}
</details>
再試行の待ち時間は retryDelaySeconds で決めています。1回目は30秒、2回目は60秒、3回目は120秒と倍々に増やす方式(指数バックオフ)で、上限は10分です。相手のサービスが一時的に止まっているとき、短い間隔で叩き続けないための配慮です。
メール本文は、件名にチケットの件名を入れます。件名に改行が混ざるとメールのヘッダーを壊す恐れがあるため、oneLine で改行を取り除いています。
5. 受け取り側を登録し、登録 API から預ける
Workers は、HTTP を受ける fetch と、キューを受ける queue を、1つの default export にまとめます。src/index.ts に次の変更を加えます。
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,6 +4,7 @@ import { users } from './routes/users'
import { tickets } from './routes/tickets'
import { attachments } from './routes/attachments'
+import { processBatch, type NotificationMessage } from './notify'
const app = new Hono<AppEnv>()
@@ -22,4 +23,10 @@ app.route('/api/users', users)
app.route('/api/tickets', tickets)
app.route('/api', attachments)
-export default app
+export { app }
+
+// Workers は fetch(HTTP)と queue(キューの受け取り)を1つの default export にまとめる
+export default {
+ fetch: app.fetch,
+ queue: (batch: MessageBatch<NotificationMessage>, env: Env) => processBatch(batch, env),
+} satisfies ExportedHandler<Env, NotificationMessage>
export { app } は、テストから Hono のアプリを直接呼ぶために足しています。
次に、チケット登録の API から、キューに預けます。src/routes/tickets.ts に次の変更を加えます。
--- a/src/routes/tickets.ts
+++ b/src/routes/tickets.ts
@@ -2,6 +2,7 @@ import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { requireLogin, requireRole, type AppEnv } from '../auth'
import { changeStatusSchema, createTicketSchema, listQuerySchema } from '../schemas'
+import { enqueueNotification } from '../notify'
import { canTransition, type Status } from '../status'
export const tickets = new Hono<AppEnv>()
@@ -37,7 +38,9 @@ tickets.post(
RETURNING id, title, category, priority, status, requester, created_at`,
)
.bind(input.title, input.body, input.category, input.priority, user.email)
- .first()
+ .first<{ id: number }>()
+ // 担当者への通知はキューに任せ、画面の応答は待たせない
+ await enqueueNotification(c.env, { kind: 'ticket_created', ticketId: ticket!.id })
return c.json(ticket, 201)
},
)
enqueueNotification は、キューへの投入に失敗してもエラーを外へ出さず、ログに残すだけにしています。「通知が出せないせいで、申請が受け付けられない」という本末転倒を避けるためです。ただし、この方式には弱点があります。チケットの登録は成功したのに、キューへの投入だけが失敗すると、通知が静かに失われます。厳密に防ぐには、通知の予定を同じ DB に先に書いておき、後から投入する「outbox(アウトボックス)パターン」が有効です。この連載では省略します(本番で通知の取りこぼしが許されない場合は、開発会社と方式を相談してください)。
6. テストで確かめる
テストでは、本物のメール送信を呼びたくありません。次の2点を工夫しました。
SendMailに偽物の関数を渡す- テスト中は、
NOTIFY_QUEUEを「受け取り側のいない別のキュー」につなぐ
2つ目がないと、チケットを登録する既存のテストの裏で、本物の受け取り処理が動いてしまいます。vitest.config.ts に次の変更を加えます。
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,7 +7,11 @@ export default defineConfig(async () => {
plugins: [
cloudflareTest({
wrangler: { configPath: './wrangler.jsonc' },
- miniflare: { bindings: { TEST_MIGRATIONS: migrations, SESSION_SECRET: 'test-only-secret-not-for-production' } },
+ miniflare: {
+ // テスト中は、受け取り側のいないキューにつなぐ(本物の処理が動いてメールを送りに行かないため)。
+ // 受け取りの処理は test/notify.test.ts で直接呼んで確かめる
+ queueProducers: { NOTIFY_QUEUE: { queueName: 'test-queue-without-consumer' } },
+ bindings: { TEST_MIGRATIONS: migrations, SESSION_SECRET: 'test-only-secret-not-for-production' } },
}),
],
test: { setupFiles: ['./test/apply-migrations.ts'] },
テスト本体は次のとおりです。確かめているのは、次の8つです。
- 登録すると、番号だけのメッセージがキューに入る
- キューに入れられなくても、登録は 201 で成功する
- 担当者と管理者だけに送り、件名に改行を混ぜられない
- 同じメッセージが2回届いても、同じ相手に2通送らない
- 送信に失敗したら ack せず、待ち時間つきで retry する
- 一部の宛先だけ失敗したら、再試行では残りの相手にだけ送る
- チケットが無いメッセージは、再試行せずに終わらせる
- 再試行の待ち時間が、倍々に増えて10分で頭打ちになる
<details> <summary>test/notify.test.ts の全文</summary>
// test/notify.test.ts
import { env } from 'cloudflare:test'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { app } from '../src/index'
import type { Mail, SendMail } from '../src/mail'
import { processBatch, retryDelaySeconds, type NotificationMessage } from '../src/notify'
import { loginAs } from './helpers'
let staff = ''
beforeAll(async () => {
staff = await loginAs('taro@example.com', 'employee')
await loginAs('agent@example.com', 'agent')
await loginAs('admin@example.com', 'admin')
})
const newTicket = async (title = '通知テスト') => {
const res = await env.DB.prepare(
`INSERT INTO tickets (title, body, category, priority, requester) VALUES (?, '本文', 'pc', 'normal', 'taro@example.com') RETURNING id`,
)
.bind(title)
.first<{ id: number }>()
return res!.id
}
// キューの処理を、偽のメッセージと偽のメール送信で1回動かす。ack / retry の呼ばれ方を返す
const run = async (ticketId: number, send: SendMail, attempts = 1) => {
const ack = vi.fn()
const retry = vi.fn()
const batch = {
queue: 'helpdesk-notifications',
messages: [{ id: `msg-${ticketId}`, attempts, body: { kind: 'ticket_created', ticketId }, ack, retry }],
} as unknown as MessageBatch<NotificationMessage>
await processBatch(batch, env, send)
return { ack, retry }
}
describe('チケット登録 → キューに入れる', () => {
it('登録するとキューに番号だけのメッセージが入り、応答は 201 のまま', async () => {
const send = vi.fn().mockResolvedValue({})
const res = await app.request(
'/api/tickets',
{
method: 'POST',
headers: { 'content-type': 'application/json', cookie: staff },
body: JSON.stringify({ title: 'キュー確認', body: '本文', category: 'pc' }),
},
{ ...env, NOTIFY_QUEUE: { send } },
)
expect(res.status).toBe(201)
const { id } = await res.json<{ id: number }>()
expect(send).toHaveBeenCalledWith({ kind: 'ticket_created', ticketId: id })
})
it('キューに入れられなくても、チケットの登録は成功する', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const res = await app.request(
'/api/tickets',
{
method: 'POST',
headers: { 'content-type': 'application/json', cookie: staff },
body: JSON.stringify({ title: 'キュー障害', body: '本文', category: 'pc' }),
},
{ ...env, NOTIFY_QUEUE: { send: () => Promise.reject(new Error('queue down')) } },
)
expect(res.status).toBe(201)
})
})
describe('キューの処理(メール送信)', () => {
it('担当者と管理者だけに送り、成功したら ack する', async () => {
const id = await newTicket('PCが起動しない\r\nBcc: x@example.com')
const sent: Mail[] = []
const result = await run(id, async (_env, mail) => void sent.push(mail))
expect(sent.map((m) => m.to).sort()).toEqual(['admin@example.com', 'agent@example.com'])
expect(sent[0]!.subject).toContain(`#${id}`)
expect(sent[0]!.subject).not.toMatch(/[\r\n]/) // 件名に改行を混ぜられない
expect(result.ack).toHaveBeenCalledTimes(1)
expect(result.retry).not.toHaveBeenCalled()
})
it('同じメッセージが2回届いても、同じ相手には2通送らない', async () => {
const id = await newTicket()
const send = vi.fn().mockResolvedValue(undefined)
await run(id, send)
await run(id, send)
expect(send).toHaveBeenCalledTimes(2) // 宛先2人 × 1回だけ
})
it('送信に失敗したら ack せず、待ち時間つきで再試行に回す', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const id = await newTicket()
const result = await run(id, () => Promise.reject(new Error('mail api failed: 503')), 2)
expect(result.ack).not.toHaveBeenCalled()
expect(result.retry).toHaveBeenCalledWith({ delaySeconds: 60 })
})
it('一部だけ失敗したとき、再試行では送れなかった相手にだけ送る', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const id = await newTicket()
const flaky: SendMail = async (_env, mail) => {
if (mail.to === 'admin@example.com') throw new Error('mail api failed: 500')
}
const first = await run(id, flaky)
expect(first.retry).toHaveBeenCalledTimes(1)
const sent: string[] = []
const second = await run(id, async (_env, mail) => void sent.push(mail.to), 2)
expect(sent).toEqual(['admin@example.com'])
expect(second.ack).toHaveBeenCalledTimes(1)
})
it('チケットが無いメッセージは再試行せず終わらせる', async () => {
const send = vi.fn()
const result = await run(999999, send)
expect(send).not.toHaveBeenCalled()
expect(result.ack).toHaveBeenCalledTimes(1)
})
})
describe('再試行の待ち時間', () => {
it('倍々に増え、10分で頭打ちになる', () => {
expect([1, 2, 3, 4, 5, 6].map(retryDelaySeconds)).toEqual([30, 60, 120, 240, 480, 600])
})
})
</details>
実行は、これまでと同じコマンドです。
npx wrangler types
npm run typecheck
npm test
筆者の環境では、型チェックが通り、テストは5ファイル36件がすべて成功しました(開発環境での確認です)。
動作確認:実際にキューを通して届くか
ローカルでも wrangler dev がキューを模擬して動かしてくれます。メール送信サービスの代わりに、受け取った内容を表示するだけの小さなサーバーを立てて確かめました。
npx wrangler d1 migrations apply helpdesk --local
npx wrangler dev --local --var MAIL_API_URL:http://127.0.0.1:8788/send
社員でログインしてチケットを登録すると、ログに QUEUE helpdesk-notifications 1/1 と出て、偽のメールサーバーに担当者宛ての内容が届きました。次に、偽のサーバーを止めたまま登録すると、0/1(失敗)と表示されました。サーバーを再開して約30秒待つと、自動でやり直されて 1/1 になり、notification_log に送信の記録が残りました。
実際の Cloudflare 環境へのデプロイと、実在のメール送信サービスへの送信は、この環境では確認できていません(未確認)。デプロイの前には、次の準備が要ります。
npx wrangler queues create helpdesk-notifications
npx wrangler queues create helpdesk-notifications-dlq
npx wrangler secret put MAIL_API_KEY
つまずきやすい点と注意
- DLQ に入ったメッセージは放置すると消えます。公式ドキュメントによると、メッセージの保持期間は無料プランで24時間、有料プランで初期値4日です。DLQ を誰が見て、どう対処するかを決めておく必要があります。DLQ の受け取り処理と監視は、第10回で扱います
- 無料プランには回数の上限があります。執筆時点(2026年11月)の公式ドキュメントでは、無料プランは1日あたり 10,000 operations です。1通の通知で、書き込み・読み取り・削除の3 operations が基本で、再試行のたびに読み取りが増えます。社内ツールの規模なら余裕がありますが、プランは契約前に公式ページで確認してください
- メール本文に秘密を入れない。問い合わせの内容そのものは、メールではなくログイン後の画面で見せる作りにしています
- メールの送信元の信頼性:実際に運用するときは、送信ドメインの認証(SPF・DKIM・DMARC といった、なりすましを防ぐ設定)が必要です。この連載では省略します
📰 出典:Cloudflare Docs「Queues Pricing」
発注者向けメモ:通知まわりを開発会社に頼むときの確認点
メール通知は、見た目は地味でも、運用で差が出る機能です。依頼するときは次の点を確認しておくと安心です。
- ☐ 通知が失敗したとき、自動でやり直す仕組みはあるか
- ☐ やり直しても失敗したものは、どこに残り、誰が気づけるか
- ☐ 同じ通知が2通届かない作りになっているか
- ☐ メール送信サービスの費用は誰が契約し、月にどのくらいを想定しているか
- ☐ 送信元ドメインの設定(SPF・DKIM・DMARC)は作業に含まれているか
開発会社への質問例です。
- 「通知メールが届かなかったとき、原因を調べる手がかりはどこに残りますか?」
- 「メール送信サービスを変えたくなったとき、直す範囲はどのくらいですか?」
工数やリスクが増えやすいのは、「届いたかどうかを画面で確認したい」「既読管理をしたい」「メール以外(チャットなど)にも通知したい」といった要望です。最初に範囲を決めておくと、見積もりのずれを防げます。金額の目安は、依頼先の見積もりで確認してください。
まとめと次回予告
この回では、通知メールを Cloudflare Queues に預けて、チケット登録の応答を待たせない構成を作りました。ポイントは次の3つです。
- キューには番号だけを入れ、送る直前に DB から読む
- 失敗したら、待ち時間を倍々に増やして再試行し、上限を超えたら DLQ に移す
- 同じ通知が2回届いても、送信記録で二重送信を防ぐ
次回(第6回)は「コメントと担当者の割り当て」です。チケットにコメントを残し、担当者を割り当てたときに、今回のキューの仕組みを使って通知する対象を広げます。
この連載の記事一覧
この記事は連載「Honoで作る社内ヘルプデスク」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Honoで作る社内ヘルプデスク 第0回】連載の全体像と、Hono+Cloudflare Workersの雛形を動かす
- 【Honoで作る社内ヘルプデスク 第1回】チケット登録APIを作る:D1のマイグレーションとZodの入力チェック
- 【Honoで作る社内ヘルプデスク 第2回】チケットの一覧と状態遷移を作る:ページングと「戻せない状態」のルール
- 【Honoで作る社内ヘルプデスク 第3回】ログインと権限を付ける:パスワードの保存・Cookie・「社員/担当者/管理者」の線引き
- 【Honoで作る社内ヘルプデスク 第4回】添付ファイルを R2 に保存する:種類・大きさの制限とダウンロード権限
- 【Honoで作る社内ヘルプデスク 第5回】通知メールをCloudflare Queuesで非同期に送る(この記事)









コメント
コメント一覧 (1件)
[…] […]