Skip to content

[SES-PHASE-1-6] - PLU-744: add admin whitelist endpoint#1632

Open
m0nggh wants to merge 1 commit into
feat/ses/add-suppression-checkfrom
feat/ses/add-admin-whitelisting
Open

[SES-PHASE-1-6] - PLU-744: add admin whitelist endpoint#1632
m0nggh wants to merge 1 commit into
feat/ses/add-suppression-checkfrom
feat/ses/add-admin-whitelisting

Conversation

@m0nggh
Copy link
Copy Markdown
Contributor

@m0nggh m0nggh commented May 19, 2026

TL;DR

Adds an admin-only API endpoint to remove emails from the suppression list.

What changed?

A new /api/admin/email-suppression/whitelist GET endpoint has been added, protected by an isAdminOperation middleware check that returns a 403 if the request is not from a Plumber admin. The endpoint accepts a comma-separated emails query parameter, handles + characters that get decoded as spaces in query strings, and calls EmailSuppressionEntry.whitelistEmails to remove the specified emails from the suppression list. The operation is logged with the requested and successfully whitelisted emails.

How to test?

Note that this is to be done on Plumber admin: will release a PR on plumber admin to test on staging first
Refer to PR for the change

  1. Make a request with a valid admin token:
  2. Verify the response includes the whitelisted array and count.
  3. Confirm that emails with + characters are handled correctly (e.g., b+1@example.com should not be mangled).
  4. Attempt the same request without an admin token and confirm a 403 is returned (make the http call on Plumber app)
  5. Omit the emails parameter or pass an empty value and confirm a 400 is returned.

Why make this change?

Emails can end up on the suppression list after a bounce or complaint, preventing legitimate users from receiving emails. This endpoint gives Plumber admins a way to manually remove specific emails from the suppression list without requiring direct database access.

@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from 9c04394 to d5647a6 Compare May 19, 2026 09:22
@m0nggh m0nggh force-pushed the feat/ses/add-suppression-check branch 2 times, most recently from 6a8790b to 38bd775 Compare May 19, 2026 09:50
@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from d5647a6 to 19d6d37 Compare May 19, 2026 09:50
@datadog-opengovsg

This comment has been minimized.

@m0nggh m0nggh force-pushed the feat/ses/add-suppression-check branch from 38bd775 to 0e74293 Compare May 19, 2026 23:23
@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from 19d6d37 to e533f62 Compare May 19, 2026 23:23
@m0nggh m0nggh changed the title add admin whitelist endpoint [SES-PHASE-1-6] - PLU-744: add admin whitelist endpoint May 20, 2026
@linear
Copy link
Copy Markdown

linear Bot commented May 20, 2026

PLU-744

@m0nggh m0nggh marked this pull request as ready for review May 20, 2026 09:40
@m0nggh m0nggh requested a review from a team as a code owner May 20, 2026 09:40
Comment on lines +26 to +37
const emailsParam = req.query.emails as string
if (!emailsParam) {
res.status(400).json({ error: 'emails query parameter is required' })
return
}

// `+` in query strings is decoded to a space; restore it since spaces
// are not valid in email addresses. This lets callers paste URLs with
// unencoded `+` characters directly.
const emails = emailsParam
.split(',')
.map((e) => e.trim().replace(/ /g, '+'))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type assertion bug: req.query.emails can be a string array if the parameter is repeated in the URL (e.g., ?emails=a@x.com&emails=b@x.com). The code assumes it's always a string and calls .split(',') on it, which will throw TypeError: split is not a function if an array is passed.

const emailsParam = req.query.emails
if (!emailsParam || Array.isArray(emailsParam)) {
  res.status(400).json({ error: 'emails query parameter is required and must be a single value' })
  return
}
const emails = (emailsParam as string)
  .split(',')
  .map((e) => e.trim().replace(/ /g, '+'))
  .filter(Boolean)
Suggested change
const emailsParam = req.query.emails as string
if (!emailsParam) {
res.status(400).json({ error: 'emails query parameter is required' })
return
}
// `+` in query strings is decoded to a space; restore it since spaces
// are not valid in email addresses. This lets callers paste URLs with
// unencoded `+` characters directly.
const emails = emailsParam
.split(',')
.map((e) => e.trim().replace(/ /g, '+'))
const emailsParam = req.query.emails
if (!emailsParam || Array.isArray(emailsParam)) {
res.status(400).json({ error: 'emails query parameter is required and must be a single value' })
return
}
const emails = (emailsParam as string)
.split(',')
.map((e) => e.trim().replace(/ /g, '+'))
.filter(Boolean)

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from e533f62 to badc3fe Compare May 22, 2026 11:16
@m0nggh m0nggh force-pushed the feat/ses/add-suppression-check branch from 0e74293 to 143cf2f Compare May 22, 2026 11:16
// `+` in query strings is decoded to a space; restore it since spaces
// are not valid in email addresses. This lets callers paste URLs with
// unencoded `+` characters directly.
const emails = emailsParam
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: zod this?


// Mount routes that admins can access before blockAdminOperations
router.use('/apps', appsRouter)
router.use('/admin', adminRouter)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be extra safe, we should also add guardrails for this route to our WAF

* Whitelists one or more emails from the suppression list.
* Requires x-plumber-admin-token header.
*/
router.get('/email-suppression/whitelist', async (req, res) => {
Copy link
Copy Markdown
Contributor

@ogp-weeloong ogp-weeloong Jun 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eh, why use a GET here?

For writes, it's more idiomatic to use POST / PUT / PATCH / DELETE. Primarily we're concerned about accidentlly exposing ourselves to CSRF attacks.

The more important part is actually for POST requests, browsers put up the form resubmission warning.

For this case, POST is fine, but tbh we don't need to enforce resubmission warning; PUT or PATCH also OK. DELETE feels weird

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok as discussed - we're safe behind the plumber admin header but still kinda dangerous to propagate this in the code. would like to avoid if possible unless we have a really really strong use case.

Copy link
Copy Markdown
Contributor

@ogp-weeloong ogp-weeloong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GET -> POST

@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from badc3fe to a291c21 Compare June 8, 2026 09:03
@m0nggh m0nggh force-pushed the feat/ses/add-suppression-check branch 2 times, most recently from 1b169a3 to efe4153 Compare June 8, 2026 11:01
@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch 2 times, most recently from 983424e to 68d56fe Compare June 8, 2026 12:02
@m0nggh m0nggh force-pushed the feat/ses/add-suppression-check branch from efe4153 to 5b575a1 Compare June 8, 2026 12:02
@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from 68d56fe to 08ba585 Compare June 8, 2026 12:04
@m0nggh m0nggh force-pushed the feat/ses/add-suppression-check branch 2 times, most recently from 1a2f127 to 87de761 Compare June 8, 2026 15:02
@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from 08ba585 to 067c7be Compare June 8, 2026 15:02
@m0nggh m0nggh force-pushed the feat/ses/add-suppression-check branch from 87de761 to 80cc550 Compare June 8, 2026 15:03
@m0nggh m0nggh force-pushed the feat/ses/add-admin-whitelisting branch from 067c7be to 8dbd477 Compare June 8, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants