@upyo/smtp 0.5.0-dev.86 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/index.cjs +583 -51
- package/dist/index.d.cts +197 -8
- package/dist/index.d.ts +197 -8
- package/dist/index.js +583 -52
- package/package.json +3 -12
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createFailedReceipt } from "@upyo/core";
|
|
1
2
|
import { Socket } from "node:net";
|
|
2
3
|
import { TLSSocket, connect } from "node:tls";
|
|
3
4
|
import { Buffer } from "node:buffer";
|
|
@@ -30,15 +31,344 @@ function createSmtpConfig(config) {
|
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/oauth2.ts
|
|
36
|
+
/**
|
|
37
|
+
* An error thrown when SMTP authentication fails, including OAuth 2.0 token
|
|
38
|
+
* acquisition and SASL exchange failures.
|
|
39
|
+
*
|
|
40
|
+
* @since 0.5.0
|
|
41
|
+
*/
|
|
42
|
+
var SmtpAuthError = class extends Error {
|
|
43
|
+
/**
|
|
44
|
+
* Creates a new {@link SmtpAuthError}.
|
|
45
|
+
* @param message A human-readable description of the failure.
|
|
46
|
+
*/
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "SmtpAuthError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Encodes a string as Base64 in a cross-runtime, UTF-8-safe manner.
|
|
54
|
+
*
|
|
55
|
+
* Unlike calling `btoa()` directly, this first encodes the input as UTF-8 so
|
|
56
|
+
* that non-ASCII characters (e.g., in an internationalized address) do not
|
|
57
|
+
* throw.
|
|
58
|
+
*
|
|
59
|
+
* @param input The string to encode.
|
|
60
|
+
* @returns The Base64-encoded representation of the UTF-8 bytes of `input`.
|
|
61
|
+
*/
|
|
62
|
+
function toBase64(input) {
|
|
63
|
+
const bytes = new TextEncoder().encode(input);
|
|
64
|
+
let binary = "";
|
|
65
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
66
|
+
return btoa(binary);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Builds the SASL `XOAUTH2` initial client response for SMTP authentication.
|
|
70
|
+
*
|
|
71
|
+
* The unencoded payload follows Google's XOAUTH2 format
|
|
72
|
+
* (`user=<user>^Aauth=Bearer <token>^A^A`, where `^A` is the `0x01` control
|
|
73
|
+
* character) and is returned Base64-encoded, ready to be sent as the argument
|
|
74
|
+
* of `AUTH XOAUTH2`.
|
|
75
|
+
*
|
|
76
|
+
* @param user The user (email address) to authenticate as.
|
|
77
|
+
* @param accessToken The OAuth 2.0 access token.
|
|
78
|
+
* @returns The Base64-encoded XOAUTH2 initial client response.
|
|
79
|
+
* @since 0.5.0
|
|
80
|
+
*/
|
|
81
|
+
function formatXoauth2(user, accessToken) {
|
|
82
|
+
return toBase64(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Builds the SASL `OAUTHBEARER` initial client response for SMTP
|
|
86
|
+
* authentication, as defined by [RFC 7628].
|
|
87
|
+
*
|
|
88
|
+
* The unencoded payload is
|
|
89
|
+
* `n,a=<user>,^A[host=<host>^A][port=<port>^A]auth=Bearer <token>^A^A`
|
|
90
|
+
* (where `^A` is the `0x01` control character) and is returned Base64-encoded,
|
|
91
|
+
* ready to be sent as the argument of `AUTH OAUTHBEARER`.
|
|
92
|
+
*
|
|
93
|
+
* [RFC 7628]: https://www.rfc-editor.org/rfc/rfc7628
|
|
94
|
+
*
|
|
95
|
+
* @param user The user (email address) to authenticate as.
|
|
96
|
+
* @param accessToken The OAuth 2.0 access token.
|
|
97
|
+
* @param host The server host to include as the `host` key/value, if any.
|
|
98
|
+
* @param port The server port to include as the `port` key/value, if any.
|
|
99
|
+
* @returns The Base64-encoded OAUTHBEARER initial client response.
|
|
100
|
+
* @since 0.5.0
|
|
101
|
+
*/
|
|
102
|
+
function formatOauthbearer(user, accessToken, host, port) {
|
|
103
|
+
let response = `n,a=${escapeSaslName(user)},\x01`;
|
|
104
|
+
if (host != null) response += `host=${host}\x01`;
|
|
105
|
+
if (port != null) response += `port=${port}\x01`;
|
|
106
|
+
response += `auth=Bearer ${accessToken}\x01\x01`;
|
|
107
|
+
return toBase64(response);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Escapes a SASL name (GS2 `authzid`) per RFC 5801, encoding `,` as `=2C` and
|
|
111
|
+
* `=` as `=3D` so that identities containing those characters do not corrupt
|
|
112
|
+
* the GS2 header.
|
|
113
|
+
*
|
|
114
|
+
* @param name The SASL name to escape.
|
|
115
|
+
* @returns The escaped SASL name.
|
|
116
|
+
*/
|
|
117
|
+
function escapeSaslName(name) {
|
|
118
|
+
return name.replace(/=/g, "=3D").replace(/,/g, "=2C");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Chooses an OAuth 2.0 SASL mechanism based on the mechanisms advertised by the
|
|
122
|
+
* server in its `AUTH` capability line.
|
|
123
|
+
*
|
|
124
|
+
* `XOAUTH2` is preferred when available (it is the de-facto standard supported
|
|
125
|
+
* by Gmail and Outlook); otherwise `OAUTHBEARER` is used when advertised. When
|
|
126
|
+
* neither is advertised, `xoauth2` is returned as a best-effort default.
|
|
127
|
+
*
|
|
128
|
+
* @param capabilities The capability lines parsed from the server's EHLO
|
|
129
|
+
* response.
|
|
130
|
+
* @returns The selected mechanism, either `"xoauth2"` or `"oauthbearer"`.
|
|
131
|
+
* @since 0.5.0
|
|
132
|
+
*/
|
|
133
|
+
function selectOAuth2Mechanism(capabilities) {
|
|
134
|
+
const authLine = capabilities.find((cap) => cap.toUpperCase().startsWith("AUTH"));
|
|
135
|
+
const mechanisms = authLine == null ? [] : authLine.toUpperCase().replace(/=/g, " ").split(/\s+/).slice(1);
|
|
136
|
+
if (mechanisms.includes("XOAUTH2")) return "xoauth2";
|
|
137
|
+
if (mechanisms.includes("OAUTHBEARER")) return "oauthbearer";
|
|
138
|
+
return "xoauth2";
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Validates that an OAuth 2.0 token endpoint is safe to send client credentials
|
|
142
|
+
* to, requiring HTTPS except for loopback addresses (for local testing).
|
|
143
|
+
*
|
|
144
|
+
* @param endpoint The token endpoint URL to validate.
|
|
145
|
+
* @throws {TypeError} If the URL is malformed or uses an insecure scheme.
|
|
146
|
+
*/
|
|
147
|
+
function assertSecureTokenEndpoint(endpoint) {
|
|
148
|
+
let url;
|
|
149
|
+
try {
|
|
150
|
+
url = new URL(endpoint);
|
|
151
|
+
} catch {
|
|
152
|
+
throw new TypeError(`Invalid OAuth 2.0 token endpoint URL: ${endpoint}`);
|
|
153
|
+
}
|
|
154
|
+
if (url.protocol === "https:") return;
|
|
155
|
+
const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
156
|
+
if (url.protocol === "http:" && isLoopback) return;
|
|
157
|
+
throw new TypeError("OAuth 2.0 token endpoint must use HTTPS to protect client credentials: " + endpoint);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The number of milliseconds before a cached access token's expiry at which it
|
|
161
|
+
* is considered stale and eligible for refresh.
|
|
162
|
+
*/
|
|
163
|
+
const REFRESH_SAFETY_MARGIN_MS = 6e4;
|
|
164
|
+
/**
|
|
165
|
+
* The default access token lifetime (in seconds) assumed when the token
|
|
166
|
+
* endpoint omits `expires_in`.
|
|
167
|
+
*/
|
|
168
|
+
const DEFAULT_EXPIRES_IN = 3600;
|
|
169
|
+
/**
|
|
170
|
+
* How long to wait for the token endpoint before aborting the request, so a
|
|
171
|
+
* stalled endpoint cannot block authentication indefinitely.
|
|
172
|
+
*/
|
|
173
|
+
const TOKEN_REQUEST_TIMEOUT_MS = 6e4;
|
|
174
|
+
/** Maximum number of response-body characters to include in error messages. */
|
|
175
|
+
const MAX_ERROR_BODY_LENGTH = 500;
|
|
176
|
+
/**
|
|
177
|
+
* Truncates a token-endpoint response body so an oversized payload (e.g. a
|
|
178
|
+
* large proxy/CDN error page) does not bloat error messages and logs.
|
|
179
|
+
*
|
|
180
|
+
* @param text The response body text.
|
|
181
|
+
* @returns The text, truncated with an ellipsis if it exceeds the limit.
|
|
182
|
+
*/
|
|
183
|
+
function truncateErrorBody(text) {
|
|
184
|
+
return text.length > MAX_ERROR_BODY_LENGTH ? `${text.slice(0, MAX_ERROR_BODY_LENGTH)}…` : text;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Mirrors a promise but rejects early if the given abort signal fires, without
|
|
188
|
+
* cancelling the underlying promise. This lets a single waiter abort its own
|
|
189
|
+
* wait on a shared operation without affecting other waiters.
|
|
190
|
+
*
|
|
191
|
+
* @param promise The promise to await.
|
|
192
|
+
* @param signal An optional abort signal that rejects the returned promise.
|
|
193
|
+
* @returns A promise that settles with `promise`, or rejects when `signal`
|
|
194
|
+
* aborts.
|
|
195
|
+
*/
|
|
196
|
+
function abortable(promise, signal) {
|
|
197
|
+
if (signal == null) return promise;
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const abortReason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
200
|
+
const onAbort = () => reject(abortReason());
|
|
201
|
+
if (signal.aborted) reject(abortReason());
|
|
202
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
203
|
+
promise.then((value) => {
|
|
204
|
+
signal.removeEventListener("abort", onAbort);
|
|
205
|
+
resolve(value);
|
|
206
|
+
}, (error) => {
|
|
207
|
+
signal.removeEventListener("abort", onAbort);
|
|
208
|
+
reject(error);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Acquires and caches OAuth 2.0 access tokens for SMTP authentication.
|
|
214
|
+
*
|
|
215
|
+
* Depending on the {@link SmtpOAuth2Auth} configuration, the manager either
|
|
216
|
+
* returns a static token, invokes a provider callback on every request, or
|
|
217
|
+
* exchanges a refresh token for an access token at the configured token
|
|
218
|
+
* endpoint and caches it until shortly before it expires. A single manager is
|
|
219
|
+
* shared across all pooled connections of a transport so that the refresh-token
|
|
220
|
+
* exchange happens at most once per token lifetime.
|
|
221
|
+
*
|
|
222
|
+
* @since 0.5.0
|
|
223
|
+
*/
|
|
224
|
+
var OAuth2TokenManager = class {
|
|
225
|
+
auth;
|
|
226
|
+
fetchFn;
|
|
227
|
+
cached;
|
|
228
|
+
pending;
|
|
229
|
+
/**
|
|
230
|
+
* Creates a new {@link OAuth2TokenManager}.
|
|
231
|
+
*
|
|
232
|
+
* @param auth The OAuth 2.0 authentication configuration.
|
|
233
|
+
* @param fetchFn The `fetch` implementation to use for the refresh-token
|
|
234
|
+
* exchange. Defaults to the global `fetch`; primarily
|
|
235
|
+
* overridden in tests.
|
|
236
|
+
* @throws {TypeError} If a refresh-token configuration has an insecure
|
|
237
|
+
* (non-HTTPS) token endpoint.
|
|
238
|
+
*/
|
|
239
|
+
constructor(auth, fetchFn = fetch) {
|
|
240
|
+
if ("refreshToken" in auth) assertSecureTokenEndpoint(auth.tokenEndpoint);
|
|
241
|
+
this.auth = auth;
|
|
242
|
+
this.fetchFn = fetchFn;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Returns a valid OAuth 2.0 access token, acquiring or refreshing it as
|
|
246
|
+
* needed.
|
|
247
|
+
*
|
|
248
|
+
* @param signal An optional {@link AbortSignal} to cancel token acquisition.
|
|
249
|
+
* @returns The current access token.
|
|
250
|
+
* @throws {SmtpAuthError} If a refresh-token exchange fails or returns an
|
|
251
|
+
* unexpected response.
|
|
252
|
+
*/
|
|
253
|
+
async getAccessToken(signal) {
|
|
254
|
+
signal?.throwIfAborted();
|
|
255
|
+
const auth = this.auth;
|
|
256
|
+
if ("accessToken" in auth) return typeof auth.accessToken === "function" ? await auth.accessToken(signal) : auth.accessToken;
|
|
257
|
+
const cached = this.cached;
|
|
258
|
+
if (cached != null && cached.expiresAt > Date.now()) return cached.accessToken;
|
|
259
|
+
this.pending ??= this.refresh(auth).then((result) => {
|
|
260
|
+
const lifetimeMs = result.expiresIn * 1e3;
|
|
261
|
+
const safetyMargin = Math.min(REFRESH_SAFETY_MARGIN_MS, lifetimeMs / 2);
|
|
262
|
+
this.cached = {
|
|
263
|
+
accessToken: result.accessToken,
|
|
264
|
+
expiresAt: Date.now() + lifetimeMs - safetyMargin
|
|
265
|
+
};
|
|
266
|
+
return result;
|
|
267
|
+
}).finally(() => {
|
|
268
|
+
this.pending = void 0;
|
|
269
|
+
});
|
|
270
|
+
const { accessToken } = await abortable(this.pending, signal);
|
|
271
|
+
return accessToken;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Exchanges the configured refresh token for a fresh access token.
|
|
275
|
+
*
|
|
276
|
+
* The request deliberately runs without a caller-specific abort signal; it is
|
|
277
|
+
* shared across concurrent callers, each of which applies its own
|
|
278
|
+
* cancellation separately (see {@link getAccessToken}).
|
|
279
|
+
*
|
|
280
|
+
* @param auth The refresh-token authentication configuration.
|
|
281
|
+
* @returns The new access token and its lifetime in seconds.
|
|
282
|
+
* @throws {SmtpAuthError} If the request fails or the response is invalid.
|
|
283
|
+
*/
|
|
284
|
+
async refresh(auth) {
|
|
285
|
+
const body = new URLSearchParams({
|
|
286
|
+
grant_type: "refresh_token",
|
|
287
|
+
client_id: auth.clientId,
|
|
288
|
+
refresh_token: auth.refreshToken
|
|
289
|
+
});
|
|
290
|
+
if (auth.clientSecret != null) body.set("client_secret", auth.clientSecret);
|
|
291
|
+
if (auth.scope != null) body.set("scope", auth.scope);
|
|
292
|
+
const controller = new AbortController();
|
|
293
|
+
const timeout = setTimeout(() => {
|
|
294
|
+
controller.abort(/* @__PURE__ */ new Error("OAuth 2.0 token request timed out"));
|
|
295
|
+
}, TOKEN_REQUEST_TIMEOUT_MS);
|
|
296
|
+
let response;
|
|
297
|
+
let text;
|
|
298
|
+
const fetchFn = this.fetchFn;
|
|
299
|
+
try {
|
|
300
|
+
response = await fetchFn(auth.tokenEndpoint, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: {
|
|
303
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
304
|
+
"accept": "application/json"
|
|
305
|
+
},
|
|
306
|
+
body,
|
|
307
|
+
signal: controller.signal
|
|
308
|
+
});
|
|
309
|
+
text = await response.text();
|
|
310
|
+
} catch (cause) {
|
|
311
|
+
throw new SmtpAuthError(`Failed to request an OAuth 2.0 access token from ${auth.tokenEndpoint}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
312
|
+
} finally {
|
|
313
|
+
clearTimeout(timeout);
|
|
314
|
+
}
|
|
315
|
+
const safeText = truncateErrorBody(text);
|
|
316
|
+
if (!response.ok) throw new SmtpAuthError(`OAuth 2.0 token endpoint responded with HTTP ${response.status}: ${safeText}`);
|
|
317
|
+
let json;
|
|
318
|
+
try {
|
|
319
|
+
json = JSON.parse(text);
|
|
320
|
+
} catch {
|
|
321
|
+
throw new SmtpAuthError(`OAuth 2.0 token endpoint returned a non-JSON response: ${safeText}`);
|
|
322
|
+
}
|
|
323
|
+
if (typeof json !== "object" || json == null || typeof json.access_token !== "string") throw new SmtpAuthError(`OAuth 2.0 token endpoint response did not include an access_token: ${safeText}`);
|
|
324
|
+
const record = json;
|
|
325
|
+
const rawExpiresIn = record.expires_in;
|
|
326
|
+
let parsedExpiresIn;
|
|
327
|
+
if (typeof rawExpiresIn === "number") parsedExpiresIn = rawExpiresIn;
|
|
328
|
+
else if (typeof rawExpiresIn === "string" && rawExpiresIn.trim() !== "") parsedExpiresIn = Number(rawExpiresIn);
|
|
329
|
+
else parsedExpiresIn = NaN;
|
|
330
|
+
const expiresIn = Number.isFinite(parsedExpiresIn) && parsedExpiresIn >= 0 ? parsedExpiresIn : DEFAULT_EXPIRES_IN;
|
|
331
|
+
return {
|
|
332
|
+
accessToken: record.access_token,
|
|
333
|
+
expiresIn
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
33
338
|
//#endregion
|
|
34
339
|
//#region src/smtp-connection.ts
|
|
340
|
+
/**
|
|
341
|
+
* The maximum length of an SMTP command line, including the terminating CRLF,
|
|
342
|
+
* as specified by RFC 5321 §4.5.3.1.4.
|
|
343
|
+
*/
|
|
344
|
+
const MAX_COMMAND_LINE_LENGTH = 512;
|
|
345
|
+
/** The length of the CRLF terminator appended to every command. */
|
|
346
|
+
const CRLF_LENGTH = 2;
|
|
347
|
+
/**
|
|
348
|
+
* How long, in milliseconds, to wait for the graceful `QUIT` to flush during
|
|
349
|
+
* teardown before giving up, so an unresponsive server cannot block shutdown
|
|
350
|
+
* for the full socket timeout.
|
|
351
|
+
*/
|
|
352
|
+
const QUIT_TIMEOUT_MS = 5e3;
|
|
353
|
+
/**
|
|
354
|
+
* Whether a host refers to the local loopback interface, for which cleartext
|
|
355
|
+
* OAuth 2.0 authentication is permitted (e.g. local testing and development).
|
|
356
|
+
*
|
|
357
|
+
* @param host The host to check.
|
|
358
|
+
* @returns `true` if the host is a loopback address.
|
|
359
|
+
*/
|
|
360
|
+
function isLoopbackHost(host) {
|
|
361
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
362
|
+
}
|
|
35
363
|
var SmtpConnection = class {
|
|
36
364
|
socket = null;
|
|
37
365
|
config;
|
|
38
366
|
authenticated = false;
|
|
39
367
|
capabilities = [];
|
|
40
|
-
|
|
368
|
+
tokenManager;
|
|
369
|
+
constructor(config, tokenManager) {
|
|
41
370
|
this.config = createSmtpConfig(config);
|
|
371
|
+
this.tokenManager = tokenManager ?? null;
|
|
42
372
|
}
|
|
43
373
|
connect(signal) {
|
|
44
374
|
if (this.socket) throw new Error("Connection already established");
|
|
@@ -208,29 +538,44 @@ var SmtpConnection = class {
|
|
|
208
538
|
});
|
|
209
539
|
}
|
|
210
540
|
async authenticate(signal) {
|
|
211
|
-
|
|
541
|
+
const auth = this.config.auth;
|
|
542
|
+
if (!auth) return;
|
|
212
543
|
if (this.authenticated) return;
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
544
|
+
if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
|
|
545
|
+
if ("accessToken" in auth || "refreshToken" in auth) {
|
|
546
|
+
if (!(this.socket instanceof TLSSocket) && !isLoopbackHost(this.config.host)) throw new SmtpAuthError("OAuth 2.0 authentication requires a TLS-secured connection to protect the access token; use `secure: true` or STARTTLS.");
|
|
547
|
+
const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
|
|
548
|
+
switch (mechanism) {
|
|
549
|
+
case "xoauth2":
|
|
550
|
+
await this.authXoauth2(auth, signal);
|
|
551
|
+
break;
|
|
552
|
+
case "oauthbearer":
|
|
553
|
+
await this.authOauthbearer(auth, signal);
|
|
554
|
+
break;
|
|
555
|
+
default: throw new SmtpAuthError(`Unsupported authentication method: ${mechanism}`);
|
|
556
|
+
}
|
|
557
|
+
} else {
|
|
558
|
+
const method = auth.method ?? "plain";
|
|
559
|
+
switch (method) {
|
|
560
|
+
case "plain":
|
|
561
|
+
await this.authPlain(auth, signal);
|
|
562
|
+
break;
|
|
563
|
+
case "login":
|
|
564
|
+
await this.authLogin(auth, signal);
|
|
565
|
+
break;
|
|
566
|
+
default: throw new Error(`Unsupported authentication method: ${method}`);
|
|
567
|
+
}
|
|
223
568
|
}
|
|
224
569
|
this.authenticated = true;
|
|
225
570
|
}
|
|
226
|
-
async authPlain(signal) {
|
|
227
|
-
const { user, pass } =
|
|
571
|
+
async authPlain(auth, signal) {
|
|
572
|
+
const { user, pass } = auth;
|
|
228
573
|
const credentials = btoa(`\0${user}\0${pass}`);
|
|
229
574
|
const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal);
|
|
230
575
|
if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`);
|
|
231
576
|
}
|
|
232
|
-
async authLogin(signal) {
|
|
233
|
-
const { user, pass } =
|
|
577
|
+
async authLogin(auth, signal) {
|
|
578
|
+
const { user, pass } = auth;
|
|
234
579
|
let response = await this.sendCommand("AUTH LOGIN", signal);
|
|
235
580
|
if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`);
|
|
236
581
|
response = await this.sendCommand(btoa(user), signal);
|
|
@@ -238,19 +583,80 @@ var SmtpConnection = class {
|
|
|
238
583
|
response = await this.sendCommand(btoa(pass), signal);
|
|
239
584
|
if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`);
|
|
240
585
|
}
|
|
586
|
+
/**
|
|
587
|
+
* Resolves an OAuth 2.0 access token via the connection's token manager,
|
|
588
|
+
* creating a standalone manager from the auth config if none was injected.
|
|
589
|
+
*/
|
|
590
|
+
async getOAuth2Token(auth, signal) {
|
|
591
|
+
this.tokenManager ??= new OAuth2TokenManager(auth);
|
|
592
|
+
return await this.tokenManager.getAccessToken(signal);
|
|
593
|
+
}
|
|
594
|
+
async authXoauth2(auth, signal) {
|
|
595
|
+
const token = await this.getOAuth2Token(auth, signal);
|
|
596
|
+
const initialResponse = formatXoauth2(auth.user, token);
|
|
597
|
+
const response = await this.sendSaslAuth("XOAUTH2", initialResponse, signal);
|
|
598
|
+
await this.finishOAuth2(response, "XOAUTH2", "", signal);
|
|
599
|
+
}
|
|
600
|
+
async authOauthbearer(auth, signal) {
|
|
601
|
+
const token = await this.getOAuth2Token(auth, signal);
|
|
602
|
+
const initialResponse = formatOauthbearer(auth.user, token, this.config.host, this.config.port);
|
|
603
|
+
const response = await this.sendSaslAuth("OAUTHBEARER", initialResponse, signal);
|
|
604
|
+
await this.finishOAuth2(response, "OAUTHBEARER", "AQ==", signal);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Sends a SASL `AUTH` command with its Base64 initial response.
|
|
608
|
+
*
|
|
609
|
+
* When the resulting command line would exceed the SMTP command-line length
|
|
610
|
+
* limit (e.g. a long Outlook JWT access token), RFC 4954 requires the client
|
|
611
|
+
* to omit the initial response and send it on its own line after the server's
|
|
612
|
+
* `334` challenge. This method transparently falls back to that two-step
|
|
613
|
+
* form, since some servers reject the over-long single-line command outright.
|
|
614
|
+
*
|
|
615
|
+
* @param mechanism The SASL mechanism name (e.g. `XOAUTH2`).
|
|
616
|
+
* @param initialResponse The Base64-encoded initial client response.
|
|
617
|
+
* @param signal An optional {@link AbortSignal}.
|
|
618
|
+
* @returns The server's response to the initial response.
|
|
619
|
+
*/
|
|
620
|
+
async sendSaslAuth(mechanism, initialResponse, signal) {
|
|
621
|
+
const inlineCommand = `AUTH ${mechanism} ${initialResponse}`;
|
|
622
|
+
if (inlineCommand.length + CRLF_LENGTH <= MAX_COMMAND_LINE_LENGTH) return await this.sendCommand(inlineCommand, signal);
|
|
623
|
+
const challenge = await this.sendCommand(`AUTH ${mechanism}`, signal);
|
|
624
|
+
if (challenge.code !== 334) return challenge;
|
|
625
|
+
return await this.sendCommand(initialResponse, signal);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Interprets the server's reply to an OAuth SASL initial response, draining
|
|
629
|
+
* the failure challenge continuation when authentication is rejected.
|
|
630
|
+
*
|
|
631
|
+
* @throws {SmtpAuthError} If authentication did not succeed.
|
|
632
|
+
*/
|
|
633
|
+
async finishOAuth2(response, mechanism, continuation, signal) {
|
|
634
|
+
if (response.code === 235) return;
|
|
635
|
+
if (response.code === 334) {
|
|
636
|
+
let finalMessage = "";
|
|
637
|
+
try {
|
|
638
|
+
const final = await this.sendCommand(continuation, signal);
|
|
639
|
+
finalMessage = ` (${final.message})`;
|
|
640
|
+
} catch {
|
|
641
|
+
signal?.throwIfAborted();
|
|
642
|
+
}
|
|
643
|
+
throw new SmtpAuthError(`${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`);
|
|
644
|
+
}
|
|
645
|
+
throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
|
|
646
|
+
}
|
|
241
647
|
async sendMessage(message, signal) {
|
|
242
648
|
const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
|
|
243
|
-
if (mailResponse.code !== 250) throw new
|
|
649
|
+
if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
|
|
244
650
|
for (const recipient of message.envelope.to) {
|
|
245
651
|
signal?.throwIfAborted();
|
|
246
652
|
const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
|
|
247
|
-
if (rcptResponse.code !== 250) throw new
|
|
653
|
+
if (rcptResponse.code !== 250) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
|
|
248
654
|
}
|
|
249
655
|
const dataResponse = await this.sendCommand("DATA", signal);
|
|
250
|
-
if (dataResponse.code !== 354) throw new
|
|
656
|
+
if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
|
|
251
657
|
const content = message.raw.replace(/\n\./g, "\n..");
|
|
252
658
|
const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
|
|
253
|
-
if (finalResponse.code !== 250) throw new
|
|
659
|
+
if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
|
|
254
660
|
const messageId = this.extractMessageId(finalResponse.message);
|
|
255
661
|
return messageId;
|
|
256
662
|
}
|
|
@@ -259,11 +665,27 @@ var SmtpConnection = class {
|
|
|
259
665
|
return match ? match[1] : `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
260
666
|
}
|
|
261
667
|
async quit() {
|
|
262
|
-
|
|
668
|
+
const socket = this.socket;
|
|
669
|
+
if (!socket) return;
|
|
670
|
+
if (socket.writable) await new Promise((resolve) => {
|
|
671
|
+
const done = () => {
|
|
672
|
+
clearTimeout(timer);
|
|
673
|
+
socket.off("error", done);
|
|
674
|
+
socket.off("close", done);
|
|
675
|
+
resolve();
|
|
676
|
+
};
|
|
677
|
+
const timer = setTimeout(done, QUIT_TIMEOUT_MS);
|
|
678
|
+
socket.once("error", done);
|
|
679
|
+
socket.once("close", done);
|
|
680
|
+
try {
|
|
681
|
+
socket.write("QUIT\r\n", done);
|
|
682
|
+
} catch {
|
|
683
|
+
done();
|
|
684
|
+
}
|
|
685
|
+
});
|
|
263
686
|
try {
|
|
264
|
-
|
|
687
|
+
socket.destroy();
|
|
265
688
|
} catch {}
|
|
266
|
-
this.socket.destroy();
|
|
267
689
|
this.socket = null;
|
|
268
690
|
this.authenticated = false;
|
|
269
691
|
this.capabilities = [];
|
|
@@ -274,6 +696,59 @@ var SmtpConnection = class {
|
|
|
274
696
|
if (response.code !== 250) throw new Error(`RESET failed: ${response.message}`);
|
|
275
697
|
}
|
|
276
698
|
};
|
|
699
|
+
/**
|
|
700
|
+
* Error thrown when an SMTP command receives an unsuccessful server reply.
|
|
701
|
+
*
|
|
702
|
+
* @since 0.5.0
|
|
703
|
+
*/
|
|
704
|
+
var SmtpResponseError = class extends Error {
|
|
705
|
+
/**
|
|
706
|
+
* The numeric SMTP reply code returned by the server.
|
|
707
|
+
*/
|
|
708
|
+
code;
|
|
709
|
+
/**
|
|
710
|
+
* The SMTP command that produced the reply.
|
|
711
|
+
*/
|
|
712
|
+
command;
|
|
713
|
+
/**
|
|
714
|
+
* The textual SMTP reply returned by the server.
|
|
715
|
+
*/
|
|
716
|
+
response;
|
|
717
|
+
/**
|
|
718
|
+
* Creates an SMTP response error.
|
|
719
|
+
*
|
|
720
|
+
* @param message Human-readable error message.
|
|
721
|
+
* @param code The numeric SMTP reply code.
|
|
722
|
+
* @param command The SMTP command that produced the reply.
|
|
723
|
+
* @param response The textual SMTP reply returned by the server.
|
|
724
|
+
*/
|
|
725
|
+
constructor(message, code, command, response) {
|
|
726
|
+
super(message);
|
|
727
|
+
this.name = "SmtpResponseError";
|
|
728
|
+
this.code = code;
|
|
729
|
+
this.command = command;
|
|
730
|
+
this.response = response;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
/**
|
|
734
|
+
* Decodes the Base64 JSON error challenge a server sends after a failed OAuth
|
|
735
|
+
* SASL exchange, falling back to the raw message when it is not valid Base64.
|
|
736
|
+
*
|
|
737
|
+
* The bytes are decoded as UTF-8 (via `TextDecoder`) so non-ASCII challenge
|
|
738
|
+
* messages are not corrupted, unlike decoding `atob`'s Latin-1 output directly.
|
|
739
|
+
*
|
|
740
|
+
* @param message The challenge text from the server's 334 response.
|
|
741
|
+
* @returns A human-readable description of the failure.
|
|
742
|
+
*/
|
|
743
|
+
function decodeOAuth2Challenge(message) {
|
|
744
|
+
try {
|
|
745
|
+
const binary = atob(message.trim());
|
|
746
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
747
|
+
return new TextDecoder().decode(bytes);
|
|
748
|
+
} catch {
|
|
749
|
+
return message;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
277
752
|
|
|
278
753
|
//#endregion
|
|
279
754
|
//#region src/dkim/canonicalize.ts
|
|
@@ -761,6 +1236,7 @@ function encodeBase64(data) {
|
|
|
761
1236
|
* ```
|
|
762
1237
|
*/
|
|
763
1238
|
var SmtpTransport = class {
|
|
1239
|
+
id = "smtp";
|
|
764
1240
|
/**
|
|
765
1241
|
* The SMTP configuration used by this transport.
|
|
766
1242
|
*/
|
|
@@ -771,6 +1247,13 @@ var SmtpTransport = class {
|
|
|
771
1247
|
poolSize;
|
|
772
1248
|
connectionPool = [];
|
|
773
1249
|
/**
|
|
1250
|
+
* A token manager shared across all pooled connections, present only when the
|
|
1251
|
+
* configured authentication uses OAuth 2.0. Sharing it ensures the
|
|
1252
|
+
* refresh-token exchange happens at most once per token lifetime instead of
|
|
1253
|
+
* once per connection.
|
|
1254
|
+
*/
|
|
1255
|
+
tokenManager;
|
|
1256
|
+
/**
|
|
774
1257
|
* Creates a new SMTP transport instance.
|
|
775
1258
|
*
|
|
776
1259
|
* @param config SMTP configuration including server details, authentication,
|
|
@@ -779,6 +1262,8 @@ var SmtpTransport = class {
|
|
|
779
1262
|
constructor(config) {
|
|
780
1263
|
this.config = config;
|
|
781
1264
|
this.poolSize = config.poolSize ?? 5;
|
|
1265
|
+
const auth = config.auth;
|
|
1266
|
+
this.tokenManager = auth != null && ("accessToken" in auth || "refreshToken" in auth) ? new OAuth2TokenManager(auth) : void 0;
|
|
782
1267
|
}
|
|
783
1268
|
/**
|
|
784
1269
|
* Sends a single email message via SMTP.
|
|
@@ -805,11 +1290,14 @@ var SmtpTransport = class {
|
|
|
805
1290
|
* cancellation.
|
|
806
1291
|
* @returns A promise that resolves to a receipt indicating success or
|
|
807
1292
|
* failure.
|
|
1293
|
+
* @throws {DOMException} If the operation is aborted through
|
|
1294
|
+
* `options.signal`.
|
|
808
1295
|
*/
|
|
809
1296
|
async send(message, options) {
|
|
810
1297
|
options?.signal?.throwIfAborted();
|
|
811
|
-
|
|
1298
|
+
let connection;
|
|
812
1299
|
try {
|
|
1300
|
+
connection = await this.getConnection(options?.signal);
|
|
813
1301
|
options?.signal?.throwIfAborted();
|
|
814
1302
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
815
1303
|
options?.signal?.throwIfAborted();
|
|
@@ -817,14 +1305,13 @@ var SmtpTransport = class {
|
|
|
817
1305
|
await this.returnConnection(connection);
|
|
818
1306
|
return {
|
|
819
1307
|
successful: true,
|
|
820
|
-
messageId
|
|
1308
|
+
messageId,
|
|
1309
|
+
provider: "smtp"
|
|
821
1310
|
};
|
|
822
1311
|
} catch (error) {
|
|
823
|
-
await this.discardConnection(connection);
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
errorMessages: [error instanceof Error ? error.message : String(error)]
|
|
827
|
-
};
|
|
1312
|
+
if (connection != null) await this.discardConnection(connection);
|
|
1313
|
+
options?.signal?.throwIfAborted();
|
|
1314
|
+
return createSmtpFailure(error instanceof Error ? error.message : String(error), error);
|
|
828
1315
|
}
|
|
829
1316
|
}
|
|
830
1317
|
/**
|
|
@@ -854,20 +1341,30 @@ var SmtpTransport = class {
|
|
|
854
1341
|
* @param options Optional transport options including `AbortSignal` for
|
|
855
1342
|
* cancellation.
|
|
856
1343
|
* @returns An async iterable of receipts, one for each message.
|
|
1344
|
+
* @throws {DOMException} If the operation is aborted through
|
|
1345
|
+
* `options.signal`.
|
|
857
1346
|
*/
|
|
858
1347
|
async *sendMany(messages, options) {
|
|
859
1348
|
options?.signal?.throwIfAborted();
|
|
860
|
-
|
|
1349
|
+
let connection;
|
|
1350
|
+
try {
|
|
1351
|
+
connection = await this.getConnection(options?.signal);
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
options?.signal?.throwIfAborted();
|
|
1354
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1355
|
+
for await (const _ of messages) {
|
|
1356
|
+
options?.signal?.throwIfAborted();
|
|
1357
|
+
yield createSmtpFailure(errorMessage, error);
|
|
1358
|
+
}
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
861
1361
|
let connectionValid = true;
|
|
862
1362
|
try {
|
|
863
1363
|
const isAsyncIterable = Symbol.asyncIterator in messages;
|
|
864
1364
|
if (isAsyncIterable) for await (const message of messages) {
|
|
865
1365
|
options?.signal?.throwIfAborted();
|
|
866
1366
|
if (!connectionValid) {
|
|
867
|
-
yield
|
|
868
|
-
successful: false,
|
|
869
|
-
errorMessages: ["Connection is no longer valid"]
|
|
870
|
-
};
|
|
1367
|
+
yield createSmtpFailure("Connection is no longer valid");
|
|
871
1368
|
continue;
|
|
872
1369
|
}
|
|
873
1370
|
try {
|
|
@@ -876,23 +1373,19 @@ var SmtpTransport = class {
|
|
|
876
1373
|
const messageId = await connection.sendMessage(smtpMessage, options?.signal);
|
|
877
1374
|
yield {
|
|
878
1375
|
successful: true,
|
|
879
|
-
messageId
|
|
1376
|
+
messageId,
|
|
1377
|
+
provider: "smtp"
|
|
880
1378
|
};
|
|
881
1379
|
} catch (error) {
|
|
1380
|
+
options?.signal?.throwIfAborted();
|
|
882
1381
|
connectionValid = false;
|
|
883
|
-
yield
|
|
884
|
-
successful: false,
|
|
885
|
-
errorMessages: [error instanceof Error ? error.message : String(error)]
|
|
886
|
-
};
|
|
1382
|
+
yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
|
|
887
1383
|
}
|
|
888
1384
|
}
|
|
889
1385
|
else for (const message of messages) {
|
|
890
1386
|
options?.signal?.throwIfAborted();
|
|
891
1387
|
if (!connectionValid) {
|
|
892
|
-
yield
|
|
893
|
-
successful: false,
|
|
894
|
-
errorMessages: ["Connection is no longer valid"]
|
|
895
|
-
};
|
|
1388
|
+
yield createSmtpFailure("Connection is no longer valid");
|
|
896
1389
|
continue;
|
|
897
1390
|
}
|
|
898
1391
|
try {
|
|
@@ -901,14 +1394,13 @@ var SmtpTransport = class {
|
|
|
901
1394
|
const messageId = await connection.sendMessage(smtpMessage, options?.signal);
|
|
902
1395
|
yield {
|
|
903
1396
|
successful: true,
|
|
904
|
-
messageId
|
|
1397
|
+
messageId,
|
|
1398
|
+
provider: "smtp"
|
|
905
1399
|
};
|
|
906
1400
|
} catch (error) {
|
|
1401
|
+
options?.signal?.throwIfAborted();
|
|
907
1402
|
connectionValid = false;
|
|
908
|
-
yield
|
|
909
|
-
successful: false,
|
|
910
|
-
errorMessages: [error instanceof Error ? error.message : String(error)]
|
|
911
|
-
};
|
|
1403
|
+
yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
|
|
912
1404
|
}
|
|
913
1405
|
}
|
|
914
1406
|
if (connectionValid) await this.returnConnection(connection);
|
|
@@ -921,8 +1413,13 @@ var SmtpTransport = class {
|
|
|
921
1413
|
async getConnection(signal) {
|
|
922
1414
|
signal?.throwIfAborted();
|
|
923
1415
|
if (this.connectionPool.length > 0) return this.connectionPool.pop();
|
|
924
|
-
const connection = new SmtpConnection(this.config);
|
|
925
|
-
|
|
1416
|
+
const connection = new SmtpConnection(this.config, this.tokenManager);
|
|
1417
|
+
try {
|
|
1418
|
+
await this.connectAndSetup(connection, signal);
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
await this.discardConnection(connection);
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
926
1423
|
return connection;
|
|
927
1424
|
}
|
|
928
1425
|
async connectAndSetup(connection, signal) {
|
|
@@ -996,6 +1493,40 @@ var SmtpTransport = class {
|
|
|
996
1493
|
await this.closeAllConnections();
|
|
997
1494
|
}
|
|
998
1495
|
};
|
|
1496
|
+
function createSmtpFailure(message, error) {
|
|
1497
|
+
if (error instanceof SmtpResponseError) {
|
|
1498
|
+
const classification = classifySmtpReply(error.code);
|
|
1499
|
+
return createFailedReceipt(message, {
|
|
1500
|
+
provider: "smtp",
|
|
1501
|
+
code: `smtp.${error.code}`,
|
|
1502
|
+
category: classification.category,
|
|
1503
|
+
retryable: classification.retryable,
|
|
1504
|
+
attempts: 1,
|
|
1505
|
+
providerDetails: {
|
|
1506
|
+
command: error.command,
|
|
1507
|
+
response: error.response
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
return createFailedReceipt(message, {
|
|
1512
|
+
provider: "smtp",
|
|
1513
|
+
attempts: 1
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
function classifySmtpReply(code) {
|
|
1517
|
+
if (code >= 400 && code < 500) return {
|
|
1518
|
+
category: "service-unavailable",
|
|
1519
|
+
retryable: true
|
|
1520
|
+
};
|
|
1521
|
+
if (code >= 500 && code < 600) return {
|
|
1522
|
+
category: "rejected",
|
|
1523
|
+
retryable: false
|
|
1524
|
+
};
|
|
1525
|
+
return {
|
|
1526
|
+
category: "unknown",
|
|
1527
|
+
retryable: false
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
999
1530
|
|
|
1000
1531
|
//#endregion
|
|
1001
|
-
export { SmtpTransport };
|
|
1532
|
+
export { SmtpAuthError, SmtpTransport };
|