Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions emails/ContestEmail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -52,7 +56,8 @@ export default function ContestEmail({
endTime,
duration,
url,
}: Contest) {
unsubscribeUrl,
}: ContestEmailProps) {
const theme = platformStyles[platform as keyof typeof platformStyles];
return (
<Html>
Expand Down Expand Up @@ -88,10 +93,17 @@ export default function ContestEmail({
<Text className="text-xs text-gray-500 mt-6">
You’re receiving this email because you opted in for contest alerts.
</Text>
{unsubscribeUrl && (
<Text className="text-xs text-gray-500 mt-2">
<Link href={unsubscribeUrl} className="underline text-gray-600">
Unsubscribe from contest emails
</Link>
</Text>
)}
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
}
56 changes: 56 additions & 0 deletions src/app/api/unsubscribe-contest/route.ts
Original file line number Diff line number Diff line change
@@ -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(
`<html><body style="font-family:sans-serif;padding:24px;"><h2>Invalid unsubscribe link</h2><p>Please sign in and update your notification settings manually.</p></body></html>`,
{
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(
`<html><body style="font-family:sans-serif;padding:24px;"><h2>User not found</h2><p>This unsubscribe link is no longer valid.</p></body></html>`,
{
status: 404,
headers: { "Content-Type": "text/html; charset=utf-8" },
}
);
}

return new Response(
`<html><body style="font-family:sans-serif;padding:24px;"><h2>Unsubscribed successfully</h2><p>You will no longer receive contest update emails.</p></body></html>`,
{
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
}
);
} catch (error) {
console.error("Error unsubscribing from contest emails:", error);
return new Response(
`<html><body style="font-family:sans-serif;padding:24px;"><h2>Unable to unsubscribe</h2><p>Please try again later.</p></body></html>`,
{
status: 500,
headers: { "Content-Type": "text/html; charset=utf-8" },
}
);
}
}
52 changes: 29 additions & 23 deletions src/helpers/sendContestDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,22 @@ 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;
}
export async function sendContestDetails(contestDetails: Contest[]): Promise<ApiResponse> {
try {
console.log("📧 Sending contest details...");
await dbConnect();
const usersLeetCode = await UserModel.find({ LeetCode: true, emailNotifications: true });
const usersCodeForces = await UserModel.find({ CodeForces: true, emailNotifications: true });
const usersCodeChef = await UserModel.find({ CodeChef: true, emailNotifications: true });
const emails_leetcode = usersLeetCode.map(user => 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: {
Expand All @@ -32,25 +31,32 @@ export async function sendContestDetails(contestDetails: Contest[]): Promise<Api
}
});
for (const contest of contestDetails) {
const emails: string[] = [];
let recipients: { _id: string; email: string }[] = [];
if (contest.platform === 'LeetCode') {
emails.push(...emails_leetcode);
recipients = usersLeetCode.map((user) => ({ _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 {
Expand All @@ -64,4 +70,4 @@ export async function sendContestDetails(contestDetails: Contest[]): Promise<Api
message: "Failed to send contest details.",
};
}
}
}
25 changes: 25 additions & 0 deletions src/lib/unsubscribeToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createHmac, timingSafeEqual } from "crypto";

const getUnsubscribeSecret = () => 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);
};