Skip to content

Feature/email subscriptions - #7

Open
MohamedEBR wants to merge 8 commits into
mainfrom
feature/email-subscriptions
Open

Feature/email subscriptions#7
MohamedEBR wants to merge 8 commits into
mainfrom
feature/email-subscriptions

Conversation

@MohamedEBR

@MohamedEBR MohamedEBR commented Dec 27, 2025

Copy link
Copy Markdown

Email Subscriptions for Events

Summary

Implements email subscription functionality for events. Users can subscribe to upcoming events and receive email reminders 24 hours before the event starts.


Features

  • Subscribe to events — Users can click "Get Reminder" on any upcoming event and enter their email
  • Instant confirmation email — Sent immediately upon subscription via Brevo API
  • 24-hour reminder email — Automated reminder sent 24 hours before event start time
  • Unsubscribe flow — Every email includes an unsubscribe link with confirmation page
  • Duplicate handling — Gracefully handles re-subscriptions without sending duplicate emails
  • Rate limiting — IP and email-based rate limiting to prevent abuse

Technical Details

Component Description
Database PostgreSQL for storing subscriptions with reminder scheduling
Email API Brevo (Sendinblue) for transactional emails
Cron /api/cron/reminders endpoint — set up external cron to call every 15 min
Rate Limiting Redis (falls back to in-memory if not configured)

New Files

File Description
components/SubscribeDialog.tsx Email subscription modal
app/api/cron/reminders/route.ts Cron endpoint for reminders
app/api/unsubscribe/route.ts Unsubscribe API
pages/unsubscribe.tsx Unsubscribe confirmation page
.env.example Environment variables template

Modified Files

File Changes
components/EventCard.tsx Added "Get Reminder" button
pages/events.tsx Integrated subscribe dialog and toast notifications
lib/email/send.ts Added unsubscribe links to emails
lib/utils.ts Added cleanEvent helper function
app/api/events/route.ts Refactored to use shared cleanEvent from utils

Environment Variables Required

BREVO_API_KEY=
DATABASE_URL=
NEXT_PUBLIC_BASE_URL=
MAIL_FROM=
MAIL_FROM_NAME=
CRON_SECRET=

Cron Setup
The reminder system requires an external cron job to call /api/cron/reminders every 15 minutes.

Request format:

GET https://your-domain.com/api/cron/reminders
Authorization: Bearer YOUR_CRON_SECRET

Options for setting up cron:

  • cron-job.org (free)
  • Railway Cron
  • Any external scheduler

Testing

  • All unit tests passing
  • Build passes
  • Subscribe flow tested
  • Confirmation email tested
  • Reminder email tested
  • Unsubscribe flow tested

Implements event subscription API, 24h reminder scheduling, and email notifications. Adds database schema for subscriptions, utility functions for event parsing and email validation, and integrates Brevo for transactional emails. Updates environment variables and dependencies to support PostgreSQL, scheduling, and email delivery.
Introduces a new /api/jobs endpoint for triggering scheduled reminders with secret-based authorization, documented in README. Subscription logic is improved to only send confirmation emails for new subscriptions and to handle event errors more gracefully. The scheduler and subscription queries are tightened to avoid duplicate reminders. Adds initial Vitest-based test suite and configuration.
Introduces a unified rate limiting system with in-memory and Redis-backed options, configurable per endpoint. Adds rate limit enforcement to subscription, unsubscription, and cron job API routes. Updates documentation and environment configuration for Redis support. Includes tests for both in-memory and Redis rate limiting logic.
Introduces a SubscribeDialog component and integrates event reminder subscription into the events page, allowing users to subscribe for email reminders. Adds a new API route for cron-based reminder processing, updates EventCard to support subscription, and adjusts tsconfig target. Also includes an example .env file for configuration.
Introduces unsubscribe functionality for event reminder emails. Adds API route and frontend page for unsubscribing, updates email sending logic to include unsubscribe links, and documents the base URL in .env.example.
@netlify

netlify Bot commented Dec 27, 2025

Copy link
Copy Markdown

Deploy Preview for mcss-website-2026 ready!

