Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

QStashで実現するタイムゾーン対応メールスケジューリング:ユーザーの現地時間に合わせた通知送信の実装ガイド

ドキュメントフィードバックツール「docsly」では、ユーザーが直近1週間または1か月に受け取ったフィードバックのサマリーをメールで届ける新機能をリリースしました。メール送信自体は珍しい課題ではありませんが、私たちはこの点でも最高のユーザー体験を提供したいと考えました。そこで、深夜や早朝といった変な時間帯にメールが届かないよう、すべてのメールをユーザーのタイムゾーンで送信することを決めました。さらに、ユーザーが受信頻度を選べること、そしてスケジュール済みのメール通知をいつでもキャンセルできることも要件に含めました。

実装が難しかった理由

このソリューションの実装は一筋縄ではいきませんでした。理由は以下の3つです。

  1. ユーザーのタイムゾーン情報をデータベースに保存したくなかった
  2. 毎分・毎時でcronジョブを回して「メール送信時刻かどうか」をチェックする運用は避けたかった
  3. ユーザーがスケジュール済みのメール通知を自由にキャンセルできるようにしたかった

そこで私たちが考案したのが、「ユーザーのタイムゾーンでcronジョブをスケジュールし、キャンセルされたらそのジョブを削除する」というユニークなアプローチです。残る疑問は「どうやって実現するか」でした。

私たちはすでにUpstashをRedisストアとして利用しており、その過程でQStashが「Schedules(スケジュール)機能」をサポートしていることを発見しました。さらに調べてみると、QStashはCRON式にも対応しています。これを受けて、QStashでcronジョブをスケジューリングすることに決めました。

本記事では、Next.jsアプリケーションにおいてQStashとUpstash Redisを組み合わせて、ユーザーのタイムゾーンに合わせたメール送信をスケジュールする手順を解説します。完全なソースコードはGitHubでも公開されています。

ユーザーのタイムゾーンでメールをスケジュールするNext.jsアプリを作る

前提条件

このチュートリアルを進めるには、以下が必要です。

  • Upstashアカウント
  • Node.js開発環境

プロジェクトのセットアップ

まず、次のコマンドで新しいNext.jsプロジェクトを作成します。

npx create-next-app qstash-email-scheduling

続いて、Upstashと連携するために以下の依存パッケージをインストールします。

npm install --save @upstash/redis axios

プロジェクトのルートに .env.local ファイルを作成し、Upstashアカウントから取得した以下の環境変数を追加してください。

UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
QSTASH_URL=
QSTASH_TOKEN=
QSATSH_CURRENT_SIGNING_KEY=
QSATSH_NEXT_SIGNING_KEY=

ソリューション全体像

コードを実装する前に、全体像を確認しておきましょう。今回は次の3つのNext.js APIルートを作成します。

  • POST /api/schedule-cron — ユーザーのタイムゾーンでメール用cronジョブをスケジュールする
  • POST /api/cancel-schedule — スケジュール済みのメールcronジョブをキャンセルする
  • POST /api/send-email — スケジュールされたcronジョブによってトリガーされ、メールを送信する

APIルートに加えて、ユーザーが希望するメール受信時刻を選択できるシンプルなフォームも作成します。

ユーザーインターフェースの作成

UIとして、app/page.tsx に新しいページを以下のコードで作成します。

"use client";
 
import { useState } from "react";
 
import axios from "axios";
 
export default function Home() {
 const userId = "tony-stark-11";
 const [selectedTime, setSelectedTime] = useState("10:00");
 
 async function createEmailNotificationSchedule() {
 try {
 await axios.post(
 "/api/schedule-cron",
 {
 userId,
 selectedTime,
 utcOffset: new Date().getTimezoneOffset(),
 },
 {
 headers: {
 "Content-Type": "application/json",
 },
 },
 );
 alert("Email notification scheduled");
 } catch (e) {
 console.log("Client side error", e);
 alert("Error scheduling email notification");
 }
 }
 
 async function cancelEmailNotificationSchedule() {
 try {
 await axios.post(
 "/api/cancel-schedule",
 {
 userId,
 },
 {
 headers: {
 "Content-Type": "application/json",
 },
 },
 );
 alert("Email notification schedule cancelled");
 } catch (e) {
 console.log("Client side error", e);
 alert("Error scheduling email notification");
 }
 }
 
 return (
 <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center p-24">
 <h1 className="mb-4 text-xl font-bold text-neutral-600">
 Email Notification for {userId}
 </h1>
 Send daily email summary at:
 <select
 onChange={(e) => setSelectedTime(e.target.value)}
 className="mt-4 h-12 w-64 rounded-lg border-2 border-neutral-600 bg-neutral-800 p-2
 text-white"
 >
 {new Array(24).fill(0).map((_, i) => {
 const time = i < 10 ? `0${i}:00` : `${i}:00`;
 return (
 <option key={i} value={time}>
 {time}
 </option>
 );
 })}
 </select>
 <button
 className="mt-4 rounded bg-green-700 px-4 py-2 text-white"
 onClick={createEmailNotificationSchedule}
 >
 Schedule
 </button>
 <button
 className="mt-4 rounded bg-red-500 px-4 py-2 text-white"
 onClick={cancelEmailNotificationSchedule}
 >
 Cancel Schedule
 </button>
 </main>
 );
}

上記のコードは、毎日のメールサマリーを受け取りたい時刻を選択できるドロップダウン付きフォームを生成します。ユーザーはここから通知をスケジュールしたりキャンセルしたりでき、アプリケーションはHTTP POSTリクエスト経由でサーバーと通信します。各HTTPエンドポイントは以降のセクションで作成していきます。

QStashで実現するタイムゾーン対応メールスケジューリング:ユーザーの現地時間に合わせた通知送信の実装ガイド

メール用cronジョブのスケジュール

QStashで実現するタイムゾーン対応メールスケジューリング:ユーザーの現地時間に合わせた通知送信の実装ガイド

まずは POST /api/schedule-cron ルートから作成します。このルートは、ユーザーのタイムゾーンでメール用cronジョブをスケジュールするために使用します。スケジューリングにはQStashライブラリを利用します。

import { NextApiRequest, NextApiResponse } from "next";
 
import { Redis } from "@upstash/redis";
import axios from "axios";
 
export const QSTASH_CONFIG = {
 QSTASH_URL: process.env.QSTASH_URL,
 QSTASH_TOKEN: process.env.QSTASH_TOKEN,
 QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY,
};
 
export const upstash = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL!,
 token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
 
// 自分のドメインに合わせて編集してください
const SUMMARY_ENDPOINT = "https://<your-domain>/api/send-email";
 
export default async function scheduleSummary(
 req: NextApiRequest,
 res: NextApiResponse,
) {
 console.log("========SCHEDULE SUMMARY========");
 if (req.method !== "POST") {
 return res.status(400).json({ message: "bad request" });
 }
 const { body } = req;
 
 const { userId, selectedTime, utcOffset } = body;
 
 const emailScheduleKey = `email-schedule-${userId}`;
 
 const scheduleId = await upstash.get(emailScheduleKey);
 
 // 新しいスケジュールを作成する前に既存のものを削除
 if (scheduleId) {
 try {
 await axios.delete(
 `https://qstash.upstash.io/v1/schedules/${scheduleId}`,
 {
 headers: {
 Authorization: `Bearer ${QSTASH_CONFIG.QSTASH_TOKEN}`,
 },
 },
 );
 } catch (e) {
 console.log("Schedule not found in QStash ");
 }
 await upstash.del(emailScheduleKey);
 }
 
 const [hour, min] = convertToUTC(selectedTime, utcOffset).split(":");
 const selectedCron = `${min} ${hour} * * *`;
 
 // 新しいスケジュールを作成して保存
 try {
 const { data, status } = await axios.post(
 `${QSTASH_CONFIG.QSTASH_URL}${SUMMARY_ENDPOINT}`,
 { userId },
 {
 headers: {
 "Content-Type": "application/json",
 Authorization: `Bearer ${QSTASH_CONFIG.QSTASH_TOKEN}`,
 "Upstash-Cron": selectedCron,
 },
 },
 );
 console.log({ data, status });
 if (data.scheduleId) {
 await upstash.set(emailScheduleKey, data.scheduleId);
 }
 } catch (e) {
 console.log({ e });
 }
 
 return res.status(200).json({ message: "success" });
}
 
function convertToUTC(timeString: string, utcOffset: number) {
 const [hours, minutes] = timeString.split(":").map(Number);
 const timeInMinutes = hours * 60 + minutes;
 const utcTimeInMinutes = (timeInMinutes + utcOffset + 1440) % 1440;
 const utcHours = Math.floor(utcTimeInMinutes / 60);
 const utcMinutes = utcTimeInMinutes % 60;
 return `${utcHours.toString().padStart(2, "0")}:${utcMinutes
 .toString()
 .padStart(2, "0")}`;
}

このコードの中核となるのは、APIエンドポイントとして機能する scheduleSummary 関数です。この関数は受信したPOSTリクエストを処理し、以下の手順を実行します。

  1. リクエストメソッドがPOSTであることを検証する
  2. リクエストボディからuserId、selectedTime、utcOffsetを抽出する
  3. Upstashデータベース内でユーザーのメールスケジュール用キーを組み立てる
  4. 既存のスケジュールがあれば取得し、QStashから削除する
  5. convertToUTC 関数を使って、ユーザーが選択した時刻をUTC形式へ変換する。この関数は時刻文字列とUTCオフセットを受け取り、オフセットを考慮しながらUTC時刻を算出する
  6. QStashのスケジューリング機能でメールサマリー送信用の新しいスケジュールを作成し、/api/send-email エンドポイントが受け取るペイロードとして userId を設定する
  7. 新しく作成されたスケジュールIDをUpstashデータベースに保存する

