Skip to content

fix(api): mask email addresses in mail failure logs and audit entries - #1608

Merged
joshunrau merged 1 commit into
DouglasNeuroInformatics:mainfrom
joshunrau:fix/redact-logged-emails
Oct 4, 2026
Merged

joshunrau merged 1 commit into
DouglasNeuroInformatics:mainfrom
joshunrau:fix/redact-logged-emails

Conversation

@joshunrau

Copy link
Copy Markdown
Collaborator

Closes #1547

What changed

Email addresses were leaking in two places:

  • The mail failure log. MailService.deliver() logged Failed 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 new redactEmails helper in apps/api/src/mail/mail.utils.ts.
  • The SEND_EMAIL audit entry. metadata.recipient stored the plaintext address. Every admin can read that entry through GET /v1/audit/logs and 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.org becomes b***@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.recipient still 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 (no logger/debug), the SMTP test() path (returns only an error code), and the other loggingService and auditLogger call sites.

Verification

  • Unit tests:
    • apps/api/src/mail/__tests__/mail.utils.spec.ts: redactEmails cases.
    • 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.
  • E2E: 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 holds p***@example.org and never the address. New ApiClient helpers: 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 read Failed to send "…" to p***@example.org: Error: getaddrinfo ENOTFOUND smtp.invalid.test.
  • turbo run lint for api and testing: tsc and eslint are green with no rewrites. prettier --check is clean on every changed file. Commitlint passes.

Exceptions:

  • The full pnpm lint didn't finish locally. astro check in apps/outreach, which this PR doesn't touch, failed writing its telemetry config outside my sandbox.
  • The full e2e suite wasn't run, only the mail spec.
  • I didn't watch the new tests fail on purpose.

Not changed

  • SEND_EMAIL entries written before this change still hold plaintext recipients. Scrubbing them would need a one-off backfill.
  • AbilityFactory.createForPayload logs the whole token payload, including username, at verbose level. 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

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>
@joshunrau joshunrau closed this Oct 4, 2026
@joshunrau joshunrau reopened this Oct 4, 2026
@joshunrau
joshunrau merged commit d111f50 into DouglasNeuroInformatics:main Oct 4, 2026
4 checks passed
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.

Logging and email

1 participant