From 42588195586d71f237d74dc43c9e62a282e20ee3 Mon Sep 17 00:00:00 2001 From: leone Date: Thu, 6 Aug 2026 15:23:11 +0800 Subject: [PATCH] fix(auth): prefer AUTH LOGIN over PLAIN when the server advertises both Servers that list PLAIN but reject it on use (observed with QQ Mail, which answers PLAIN with 502/535 yet completes LOGIN) made sign-in email delivery fail even with valid credentials. Try LOGIN first and fall back to PLAIN only when LOGIN is not advertised. The fake SMTP server in the tests now speaks the LOGIN challenge-response and a PLAIN-only case keeps the fallback covered. --- plugins/auth/src/smtp.ts | 14 ++++++------- plugins/auth/test/smtp.test.ts | 37 ++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/plugins/auth/src/smtp.ts b/plugins/auth/src/smtp.ts index 7ee81a21..f4a57a01 100644 --- a/plugins/auth/src/smtp.ts +++ b/plugins/auth/src/smtp.ts @@ -159,13 +159,13 @@ async function openSocket(options: SmtpOptions, timeoutMs: number): Promise { const transcript: string[] = []; const messages: string[] = []; @@ -19,6 +19,7 @@ async function fakeSmtp( let buffer = ""; let inData = false; let message = ""; + let authStep: "idle" | "user" | "pass" = "idle"; socket.setEncoding("utf8"); socket.write("220 fake.smtp.test ESMTP\r\n"); socket.on("data", (chunk: string) => { @@ -38,14 +39,27 @@ async function fakeSmtp( continue; } transcript.push(line); + if (authStep === "user") { + authStep = "pass"; + socket.write("334 UGFzc3dvcmQ6\r\n"); + continue; + } + if (authStep === "pass") { + authStep = "idle"; + socket.write(options.rejectAuth ? "535 5.7.8 bad credentials\r\n" : "235 2.7.0 Accepted\r\n"); + continue; + } const verb = line.split(" ")[0]!.toUpperCase(); if (verb === "EHLO") socket.write( - `250-fake.smtp.test\r\n${options.offerStartTls ? "250-STARTTLS\r\n" : ""}250 AUTH PLAIN LOGIN\r\n`, + `250-fake.smtp.test\r\n${options.offerStartTls ? "250-STARTTLS\r\n" : ""}250 AUTH ${options.mechanisms ?? "PLAIN LOGIN"}\r\n`, ); - else if (verb === "AUTH") - socket.write(options.rejectAuth ? "535 5.7.8 bad credentials\r\n" : "235 2.7.0 Accepted\r\n"); - else if (verb === "MAIL") socket.write("250 2.1.0 Ok\r\n"); + else if (verb === "AUTH") { + if (/^AUTH LOGIN$/i.test(line)) { + authStep = "user"; + socket.write("334 VXNlcm5hbWU6\r\n"); + } else socket.write(options.rejectAuth ? "535 5.7.8 bad credentials\r\n" : "235 2.7.0 Accepted\r\n"); + } else if (verb === "MAIL") socket.write("250 2.1.0 Ok\r\n"); else if (verb === "RCPT") socket.write(options.rejectRecipient ? "550 5.1.1 no such user\r\n" : "250 2.1.5 Ok\r\n"); else if (verb === "DATA") { @@ -103,14 +117,21 @@ test("a message is delivered over the full SMTP conversation", async (t) => { assert.match(receipt, /queued as FAKE1/); assert.deepEqual( server.transcript.map((line) => line.split(" ")[0]), - ["EHLO", "AUTH", "MAIL", "RCPT", "DATA", "QUIT"], + ["EHLO", "AUTH", Buffer.from("apikey").toString("base64"), Buffer.from("s3cret").toString("base64"), "MAIL", "RCPT", "DATA", "QUIT"], ); + assert.equal(server.transcript[1]!, "AUTH LOGIN"); + assert.match(server.messages[0]!, /^\.leading dot survives$/m); +}); + +test("AUTH PLAIN is used when the server does not advertise LOGIN", async (t) => { + const server = await fakeSmtp({ mechanisms: "PLAIN" }); + t.after(() => server.close()); + assert.equal(await smtpDeliver(options(server.port), null), "authenticated"); assert.match(server.transcript[1]!, /^AUTH PLAIN /); assert.equal( Buffer.from(server.transcript[1]!.slice("AUTH PLAIN ".length), "base64").toString("utf8"), "\0apikey\0s3cret", ); - assert.match(server.messages[0]!, /^\.leading dot survives$/m); }); test("a rejected recipient and rejected credentials both surface as errors", async (t) => { @@ -137,7 +158,7 @@ test("verification authenticates without sending a message", async (t) => { assert.equal(await smtpDeliver(options(server.port), null), "authenticated"); assert.deepEqual( server.transcript.map((line) => line.split(" ")[0]), - ["EHLO", "AUTH", "QUIT"], + ["EHLO", "AUTH", Buffer.from("apikey").toString("base64"), Buffer.from("s3cret").toString("base64"), "QUIT"], ); assert.equal(server.messages.length, 0); });