From ea113e992be39115d5bed2e08fe9d508845f2e67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:22:23 +0000 Subject: [PATCH 1/2] Initial plan From f822bb557b604188ff59999a1cdbe34c2efa3b1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:27:08 +0000 Subject: [PATCH 2/2] feat: add one-click contest email unsubscribe flow Agent-Logs-Url: https://github.com/vansh1293/Notify/sessions/e92135f5-88ac-4fa0-982a-68d30418930c Co-authored-by: Himaanshuuuu04 <145840915+Himaanshuuuu04@users.noreply.github.com> --- README.md | 1 + emails/ContestEmail.tsx | 16 ++++++- src/app/api/unsubscribe-contest/route.ts | 56 ++++++++++++++++++++++++ src/helpers/sendContestDetails.ts | 52 ++++++++++++---------- src/lib/unsubscribeToken.ts | 25 +++++++++++ 5 files changed, 125 insertions(+), 25 deletions(-) create mode 100644 src/app/api/unsubscribe-contest/route.ts create mode 100644 src/lib/unsubscribeToken.ts diff --git a/README.md b/README.md index 3fcbfe8..411fe53 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ A huge shoutout to [Tashif Khan](https://github.com/Tashifkhan) for generously p - Customizable notification timing (1 hour, 1 day before) - Multiple reminder types: browser notifications, email alerts +- One-click unsubscribe link in contest update emails - Contest difficulty and duration-based filtering - Time zone intelligent scheduling diff --git a/emails/ContestEmail.tsx b/emails/ContestEmail.tsx index 578a6b9..859a949 100644 --- a/emails/ContestEmail.tsx +++ b/emails/ContestEmail.tsx @@ -13,6 +13,10 @@ import { import * as React from "react"; import { Contest } from "@/model/Contest"; +type ContestEmailProps = Contest & { + unsubscribeUrl?: string; +}; + const platformStyles = { LeetCode: { color: "text-yellow-600", @@ -52,7 +56,8 @@ export default function ContestEmail({ endTime, duration, url, -}: Contest) { + unsubscribeUrl, +}: ContestEmailProps) { const theme = platformStyles[platform as keyof typeof platformStyles]; return ( @@ -88,10 +93,17 @@ export default function ContestEmail({ You’re receiving this email because you opted in for contest alerts. + {unsubscribeUrl && ( + + + Unsubscribe from contest emails + + + )} ); -} \ No newline at end of file +} diff --git a/src/app/api/unsubscribe-contest/route.ts b/src/app/api/unsubscribe-contest/route.ts new file mode 100644 index 0000000..4c3d897 --- /dev/null +++ b/src/app/api/unsubscribe-contest/route.ts @@ -0,0 +1,56 @@ +import dbConnect from "@/lib/dbConnect"; +import { isValidUnsubscribeToken } from "@/lib/unsubscribeToken"; +import UserModel from "@/model/User"; + +export async function GET(req: Request) { + try { + await dbConnect(); + + const { searchParams } = new URL(req.url); + const userId = searchParams.get("userId"); + const token = searchParams.get("token"); + + if (!userId || !token || !isValidUnsubscribeToken(userId, token)) { + return new Response( + `

Invalid unsubscribe link

Please sign in and update your notification settings manually.

`, + { + status: 400, + headers: { "Content-Type": "text/html; charset=utf-8" }, + } + ); + } + + const updatedUser = await UserModel.findByIdAndUpdate( + userId, + { emailNotifications: false }, + { new: true } + ); + + if (!updatedUser) { + return new Response( + `

User not found

This unsubscribe link is no longer valid.

`, + { + status: 404, + headers: { "Content-Type": "text/html; charset=utf-8" }, + } + ); + } + + return new Response( + `

Unsubscribed successfully

You will no longer receive contest update emails.

`, + { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + } + ); + } catch (error) { + console.error("Error unsubscribing from contest emails:", error); + return new Response( + `

