Repository navigation
fix(api): mask email addresses in mail failure logs and audit entries - #1608
Merged
joshunrau merged 1 commit intoOct 4, 2026
Merged
Conversation
A failed send logged the recipient in plaintext, and SMTP servers echo the rejected address back in their error, so the whole log line is now passed through redactEmails. The SEND_EMAIL audit entry, which every admin can read and download, stored the plaintext recipient too; it now stores the masked form. Addresses are masked to their first character and full domain (bob.smith@example.org -> b***@example.org) rather than hashed, because an unsalted hash of an address is reversed by hashing a candidate list. The delivery result returned to the caller still carries the full address, which the clinician needs for the manual-send fallback. Closes DouglasNeuroInformatics#1547 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1547
What changed
Email addresses were leaking in two places:
MailService.deliver()loggedFailed to send "<subject>" to <recipient>: <error>. Leaving out the recipient wasn't enough, because SMTP servers echo the rejected address back in their error (550 5.1.1 <bob@example.org>: Recipient address rejected). The whole line now goes through a newredactEmailshelper inapps/api/src/mail/mail.utils.ts.SEND_EMAILaudit entry.metadata.recipientstored the plaintext address. Every admin can read that entry throughGET /v1/audit/logsand the audit-log JSON download. It now stores the masked form.Addresses are masked to their first character and full domain, so
bob.smith@example.orgbecomesb***@example.org. That still shows which domain the mail went to. I masked rather than hashed because an unsalted hash of an email can be reversed by hashing a list of candidate addresses. Every mask has the same length, so it doesn't reveal how long the local part is. The local-part pattern is any run of characters other than whitespace and the RFC 5322 specials, so apostrophes and non-ASCII letters are masked too.The HTTP response is unchanged:
EmailDeliveryResult.recipientstill returns the full address, because the clinician who typed it needs it for the manual-send fallback.Other sources I checked and found clean: libnest's logging middleware (logs only
req.url), nodemailer (nologger/debug), the SMTPtest()path (returns only an error code), and the otherloggingServiceandauditLoggercall sites.Verification
apps/api/src/mail/__tests__/mail.utils.spec.ts:redactEmailscases.apps/api/src/mail/__tests__/mail.service.spec.ts: the failure log masks both the recipient and the address echoed in the SMTP error.apps/api/src/assignments/__tests__/assignments.controller.spec.ts: the audit metadata is masked, and the full recipient is still returned.testing/src/specs/mail.spec.ts, "should record only a masked recipient in the audit log". It sends to the suite's unroutable SMTP host, so the send fails and is still audited, then reads the audit entry back and asserts it holdsp***@example.organd never the address. NewApiClienthelpers:sendAssignmentEmail,findAuditLogs.Results:
pnpm test run --project api: 29 files, 469 tests, all passing, integration suite included.pnpm --filter @opendatacapture/testing test:e2e src/specs/mail.spec.ts --project=chromium: 20/20 passing (setup, the 14 mail tests, teardown). The API's own log lines during that run readFailed to send "…" to p***@example.org: Error: getaddrinfo ENOTFOUND smtp.invalid.test.turbo run lintforapiandtesting:tscandeslintare green with no rewrites.prettier --checkis clean on every changed file. Commitlint passes.Exceptions:
pnpm lintdidn't finish locally.astro checkinapps/outreach, which this PR doesn't touch, failed writing its telemetry config outside my sandbox.Not changed
SEND_EMAILentries written before this change still hold plaintext recipients. Scrubbing them would need a one-off backfill.AbilityFactory.createForPayloadlogs the whole token payload, includingusername, atverboselevel. Usernames may be email addresses in some deployments. It's off by default (VERBOSE=false).Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com