メールサマリーの送信

POST /api/send-email ルートは、スケジュールされたcronジョブによってトリガーされ、メールを送信します。ここでは @upstash/qstash/nextjs ライブラリを使ってリクエストの署名を検証します。これにより、リクエストが本当にQStashから来ているものであり、他の送信元ではないことを保証できます。

POST /api/send-email のハンドラーはリクエストボディで userId を受け取ります。あとはこの userId をもとに、メールを準備・送信する関数を実装すれば完成です。

import { NextApiRequest, NextApiResponse } from "next";
 
import { verifySignature } from "@upstash/qstash/nextjs";
 
async function handler(request: NextApiRequest, res: NextApiResponse) {
 console.log("==========Project summary handler==========");
 if (request.method !== "POST") {
 return res.status(400).json({ message: "bad request" });
 }
 const { body } = request;
 const { userId } = body;
 
 // メールの準備と送信処理
 
 return res.status(200).json({ message: "success" });
}
 
export default verifySignature(handler);
 
export const config = {
 api: {
 bodyParser: false,
 },
};

スケジュール済みメールcronジョブのキャンセル

QStashで実現するタイムゾーン対応メールスケジューリング:ユーザーの現地時間に合わせた通知送信の実装ガイド

次に、POST /api/cancel-schedule ルートを作成します。このルートは、スケジュール済みのメールcronジョブをキャンセルするために使用します。こちらもQStashライブラリを使ってジョブを削除します。

import { NextApiRequest, NextApiResponse } from "next";
import axios from "axios";
import { Redis } from "@upstash/redis";
 
export const QSTASH_CONFIG = {
 QSTASH_URL: process.env.QSTASH_URL,
 QSTASH_TOKEN: process.env.QSTASH_TOKEN,
 QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY,
};
 
export const upstash = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL!,
 token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
 
export default async function scheduleSummary(
 req: NextApiRequest,
 res: NextApiResponse
) {
 console.log("========REMOVE SCHEDULE SUMMARY========");
 if (req.method !== "POST") {
 return res.status(400).json({ message: "bad request" });
 }
 const { body } = req;
 
 const { userId } = body;
 
 const emailScheduleKey = `email-schedule-${userId}`;
 
 const scheduleId = await upstash.get(emailScheduleKey);
 
 // 既存のスケジュールを削除
 if (scheduleId) {
 try {
 await axios.delete(
 `https://qstash.upstash.io/v1/schedules/${scheduleId}`,
 {
 headers: {
 Authorization: `Bearer ${QSTASH_CONFIG.QSTASH_TOKEN}`,
 },
 }
 );
 } catch (e) {
 console.log("Schedule not found in QStash ");
 }
 await upstash.del(emailScheduleKey);
 }
 
 
 return res.status(200).json({ message: "success" });
}

まとめ

今回の取り組みでは、Upstash RedisとQStashを活用することで、ユーザーのタイムゾーンに合わせたメールスケジューリングを実現しました。ユーザーのタイムゾーン情報をデータベースに保存することなく実装できた点、そしてユーザーがいつでもスケジュール済みのメール通知をキャンセルできる仕組みを提供できた点が大きな成果です。

なお、プロダクトのドキュメントを運用しているなら、docslyの活用もぜひ検討してみてください。docslyは技術ドキュメント向けに設計されたフィードバックツールで、ユーザーからのフィードバックを収集し、実行可能なインサイトへと変換するのに役立ちます。

  1. Deno KVとUpstash Redisを徹底比較!パフォーマンスとコストの実測ベンチマーク

    約2週間前、私たちはCloudflare KVとUpstash Redisのパフォーマンスおよびコストを比較しました。今回は、Denoのグローバルエッジネットワーク上で動作するDenoネイティブのキーバリューストア「Deno KV」を取り上げます。 Deno KVはアーキテクチャの面でUpstash Redisとよく似ています。どちらのストアも、すべての書き込みが送信されるプライマリリージョンを持ち、そこから他のすべてのリージョンへレプリケーションされます。読み取りはクライアントに最も近いリージョンから提供されます。RedisにはKVにはない多くの機能があるため、利用可能な機能には多くの違いが

  2. Redis・WebSocket・Vue.jsでリアルタイム通知サービスを構築する方法

    Webアプリケーションを操作している最中に、リアルタイムで通知を受け取るのはごく一般的な光景です。通知はチャットボットやアラートシステムから届くこともあれば、アプリが特定のユーザー(あるいは複数のユーザー)に向けて発信するイベントによってトリガーされることもあります。通知の発生源が何であれ、近年ではRedisを活用して通知サービスを構築するケースが急速に増えています。 マイクロサービスアーキテクチャを採用した現代のアプリケーションでは、Redisはシンプルなキャッシュやプライマリデータベースとして利用されることが多いですが、それ以外にも、Redis Streamsによる永続的なメッセージング