Skip to content
Merged
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
19 changes: 19 additions & 0 deletions app/api/cron/email-verification-reminders/route.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}
50 changes: 50 additions & 0 deletions lib/developer-contact-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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 };
}
62 changes: 62 additions & 0 deletions lib/email-verification-reminders.js
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 5 additions & 5 deletions lib/lifecycle-email.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,21 +105,21 @@ 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);
const url = verificationUrl.toString();
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,
Expand Down
3 changes: 3 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -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 [
{
Expand Down
42 changes: 42 additions & 0 deletions tests/developer-contact-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
buildDeveloperContact,
createEmailVerification,
getDeveloperContact,
isVerificationReminderDue,
iterateWeeklyDigestContacts,
normalizeContactEmail,
recordEmailVerificationReminder,
saveDeveloperContact,
setProductUpdatesPreference,
verifyDeveloperContactEmail,
Expand Down Expand Up @@ -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',
Expand Down
44 changes: 44 additions & 0 deletions tests/email-verification-reminders.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
4 changes: 4 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
{
"path": "/api/cron/weekly-digest",
"schedule": "0 13 * * 1"
},
{
"path": "/api/cron/email-verification-reminders",
"schedule": "0 14 * * *"
}
]
}