From 6c77689921a54a9204d691ac9746aea270cb3a54 Mon Sep 17 00:00:00 2001 From: sajeetharan Date: Mon, 17 Aug 2026 12:58:53 +0530 Subject: [PATCH] fix: restore cards and remind unverified contacts --- .../email-verification-reminders/route.js | 19 ++++++ lib/developer-contact-store.js | 50 +++++++++++++++ lib/email-verification-reminders.js | 62 +++++++++++++++++++ lib/lifecycle-email.js | 10 +-- next.config.js | 3 + tests/developer-contact-store.test.js | 42 +++++++++++++ tests/email-verification-reminders.test.js | 44 +++++++++++++ vercel.json | 4 ++ 8 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 app/api/cron/email-verification-reminders/route.js create mode 100644 lib/email-verification-reminders.js create mode 100644 tests/email-verification-reminders.test.js diff --git a/app/api/cron/email-verification-reminders/route.js b/app/api/cron/email-verification-reminders/route.js new file mode 100644 index 0000000..eb0b103 --- /dev/null +++ b/app/api/cron/email-verification-reminders/route.js @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { sendEmailVerificationReminders } from '../../../../lib/email-verification-reminders.js'; + +export const maxDuration = 300; + +export async function GET(request) { + const cronSecret = process.env.CRON_SECRET?.trim(); + if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const summary = await sendEmailVerificationReminders(); + return NextResponse.json({ ok: true, ...summary }); + } catch (error) { + console.error('Email verification reminders failed:', error.message); + return NextResponse.json({ error: 'Email verification reminders failed' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/lib/developer-contact-store.js b/lib/developer-contact-store.js index 5c91ac9..c18c8f1 100644 --- a/lib/developer-contact-store.js +++ b/lib/developer-contact-store.js @@ -4,6 +4,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; const CONTACT_SOURCES = new Set(['github-oauth', 'self-nomination']); const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000; +export const VERIFICATION_REMINDER_INTERVAL_MS = 72 * 60 * 60 * 1000; export class DeveloperContactValidationError extends Error {} @@ -193,4 +194,53 @@ export async function verifyDeveloperContactEmail(login, token, options = {}) { }); return { verified: true }; +} + +export function isVerificationReminderDue(contact, now = new Date()) { + if (!contact?.email || contact.emailVerified === true || contact.transactionalEmailsEnabled !== true) { + return false; + } + + const lastReminderAt = Date.parse(contact.lastVerificationReminderAt || ''); + return !Number.isFinite(lastReminderAt) || now.getTime() - lastReminderAt >= VERIFICATION_REMINDER_INTERVAL_MS; +} + +export async function* iterateVerificationReminderContacts(options = {}) { + const container = options.container === undefined ? getContactContainer() : options.container; + if (!container) return; + + const now = options.now ? new Date(options.now) : new Date(); + const iterator = container.items.query({ + query: `SELECT c.id, c.login, c.email, c.emailVerified, c.transactionalEmailsEnabled, c.lastVerificationReminderAt + FROM c`, + }, { maxItemCount: options.pageSize || 100 }); + + while (iterator.hasMoreResults()) { + const { resources = [] } = await iterator.fetchNext(); + for (const contact of resources) { + if (isVerificationReminderDue(contact, now)) yield contact; + } + } +} + +export async function recordEmailVerificationReminder(login, delivery, options = {}) { + const container = options.container === undefined ? getContactContainer() : options.container; + if (!container || !login) return { updated: false, reason: 'not_configured' }; + + const id = String(login).trim().toLowerCase(); + const contact = await readExistingContact(container, id); + if (!contact) return { updated: false, reason: 'not_found' }; + if (contact.emailVerified === true) return { updated: false, reason: 'already_verified' }; + + const sentAt = delivery.sentAt || new Date().toISOString(); + const document = { + ...stripSystemFields(contact), + lastVerificationReminderAt: sentAt, + verificationReminderCount: Math.max(Number(contact.verificationReminderCount) || 0, 0) + 1, + updatedAt: sentAt, + }; + const { resource } = await container.item(id, id).replace(document, { + accessCondition: { type: 'IfMatch', condition: contact._etag }, + }); + return { updated: true, contact: resource || document }; } \ No newline at end of file diff --git a/lib/email-verification-reminders.js b/lib/email-verification-reminders.js new file mode 100644 index 0000000..69802e1 --- /dev/null +++ b/lib/email-verification-reminders.js @@ -0,0 +1,62 @@ +import { + createEmailVerification, + isVerificationReminderDue, + iterateVerificationReminderContacts, + recordEmailVerificationReminder, +} from './developer-contact-store.js'; +import { buildEmailVerificationEmail, sendLifecycleEmail } from './lifecycle-email.js'; + +export async function sendEmailVerificationReminders(options = {}) { + const now = options.now ? new Date(options.now) : new Date(); + const sentAt = now.toISOString(); + const dateKey = sentAt.slice(0, 10); + const contacts = options.contacts || iterateVerificationReminderContacts({ + container: options.contactsContainer, + now, + }); + const createVerification = options.createVerification || (login => createEmailVerification(login, { + container: options.contactsContainer, + now, + })); + const sendEmail = options.sendEmail || sendLifecycleEmail; + const recordDelivery = options.recordDelivery || ((login, delivery) => recordEmailVerificationReminder(login, delivery, { + container: options.contactsContainer, + })); + const summary = { scanned: 0, eligible: 0, sent: 0, skipped: 0, failed: 0 }; + + for await (const contact of contacts) { + summary.scanned += 1; + if (!isVerificationReminderDue(contact, now)) { + summary.skipped += 1; + continue; + } + summary.eligible += 1; + + try { + const verification = await createVerification(contact.login); + if (!verification.created) { + summary.skipped += 1; + continue; + } + const delivery = await sendEmail({ + to: verification.email, + message: buildEmailVerificationEmail({ + login: contact.login, + token: verification.token, + reminder: true, + }), + idempotencyKey: `email-verification-reminder-${contact.id}-${dateKey}`, + }); + if (!delivery.sent) { + summary.failed += 1; + continue; + } + await recordDelivery(contact.login, { sentAt, providerId: delivery.id || null }); + summary.sent += 1; + } catch { + summary.failed += 1; + } + } + + return summary; +} \ No newline at end of file diff --git a/lib/lifecycle-email.js b/lib/lifecycle-email.js index 3bac642..0290ef5 100644 --- a/lib/lifecycle-email.js +++ b/lib/lifecycle-email.js @@ -105,7 +105,7 @@ export function buildNominationApprovedEmail({ login, name }) { }; } -export function buildEmailVerificationEmail({ login, token }) { +export function buildEmailVerificationEmail({ login, token, reminder = false }) { const verificationUrl = new URL('/api/contact/verification', getSiteUrl()); verificationUrl.searchParams.set('login', login); verificationUrl.searchParams.set('token', token); @@ -113,13 +113,13 @@ export function buildEmailVerificationEmail({ login, token }) { const greeting = login; return { - subject: 'Verify your DevGlobe email', - text: `DevGlobe - ${TAGLINE}\n\nHi ${greeting},\n\nVerify your email address for DevGlobe by opening this link. It expires in 24 hours and can be used once.\n\nVerify email: ${url}\nGenerate identity card: ${getSiteUrl()}/share/${encodeURIComponent(login)}\nStar DevGlobe on GitHub: ${REPOSITORY_URL}\n\nIf you did not request this, you can ignore this message.\n\nDevGlobe`, + subject: `${reminder ? 'Reminder: v' : 'V'}erify your DevGlobe email`, + text: `DevGlobe - ${TAGLINE}\n\nHi ${greeting},\n\n${reminder ? 'This is a reminder to verify' : 'Verify'} your email address for DevGlobe by opening this link. It expires in 24 hours and can be used once.\n\nVerify email: ${url}\nGenerate identity card: ${getSiteUrl()}/share/${encodeURIComponent(login)}\nStar DevGlobe on GitHub: ${REPOSITORY_URL}\n\nIf you did not request this, you can ignore this message.\n\nDevGlobe`, html: emailLayout({ - preview: 'Verify your email address for DevGlobe.', + preview: `${reminder ? 'Reminder: v' : 'V'}erify your email address for DevGlobe.`, heading: 'Verify your email', greeting, - body: 'Confirm this email address for your DevGlobe profile. This link expires in 24 hours and can be used once. If you did not request this, you can ignore this message.', + body: `${reminder ? 'This is a reminder to confirm' : 'Confirm'} this email address for your DevGlobe profile. This link expires in 24 hours and can be used once. If you did not request this, you can ignore this message.`, login, action: 'Verify email', primaryUrl: url, diff --git a/next.config.js b/next.config.js index 6a135a9..8cad33b 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + outputFileTracingIncludes: { + '/api/card': ['./node_modules/@fontsource/manrope/files/manrope-latin-*-normal.woff'], + }, async headers() { return [ { diff --git a/tests/developer-contact-store.test.js b/tests/developer-contact-store.test.js index c335dec..8092a4d 100644 --- a/tests/developer-contact-store.test.js +++ b/tests/developer-contact-store.test.js @@ -5,8 +5,10 @@ import { buildDeveloperContact, createEmailVerification, getDeveloperContact, + isVerificationReminderDue, iterateWeeklyDigestContacts, normalizeContactEmail, + recordEmailVerificationReminder, saveDeveloperContact, setProductUpdatesPreference, verifyDeveloperContactEmail, @@ -164,6 +166,46 @@ test('pages through verified weekly digest opt-ins', async () => { assert.deepEqual(contacts, ['One', 'Two']); }); +test('verification reminders are due only for opted-in unverified contacts after 72 hours', () => { + const now = new Date('2026-08-17T14:00:00.000Z'); + const contact = { + email: 'dev@example.com', + emailVerified: false, + transactionalEmailsEnabled: true, + }; + + assert.equal(isVerificationReminderDue(contact, now), true); + assert.equal(isVerificationReminderDue({ + ...contact, + lastVerificationReminderAt: '2026-08-14T14:00:00.000Z', + }, now), true); + assert.equal(isVerificationReminderDue({ + ...contact, + lastVerificationReminderAt: '2026-08-15T14:00:00.000Z', + }, now), false); + assert.equal(isVerificationReminderDue({ ...contact, emailVerified: true }, now), false); + assert.equal(isVerificationReminderDue({ ...contact, transactionalEmailsEnabled: false }, now), false); +}); + +test('records a successful verification reminder', async () => { + const container = fakeContainer({ + id: 'octocat', + login: 'OctoCat', + email: 'dev@example.com', + emailVerified: false, + verificationReminderCount: 1, + _etag: 'etag-1', + }); + + const result = await recordEmailVerificationReminder('OctoCat', { + sentAt: timestamp, + }, { container }); + + assert.equal(result.updated, true); + assert.equal(container.saved.lastVerificationReminderAt, timestamp); + assert.equal(container.saved.verificationReminderCount, 2); +}); + test('creates a hashed email verification token that expires after 24 hours', async () => { const container = fakeContainer({ id: 'octocat', diff --git a/tests/email-verification-reminders.test.js b/tests/email-verification-reminders.test.js new file mode 100644 index 0000000..138c08d --- /dev/null +++ b/tests/email-verification-reminders.test.js @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { sendEmailVerificationReminders } from '../lib/email-verification-reminders.js'; + +test('sends due verification reminders and skips verified or recent contacts', async () => { + const now = new Date('2026-08-17T14:00:00.000Z'); + const contacts = [ + { id: 'due', login: 'due', email: 'due@example.com', emailVerified: false, transactionalEmailsEnabled: true }, + { id: 'recent', login: 'recent', email: 'recent@example.com', emailVerified: false, transactionalEmailsEnabled: true, lastVerificationReminderAt: '2026-08-16T14:00:00.000Z' }, + { id: 'verified', login: 'verified', email: 'verified@example.com', emailVerified: true, transactionalEmailsEnabled: true }, + ]; + const sent = []; + const recorded = []; + const summary = await sendEmailVerificationReminders({ + contacts, + now, + createVerification: async login => ({ created: true, email: `${login}@example.com`, token: `${login}-token` }), + sendEmail: async message => { + sent.push(message); + return { sent: true, id: 'email-1' }; + }, + recordDelivery: async (login, delivery) => recorded.push({ login, delivery }), + }); + + assert.deepEqual(summary, { scanned: 3, eligible: 1, sent: 1, skipped: 2, failed: 0 }); + assert.equal(sent[0].idempotencyKey, 'email-verification-reminder-due-2026-08-17'); + assert.match(sent[0].message.subject, /^Reminder:/); + assert.deepEqual(recorded, [{ + login: 'due', + delivery: { sentAt: '2026-08-17T14:00:00.000Z', providerId: 'email-1' }, + }]); +}); + +test('does not record a failed reminder delivery', async () => { + const summary = await sendEmailVerificationReminders({ + contacts: [{ id: 'due', login: 'due', email: 'due@example.com', emailVerified: false, transactionalEmailsEnabled: true }], + now: new Date('2026-08-17T14:00:00.000Z'), + createVerification: async () => ({ created: true, email: 'due@example.com', token: 'token' }), + sendEmail: async () => ({ sent: false, reason: 'unavailable' }), + recordDelivery: async () => assert.fail('failed delivery must not be recorded'), + }); + + assert.deepEqual(summary, { scanned: 1, eligible: 1, sent: 0, skipped: 0, failed: 1 }); +}); \ No newline at end of file diff --git a/vercel.json b/vercel.json index 7daf838..f013ca2 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,10 @@ { "path": "/api/cron/weekly-digest", "schedule": "0 13 * * 1" + }, + { + "path": "/api/cron/email-verification-reminders", + "schedule": "0 14 * * *" } ] }