MENU

問い合わせ


    【Honoで作る社内ヘルプデスク 第5回】通知メールをCloudflare Queuesで非同期に送る

    社内ヘルプデスクに問い合わせが届いたら、担当者にメールで知らせたい。ところが、チケットを登録する処理の中でそのままメールを送ると、メール送信サービスが遅いだけで画面が固まり、止まっていれば登録そのものが失敗してしまいます。

    「問い合わせが来たことに担当者がすぐ気づける仕組みがほしい。でも、メールが送れないせいで申請まで止まるのは困る」

    結論から言うと、通知は「キュー(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」

    この回で作るものの全体像

    1. wrangler.jsonc にキュー(預ける側・受け取る側・DLQ)を設定する
    2. 送信の記録テーブル notification_log を作る(二重送信の防止用)
    3. メール送信の部品 src/mail.ts を作る
    4. 通知の処理 src/notify.ts を作る(預ける関数・受け取る関数・再試行の待ち時間)
    5. チケット登録の API から、キューに預ける
    6. テストで、成功・失敗・二重配信を確かめる

    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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      コメント

      目次