fix(email): keep the webhook payload out of the logs, and bound the response read (#1225) (#1233)

Round 4 of external review, on the merged commit. Both findings are
consequences of the round-3 streaming change, which is exactly why the round
was worth running.

An AxiosError carries the request it failed on: `config.data` is the ENTIRE
serialised message, base64 attachments included, and `config.headers` holds
the signature. emailProcessor logs the error object and winston serialises it,
so a DNS blip or a refused connection wrote password-reset links, guest
recovery codes and multi-megabyte invoices into combined.log — verified
against axios rather than assumed. Every rejection is now caught and replaced
with a message-and-code-only error, so nothing downstream can serialise the
request back out of it.

readBounded had no deadline. axios' `timeout` covers the response HEADERS, and
with responseType 'stream' it has already resolved by the time the body is
read — so a receiver that answered 2xx and never closed its body left the
await hanging, the queue row stayed pending, and the next processor pass sent
the same message again. An unclosed stream was duplicate email. There is now a
10s wall clock that destroys the stream, with the timer unref'd so a hung read
cannot hold the process open at exit.

23 transport tests (2 new, both failing without these fixes), 63 across the
email suites, eslint clean.

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-29 12:15:25 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 25a7e64951
commit 0d41fe5bf1
2 changed files with 116 additions and 35 deletions
@@ -48,6 +48,7 @@ beforeEach(() => {
process.env.EMAIL_WEBHOOK_SECRET = SECRET;
transport.__testing.setAllowPrivateUrls(false);
transport.__testing.resetSecretWarning();
transport.__testing.setReadTimeout(10000);
validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' });
axios.post.mockResolvedValue({ status: 200, data: streamOf('') });
});
@@ -257,6 +258,45 @@ describe('delivery result', () => {
expect(result.messageId).toEqual(expect.any(String));
});
it('never lets the request body escape inside a network error', async () => {
// An AxiosError carries config.data — the whole serialised message, base64
// attachments included — and config.headers holds the signature. Callers
// log the error object and winston serialises it, so propagating the raw
// error writes password-reset links and invoices into combined.log.
const axiosError = Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
config: {
data: JSON.stringify({ html: 'RESET-LINK-SECRET' }),
headers: { 'X-PicPeak-Signature': 'the-signature' },
},
});
axios.post.mockRejectedValue(axiosError);
const caught = await transport.send(MAIL).catch((e) => e);
expect(caught).toBeInstanceOf(Error);
expect(caught.config).toBeUndefined();
const serialised = JSON.stringify({ msg: caught.message, ...caught });
expect(serialised).not.toContain('RESET-LINK-SECRET');
expect(serialised).not.toContain('the-signature');
// The useful part still survives for diagnosis.
expect(caught.message).toContain('ECONNREFUSED');
});
it('gives up on a response stream that never closes, instead of hanging', async () => {
// axios' timeout covers the headers; with responseType 'stream' it has
// already resolved. Without a deadline this await hangs, the queue row
// stays pending, and the next pass sends the same email again.
const { PassThrough } = require('stream');
const neverEnds = new PassThrough(); // written to by nobody, never ended
axios.post.mockResolvedValue({ status: 200, data: neverEnds });
transport.__testing.setReadTimeout(25);
const result = await transport.send(MAIL);
expect(result.messageId).toEqual(expect.any(String));
// The stream is torn down rather than left dangling.
expect(neverEnds.destroyed).toBe(true);
});
it('prefers a messageId the receiver reports', async () => {
axios.post.mockResolvedValue({ status: 200, data: streamOf(JSON.stringify({ messageId: 'from-n8n-123' })) });
expect((await transport.send(MAIL)).messageId).toBe('from-n8n-123');