From 2d0b48cb31e864cce189fd28bf2b226f751b1c0c Mon Sep 17 00:00:00 2001 From: sajeetharan Date: Mon, 17 Aug 2026 13:16:15 +0530 Subject: [PATCH] feat: schedule email reminders with Azure Functions --- README.md | 15 ++++- .../function.json | 13 ++++ .../email-verification-reminders/index.js | 21 +++++++ ...azure-email-verification-reminders.test.js | 60 +++++++++++++++++++ vercel.json | 4 -- 5 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 functions/email-verification-reminders/function.json create mode 100644 functions/email-verification-reminders/index.js create mode 100644 tests/azure-email-verification-reminders.test.js diff --git a/README.md b/README.md index bd9f6dc..4f4ddee 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ Required environment variables: | `EMAIL_FROM` | Sender on a domain verified by Resend | | `COSMOS_WATCHLIST_CONTAINER` | Optional private watchlist container name (default: `watchlists`) | | `COSMOS_IMPACT_HISTORY_CONTAINER` | Optional impact snapshot container name (default: `impact-history`) | -| `CRON_SECRET` | Bearer token used by Vercel Cron for the weekly digest endpoint | +| `CRON_SECRET` | Bearer token shared by protected cron endpoints and Azure Timer Functions | | `EMAIL_PREFERENCE_SECRET` | HMAC secret for weekly-email unsubscribe links; defaults to `SESSION_SECRET` | Lifecycle emails are transactional and best-effort. Claims use the verified primary email authorized through GitHub OAuth; self-nominations collect an explicitly consented notification address. Addresses are stored only in the private `developer-contacts` container and are never projected by public APIs or copied into developer documents. Create the container before deployment: @@ -254,6 +254,19 @@ CRON_SECRET=the-same-secret-configured-in-vercel The timer resumes the current UTC day's capture in RU-bounded batches. Keep `IMPACT_HISTORY_CONCURRENCY` and `IMPACT_HISTORY_BATCH_SIZE` on the Vercel application because the Next.js endpoint performs the Cosmos work. +### Email verification reminders + +The Azure Functions app invokes the protected reminder endpoint daily at 14:00 UTC. The application sends reminders only to unverified contacts who consented to transactional email and have not received a reminder in the previous 72 hours; verified contacts stop receiving reminders immediately. + +Configure these application settings on the Azure Function App: + +```env +EMAIL_VERIFICATION_REMINDERS_URL=https://www.devglobe.dev/api/cron/email-verification-reminders +CRON_SECRET=the-same-secret-configured-in-vercel +``` + +Deploy the complete `functions` directory so the timer and its `function.json` are included. Do not configure the same reminder schedule in Vercel, or each due batch may be invoked twice. + ## 🤝 Contributing Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions and areas where help is needed. diff --git a/functions/email-verification-reminders/function.json b/functions/email-verification-reminders/function.json new file mode 100644 index 0000000..b9b8088 --- /dev/null +++ b/functions/email-verification-reminders/function.json @@ -0,0 +1,13 @@ +{ + "bindings": [ + { + "name": "timer", + "type": "timerTrigger", + "direction": "in", + "schedule": "0 0 14 * * *", + "runOnStartup": false, + "useMonitor": true + } + ], + "scriptFile": "index.js" +} \ No newline at end of file diff --git a/functions/email-verification-reminders/index.js b/functions/email-verification-reminders/index.js new file mode 100644 index 0000000..31adc70 --- /dev/null +++ b/functions/email-verification-reminders/index.js @@ -0,0 +1,21 @@ +module.exports = async function emailVerificationReminders(context) { + const endpoint = process.env.EMAIL_VERIFICATION_REMINDERS_URL; + const secret = process.env.CRON_SECRET; + if (!endpoint || !secret) { + throw new Error('EMAIL_VERIFICATION_REMINDERS_URL and CRON_SECRET are required'); + } + + const response = await fetch(endpoint, { + headers: { Authorization: `Bearer ${secret}` }, + }); + const result = await response.json(); + context.log('DevGlobe email verification reminders', { + status: response.status, + scanned: result.scanned, + eligible: result.eligible, + sent: result.sent, + skipped: result.skipped, + failed: result.failed, + }); + if (!response.ok) throw new Error(`Email verification reminders returned ${response.status}`); +}; \ No newline at end of file diff --git a/tests/azure-email-verification-reminders.test.js b/tests/azure-email-verification-reminders.test.js new file mode 100644 index 0000000..d677912 --- /dev/null +++ b/tests/azure-email-verification-reminders.test.js @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const runEmailVerificationReminders = require('../functions/email-verification-reminders/index.js'); + +test('Azure timer invokes the protected verification reminder endpoint', async () => { + const originalFetch = global.fetch; + const originalUrl = process.env.EMAIL_VERIFICATION_REMINDERS_URL; + const originalSecret = process.env.CRON_SECRET; + const logs = []; + + try { + delete process.env.EMAIL_VERIFICATION_REMINDERS_URL; + delete process.env.CRON_SECRET; + await assert.rejects( + runEmailVerificationReminders({ log() {} }), + /EMAIL_VERIFICATION_REMINDERS_URL and CRON_SECRET are required/ + ); + + process.env.EMAIL_VERIFICATION_REMINDERS_URL = 'https://example.test/api/cron/email-verification-reminders'; + process.env.CRON_SECRET = 'test-secret'; + global.fetch = async (url, options) => { + assert.equal(url, process.env.EMAIL_VERIFICATION_REMINDERS_URL); + assert.equal(options.headers.Authorization, 'Bearer test-secret'); + return { + ok: true, + status: 200, + json: async () => ({ scanned: 7, eligible: 7, sent: 7, skipped: 0, failed: 0 }), + }; + }; + + await runEmailVerificationReminders({ log: (...args) => logs.push(args) }); + assert.deepEqual(logs[0][1], { + status: 200, + scanned: 7, + eligible: 7, + sent: 7, + skipped: 0, + failed: 0, + }); + + global.fetch = async () => ({ + ok: false, + status: 500, + json: async () => ({ error: 'failed' }), + }); + await assert.rejects( + runEmailVerificationReminders({ log() {} }), + /Email verification reminders returned 500/ + ); + } finally { + global.fetch = originalFetch; + if (originalUrl === undefined) delete process.env.EMAIL_VERIFICATION_REMINDERS_URL; + else process.env.EMAIL_VERIFICATION_REMINDERS_URL = originalUrl; + if (originalSecret === undefined) delete process.env.CRON_SECRET; + else process.env.CRON_SECRET = originalSecret; + } +}); \ No newline at end of file diff --git a/vercel.json b/vercel.json index f013ca2..7daf838 100644 --- a/vercel.json +++ b/vercel.json @@ -5,10 +5,6 @@ { "path": "/api/cron/weekly-digest", "schedule": "0 13 * * 1" - }, - { - "path": "/api/cron/email-verification-reminders", - "schedule": "0 14 * * *" } ] }