Unable to unsubscribe

Please try again later.

`, + { + status: 500, + headers: { "Content-Type": "text/html; charset=utf-8" }, + } + ); + } +} diff --git a/src/helpers/sendContestDetails.ts b/src/helpers/sendContestDetails.ts index 9d9858f..d1c2317 100644 --- a/src/helpers/sendContestDetails.ts +++ b/src/helpers/sendContestDetails.ts @@ -7,10 +7,11 @@ import UserModel from '@/model/User'; import { Contest } from '@/model/Contest'; import * as React from 'react'; import ContestEmail from '../../emails/ContestEmail'; +import { createUnsubscribeToken } from '@/lib/unsubscribeToken'; interface MailOptions { from: string | undefined; - to: string[]; + to: string; subject: string; html: string; } @@ -18,12 +19,10 @@ export async function sendContestDetails(contestDetails: Contest[]): Promise user.email); - const emails_codeforces = usersCodeForces.map(user => user.email); - const emails_codechef = usersCodeChef.map(user => user.email); + const usersLeetCode = await UserModel.find({ LeetCode: true, emailNotifications: true }).select('_id email'); + const usersCodeForces = await UserModel.find({ CodeForces: true, emailNotifications: true }).select('_id email'); + const usersCodeChef = await UserModel.find({ CodeChef: true, emailNotifications: true }).select('_id email'); + const appUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; const transporter = nodemailer.createTransport({ service: 'gmail', auth: { @@ -32,25 +31,32 @@ export async function sendContestDetails(contestDetails: Contest[]): Promise ({ _id: String(user._id), email: user.email })); } else if (contest.platform === 'CodeForces') { - emails.push(...emails_codeforces); + recipients = usersCodeForces.map((user) => ({ _id: String(user._id), email: user.email })); } else if (contest.platform === 'CodeChef') { - emails.push(...emails_codechef); + recipients = usersCodeChef.map((user) => ({ _id: String(user._id), email: user.email })); } - const htmlcontent = await render(React.createElement(ContestEmail, { ...contest })); - const mailOptions: MailOptions = { - from: process.env.NODEMAILER_EMAIL, - to: emails, - subject: 'Contest Update', - html: htmlcontent - }; - try { - await transporter.sendMail(mailOptions); - } catch (err) { - console.error(`❌ Failed to send mail for contest "${contest.name}":`, err); + + for (const user of recipients) { + const token = createUnsubscribeToken(user._id); + const unsubscribeUrl = token + ? `${appUrl}/api/unsubscribe-contest?userId=${encodeURIComponent(user._id)}&token=${token}` + : undefined; + const htmlcontent = await render(React.createElement(ContestEmail, { ...contest, unsubscribeUrl })); + const mailOptions: MailOptions = { + from: process.env.NODEMAILER_EMAIL, + to: user.email, + subject: 'Contest Update', + html: htmlcontent + }; + try { + await transporter.sendMail(mailOptions); + } catch (err) { + console.error(`❌ Failed to send mail for contest "${contest.name}" to "${user.email}":`, err); + } } } return { @@ -64,4 +70,4 @@ export async function sendContestDetails(contestDetails: Contest[]): Promise process.env.NEXTAUTH_SECRET; + +export const createUnsubscribeToken = (userId: string) => { + const secret = getUnsubscribeSecret(); + if (!secret) return null; + + return createHmac("sha256", secret).update(userId).digest("hex"); +}; + +export const isValidUnsubscribeToken = (userId: string, token: string) => { + const secret = getUnsubscribeSecret(); + if (!secret || !token) return false; + + const expectedToken = createHmac("sha256", secret).update(userId).digest("hex"); + const expectedBuffer = Buffer.from(expectedToken); + const tokenBuffer = Buffer.from(token); + + if (expectedBuffer.length !== tokenBuffer.length) { + return false; + } + + return timingSafeEqual(expectedBuffer, tokenBuffer); +};