ที่ docsly เรา Launch Feature ใหม่ส่ง Email Notifications ให้ Users พร้อม Summary Feedback ที่ได้รับในสัปดาห์/เดือนผ่านมา ส่ง Email ไม่ใช่ปัญหาใหม่ แต่เราอยากให้ UX ดีที่สุด: ส่ง Emails ใน Timezone ของ User หลีกเลี่ยง Odd Hours ให้ User เลือก Frequency ได้ และ Cancel Scheduled Email Notifications ได้ทุกเมื่อ
Implementation Tricky เพราะ:
- ไม่อยากเก็บ User Timezone ใน Database ของเรา
- ไม่อยาก Run Cron Job ทุกนาที/ทุกชั่วโมง Check เวลาส่ง Email
- อยากให้ User Cancel Scheduled Emails ได้
เรามา up with Unique Solution: Schedule Cron Jobs ใน Timezone ของ User แล้ว Cancel Cron Job ตอน User Cancel Email Notifications แต่คำถามคือ: How?
เราใช้ Upstash เป็น Redis Store อยู่แล้ว พบว่า QStash รองรับ Schedules Feature และ CRON Expressions จึงตัดสินใจใช้ QStash Schedule Cron Jobs
บทความนี้ Walkthrough การ Schedule Emails ใน User Timezone ด้วย QStash และ Upstash Redis ใน Next.js Application Source Code เต็มบน GitHub
Building a Next.js Application to Schedule Emails in User's Timezone
Prerequisites - เงื่อนไขก่อนเริ่ม
- Upstash Account
- Node.js Development Environment
Set up the Project - ตั้งค่าโปรเจกต์
สร้าง Next.js Project ใหม่:
npx create-next-app qstash-email-scheduling
Install Dependencies ติดต่อ Upstash:
npm install --save @upstash/redis axios
สร้าง .env.local Root Project ใส่ Environment Variables จาก Upstash Account:
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
QSTASH_URL=
QSTASH_TOKEN=
QSATSH_CURRENT_SIGNING_KEY=
QSATSH_NEXT_SIGNING_KEY=
Solution Overview - ภาพรวม Solution
ก่อน Implement Code ดู Solution Overview สร้าง 3 Next.js API Routes:
POST /api/schedule-cron- Schedule Email Cron Job ใน User TimezonePOST /api/cancel-schedule- Cancel Scheduled Email Cron JobPOST /api/send-email- Triggered by Scheduled Cron Job ส่ง Email
นอกจาก API Routes ยังสร้าง Simple Form รับ Preferred Time ของ User
Create the User Interface - สร้าง User Interface
สร้าง Page ใหม่ 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>
);
}
Code สร้าง Form Dropdown User ตั้ง Preferred Time รับ Daily Email Summaries Schedule/Cancel Notifications สื่อสาร Server ผ่าน HTTP POST Requests สร้าง HTTP Endpoints ต่อไป

Schedule Email Cron Job - จัดตาราง Email Cron Job

สร้าง POST /api/schedule-cron Route ใช้ QStash Library Schedule Cron Job:
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")}`;
}
Core คือ scheduleSummary Function API Endpoint Handle POST Requests Steps:
- Validate Request Method ต้องเป็น POST
- Extract Data: userId, selectedTime, utcOffset
- Construct Key User Email Schedule ใน Upstash DB
- Retrieve Existing Schedule User Remove จาก QStash
- Convert Selected Time เป็น UTC ด้วย
convertToUTCFunction รับ Time String และ UTC Offset Convert เป็น UTC Format - Create New Schedule ส่ง Email Summaries ด้วย QStash Scheduling เพิ่ม
userIdPayload ให้/api/send-emailEndpoint - Store New Schedule ID ใน Upstash Database
Send Email Summary - ส่ง Email Summary
POST /api/send-email Route Triggered by Scheduled Cron Job ส่ง Email ใช้ @upstash/qstash/nextjs Library Verify Signature Request รับรองมาจาก QStash ไม่ใช่ที่อื่น
Function รับ userId ใน Request Body Implement Function Prepare/Send Email ตาม 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;
// prepare and send email
return res.status(200).json({ message: "success" });
}
export default verifySignature(handler);
export const config = { api: { bodyParser: false } };
Cancel Scheduled Email Cron Job - ยกเลิก Scheduled Email Cron Job

สร้าง POST /api/cancel-schedule Route Cancel Cron Job ใช้ QStash Library:
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" });
}
Conclusion - สรุป
สรุป: เรา Schedule Emails ใน User Timezone ด้วย Upstash Redis และ QStash สำเร็จ โดยไม่เก็บ User Timezone ใน Database ให้ User Cancel Scheduled Email Notifications ได้ทุกเมื่อ
หากคุณ Maintain Documentation สินค้า ควร Check Out docsly Feedback Tool สำหรับ Technical Documentation ช่วย Collect Feedback จาก Users เปลี่ยนเป็น Actionable Insights