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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions functions/email-verification-reminders/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"bindings": [
{
"name": "timer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 0 14 * * *",
"runOnStartup": false,
"useMonitor": true
}
],
"scriptFile": "index.js"
}
21 changes: 21 additions & 0 deletions functions/email-verification-reminders/index.js
Original file line number Diff line number Diff line change
@@ -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}`);
};
60 changes: 60 additions & 0 deletions tests/azure-email-verification-reminders.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
4 changes: 0 additions & 4 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@
{
"path": "/api/cron/weekly-digest",
"schedule": "0 13 * * 1"
},
{
"path": "/api/cron/email-verification-reminders",
"schedule": "0 14 * * *"
}
]
}