Name Link
🔨 Latest commit 12e6465
🔍 Latest deploy log https://app.netlify.com/projects/mcss-website-2026/deploys/69538ebab355d500088ef5e1
😎 Deploy Preview https://deploy-preview-7--mcss-website-2026.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread app/api/events/route.ts
Comment on lines 2 to 4
import { fetchEventsFromSheet } from "@/lib/parseCsv";

function parseDate(value: string) {
if (!value) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d.toISOString();
}

function cleanEvent(raw: Record<string, any>) {
const mapKey = (k: string) => {
const lower = k.toLowerCase().trim();
if (lower === "title" || lower === "name") return "title";
if (lower === "description" || lower === "desc") return "description";
if (lower === "date") return "date";
if (lower === "location" || lower === "venue") return "location";
if (lower === "isupcoming" || lower === "upcoming") return "isUpcoming";
if (lower === "link") return "link";
return lower;
};

const out: any = {};
for (const k of Object.keys(raw)) {
const nk = mapKey(k);
out[nk] = raw[k];
}

out.isUpcoming = Boolean(out.isupcoming || out.isUpcoming || out["isUpcoming"]);
if (typeof out.isUpcoming === "string") {
out.isUpcoming = out.isUpcoming.toLowerCase() === "true";
}

out.date = parseDate(out.date || out.start_date || out["start date"] || "");
out.id = out.id || `${(out.title || "event").toString().toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${out.date || ""}`;

return out;
}
import { cleanEvent } from "@/lib/utils";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some adjustments were made to some of the files hit by these changes. Would you be able to review the merge conflict here? I don't want it to break existing functionality

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread app/api/subscriptions/route.ts Outdated
Comment on lines +55 to +58
const ip = getClientIp(request);
const ipRes = await allow('subs:ip', ip, 10 * 60 * 1000, 20);
if (!ipRes.allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429, headers: { 'retry-after': String(Math.ceil(ipRes.retryAfterMs / 1000)) } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With regards to the IP rate limiting:

Attackers could circumvent IP-based rate limits if behind proxies. Make sure getClientIp accurately resolves IPs respecting trusted proxy headers

Comment thread app/api/subscriptions/route.ts Outdated
Comment on lines +114 to +117
const ip = getClientIp(request);
const ipRes = await allow('unsub:ip', ip, 10 * 60 * 1000, 30);
if (!ipRes.allowed) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429, headers: { 'retry-after': String(Math.ceil(ipRes.retryAfterMs / 1000)) } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here:

Attackers could circumvent IP-based rate limits if behind proxies. Make sure getClientIp accurately resolves IPs respecting trusted proxy headers

Comment thread lib/rateLimit.ts Outdated
@@ -0,0 +1,72 @@
import { getRedis } from '@/lib/redis';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't have money for redis unfortunately... Also don't think caching would be super beneficial here considering these are 24hour jobs and we aren't too concerned about how long they would take. Would it be possible to simplify the logic by removing the caching?

Comment thread db/schema.sql Outdated
Comment on lines +1 to +18
CREATE TABLE IF NOT EXISTS subscriptions (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
event_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reminder_scheduled_for TIMESTAMPTZ,
reminder_sent_at TIMESTAMPTZ,
last_notified_at TIMESTAMPTZ
);

-- Ensure a single subscription per email+event
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_email_event
ON subscriptions (email, event_id);

-- Speed up due-reminder queries
CREATE INDEX IF NOT EXISTS idx_subscriptions_reminder_scheduled_for
ON subscriptions (reminder_scheduled_for);

@realdevvora realdevvora Dec 27, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we would need to create a database for these changes. Would it be possible to look into creating an email newsletter subscription?: https://www.brevo.com/landing/newsletter

This might serve our use case well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented it without a database, subscriptions are stored using Brevo's Contacts API instead. When a user subscribes, their info is saved as contact attributes in Brevo. A cron job checks daily and sends reminder emails 24h before events.

@realdevvora

realdevvora commented Dec 27, 2025

Copy link
Copy Markdown
Contributor

Looks good overall, I like the consideration that went into configuring things with external services, I think this is really useful to keep in mind for our application.

Do first look into Brevo email newsletters: https://www.brevo.com/landing/newsletter I think this might just be the key to accomplishing our use case.

Secondly, just make sure existing behaviour isn't broken from your changes with the merge conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants