diff --git a/server/package-lock.json b/server/package-lock.json index 4cc75f2..56e5b5e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,7 +21,7 @@ "jimp": "^1.6.1", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", - "nodemailer": "^6.9.16", + "nodemailer": "^9.0.5", "otplib": "^12.0.1", "qrcode": "^1.5.4", "socket.io": "^4.7.2", @@ -1941,9 +1941,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3434,9 +3434,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -3544,9 +3544,9 @@ "license": "BSD-3-Clause" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3913,9 +3913,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -4931,9 +4931,9 @@ "license": "MIT" }, "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", diff --git a/server/package.json b/server/package.json index ce773d0..fa82e59 100644 --- a/server/package.json +++ b/server/package.json @@ -23,7 +23,7 @@ "jimp": "^1.6.1", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", - "nodemailer": "^6.9.16", + "nodemailer": "^9.0.5", "otplib": "^12.0.1", "qrcode": "^1.5.4", "socket.io": "^4.7.2", diff --git a/server/test/email-nodemailer-live.test.js b/server/test/email-nodemailer-live.test.js new file mode 100644 index 0000000..560c292 --- /dev/null +++ b/server/test/email-nodemailer-live.test.js @@ -0,0 +1,94 @@ +// The rest of the email tests mock nodemailer through require.cache, which proves our code +// calls sendMail correctly but says nothing about whether nodemailer still ACCEPTS what we +// hand it. That gap is exactly where a dependency bump breaks sending: the suite stays green +// while mail stops leaving the building. +// +// This drives the real library against a throwaway SMTP server on a loopback port, using the +// option and message shapes services/email.js actually builds. No network, no credentials. +const test = require('node:test'); +const assert = require('node:assert'); +const net = require('node:net'); +const nodemailer = require('nodemailer'); +const { buildSmtpMessage } = require('../services/email'); + +// A minimal SMTP server that answers enough of the protocol to accept one message and +// records the conversation, so assertions can be made about what went over the wire. +function startFakeSmtp() { + const seen = { commands: [], data: '' }; + return new Promise((resolve) => { + const srv = net.createServer((sock) => { + let inData = false; + sock.write('220 fake.local ESMTP\r\n'); + sock.on('data', (buf) => { + const chunk = buf.toString(); + if (inData) { + seen.data += chunk; + if (/\r\n\.\r\n/.test(seen.data)) { inData = false; sock.write('250 OK queued\r\n'); } + return; + } + for (const line of chunk.split('\r\n').filter(Boolean)) { + seen.commands.push(line); + const cmd = line.split(' ')[0].toUpperCase(); + if (cmd === 'EHLO' || cmd === 'HELO') sock.write('250-fake.local\r\n250 SIZE 10485760\r\n'); + else if (cmd === 'MAIL' || cmd === 'RCPT') sock.write('250 OK\r\n'); + else if (cmd === 'DATA') { inData = true; sock.write('354 send it\r\n'); } + else if (cmd === 'QUIT') { sock.write('221 bye\r\n'); sock.end(); } + else sock.write('250 OK\r\n'); + } + }); + sock.on('error', () => {}); // a client hanging up mid-conversation is not a test failure + }); + srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen })); + }); +} + +test('the installed nodemailer accepts the options and messages we build', async () => { + const { srv, port, seen } = await startFakeSmtp(); + try { + // The shape getSmtpTransporter() constructs. + const transporter = nodemailer.createTransport({ + host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false }, + }); + + // The object `from` form, used when a display name overrides the configured one. + await transporter.sendMail({ + from: { name: 'ScreenTinker', address: 'noreply@example.com' }, + to: 'user@x.com', + subject: '[ScreenTinker] Hello', + html: '
hi there
', + text: 'hi there', + }); + + assert.ok(seen.commands.some(c => /^EHLO/i.test(c)), 'greets the server'); + assert.ok(seen.commands.some(c => /^MAIL FROM:x
', + }); + transporter.close(); + } finally { + srv.close(); + } +}); + +test('buildSmtpMessage output is something the installed nodemailer can send', async () => { + const { srv, port, seen } = await startFakeSmtp(); + try { + const transporter = nodemailer.createTransport({ + host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false }, + }); + // Built by our own code rather than hand-written here, so the two cannot drift apart. + await transporter.sendMail(buildSmtpMessage('to@x.com', 'Subj', 'plain', 'rich
', 'Sender Name')); + assert.ok(seen.commands.some(c => /^RCPT TO: