From 9ed3a9ea36e9d858fa8dfd25350191d7e3361c8c Mon Sep 17 00:00:00 2001 From: Stephen McMurtry Date: Mon, 30 Mar 2026 15:53:47 -0400 Subject: [PATCH 1/3] Add GC Notify one-click unsubscribe callback endpoint Adds POST /api/v1/notify/unsubscribe to receive RFC 8058 unsubscribe webhooks from GC Notify. When a recipient clicks the unsubscribe button in Gmail (or another RFC 8058 client), Notify POSTs to this endpoint with the email address and template ID. The handler: - Authenticates via Bearer token (NOTIFY_UNSUBSCRIBE_BEARER_TOKEN env var) - Looks up the CENS topic by templateId - Removes the subscriber using the same logic as /subs/remove/:subscode: findOneAndDelete from subsConfirmed, insert into subsUnsubs, findOneAndDelete from subsExist, upsert into subsRecents - Returns 200 OK with skipped:true if the template or subscriber is not found (avoids Notify retry loops) CENS's own /subs/remove/:subscode body link is unchanged. This is a secondary path triggered only by email-client header-level unsubscribes. --- controllers/notify_callbacks.js | 196 ++++++++++++++++++++++++++++++++ server.js | 9 ++ setup.md | 32 ++++++ 3 files changed, 237 insertions(+) create mode 100644 controllers/notify_callbacks.js diff --git a/controllers/notify_callbacks.js b/controllers/notify_callbacks.js new file mode 100644 index 0000000..f2b3257 --- /dev/null +++ b/controllers/notify_callbacks.js @@ -0,0 +1,196 @@ +/*========================== + * Notify Callbacks + * + * @description: Handles inbound webhooks from GC Notify. + * Currently supports the one-click unsubscribe callback (RFC 8058). + * + * When a recipient clicks the "Unsubscribe" button in Gmail (or another RFC 8058 + * capable client), Notify sends a POST to this endpoint with the email address + * and template ID. We look up the CENS topic by templateId and remove the + * subscriber using the same logic as /subs/remove/:subscode. + * + * Configuration (environment variables): + * NOTIFY_UNSUBSCRIBE_BEARER_TOKEN – Bearer token that Notify will send in the + * Authorization header. Set this value in the + * Notify admin "Callbacks → Email unsubscribe + * requests" form. + * + * @author: Government of Canada + * @version: 1.0 + ===========================*/ + +"use strict"; + +const dbConn = module.parent.exports.dbConn; + +const processEnv = process.env, + _devLog = !!!processEnv.prodNoLog, + _errorPage = processEnv.errorPage || "https://canada.ca", + _notifyUnsubscribeBearerToken = processEnv.NOTIFY_UNSUBSCRIBE_BEARER_TOKEN || null; + +/* + * verifyBearerToken + * Middleware that validates the Authorization: Bearer header. + */ +const verifyBearerToken = ( req, res, next ) => { + + if ( !_notifyUnsubscribeBearerToken ) { + console.error( "notify_callbacks: NOTIFY_UNSUBSCRIBE_BEARER_TOKEN is not set" ); + return res.status( 500 ).json( { error: "Callback endpoint not configured" } ); + } + + const authHeader = req.headers[ "authorization" ] || ""; + const token = authHeader.replace( /^bearer\s+/i, "" ); + + if ( !token || token !== _notifyUnsubscribeBearerToken ) { + console.warn( "notify_callbacks: unauthorized callback attempt" ); + return res.status( 401 ).json( { error: "Unauthorized" } ); + } + + next(); +}; + +/* + * getTopicByTemplateId + * Looks up a topic document by its Notify templateId field. + * Returns the topic document or null. + */ +const getTopicByTemplateId = async ( templateId ) => { + try { + return await dbConn.collection( "topics" ).findOne( + { templateId: templateId }, + { + projection: { + _id: 1, + templateId: 1, + notifyKey: 1, + confirmURL: 1, + unsubURL: 1, + } + } + ); + } catch ( e ) { + console.error( "notify_callbacks: getTopicByTemplateId error", e ); + return null; + } +}; + +/* + * POST /api/v1/notify/unsubscribe + * + * Receives the GC Notify unsubscribe callback. + * Expected JSON body from Notify: + * { + * "notification_id": "", + * "email_address": "", + * "template_id": "", + * "service_id": "" + * } + * + * Performs the same removal steps as removeEmail() in subscriptions.js, + * minus the subscode-based lookup (we find by email + topicId instead). + */ +exports.notifyUnsubscribeCallback = [ + verifyBearerToken, + async ( req, res ) => { + + const { notification_id, email_address, template_id, service_id } = req.body || {}; + + if ( !email_address || !template_id ) { + return res.status( 400 ).json( { error: "Missing required fields: email_address, template_id" } ); + } + + const currDate = new Date(); + + // Resolve the CENS topic from the Notify template ID + const topic = await getTopicByTemplateId( template_id ); + + if ( !topic ) { + console.warn( "notify_callbacks: no topic found for template_id", template_id ); + // Return 200 so Notify doesn't retry — this template isn't managed by CENS + return res.status( 200 ).json( { ok: true, skipped: true, reason: "template not found" } ); + } + + const topicId = topic._id; + + // Remove from subsConfirmed (look up by email + topicId since we have no subscode) + let docSubs; + try { + docSubs = await dbConn.collection( "subsConfirmed" ).findOneAndDelete( + { email: email_address, topicId: topicId } + ); + } catch ( e ) { + console.error( "notify_callbacks: subsConfirmed findOneAndDelete error", e ); + return res.status( 500 ).json( { error: "Internal error" } ); + } + + const docValue = docSubs.value; + + if ( !docValue ) { + // Subscriber not found — already unsubscribed or never confirmed. Not an error. + _devLog && console.log( "notify_callbacks: subscriber not found, possibly already removed", email_address, topicId ); + return res.status( 200 ).json( { ok: true, skipped: true, reason: "subscriber not found" } ); + } + + // subs_logs entry (async, non-blocking) + _devLog && dbConn.collection( "subs_logs" ).updateOne( + { _id: email_address }, + { + $push: { + unsubsEmail: { + createdAt: currDate, + topicId: topicId, + via: "notify-callback", + notificationId: notification_id || null + } + }, + $currentDate: { lastUpdated: true } + } + ).catch( ( e ) => { + console.error( "notify_callbacks: subs_logs error", e ); + } ); + + // Insert unsubscribe audit record + dbConn.collection( "subsUnsubs" ).insertOne( { + createdAt: docValue.createdAt, + confirmAt: docValue.confirmAt, + unsubAt: currDate, + email: email_address, + topicId: topicId, + via: "notify-callback", + notificationId: notification_id || null + } ).catch( ( e ) => { + console.error( "notify_callbacks: subsUnsubs insertOne error", e ); + } ); + + // Remove from subsExist (the unique-guard collection) + try { + await dbConn.collection( "subsExist" ).findOneAndDelete( + { e: email_address, t: topicId } + ); + } catch ( e ) { + console.error( "notify_callbacks: subsExist findOneAndDelete error", e ); + } + + // Upsert subsRecents TTL entry so a double-click is handled gracefully + dbConn.collection( "subsRecents" ).findOneAndUpdate( + { email: email_address, topicId: topicId }, + { + $set: { + createdAt: currDate, + email: email_address, + topicId: topicId, + link: topic.unsubURL || _errorPage, + via: "notify-callback" + } + }, + { upsert: true } + ).catch( ( e ) => { + console.error( "notify_callbacks: subsRecents upsert error", e ); + } ); + + console.log( `notify_callbacks: unsubscribed ${ email_address } from topic ${ topicId } via Notify callback` ); + + return res.status( 200 ).json( { ok: true } ); + } +]; diff --git a/server.js b/server.js index 8f29b39..675c7ca 100644 --- a/server.js +++ b/server.js @@ -79,6 +79,7 @@ MongoClient.connect( processEnv.MONGODB_URI || '', {useUnifiedTopology: true} ). const adminController = require('./controllers/admin'); const mailingController = require('./controllers/mailing_view'); const userController = require('./controllers/user'); + const notifyCallbacksController = require('./controllers/notify_callbacks'); /** * Express configuration. @@ -115,6 +116,14 @@ MongoClient.connect( processEnv.MONGODB_URI || '', {useUnifiedTopology: true} ). subsController.addEmailPOST); // app.get('/api/v0.1/subs/email/getAll', subsController.getAll); // TODO: kept for later if we create a "subscription" management page. + /** + * GC Notify callback routes. + * POST /api/v1/notify/unsubscribe — receives the RFC 8058 one-click unsubscribe + * webhook from GC Notify and removes the subscriber from CENS. + * Requires Authorization: Bearer . + */ + app.post('/api/v1/notify/unsubscribe', notifyCallbacksController.notifyUnsubscribeCallback); + /** diff --git a/setup.md b/setup.md index 4bf4884..8466b81 100644 --- a/setup.md +++ b/setup.md @@ -140,6 +140,38 @@ Note: We need to set the Service ID associated to the topic details (field: `nSe * `baseFolder` Base folder where the application run. ex: "/x-notify" Default: undefined +### GC Notify unsubscribe callback + +When GC Notify's `has_unsubscribe_link` flag is enabled on a template, Notify adds RFC 8058 +`List-Unsubscribe` / `List-Unsubscribe-Post` headers to outgoing emails. Email clients such as +Gmail surface a one-click "Unsubscribe" button from these headers. When a recipient clicks that +button, Notify sends a `POST` to the configured callback URL with the email address and template ID. + +CENS can receive this callback and remove the subscriber, mirroring the same removal logic used +by the `/subs/remove/:subscode` endpoint. + +**Setup:** + +1. Set the environment variable: + * `NOTIFY_UNSUBSCRIBE_BEARER_TOKEN` — A strong random secret shared between CENS and Notify. + Notify will send this as `Authorization: Bearer ` on every callback request. + Default: none (endpoint returns 500 if unset). + +2. In the GC Notify admin, go to **API integration → Callbacks → Email unsubscribe requests** + and configure: + - **URL**: `https:///api/v1/notify/unsubscribe` + - **Bearer token**: the value of `NOTIFY_UNSUBSCRIBE_BEARER_TOKEN` + +3. Ensure each CENS topic's `templateId` field matches the Notify template UUID. The callback + uses `templateId` to resolve which topic's subscriber list to modify. + +**Notes:** +- CENS's own `/subs/remove/:subscode` body link remains active and unchanged. The Notify + callback is a secondary path triggered only by email-client header-level unsubscribe buttons. +- If the `templateId` in the callback doesn't match any CENS topic, a `200 OK` with + `{ skipped: true }` is returned (so Notify doesn't retry indefinitely). +- If the subscriber is already removed, a `200 OK` with `{ skipped: true }` is returned. + ### REDIS Default Configuration * `REDIS_ENV` Set environment value for Redis. Default: `stage` and `prod` which would leverage the redis-sentinel in production environment From 94f8ae146ff72557186fb3458b797f4c0ff46011 Mon Sep 17 00:00:00 2001 From: Stephen McMurtry Date: Mon, 30 Mar 2026 16:44:40 -0400 Subject: [PATCH 2/3] support for the new notify unsubscribe feature --- controllers/notify_callbacks.js | 196 -------------------------------- controllers/subscriptions.js | 91 +++++++++++++++ server.js | 12 +- 3 files changed, 95 insertions(+), 204 deletions(-) delete mode 100644 controllers/notify_callbacks.js diff --git a/controllers/notify_callbacks.js b/controllers/notify_callbacks.js deleted file mode 100644 index f2b3257..0000000 --- a/controllers/notify_callbacks.js +++ /dev/null @@ -1,196 +0,0 @@ -/*========================== - * Notify Callbacks - * - * @description: Handles inbound webhooks from GC Notify. - * Currently supports the one-click unsubscribe callback (RFC 8058). - * - * When a recipient clicks the "Unsubscribe" button in Gmail (or another RFC 8058 - * capable client), Notify sends a POST to this endpoint with the email address - * and template ID. We look up the CENS topic by templateId and remove the - * subscriber using the same logic as /subs/remove/:subscode. - * - * Configuration (environment variables): - * NOTIFY_UNSUBSCRIBE_BEARER_TOKEN – Bearer token that Notify will send in the - * Authorization header. Set this value in the - * Notify admin "Callbacks → Email unsubscribe - * requests" form. - * - * @author: Government of Canada - * @version: 1.0 - ===========================*/ - -"use strict"; - -const dbConn = module.parent.exports.dbConn; - -const processEnv = process.env, - _devLog = !!!processEnv.prodNoLog, - _errorPage = processEnv.errorPage || "https://canada.ca", - _notifyUnsubscribeBearerToken = processEnv.NOTIFY_UNSUBSCRIBE_BEARER_TOKEN || null; - -/* - * verifyBearerToken - * Middleware that validates the Authorization: Bearer header. - */ -const verifyBearerToken = ( req, res, next ) => { - - if ( !_notifyUnsubscribeBearerToken ) { - console.error( "notify_callbacks: NOTIFY_UNSUBSCRIBE_BEARER_TOKEN is not set" ); - return res.status( 500 ).json( { error: "Callback endpoint not configured" } ); - } - - const authHeader = req.headers[ "authorization" ] || ""; - const token = authHeader.replace( /^bearer\s+/i, "" ); - - if ( !token || token !== _notifyUnsubscribeBearerToken ) { - console.warn( "notify_callbacks: unauthorized callback attempt" ); - return res.status( 401 ).json( { error: "Unauthorized" } ); - } - - next(); -}; - -/* - * getTopicByTemplateId - * Looks up a topic document by its Notify templateId field. - * Returns the topic document or null. - */ -const getTopicByTemplateId = async ( templateId ) => { - try { - return await dbConn.collection( "topics" ).findOne( - { templateId: templateId }, - { - projection: { - _id: 1, - templateId: 1, - notifyKey: 1, - confirmURL: 1, - unsubURL: 1, - } - } - ); - } catch ( e ) { - console.error( "notify_callbacks: getTopicByTemplateId error", e ); - return null; - } -}; - -/* - * POST /api/v1/notify/unsubscribe - * - * Receives the GC Notify unsubscribe callback. - * Expected JSON body from Notify: - * { - * "notification_id": "", - * "email_address": "", - * "template_id": "", - * "service_id": "" - * } - * - * Performs the same removal steps as removeEmail() in subscriptions.js, - * minus the subscode-based lookup (we find by email + topicId instead). - */ -exports.notifyUnsubscribeCallback = [ - verifyBearerToken, - async ( req, res ) => { - - const { notification_id, email_address, template_id, service_id } = req.body || {}; - - if ( !email_address || !template_id ) { - return res.status( 400 ).json( { error: "Missing required fields: email_address, template_id" } ); - } - - const currDate = new Date(); - - // Resolve the CENS topic from the Notify template ID - const topic = await getTopicByTemplateId( template_id ); - - if ( !topic ) { - console.warn( "notify_callbacks: no topic found for template_id", template_id ); - // Return 200 so Notify doesn't retry — this template isn't managed by CENS - return res.status( 200 ).json( { ok: true, skipped: true, reason: "template not found" } ); - } - - const topicId = topic._id; - - // Remove from subsConfirmed (look up by email + topicId since we have no subscode) - let docSubs; - try { - docSubs = await dbConn.collection( "subsConfirmed" ).findOneAndDelete( - { email: email_address, topicId: topicId } - ); - } catch ( e ) { - console.error( "notify_callbacks: subsConfirmed findOneAndDelete error", e ); - return res.status( 500 ).json( { error: "Internal error" } ); - } - - const docValue = docSubs.value; - - if ( !docValue ) { - // Subscriber not found — already unsubscribed or never confirmed. Not an error. - _devLog && console.log( "notify_callbacks: subscriber not found, possibly already removed", email_address, topicId ); - return res.status( 200 ).json( { ok: true, skipped: true, reason: "subscriber not found" } ); - } - - // subs_logs entry (async, non-blocking) - _devLog && dbConn.collection( "subs_logs" ).updateOne( - { _id: email_address }, - { - $push: { - unsubsEmail: { - createdAt: currDate, - topicId: topicId, - via: "notify-callback", - notificationId: notification_id || null - } - }, - $currentDate: { lastUpdated: true } - } - ).catch( ( e ) => { - console.error( "notify_callbacks: subs_logs error", e ); - } ); - - // Insert unsubscribe audit record - dbConn.collection( "subsUnsubs" ).insertOne( { - createdAt: docValue.createdAt, - confirmAt: docValue.confirmAt, - unsubAt: currDate, - email: email_address, - topicId: topicId, - via: "notify-callback", - notificationId: notification_id || null - } ).catch( ( e ) => { - console.error( "notify_callbacks: subsUnsubs insertOne error", e ); - } ); - - // Remove from subsExist (the unique-guard collection) - try { - await dbConn.collection( "subsExist" ).findOneAndDelete( - { e: email_address, t: topicId } - ); - } catch ( e ) { - console.error( "notify_callbacks: subsExist findOneAndDelete error", e ); - } - - // Upsert subsRecents TTL entry so a double-click is handled gracefully - dbConn.collection( "subsRecents" ).findOneAndUpdate( - { email: email_address, topicId: topicId }, - { - $set: { - createdAt: currDate, - email: email_address, - topicId: topicId, - link: topic.unsubURL || _errorPage, - via: "notify-callback" - } - }, - { upsert: true } - ).catch( ( e ) => { - console.error( "notify_callbacks: subsRecents upsert error", e ); - } ); - - console.log( `notify_callbacks: unsubscribed ${ email_address } from topic ${ topicId } via Notify callback` ); - - return res.status( 200 ).json( { ok: true } ); - } -]; diff --git a/controllers/subscriptions.js b/controllers/subscriptions.js index fba4220..e938556 100644 --- a/controllers/subscriptions.js +++ b/controllers/subscriptions.js @@ -422,6 +422,97 @@ exports.confirmEmail = ( req, res, next ) => { // // Remove subscription email +// +// RFC 8058 one-click unsubscribe handler (POST /subs/remove/:subscode). +// Called silently by email clients (Gmail, Apple Mail) when the user clicks +// the email-header unsubscribe button. Performs the same removal as removeEmail +// but returns JSON 200 instead of redirecting. +// +exports.removeEmailOneClick = async ( req, res ) => { + const { subscode } = req.params; + const currDate = new Date(); + + // RFC 8058 requires the body to contain List-Unsubscribe=One-Click + const body = req.body || {}; + if ( body['List-Unsubscribe'] !== 'One-Click' ) { + res.status( 400 ).json( { error: 'Missing List-Unsubscribe=One-Click body parameter' } ); + return; + } + + let subsId; + try { + subsId = ObjectId( subscode ); + } catch ( e ) { + res.status( 400 ).json( { error: 'Invalid subscode' } ); + return; + } + + const findQuery = { subscode: subsId }; + + let docSubs; + try { + docSubs = await dbConn.collection( 'subsConfirmed' ).findOneAndDelete( findQuery ); + } catch ( e ) { + console.log( 'removeEmailOneClick: subsConfirmed error', e ); + res.status( 500 ).json( { error: 'Database error' } ); + return; + } + + const docValue = docSubs.value; + if ( !docValue ) { + // Already removed or never existed — return 200 to prevent client retries + res.json( { skipped: true } ); + return; + } + + const { topicId, email } = docValue; + const topic = await getTopic( topicId ); + + // subs_logs entry (async, non-blocking) + dbConn.collection( 'subs_logs' ).updateOne( + { _id: email }, + { + $push: { + unsubsEmail: { createdAt: currDate, topicId, subscode: subsId, via: 'one-click-header' } + }, + $currentDate: { lastUpdated: true } + } + ).catch( ( e ) => console.log( 'removeEmailOneClick: subs_logs', e ) ); + + // Insert unsub audit record + dbConn.collection( 'subsUnsubs' ).insertOne( { + createdAt: docValue.createdAt, + confirmAt: docValue.confirmAt, + unsubAt: currDate, + email, + topicId + } ); + + // Remove from subsExist + try { + await dbConn.collection( 'subsExist' ).findOneAndDelete( { e: email, t: topicId } ); + } catch ( e ) { + console.log( 'removeEmailOneClick: subsExist', e ); + } + + // Upsert subsRecents TTL entry + dbConn.collection( 'subsRecents' ).findOneAndUpdate( + { subscode: subsId }, + { + $set: { + createdAt: currDate, + email, + subscode: subsId, + topicId, + link: topic ? topic.unsubURL : null + } + }, + { upsert: true } + ).catch( ( e ) => console.log( 'removeEmailOneClick: subsRecents', e ) ); + + res.json( { ok: true } ); +}; + // // @return; a HTTP redirection // diff --git a/server.js b/server.js index 675c7ca..e41c580 100644 --- a/server.js +++ b/server.js @@ -79,7 +79,6 @@ MongoClient.connect( processEnv.MONGODB_URI || '', {useUnifiedTopology: true} ). const adminController = require('./controllers/admin'); const mailingController = require('./controllers/mailing_view'); const userController = require('./controllers/user'); - const notifyCallbacksController = require('./controllers/notify_callbacks'); /** * Express configuration. @@ -111,18 +110,15 @@ MongoClient.connect( processEnv.MONGODB_URI || '', {useUnifiedTopology: true} ). app.get('/subs/remove/:subscode/:emlParam', subsController.removeEmail); // Deprecated, to be removed after 60 days of it's deployment date app.get('/subs/confirm/:subscode', subsController.confirmEmail); app.get('/subs/remove/:subscode', subsController.removeEmail); + // RFC 8058 one-click unsubscribe: email client POSTs silently to the same URL + app.post('/subs/remove/:subscode', + bodyParser.urlencoded({extended: false, limit: '1kb'}), + subsController.removeEmailOneClick); app.post('/subs/post', bodyParser.urlencoded({extended:false, limit: '10kb'}), subsController.addEmailPOST); // app.get('/api/v0.1/subs/email/getAll', subsController.getAll); // TODO: kept for later if we create a "subscription" management page. - /** - * GC Notify callback routes. - * POST /api/v1/notify/unsubscribe — receives the RFC 8058 one-click unsubscribe - * webhook from GC Notify and removes the subscriber from CENS. - * Requires Authorization: Bearer . - */ - app.post('/api/v1/notify/unsubscribe', notifyCallbacksController.notifyUnsubscribeCallback); From 76c855575f50833c2c802a889f92b09d6cc11b13 Mon Sep 17 00:00:00 2001 From: Stephen McMurtry Date: Tue, 5 May 2026 16:33:42 -0400 Subject: [PATCH 3/3] revert changes to the setup.md file --- setup.md | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/setup.md b/setup.md index 8466b81..4bf4884 100644 --- a/setup.md +++ b/setup.md @@ -140,38 +140,6 @@ Note: We need to set the Service ID associated to the topic details (field: `nSe * `baseFolder` Base folder where the application run. ex: "/x-notify" Default: undefined -### GC Notify unsubscribe callback - -When GC Notify's `has_unsubscribe_link` flag is enabled on a template, Notify adds RFC 8058 -`List-Unsubscribe` / `List-Unsubscribe-Post` headers to outgoing emails. Email clients such as -Gmail surface a one-click "Unsubscribe" button from these headers. When a recipient clicks that -button, Notify sends a `POST` to the configured callback URL with the email address and template ID. - -CENS can receive this callback and remove the subscriber, mirroring the same removal logic used -by the `/subs/remove/:subscode` endpoint. - -**Setup:** - -1. Set the environment variable: - * `NOTIFY_UNSUBSCRIBE_BEARER_TOKEN` — A strong random secret shared between CENS and Notify. - Notify will send this as `Authorization: Bearer ` on every callback request. - Default: none (endpoint returns 500 if unset). - -2. In the GC Notify admin, go to **API integration → Callbacks → Email unsubscribe requests** - and configure: - - **URL**: `https:///api/v1/notify/unsubscribe` - - **Bearer token**: the value of `NOTIFY_UNSUBSCRIBE_BEARER_TOKEN` - -3. Ensure each CENS topic's `templateId` field matches the Notify template UUID. The callback - uses `templateId` to resolve which topic's subscriber list to modify. - -**Notes:** -- CENS's own `/subs/remove/:subscode` body link remains active and unchanged. The Notify - callback is a secondary path triggered only by email-client header-level unsubscribe buttons. -- If the `templateId` in the callback doesn't match any CENS topic, a `200 OK` with - `{ skipped: true }` is returned (so Notify doesn't retry indefinitely). -- If the subscriber is already removed, a `200 OK` with `{ skipped: true }` is returned. - ### REDIS Default Configuration * `REDIS_ENV` Set environment value for Redis. Default: `stage` and `prod` which would leverage the redis-sentinel in production environment