@blamejs/core 0.9.46 → 0.9.49

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.
@@ -0,0 +1,986 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.server.submission
4
+ * @nav Mail
5
+ * @title Mail Submission Server
6
+ * @order 542
7
+ *
8
+ * @intro
9
+ * Outbound SMTP submission listener per RFC 6409 (port 587) and
10
+ * RFC 8314 implicit-TLS submissions (port 465). Where the MX
11
+ * listener (`b.mail.server.mx`) accepts inbound mail from the
12
+ * internet to local mailboxes, the submission listener accepts
13
+ * outbound mail from authenticated MUAs / app-side mail-senders
14
+ * and routes it to upstream MXs via `b.mail.send`.
15
+ *
16
+ * Differences from the MX listener:
17
+ *
18
+ * - **AUTH required** — operator-supplied authenticator validates
19
+ * SASL credentials (PLAIN / LOGIN / SCRAM-SHA-256 / EXTERNAL /
20
+ * XOAUTH2). MAIL FROM is refused until AUTH succeeds.
21
+ *
22
+ * - **Identity binding** — under strict profile, `MAIL FROM:<x@y>`
23
+ * MUST match the authenticated actor's mailbox set; refused with
24
+ * 553 5.7.1 Sender address rejected. Permissive logs the
25
+ * mismatch but allows.
26
+ *
27
+ * - **TLS required for AUTH** (RFC 4954 §4) — pre-STARTTLS AUTH
28
+ * refused with 538 5.7.11 Encryption required for AUTH
29
+ * mechanism. Permissive profile allows plaintext AUTH for
30
+ * legacy operator-acknowledged downgrade.
31
+ *
32
+ * - **Implicit-TLS mode** — `implicitTls: true` wraps every
33
+ * connection in TLS from the SYN (port 465 per RFC 8314); no
34
+ * STARTTLS advertised because the connection is already secure.
35
+ *
36
+ * - **Outbound routing** — successful DATA hands off to the
37
+ * operator-supplied `agent.handoff({ ... })` for relay through
38
+ * `b.mail.send` to upstream MXs. The listener doesn't perform
39
+ * MX lookup or outbound delivery itself.
40
+ *
41
+ * ## Wire-protocol defenses (inherited from MX listener pattern)
42
+ *
43
+ * - SMTP smuggling (CVE-2023-51764 / -51765 / -51766 / 2024-32178 /
44
+ * RFC 5321 §2.3.8): every wire line through
45
+ * `b.guardSmtpCommand.validate`; DATA-body terminator scan
46
+ * through `b.safeSmtp.findDotTerminator` (strict-CRLF);
47
+ * smuggling shape detected via
48
+ * `b.guardSmtpCommand.detectBodySmuggling`.
49
+ *
50
+ * - STARTTLS-injection (CVE-2021-38371 Exim, CVE-2021-33515
51
+ * Dovecot): command buffer cleared at upgrade time.
52
+ *
53
+ * - Resource exhaustion: per-command line cap (1 KiB), DATA body
54
+ * cap (50 MiB per RFC 5321 §4.5.3.1.7), per-message recipient
55
+ * cap (100 per RFC 5321 §4.5.3.1.8), idle timeout (5 minutes
56
+ * per RFC 5321 §4.5.3.2.7).
57
+ *
58
+ * ## SMTP AUTH (RFC 4954)
59
+ *
60
+ * - Mechanisms negotiated per RFC 4422 (SASL) — the operator
61
+ * opts the list `auth.mechanisms` into the EHLO advertisement.
62
+ * - Initial-response variant `AUTH MECH <base64>` (RFC 4954 §4)
63
+ * supported.
64
+ * - Failed AUTH emits `mail.server.submission.auth_failed` with
65
+ * mechanism + reason; operator's rate-limit wired via
66
+ * `auth.rateLimit` (composes `b.middleware.rateLimit`) trips
67
+ * 421 4.7.0 Too many failed AUTH after the operator-configured
68
+ * budget.
69
+ *
70
+ * ## Audit lifecycle (in addition to the MX listener's)
71
+ *
72
+ * - `mail.server.submission.auth_attempt` — mechanism, actor-hash, remote
73
+ * - `mail.server.submission.auth_success` — mechanism, tenantId, scopes
74
+ * - `mail.server.submission.auth_failed` — mechanism, reason
75
+ * - `mail.server.submission.identity_mismatch` — auth identity vs MAIL FROM
76
+ * - `mail.server.submission.outbound_routed` — delivery agent ack
77
+ *
78
+ * ## What v1 does NOT ship
79
+ *
80
+ * - **DKIM signing pre-relay** — operator wires `b.mail.dkim.sign`
81
+ * in their outbound agent.
82
+ * - **CHUNKING (BDAT) extension** — RFC 3030 BDAT not yet
83
+ * supported on submission; clients use DATA instead.
84
+ * - **Per-actor outbound quota** — operator implements via
85
+ * `b.dailyByteQuota` against the authenticated actor.
86
+ *
87
+ * ## Composition contract
88
+ *
89
+ * Every gate is a primitive that already exists. Submission listener
90
+ * composes `b.guardSmtpCommand` (wire-protocol gate + smuggling
91
+ * defense), `b.safeSmtp` (wire-protocol parser), the operator's
92
+ * authenticator (SASL verify), `b.mail.send` (outbound MX routing),
93
+ * and the framework's TLS posture via `b.network.tls.context`.
94
+ *
95
+ * @card
96
+ * Outbound SMTP submission listener (RFC 6409 / RFC 8314). AUTH-
97
+ * required before MAIL FROM; identity-binding under strict profile;
98
+ * TLS-required-for-AUTH (RFC 4954 §4); implicit-TLS mode for
99
+ * port 465. Composes b.guardSmtpCommand + b.safeSmtp + operator
100
+ * SASL authenticator + b.mail.send for outbound routing.
101
+ */
102
+
103
+ var net = require("node:net");
104
+ var nodeTls = require("node:tls");
105
+ var lazyRequire = require("./lazy-require");
106
+ var C = require("./constants");
107
+ var bCrypto = require("./crypto");
108
+ var numericBounds = require("./numeric-bounds");
109
+ var safeAsync = require("./safe-async");
110
+ var safeBuffer = require("./safe-buffer");
111
+ var safeSmtp = require("./safe-smtp");
112
+ var validateOpts = require("./validate-opts");
113
+ var guardSmtpCommand = require("./guard-smtp-command");
114
+ var guardDomain = require("./guard-domain");
115
+ var mailServerRateLimit = require("./mail-server-rate-limit");
116
+ var { defineClass } = require("./framework-error");
117
+
118
+ var audit = lazyRequire(function () { return require("./audit"); });
119
+
120
+ var MailServerSubmissionError = defineClass("MailServerSubmissionError", { alwaysPermanent: true });
121
+
122
+ var DEFAULT_MAX_LINE_BYTES = C.BYTES.kib(1);
123
+ var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(50);
124
+ var DEFAULT_MAX_RCPTS_PER_MESSAGE = 100; // allow:raw-byte-literal — RFC 5321 §4.5.3.1.8 recipient cap
125
+ var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(5);
126
+ var DEFAULT_GREETING = "blamejs Submission";
127
+ var DEFAULT_AUTH_MECHANISMS = Object.freeze(["PLAIN", "LOGIN"]);
128
+
129
+ var REPLY_220_READY = "220";
130
+ var REPLY_221_BYE = "221";
131
+ var REPLY_235_AUTH_OK = "235"; // allow:raw-byte-literal — SMTP AUTH success code
132
+ var REPLY_250_OK = "250";
133
+ var REPLY_334_AUTH_CHALLENGE = "334"; // allow:raw-byte-literal — SMTP AUTH challenge code
134
+ var REPLY_354_START_INPUT = "354";
135
+ var REPLY_421_SERVICE_NOT_AVAIL = "421"; // allow:raw-byte-literal — SMTP transient code
136
+ var REPLY_451_LOCAL_ERROR = "451"; // allow:raw-byte-literal — SMTP transient code
137
+ var REPLY_452_INSUFFICIENT_STG = "452"; // allow:raw-byte-literal — SMTP transient code
138
+ var REPLY_500_SYNTAX = "500"; // allow:raw-byte-literal — SMTP permanent code
139
+ var REPLY_501_BAD_ARGS = "501"; // allow:raw-byte-literal — SMTP permanent code
140
+ var REPLY_502_NOT_IMPLEMENTED = "502"; // allow:raw-byte-literal — SMTP permanent code
141
+ var REPLY_503_BAD_SEQUENCE = "503"; // allow:raw-byte-literal — SMTP permanent code
142
+ var REPLY_530_AUTH_REQUIRED = "530"; // allow:raw-byte-literal — SMTP permanent code
143
+ var REPLY_535_AUTH_FAILED = "535"; // allow:raw-byte-literal — RFC 4954 §6 AUTH refusal
144
+ var REPLY_538_AUTH_NEEDS_TLS = "538"; // allow:raw-byte-literal — RFC 4954 §4 AUTH-needs-TLS
145
+ var REPLY_550_MAILBOX_UNAVAIL = "550"; // allow:raw-byte-literal — SMTP permanent code (recipient-policy refusal shape)
146
+ var REPLY_552_SIZE_EXCEEDED = "552"; // allow:raw-byte-literal — SMTP permanent code
147
+ var REPLY_553_SENDER_REJECTED = "553"; // allow:raw-byte-literal — identity-binding mismatch
148
+ var REPLY_554_TRANSACTION_FAILED = "554"; // allow:raw-byte-literal — SMTP permanent code
149
+
150
+ var RE_MAIL_FROM = /^MAIL\s+FROM:\s*<([^>]*)>(?:\s+(.*))?$/i;
151
+ var RE_RCPT_TO = /^RCPT\s+TO:\s*<([^>]+)>(?:\s+.*)?$/i;
152
+ var RE_SIZE = /SIZE=(\d+)/i;
153
+ var RE_AUTH = /^AUTH\s+([A-Za-z0-9_-]{1,32})(?:\s+(.*))?$/i;
154
+
155
+ /**
156
+ * @primitive b.mail.server.submission.create
157
+ * @signature b.mail.server.submission.create(opts)
158
+ * @since 0.9.47
159
+ * @status stable
160
+ * @related b.mail.server.mx.create, b.guardSmtpCommand.detectBodySmuggling, b.safeSmtp.findDotTerminator
161
+ *
162
+ * Build the submission listener. Returns
163
+ * `{ listen({ port?, address? }), close({ timeoutMs? }),
164
+ * connectionCount(), _portForTest() }`.
165
+ *
166
+ * @opts
167
+ * tlsContext: TlsContext, // required — b.network.tls.context() output
168
+ * implicitTls: boolean, // wrap connection in TLS from the SYN (port 465); default false
169
+ * greeting: string, // EHLO/220 banner; default "blamejs Submission"
170
+ * auth: object, // SASL config (required unless permissive profile)
171
+ * mechanisms: string[], // SASL mechs to advertise; default ["PLAIN","LOGIN"]
172
+ * verify: function, // async (mechanism, credentials) => { ok, actor }
173
+ * rateLimit: object, // optional b.middleware.rateLimit instance for failure budget
174
+ * agent: object, // outbound delivery handoff (handoff({ ... }) → ack)
175
+ * identityBinding: "strict" | "permissive", // MAIL FROM must match auth identity (default strict)
176
+ * maxLineBytes: number, // default 1 KiB
177
+ * maxMessageBytes: number, // default 50 MiB
178
+ * maxRcptsPerMessage: number, // default 100
179
+ * idleTimeoutMs: number, // default 5 minutes
180
+ * profile: string, // "strict" | "balanced" | "permissive"; default "strict"
181
+ *
182
+ * @example
183
+ * var tls = b.network.tls.context({ cert: certPem, key: keyPem });
184
+ * var server = b.mail.server.submission.create({
185
+ * tlsContext: tls,
186
+ * greeting: "smtp.example.com Submission blamejs",
187
+ * auth: {
188
+ * mechanisms: ["PLAIN", "SCRAM-SHA-256"],
189
+ * verify: async function (mech, creds) {
190
+ * var actor = await myAuthService.verify(mech, creds);
191
+ * return actor ? { ok: true, actor: actor } : { ok: false };
192
+ * },
193
+ * },
194
+ * agent: b.mail.agent.create({ outboundSend: b.mail.send }),
195
+ * });
196
+ * await server.listen({ port: 587 });
197
+ */
198
+ function create(opts) {
199
+ validateOpts.requireObject(opts, "mail.server.submission.create",
200
+ MailServerSubmissionError, "mail-server-submission/bad-opts");
201
+ if (!opts.tlsContext) {
202
+ throw new MailServerSubmissionError("mail-server-submission/no-tls-context",
203
+ "mail.server.submission.create: tlsContext is required");
204
+ }
205
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
206
+ ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
207
+ "mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
208
+
209
+ var profile = opts.profile || "strict";
210
+
211
+ if (profile !== "permissive" && !opts.auth) {
212
+ throw new MailServerSubmissionError("mail-server-submission/no-auth",
213
+ "mail.server.submission.create: opts.auth required under strict / balanced profiles " +
214
+ "(submission listener is authenticated by design; opt down to 'permissive' for legacy plaintext)");
215
+ }
216
+ if (opts.auth) {
217
+ if (typeof opts.auth.verify !== "function") {
218
+ throw new MailServerSubmissionError("mail-server-submission/bad-auth",
219
+ "mail.server.submission.create: opts.auth.verify must be an async function (mechanism, credentials) => { ok, actor }");
220
+ }
221
+ if (opts.auth.mechanisms !== undefined &&
222
+ (!Array.isArray(opts.auth.mechanisms) || opts.auth.mechanisms.length === 0)) {
223
+ throw new MailServerSubmissionError("mail-server-submission/bad-auth",
224
+ "mail.server.submission.create: opts.auth.mechanisms must be a non-empty array if provided");
225
+ }
226
+ }
227
+
228
+ var greeting = opts.greeting || DEFAULT_GREETING;
229
+ var maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
230
+ var maxMessageBytes = opts.maxMessageBytes || DEFAULT_MAX_MESSAGE_BYTES;
231
+ var maxRcptsPerMsg = opts.maxRcptsPerMessage || DEFAULT_MAX_RCPTS_PER_MESSAGE;
232
+ var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
233
+ var authConfig = opts.auth || null;
234
+ var authMechanisms = authConfig && authConfig.mechanisms
235
+ ? authConfig.mechanisms.map(function (m) { return String(m).toUpperCase(); })
236
+ : DEFAULT_AUTH_MECHANISMS.slice();
237
+ var identityBinding = opts.identityBinding || "strict";
238
+ var implicitTls = opts.implicitTls === true;
239
+
240
+ // Default-on per-IP rate limit (see lib/mail-server-rate-limit.js).
241
+ // Operators pass `rateLimit: false` to disable, a rate-limit handle
242
+ // to share across listeners, or an opts object to override defaults.
243
+ var rateLimit;
244
+ if (opts.rateLimit === false) {
245
+ rateLimit = mailServerRateLimit.create({ disabled: true });
246
+ } else if (opts.rateLimit && typeof opts.rateLimit.admitConnection === "function") {
247
+ rateLimit = opts.rateLimit;
248
+ } else {
249
+ rateLimit = mailServerRateLimit.create(opts.rateLimit || {});
250
+ }
251
+
252
+ // Default-on guardDomain hardening for HELO / MAIL FROM / RCPT TO.
253
+ // Same posture as mail-server-mx — IDN homograph (CVE-2017-5469
254
+ // class), special-use-domain refusal (RFC 6761), label-length cap
255
+ // (RFC 1035 §2.3.4), bare-IP-as-domain refusal (CVE-2021-22931
256
+ // class). Operators with a closed-network deployment pass
257
+ // `guardDomain: false` to skip; the default keeps protection on.
258
+ var guardDomainProfile;
259
+ if (opts.guardDomain === false) {
260
+ guardDomainProfile = null;
261
+ } else {
262
+ guardDomainProfile = guardDomain.buildProfile({
263
+ profile: opts.guardDomain && typeof opts.guardDomain === "object"
264
+ ? (opts.guardDomain.profile || profile)
265
+ : profile,
266
+ });
267
+ }
268
+ function _validateDomainHardened(d, label) {
269
+ if (!guardDomainProfile) return { ok: true };
270
+ var verdict = guardDomain.validate(d, guardDomainProfile);
271
+ if (!verdict.ok) {
272
+ _emit("mail.server.submission.domain_refused", {
273
+ reason: verdict.issues && verdict.issues[0] && verdict.issues[0].kind,
274
+ domain: d,
275
+ label: label,
276
+ }, "denied");
277
+ }
278
+ return verdict;
279
+ }
280
+
281
+ var tcpServer = null;
282
+ var listening = false;
283
+ var connections = new Set();
284
+
285
+ function _emit(action, metadata, outcome) {
286
+ try {
287
+ audit().safeEmit({
288
+ action: action,
289
+ outcome: outcome || "success",
290
+ metadata: metadata || {},
291
+ });
292
+ } catch (_e) { /* drop-silent */ }
293
+ }
294
+
295
+ function _handleConnection(rawSocket) {
296
+ var remoteAddress = rawSocket.remoteAddress || "0.0.0.0";
297
+ var admit = rateLimit.admitConnection(remoteAddress);
298
+ if (!admit.ok) {
299
+ // 421 4.7.0 — transient; sender retries elsewhere.
300
+ _emit("mail.server.submission.rate_limit_refused",
301
+ { remoteAddress: remoteAddress, reason: admit.reason }, "denied");
302
+ try {
303
+ rawSocket.write("421 4.7.0 Too many connections from your IP\r\n");
304
+ } catch (_e) { /* socket may already be torn down */ }
305
+ try { rawSocket.destroy(); } catch (_e2) { /* idempotent */ }
306
+ return;
307
+ }
308
+ rawSocket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
309
+
310
+ var connectionId = "submitconn-" + bCrypto.generateToken(8); // allow:raw-byte-literal — connection-id length
311
+ var socket = implicitTls
312
+ ? new nodeTls.TLSSocket(rawSocket, { isServer: true, secureContext: opts.tlsContext })
313
+ : rawSocket;
314
+ connections.add(socket);
315
+
316
+ var state = {
317
+ id: connectionId,
318
+ remoteAddress: remoteAddress,
319
+ remotePort: rawSocket.remotePort || null,
320
+ tls: implicitTls,
321
+ stage: "connect",
322
+ helo: null,
323
+ authenticated: false,
324
+ actor: null,
325
+ mailFrom: null,
326
+ rcpts: [],
327
+ // Pending AUTH state (multi-step mechanisms).
328
+ authPending: null,
329
+ };
330
+
331
+ var lineBuffer = "";
332
+ var bodyCollector = null;
333
+ var inDataBody = false;
334
+
335
+ socket.setTimeout(idleTimeoutMs);
336
+ socket.on("timeout", function () {
337
+ _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.4.2 Idle timeout");
338
+ _closeConnection(socket);
339
+ });
340
+ socket.on("error", function (err) {
341
+ _emit("mail.server.submission.socket_error",
342
+ { connectionId: state.id, code: (err && err.code) || "unknown" }, "warning");
343
+ _closeConnection(socket);
344
+ });
345
+ socket.on("close", function () { connections.delete(socket); });
346
+
347
+ _emit("mail.server.submission.connect", {
348
+ connectionId: state.id,
349
+ remoteAddress: state.remoteAddress,
350
+ remotePort: state.remotePort,
351
+ tls: state.tls,
352
+ });
353
+
354
+ _writeReply(socket, REPLY_220_READY, greeting + " ready");
355
+
356
+ socket.on("data", function (chunk) {
357
+ try { _ingestBytes(state, socket, chunk); }
358
+ catch (err) {
359
+ _emit("mail.server.submission.handler_threw",
360
+ { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
361
+ try { _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
362
+ catch (_e) { /* socket already gone */ }
363
+ _closeConnection(socket);
364
+ }
365
+ });
366
+
367
+ function _ingestBytes(state, socket, chunk) {
368
+ if (inDataBody) {
369
+ try { bodyCollector.push(chunk); }
370
+ catch (_e) {
371
+ _emit("mail.server.submission.data_refused",
372
+ { connectionId: state.id, reason: "body-too-large", maxBytes: maxMessageBytes },
373
+ "denied");
374
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
375
+ "5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
376
+ _resetTransaction(state);
377
+ inDataBody = false; bodyCollector = null;
378
+ return;
379
+ }
380
+ var collected = bodyCollector.result();
381
+ if (guardSmtpCommand.detectBodySmuggling(collected)) {
382
+ _emit("mail.server.submission.smtp_smuggling_detected",
383
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
384
+ "denied");
385
+ _writeReply(socket, REPLY_554_TRANSACTION_FAILED,
386
+ "5.7.0 Bare-LF in DATA body refused (RFC 5321 §2.3.8; CVE-2023-51764 SMTP smuggling)");
387
+ _resetTransaction(state);
388
+ inDataBody = false; bodyCollector = null;
389
+ return;
390
+ }
391
+ var endIdx = safeSmtp.findDotTerminator(collected);
392
+ if (endIdx !== -1) {
393
+ var body = collected.subarray(0, endIdx);
394
+ _finalizeDataBody(state, socket, body);
395
+ inDataBody = false; bodyCollector = null;
396
+ }
397
+ return;
398
+ }
399
+
400
+ lineBuffer += chunk.toString("utf8");
401
+ if (lineBuffer.length > maxLineBytes * 4) {
402
+ _writeReply(socket, REPLY_500_SYNTAX,
403
+ "5.5.6 Line too long (>" + maxLineBytes + " bytes)");
404
+ _closeConnection(socket);
405
+ return;
406
+ }
407
+ var crlf;
408
+ while ((crlf = lineBuffer.indexOf("\r\n")) !== -1) {
409
+ var line = lineBuffer.slice(0, crlf);
410
+ lineBuffer = lineBuffer.slice(crlf + 2);
411
+ _handleCommand(state, socket, line);
412
+ if (inDataBody) return;
413
+ }
414
+ }
415
+
416
+ function _handleCommand(state, socket, line) {
417
+ // Pending multi-step AUTH challenge — operator-supplied
418
+ // mechanism may need additional roundtrips. We delegate to
419
+ // authConfig.verify with the new client response.
420
+ if (state.authPending) {
421
+ return _continueAuthExchange(state, socket, line);
422
+ }
423
+
424
+ // guardSmtpCommand check (smuggling + shape).
425
+ try {
426
+ guardSmtpCommand.validate(line, { profile: profile, maxLineBytes: maxLineBytes });
427
+ } catch (err) {
428
+ if (err.code === "guard-smtp-command/bare-lf" ||
429
+ err.code === "guard-smtp-command/bare-cr" ||
430
+ err.code === "guard-smtp-command/nul-byte") {
431
+ _emit("mail.server.submission.smtp_smuggling_detected",
432
+ { connectionId: state.id, code: err.code, line: line.slice(0, 200) }, // allow:raw-byte-literal — audit-log line truncation
433
+ "denied");
434
+ }
435
+ _writeReply(socket, REPLY_500_SYNTAX, "5.5.2 Syntax error (" + (err.code || "bad-line") + ")");
436
+ return;
437
+ }
438
+
439
+ var verb = line.split(/\s+/)[0].toUpperCase();
440
+ switch (verb) {
441
+ case "EHLO":
442
+ case "HELO":
443
+ return _handleEhlo(state, socket, line, verb);
444
+ case "STARTTLS":
445
+ return _handleStartTls(state, socket);
446
+ case "AUTH":
447
+ return _handleAuth(state, socket, line);
448
+ case "MAIL":
449
+ return _handleMailFrom(state, socket, line);
450
+ case "RCPT":
451
+ return _handleRcptTo(state, socket, line);
452
+ case "DATA":
453
+ return _handleData(state, socket);
454
+ case "NOOP":
455
+ return _writeReply(socket, REPLY_250_OK, "2.0.0 OK");
456
+ case "RSET":
457
+ _resetTransaction(state);
458
+ return _writeReply(socket, REPLY_250_OK, "2.0.0 Reset");
459
+ case "QUIT":
460
+ _writeReply(socket, REPLY_221_BYE, "2.0.0 Bye");
461
+ return _closeConnection(socket);
462
+ case "VRFY":
463
+ case "EXPN":
464
+ return _writeReply(socket, REPLY_502_NOT_IMPLEMENTED, "5.5.1 Command not implemented");
465
+ default:
466
+ _writeReply(socket, REPLY_500_SYNTAX, "5.5.2 Unknown command");
467
+ }
468
+ }
469
+
470
+ function _handleEhlo(state, socket, line, verb) {
471
+ var helo = line.slice(verb.length).trim();
472
+ if (!helo) {
473
+ _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 " + verb + " requires a domain argument");
474
+ return;
475
+ }
476
+ // Skip guardDomain on address literals (RFC 5321 §4.1.3 valid
477
+ // bracket-form; already constrained by b.guardSmtpCommand).
478
+ // Bare-IP refused — CVE-2021-22931 class.
479
+ if (helo[0] !== "[" && guardDomainProfile) {
480
+ var __heloVerdict = _validateDomainHardened(helo, "helo");
481
+ if (!__heloVerdict.ok) {
482
+ _writeReply(socket, REPLY_501_BAD_ARGS,
483
+ "5.5.4 " + verb + " domain refused (" +
484
+ (__heloVerdict.issues && __heloVerdict.issues[0] && __heloVerdict.issues[0].kind) + ")");
485
+ return;
486
+ }
487
+ }
488
+ state.helo = helo;
489
+ state.stage = "ehlo";
490
+ if (verb === "EHLO") {
491
+ var caps = ["PIPELINING", "SIZE " + maxMessageBytes, "8BITMIME", "ENHANCEDSTATUSCODES"];
492
+ // STARTTLS advertised only on explicit-STARTTLS port (587),
493
+ // not on implicit-TLS (465 already wrapped). RFC 8314 §3.3.
494
+ if (!state.tls && !implicitTls) caps.unshift("STARTTLS");
495
+ // AUTH advertised only when authConfig wired AND we're on a
496
+ // TLS-protected connection (or operator opted to permissive).
497
+ if (authConfig && (state.tls || profile === "permissive")) {
498
+ caps.push("AUTH " + authMechanisms.join(" "));
499
+ }
500
+ var lines = [greeting + " greets " + helo];
501
+ for (var i = 0; i < caps.length; i += 1) lines.push(caps[i]);
502
+ _writeMultiline(socket, REPLY_250_OK, lines);
503
+ } else {
504
+ _writeReply(socket, REPLY_250_OK, greeting + " greets " + helo);
505
+ }
506
+ _emit("mail.server.submission.helo",
507
+ { connectionId: state.id, verb: verb, helo: helo, tls: state.tls });
508
+ }
509
+
510
+ function _handleStartTls(state, socket) {
511
+ if (state.tls) {
512
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 TLS already active");
513
+ return;
514
+ }
515
+ if (implicitTls) {
516
+ _writeReply(socket, REPLY_502_NOT_IMPLEMENTED,
517
+ "5.5.1 STARTTLS not available on implicit-TLS port (RFC 8314)");
518
+ return;
519
+ }
520
+ _writeReply(socket, REPLY_220_READY, "2.0.0 Ready to start TLS");
521
+ // CVE-2021-38371 / CVE-2021-33515 defense: clear pre-handshake
522
+ // buffer at upgrade time.
523
+ lineBuffer = ""; bodyCollector = null; inDataBody = false;
524
+ var tlsSocket = new nodeTls.TLSSocket(socket, {
525
+ isServer: true, secureContext: opts.tlsContext,
526
+ });
527
+ tlsSocket.on("secure", function () {
528
+ state.tls = true; state.stage = "ehlo"; state.helo = null;
529
+ // Authenticated state SURVIVES STARTTLS upgrade — credentials
530
+ // verified pre-STARTTLS under permissive remain valid post-
531
+ // STARTTLS. Operator opts down to permissive only with this
532
+ // tradeoff acknowledged.
533
+ });
534
+ tlsSocket.on("error", function (err) {
535
+ _emit("mail.server.submission.tls_handshake_failed",
536
+ { connectionId: state.id, code: (err && err.code) || "unknown" }, "failure");
537
+ _closeConnection(socket);
538
+ });
539
+ tlsSocket.on("data", function (chunk) {
540
+ try { _ingestBytes(state, tlsSocket, chunk); }
541
+ catch (err) {
542
+ _emit("mail.server.submission.handler_threw",
543
+ { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
544
+ _closeConnection(tlsSocket);
545
+ }
546
+ });
547
+ }
548
+
549
+ function _handleAuth(state, socket, line) {
550
+ if (!authConfig) {
551
+ _writeReply(socket, REPLY_502_NOT_IMPLEMENTED, "5.5.1 AUTH not configured on this listener");
552
+ return;
553
+ }
554
+ if (!state.tls && profile !== "permissive") {
555
+ // RFC 4954 §4 — AUTH MUST NOT be advertised or accepted on
556
+ // unencrypted connections (strict + balanced enforce; permissive
557
+ // opts down).
558
+ _writeReply(socket, REPLY_538_AUTH_NEEDS_TLS,
559
+ "5.7.11 Encryption required for AUTH (RFC 4954 §4)");
560
+ return;
561
+ }
562
+ if (state.authenticated) {
563
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 Already authenticated");
564
+ return;
565
+ }
566
+ // Per-IP AUTH-failure budget — credential-stuffing class
567
+ // defense. Refuse new AUTH attempts when the rolling 15-min
568
+ // failure count for this IP has tripped the cap. 421 4.7.0 is
569
+ // transient; the sender either backs off or retries from a
570
+ // different IP (the desired behavior on a stuffing attack —
571
+ // shifts the attacker workload onto IP rotation).
572
+ var authAdmit = rateLimit.checkAuthAdmit(state.remoteAddress);
573
+ if (!authAdmit.ok) {
574
+ _emit("mail.server.submission.auth_rate_limit_refused",
575
+ { connectionId: state.id, remoteAddress: state.remoteAddress,
576
+ reason: authAdmit.reason }, "denied");
577
+ _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
578
+ "4.7.0 Too many AUTH failures from your IP");
579
+ _closeConnection(socket);
580
+ return;
581
+ }
582
+ var match = line.match(RE_AUTH);
583
+ if (!match) {
584
+ _writeReply(socket, REPLY_501_BAD_ARGS,
585
+ "5.5.4 Syntax: AUTH <SASL-mechanism> [<initial-response>] (RFC 4954)");
586
+ return;
587
+ }
588
+ var mech = match[1].toUpperCase();
589
+ var initial = match[2] || null;
590
+ if (authMechanisms.indexOf(mech) === -1) {
591
+ _writeReply(socket, REPLY_535_AUTH_FAILED,
592
+ "5.7.8 Mechanism '" + mech + "' not advertised");
593
+ return;
594
+ }
595
+ _emit("mail.server.submission.auth_attempt",
596
+ { connectionId: state.id, mechanism: mech, remoteAddress: state.remoteAddress });
597
+
598
+ // For PLAIN / LOGIN / EXTERNAL the verify call is single-step.
599
+ // SCRAM-SHA-256 / GS2-* family use multi-step challenges; the
600
+ // operator's verify returns { ok, actor, challenge, pending }
601
+ // — when `pending: true` we send 334 + the challenge and wait
602
+ // for the client response.
603
+ state.authPending = { mechanism: mech, step: 0 };
604
+ _runAuthStep(state, socket, initial);
605
+ }
606
+
607
+ function _continueAuthExchange(state, socket, line) {
608
+ _runAuthStep(state, socket, line.trim());
609
+ }
610
+
611
+ function _runAuthStep(state, socket, clientResponse) {
612
+ Promise.resolve()
613
+ .then(function () {
614
+ return authConfig.verify(state.authPending.mechanism, {
615
+ step: state.authPending.step,
616
+ clientResponse: clientResponse,
617
+ tls: state.tls,
618
+ remoteAddress: state.remoteAddress,
619
+ });
620
+ })
621
+ .then(function (result) {
622
+ state.authPending.step += 1;
623
+ if (result && result.pending && typeof result.challenge === "string") {
624
+ _writeReply(socket, REPLY_334_AUTH_CHALLENGE, result.challenge);
625
+ return;
626
+ }
627
+ if (result && result.ok === true && result.actor) {
628
+ state.authenticated = true;
629
+ state.actor = result.actor;
630
+ state.authPending = null;
631
+ _emit("mail.server.submission.auth_success", {
632
+ connectionId: state.id,
633
+ mechanism: state.authPending && state.authPending.mechanism,
634
+ tenantId: result.actor.tenantId || null,
635
+ scopes: Array.isArray(result.actor.scopes) ? result.actor.scopes : [],
636
+ });
637
+ _writeReply(socket, REPLY_235_AUTH_OK, "2.7.0 Authentication successful");
638
+ return;
639
+ }
640
+ state.authPending = null;
641
+ rateLimit.noteAuthFailure(state.remoteAddress);
642
+ _emit("mail.server.submission.auth_failed", {
643
+ connectionId: state.id, reason: (result && result.reason) || "verify-returned-fail",
644
+ }, "denied");
645
+ _writeReply(socket, REPLY_535_AUTH_FAILED, "5.7.8 Authentication credentials invalid");
646
+ })
647
+ .catch(function (err) {
648
+ state.authPending = null;
649
+ rateLimit.noteAuthFailure(state.remoteAddress);
650
+ _emit("mail.server.submission.auth_failed", {
651
+ connectionId: state.id, reason: (err && err.message) || String(err),
652
+ }, "failure");
653
+ _writeReply(socket, REPLY_535_AUTH_FAILED, "5.7.8 Authentication failed");
654
+ });
655
+ }
656
+
657
+ function _handleMailFrom(state, socket, line) {
658
+ if (!state.tls && profile !== "permissive") {
659
+ _writeReply(socket, REPLY_530_AUTH_REQUIRED, "5.7.0 Must issue a STARTTLS command first");
660
+ return;
661
+ }
662
+ if (!state.authenticated && profile !== "permissive") {
663
+ _writeReply(socket, REPLY_530_AUTH_REQUIRED,
664
+ "5.7.0 Authentication required (submission listener requires AUTH per RFC 6409)");
665
+ return;
666
+ }
667
+ if (state.stage !== "ehlo" && state.stage !== "mail") {
668
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 EHLO/HELO first");
669
+ return;
670
+ }
671
+ var match = line.match(RE_MAIL_FROM);
672
+ if (!match) {
673
+ _writeReply(socket, REPLY_501_BAD_ARGS,
674
+ "5.5.4 Syntax: MAIL FROM:<address> [SIZE=n]");
675
+ return;
676
+ }
677
+ var mailFrom = match[1].toLowerCase();
678
+ // Domain hardening on MAIL FROM. Skip address-literal + empty
679
+ // reverse-path (RFC 5321 §4.5.5).
680
+ var __mfAt = mailFrom.lastIndexOf("@");
681
+ var mailFromDomain = __mfAt === -1 ? "" : mailFrom.slice(__mfAt + 1);
682
+ if (mailFromDomain && mailFromDomain[0] !== "[" && guardDomainProfile) {
683
+ var __mfVerdict = _validateDomainHardened(mailFromDomain, "mail_from");
684
+ if (!__mfVerdict.ok) {
685
+ _writeReply(socket, REPLY_501_BAD_ARGS,
686
+ "5.5.4 MAIL FROM domain refused (" +
687
+ (__mfVerdict.issues && __mfVerdict.issues[0] && __mfVerdict.issues[0].kind) + ")");
688
+ return;
689
+ }
690
+ }
691
+ var paramStr = match[2] || "";
692
+ var sizeMatch = paramStr.match(RE_SIZE);
693
+ if (sizeMatch) {
694
+ var declaredSize = parseInt(sizeMatch[1], 10);
695
+ if (declaredSize > maxMessageBytes) {
696
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
697
+ "5.3.4 Message size exceeds fixed maximum (" + maxMessageBytes + " bytes)");
698
+ return;
699
+ }
700
+ }
701
+
702
+ // Identity binding — under strict profile, MAIL FROM MUST match
703
+ // an entry in the authenticated actor's mailbox set.
704
+ if (state.authenticated && identityBinding === "strict") {
705
+ var allowed = _actorMailboxes(state.actor);
706
+ if (allowed.length > 0 && allowed.indexOf(mailFrom) === -1) {
707
+ _emit("mail.server.submission.identity_mismatch", {
708
+ connectionId: state.id, authIdentity: state.actor.id || null,
709
+ mailFrom: mailFrom, allowed: allowed,
710
+ }, "denied");
711
+ _writeReply(socket, REPLY_553_SENDER_REJECTED,
712
+ "5.7.1 Sender address rejected: not owned by authenticated identity");
713
+ return;
714
+ }
715
+ }
716
+
717
+ state.mailFrom = mailFrom;
718
+ state.stage = "rcpt";
719
+ state.rcpts = [];
720
+ // Track in-flight async recipientPolicy verdicts so the cap-check
721
+ // counts BOTH committed + in-flight against `maxRcptsPerMsg`. Under
722
+ // SMTP PIPELINING (RFC 2920) a client can send many RCPT TO commands
723
+ // back-to-back; without this counter each one sees `state.rcpts.length`
724
+ // == 0 because the prior pushes haven't landed inside the .then() yet,
725
+ // so the cap-check passes for every command and `state.rcpts` grows
726
+ // past the limit once the verdicts resolve.
727
+ state.rcptsPending = 0;
728
+ _emit("mail.server.submission.mail_from",
729
+ { connectionId: state.id, mailFrom: mailFrom,
730
+ actor: state.actor && state.actor.id });
731
+ _writeReply(socket, REPLY_250_OK, "2.1.0 Sender OK");
732
+ }
733
+
734
+ function _handleRcptTo(state, socket, line) {
735
+ if (state.stage !== "rcpt") {
736
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 MAIL FROM first");
737
+ return;
738
+ }
739
+ // Cap-check counts BOTH committed (state.rcpts.length) AND in-flight
740
+ // (state.rcptsPending) — under PIPELINING (RFC 2920) the prior
741
+ // commands haven't pushed yet by the time the next cap-check runs.
742
+ if ((state.rcpts.length + (state.rcptsPending || 0)) >= maxRcptsPerMsg) {
743
+ _writeReply(socket, REPLY_452_INSUFFICIENT_STG,
744
+ "4.5.3 Too many recipients (limit " + maxRcptsPerMsg + ")");
745
+ return;
746
+ }
747
+ var match = line.match(RE_RCPT_TO);
748
+ if (!match) {
749
+ _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 Syntax: RCPT TO:<address>");
750
+ return;
751
+ }
752
+ var rcpt = match[1].toLowerCase();
753
+
754
+ // Domain hardening on RCPT TO. Skip address-literal form.
755
+ var __rcptAt = rcpt.lastIndexOf("@");
756
+ var __rcptDomain = __rcptAt === -1 ? "" : rcpt.slice(__rcptAt + 1);
757
+ if (__rcptDomain && __rcptDomain[0] !== "[" && guardDomainProfile) {
758
+ var __rcptVerdict = _validateDomainHardened(__rcptDomain, "rcpt_to");
759
+ if (!__rcptVerdict.ok) {
760
+ _writeReply(socket, REPLY_501_BAD_ARGS,
761
+ "5.5.4 RCPT TO domain refused (" +
762
+ (__rcptVerdict.issues && __rcptVerdict.issues[0] && __rcptVerdict.issues[0].kind) + ")");
763
+ return;
764
+ }
765
+ }
766
+
767
+ // Operator-supplied recipient policy — async predicate that
768
+ // decides whether the authenticated actor may send to this
769
+ // destination. Wires policy decisions like "block *.gov from
770
+ // this tenant" / "this actor's outbound budget is exhausted" /
771
+ // "destination is in the operator's deny list". Returns
772
+ // `{ ok: true }` on accept OR `{ ok: false, reason }` on refuse.
773
+ // When not wired, every syntactically-valid RCPT TO is accepted
774
+ // — the agent.handoff is the operator's last chance to reject.
775
+ if (typeof opts.recipientPolicy === "function") {
776
+ state.rcptsPending = (state.rcptsPending || 0) + 1;
777
+ Promise.resolve()
778
+ .then(function () {
779
+ return opts.recipientPolicy({
780
+ actor: state.actor,
781
+ mailFrom: state.mailFrom,
782
+ rcptTo: rcpt,
783
+ connectionId: state.id,
784
+ remoteAddress: state.remoteAddress,
785
+ tls: state.tls,
786
+ });
787
+ })
788
+ .then(function (verdict) {
789
+ state.rcptsPending -= 1;
790
+ if (verdict && verdict.ok === true) {
791
+ // Re-check the cap before commit — under PIPELINING the
792
+ // verdict may resolve after other in-flight RCPT TO have
793
+ // pushed, so the previously-reserved slot could already
794
+ // be over-committed. Defense-in-depth on top of the
795
+ // in-flight-aware cap-check above.
796
+ if (state.rcpts.length >= maxRcptsPerMsg) {
797
+ _emit("mail.server.submission.recipient_refused", {
798
+ connectionId: state.id, rcptTo: rcpt,
799
+ reason: "cap-exceeded-post-policy",
800
+ actor: state.actor && state.actor.id,
801
+ }, "denied");
802
+ _writeReply(socket, REPLY_452_INSUFFICIENT_STG,
803
+ "4.5.3 Too many recipients (limit " + maxRcptsPerMsg + ")");
804
+ return;
805
+ }
806
+ state.rcpts.push(rcpt);
807
+ _emit("mail.server.submission.rcpt_to",
808
+ { connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length });
809
+ _writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
810
+ return;
811
+ }
812
+ _emit("mail.server.submission.recipient_refused", {
813
+ connectionId: state.id, rcptTo: rcpt,
814
+ reason: (verdict && verdict.reason) || "policy-refused",
815
+ actor: state.actor && state.actor.id,
816
+ }, "denied");
817
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
818
+ "5.7.1 " + ((verdict && verdict.reason) || "Recipient policy refused"));
819
+ })
820
+ .catch(function (err) {
821
+ state.rcptsPending -= 1;
822
+ _emit("mail.server.submission.recipient_policy_threw", {
823
+ connectionId: state.id, rcptTo: rcpt,
824
+ error: (err && err.message) || String(err),
825
+ }, "failure");
826
+ // Recipient-policy hook failure is treated as transient
827
+ // (the operator's policy engine may be temporarily
828
+ // unavailable); 451 4.7.1 lets the sender retry.
829
+ _writeReply(socket, REPLY_451_LOCAL_ERROR,
830
+ "4.7.1 Recipient policy temporarily unavailable");
831
+ });
832
+ return;
833
+ }
834
+
835
+ state.rcpts.push(rcpt);
836
+ _emit("mail.server.submission.rcpt_to",
837
+ { connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length });
838
+ _writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
839
+ }
840
+
841
+ function _handleData(state, socket) {
842
+ if (state.stage !== "rcpt" || state.rcpts.length === 0) {
843
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 No valid recipients");
844
+ return;
845
+ }
846
+ _writeReply(socket, REPLY_354_START_INPUT, "End data with <CR><LF>.<CR><LF>");
847
+ state.stage = "data-body";
848
+ inDataBody = true;
849
+ bodyCollector = safeBuffer.boundedChunkCollector({
850
+ maxBytes: maxMessageBytes,
851
+ errorClass: MailServerSubmissionError,
852
+ sizeCode: "mail-server-submission/body-too-large",
853
+ sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
854
+ });
855
+ }
856
+
857
+ function _finalizeDataBody(state, socket, body) {
858
+ var dedotted = safeSmtp.dotUnstuff(body);
859
+ if (opts.agent && typeof opts.agent.handoff === "function") {
860
+ opts.agent.handoff({
861
+ mailFrom: state.mailFrom,
862
+ rcpts: state.rcpts.slice(),
863
+ body: dedotted,
864
+ actor: state.actor,
865
+ remote: { address: state.remoteAddress, port: state.remotePort },
866
+ tls: state.tls,
867
+ helo: state.helo,
868
+ connectionId: state.id,
869
+ direction: "outbound",
870
+ }).then(function (ack) {
871
+ _emit("mail.server.submission.outbound_routed", {
872
+ connectionId: state.id, messageId: ack && ack.messageId,
873
+ sizeBytes: dedotted.length, actor: state.actor && state.actor.id,
874
+ });
875
+ _writeReply(socket, REPLY_250_OK,
876
+ "2.6.0 Message accepted" + (ack && ack.messageId ? " <" + ack.messageId + ">" : ""));
877
+ _resetTransaction(state);
878
+ }).catch(function (err) {
879
+ _emit("mail.server.submission.data_refused",
880
+ { connectionId: state.id, reason: "agent-handoff-failed",
881
+ error: (err && err.message) || String(err) }, "failure");
882
+ _writeReply(socket, REPLY_451_LOCAL_ERROR, "4.3.0 Local delivery error");
883
+ _resetTransaction(state);
884
+ });
885
+ return;
886
+ }
887
+ _emit("mail.server.submission.data_accepted",
888
+ { connectionId: state.id, mailFrom: state.mailFrom,
889
+ rcptCount: state.rcpts.length, sizeBytes: dedotted.length });
890
+ _writeReply(socket, REPLY_250_OK, "2.6.0 Message queued (audit-only)");
891
+ _resetTransaction(state);
892
+ }
893
+
894
+ function _resetTransaction(state) {
895
+ state.mailFrom = null;
896
+ state.rcpts = [];
897
+ state.rcptsPending = 0;
898
+ state.stage = "ehlo";
899
+ }
900
+ }
901
+
902
+ async function listen(listenOpts) {
903
+ listenOpts = listenOpts || {};
904
+ if (listening) {
905
+ throw new MailServerSubmissionError("mail-server-submission/already-listening",
906
+ "listen: already listening");
907
+ }
908
+ // Port 0 (ephemeral, test mode) must NOT fall back to the protocol
909
+ // default — the `|| <default>` short-circuit was a footgun on the
910
+ // test path.
911
+ var defaultPort = implicitTls ? 465 : 587; // allow:raw-byte-literal — RFC 8314 implicit-TLS / RFC 6409 submission ports
912
+ var port = listenOpts.port === undefined ? defaultPort : listenOpts.port;
913
+ var address = listenOpts.address || "0.0.0.0";
914
+ tcpServer = net.createServer(function (socket) { _handleConnection(socket); });
915
+ return new Promise(function (resolve, reject) {
916
+ tcpServer.once("error", reject);
917
+ tcpServer.listen(port, address, function () {
918
+ listening = true;
919
+ tcpServer.removeListener("error", reject);
920
+ _emit("mail.server.submission.listening",
921
+ { port: port, address: address, implicitTls: implicitTls });
922
+ resolve({ port: tcpServer.address().port, address: address });
923
+ });
924
+ });
925
+ }
926
+
927
+ async function close(closeOpts) {
928
+ closeOpts = closeOpts || {};
929
+ if (!listening) return;
930
+ var timeoutMs = closeOpts.timeoutMs || C.TIME.seconds(30);
931
+ listening = false;
932
+ tcpServer.close();
933
+ connections.forEach(function (sock) {
934
+ try { _writeReply(sock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server shutting down"); }
935
+ catch (_e) { /* socket already gone */ }
936
+ });
937
+ var deadline = Date.now() + timeoutMs;
938
+ while (connections.size > 0 && Date.now() < deadline) {
939
+ await safeAsync.sleep(100); // allow:raw-time-literal — sub-second drain poll
940
+ }
941
+ connections.forEach(function (sock) {
942
+ try { sock.destroy(); } catch (_e) { /* best-effort */ }
943
+ });
944
+ connections.clear();
945
+ _emit("mail.server.submission.closed", {});
946
+ }
947
+
948
+ function connectionCount() { return connections.size; }
949
+
950
+ return {
951
+ listen: listen,
952
+ close: close,
953
+ connectionCount: connectionCount,
954
+ _portForTest: function () { return tcpServer ? tcpServer.address().port : null; },
955
+ };
956
+ }
957
+
958
+ function _actorMailboxes(actor) {
959
+ if (!actor) return [];
960
+ if (Array.isArray(actor.mailboxes)) return actor.mailboxes.map(function (m) { return String(m).toLowerCase(); });
961
+ if (typeof actor.mailbox === "string") return [actor.mailbox.toLowerCase()];
962
+ return [];
963
+ }
964
+
965
+ function _writeReply(socket, code, text) {
966
+ try { socket.write(code + " " + text + "\r\n"); }
967
+ catch (_e) { /* socket already closed */ }
968
+ }
969
+
970
+ function _writeMultiline(socket, code, lines) {
971
+ for (var i = 0; i < lines.length; i += 1) {
972
+ var sep = i === lines.length - 1 ? " " : "-";
973
+ try { socket.write(code + sep + lines[i] + "\r\n"); }
974
+ catch (_e) { /* socket already closed */ }
975
+ }
976
+ }
977
+
978
+ function _closeConnection(socket) {
979
+ try { socket.end(); } catch (_e) { /* best-effort */ }
980
+ try { socket.destroy(); } catch (_e) { /* best-effort */ }
981
+ }
982
+
983
+ module.exports = {
984
+ create: create,
985
+ MailServerSubmissionError: MailServerSubmissionError,
986
+ };