raffel 1.0.8 → 1.0.10-next.22f4d4e

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.
Files changed (109) hide show
  1. package/dist/adapters/index.d.ts +3 -1
  2. package/dist/adapters/index.d.ts.map +1 -1
  3. package/dist/adapters/index.js +2 -0
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/adapters/smtp.d.ts +152 -0
  6. package/dist/adapters/smtp.d.ts.map +1 -0
  7. package/dist/adapters/smtp.js +1143 -0
  8. package/dist/adapters/smtp.js.map +1 -0
  9. package/dist/adapters/websocket.d.ts +100 -1
  10. package/dist/adapters/websocket.d.ts.map +1 -1
  11. package/dist/adapters/websocket.js +461 -16
  12. package/dist/adapters/websocket.js.map +1 -1
  13. package/dist/channels/channel-manager.d.ts.map +1 -1
  14. package/dist/channels/channel-manager.js +542 -4
  15. package/dist/channels/channel-manager.js.map +1 -1
  16. package/dist/channels/history.d.ts +54 -0
  17. package/dist/channels/history.d.ts.map +1 -0
  18. package/dist/channels/history.js +78 -0
  19. package/dist/channels/history.js.map +1 -0
  20. package/dist/channels/index.d.ts +9 -1
  21. package/dist/channels/index.d.ts.map +1 -1
  22. package/dist/channels/index.js +8 -1
  23. package/dist/channels/index.js.map +1 -1
  24. package/dist/channels/recovery.d.ts +57 -0
  25. package/dist/channels/recovery.d.ts.map +1 -0
  26. package/dist/channels/recovery.js +58 -0
  27. package/dist/channels/recovery.js.map +1 -0
  28. package/dist/channels/rest-api.d.ts +32 -0
  29. package/dist/channels/rest-api.d.ts.map +1 -0
  30. package/dist/channels/rest-api.js +197 -0
  31. package/dist/channels/rest-api.js.map +1 -0
  32. package/dist/channels/ticket-store.d.ts +25 -0
  33. package/dist/channels/ticket-store.d.ts.map +1 -0
  34. package/dist/channels/ticket-store.js +70 -0
  35. package/dist/channels/ticket-store.js.map +1 -0
  36. package/dist/channels/types.d.ts +421 -5
  37. package/dist/channels/types.d.ts.map +1 -1
  38. package/dist/channels/types.js +15 -1
  39. package/dist/channels/types.js.map +1 -1
  40. package/dist/client/index.d.ts +39 -0
  41. package/dist/client/index.d.ts.map +1 -0
  42. package/dist/client/index.js +531 -0
  43. package/dist/client/index.js.map +1 -0
  44. package/dist/client/reconnect.d.ts +33 -0
  45. package/dist/client/reconnect.d.ts.map +1 -0
  46. package/dist/client/reconnect.js +60 -0
  47. package/dist/client/reconnect.js.map +1 -0
  48. package/dist/client/types.d.ts +107 -0
  49. package/dist/client/types.d.ts.map +1 -0
  50. package/dist/client/types.js +5 -0
  51. package/dist/client/types.js.map +1 -0
  52. package/dist/index.d.ts +13 -6
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +13 -3
  55. package/dist/index.js.map +1 -1
  56. package/dist/middleware/interceptors/field-filter.d.ts +51 -0
  57. package/dist/middleware/interceptors/field-filter.d.ts.map +1 -0
  58. package/dist/middleware/interceptors/field-filter.js +81 -0
  59. package/dist/middleware/interceptors/field-filter.js.map +1 -0
  60. package/dist/middleware/interceptors/guard.d.ts +51 -0
  61. package/dist/middleware/interceptors/guard.d.ts.map +1 -0
  62. package/dist/middleware/interceptors/guard.js +45 -0
  63. package/dist/middleware/interceptors/guard.js.map +1 -0
  64. package/dist/middleware/interceptors/index.d.ts +4 -0
  65. package/dist/middleware/interceptors/index.d.ts.map +1 -1
  66. package/dist/middleware/interceptors/index.js +4 -0
  67. package/dist/middleware/interceptors/index.js.map +1 -1
  68. package/dist/resource-module/index.d.ts +44 -0
  69. package/dist/resource-module/index.d.ts.map +1 -0
  70. package/dist/resource-module/index.js +146 -0
  71. package/dist/resource-module/index.js.map +1 -0
  72. package/dist/resource-module/types.d.ts +109 -0
  73. package/dist/resource-module/types.d.ts.map +1 -0
  74. package/dist/resource-module/types.js +7 -0
  75. package/dist/resource-module/types.js.map +1 -0
  76. package/dist/resource-module/watch-helpers.d.ts +16 -0
  77. package/dist/resource-module/watch-helpers.d.ts.map +1 -0
  78. package/dist/resource-module/watch-helpers.js +65 -0
  79. package/dist/resource-module/watch-helpers.js.map +1 -0
  80. package/dist/server/types.d.ts +27 -0
  81. package/dist/server/types.d.ts.map +1 -1
  82. package/dist/smtp/client.d.ts +12 -0
  83. package/dist/smtp/client.d.ts.map +1 -0
  84. package/dist/smtp/client.js +424 -0
  85. package/dist/smtp/client.js.map +1 -0
  86. package/dist/smtp/index.d.ts +13 -0
  87. package/dist/smtp/index.d.ts.map +1 -0
  88. package/dist/smtp/index.js +14 -0
  89. package/dist/smtp/index.js.map +1 -0
  90. package/dist/smtp/relay.d.ts +14 -0
  91. package/dist/smtp/relay.d.ts.map +1 -0
  92. package/dist/smtp/relay.js +418 -0
  93. package/dist/smtp/relay.js.map +1 -0
  94. package/dist/smtp/types.d.ts +194 -0
  95. package/dist/smtp/types.d.ts.map +1 -0
  96. package/dist/smtp/types.js +7 -0
  97. package/dist/smtp/types.js.map +1 -0
  98. package/dist/types/context.d.ts +15 -1
  99. package/dist/types/context.d.ts.map +1 -1
  100. package/dist/types/context.js +3 -0
  101. package/dist/types/context.js.map +1 -1
  102. package/dist/types/index.d.ts +1 -1
  103. package/dist/types/index.d.ts.map +1 -1
  104. package/dist/types/index.js.map +1 -1
  105. package/dist/ui/types/context.d.ts +15 -1
  106. package/dist/ui/types/context.d.ts.map +1 -1
  107. package/dist/ui/types/index.d.ts +1 -1
  108. package/dist/ui/types/index.d.ts.map +1 -1
  109. package/package.json +9 -1
@@ -0,0 +1,1143 @@
1
+ /**
2
+ * SMTP Adapter
3
+ *
4
+ * Exposes Raffel services as a standards-compliant SMTP server.
5
+ * Translates SMTP transactions into Envelope procedure calls.
6
+ *
7
+ * Supported RFCs:
8
+ * - RFC 5321 (SMTP core)
9
+ * - RFC 3207 (STARTTLS)
10
+ * - RFC 4954 (AUTH PLAIN / LOGIN)
11
+ * - RFC 1870 (SIZE extension)
12
+ * - RFC 6152 (8BITMIME)
13
+ * - RFC 6531 (SMTPUTF8)
14
+ * - RFC 3030 (CHUNKING / BDAT)
15
+ * - RFC 2920 (PIPELINING)
16
+ *
17
+ * Protocol mapping:
18
+ * SMTP transaction → procedure 'mail.receive' (configurable)
19
+ * AUTH attempt → procedure 'mail.authenticate' (configurable)
20
+ * VRFY command → procedure 'mail.verify' (configurable)
21
+ */
22
+ import { createServer } from 'node:net';
23
+ import * as tls from 'node:tls';
24
+ import { sid } from '../utils/id/index.js';
25
+ import { mergeContextSeeds } from '../types/index.js';
26
+ import { createAbortableContextAsync } from '../utils/context-utils.js';
27
+ import { createLogger } from '../utils/logger.js';
28
+ import { checkConnectionFilter } from './utils/connection-filter.js';
29
+ const logger = createLogger('smtp-adapter');
30
+ // ─── Constants ───────────────────────────────────────────────────────────────
31
+ const CRLF = '\r\n';
32
+ const DOT_LINE = '.\r\n';
33
+ const MAX_LINE_LENGTH = 998; // RFC 5321 §4.5.3.1.6
34
+ const DEFAULT_MAX_MESSAGE_SIZE = 50 * 1024 * 1024; // 50 MB
35
+ const DEFAULT_MAX_RECIPIENTS = 100;
36
+ const DEFAULT_MAX_AUTH_ATTEMPTS = 5;
37
+ /** SMTP timeouts per RFC 5321 §4.5.3.2 */
38
+ const DEFAULT_TIMEOUTS = {
39
+ /** Client must send initial command after greeting */
40
+ greeting: 30_000,
41
+ /** Per-command timeout */
42
+ command: 30_000,
43
+ /** DATA content (allow slow uploads) */
44
+ data: 600_000,
45
+ /** Close connection after QUIT */
46
+ quit: 5_000,
47
+ /** TLS handshake */
48
+ tls: 30_000,
49
+ };
50
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
51
+ function parseAddress(raw) {
52
+ // Match: <user@domain> or <> (null sender) with optional params
53
+ const match = raw.match(/^<([^>]*)>\s*(.*)$/);
54
+ if (!match) {
55
+ // Try without angle brackets (lenient)
56
+ const lenientMatch = raw.match(/^(\S+@\S+)\s*(.*)$/);
57
+ if (!lenientMatch)
58
+ return null;
59
+ return {
60
+ raw,
61
+ address: lenientMatch[1],
62
+ params: parseEsmtpParams(lenientMatch[2] ?? ''),
63
+ };
64
+ }
65
+ return {
66
+ raw,
67
+ address: match[1],
68
+ params: parseEsmtpParams(match[2] ?? ''),
69
+ };
70
+ }
71
+ function parseEsmtpParams(str) {
72
+ const params = {};
73
+ if (!str.trim())
74
+ return params;
75
+ for (const part of str.trim().split(/\s+/)) {
76
+ const eq = part.indexOf('=');
77
+ if (eq > 0) {
78
+ params[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1);
79
+ }
80
+ else {
81
+ params[part.toUpperCase()] = '';
82
+ }
83
+ }
84
+ return params;
85
+ }
86
+ function undotStuff(lines) {
87
+ return lines
88
+ .map((line) => (line.startsWith('..') ? line.slice(1) : line))
89
+ .join(CRLF);
90
+ }
91
+ function parseMessageHeaders(raw) {
92
+ const headers = {};
93
+ const headerEnd = raw.indexOf(CRLF + CRLF);
94
+ const headerSection = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw;
95
+ // Unfold multi-line headers
96
+ const unfolded = headerSection.replace(/\r\n([ \t])/g, ' ');
97
+ for (const line of unfolded.split(CRLF)) {
98
+ const colon = line.indexOf(':');
99
+ if (colon > 0) {
100
+ const name = line.slice(0, colon).trim().toLowerCase();
101
+ const value = line.slice(colon + 1).trim();
102
+ headers[name] = value;
103
+ }
104
+ }
105
+ return headers;
106
+ }
107
+ // ─── Connection Handler ──────────────────────────────────────────────────────
108
+ export function createSmtpConnectionHandler(router, options) {
109
+ const { hostname = 'localhost', maxMessageSize = DEFAULT_MAX_MESSAGE_SIZE, maxRecipients = DEFAULT_MAX_RECIPIENTS, maxAuthAttempts = DEFAULT_MAX_AUTH_ATTEMPTS, timeouts: userTimeouts, requireTls = false, requireAuth = false, authRequiresTls = true, authVerifier, recipientValidator, deliverProcedure = 'mail.receive', authProcedure = 'mail.authenticate', verifyProcedure = 'mail.verify', banner, implicitTls = false, } = options;
110
+ const timeouts = { ...DEFAULT_TIMEOUTS, ...userTimeouts };
111
+ const sessions = new Map();
112
+ // ─── Capabilities ────────────────────────────────────────────────────
113
+ function getCapabilities(session) {
114
+ const caps = [hostname];
115
+ if (options.tls && !session.tlsActive && !implicitTls) {
116
+ caps.push('STARTTLS');
117
+ }
118
+ if ((!authRequiresTls || session.tlsActive) && (authVerifier || authProcedure)) {
119
+ caps.push('AUTH PLAIN LOGIN');
120
+ }
121
+ caps.push(`SIZE ${maxMessageSize}`);
122
+ caps.push('8BITMIME');
123
+ caps.push('SMTPUTF8');
124
+ caps.push('PIPELINING');
125
+ caps.push('CHUNKING');
126
+ caps.push('ENHANCEDSTATUSCODES');
127
+ caps.push('HELP');
128
+ return caps;
129
+ }
130
+ // ─── Response helpers ────────────────────────────────────────────────
131
+ function reply(session, code, ...lines) {
132
+ if (session.socket.destroyed)
133
+ return;
134
+ const last = lines.length - 1;
135
+ const response = lines
136
+ .map((line, i) => `${code}${i < last ? '-' : ' '}${line}`)
137
+ .join(CRLF) + CRLF;
138
+ session.socket.write(response);
139
+ }
140
+ function resetTimeout(session, phase) {
141
+ if (session.timeout)
142
+ clearTimeout(session.timeout);
143
+ const ms = timeouts[phase];
144
+ session.timeout = setTimeout(() => {
145
+ logger.warn({ sessionId: session.id, phase, ms }, 'SMTP timeout');
146
+ reply(session, 421, `4.4.2 ${hostname} timeout - closing connection`);
147
+ session.socket.destroy();
148
+ }, ms);
149
+ }
150
+ // ─── Transaction reset ──────────────────────────────────────────────
151
+ function resetTransaction(session) {
152
+ session.sender = undefined;
153
+ session.recipients = [];
154
+ session.dataBuffer = [];
155
+ session.dataSize = 0;
156
+ session.bdatRemaining = 0;
157
+ session.bdatChunks = [];
158
+ session.bdatTotal = 0;
159
+ session.bdatLast = false;
160
+ session.smtpUtf8 = false;
161
+ session.bodyType = '7BIT';
162
+ session.declaredSize = 0;
163
+ session.state = 'ready';
164
+ }
165
+ // ─── STARTTLS ────────────────────────────────────────────────────────
166
+ function upgradeToTls(session) {
167
+ if (!options.tls)
168
+ return;
169
+ reply(session, 220, '2.0.0 Ready to start TLS');
170
+ const plainSocket = session.socket;
171
+ const secureContext = tls.createSecureContext({
172
+ cert: options.tls.cert,
173
+ key: options.tls.key,
174
+ ca: options.tls.ca,
175
+ minVersion: options.tls.minVersion ?? 'TLSv1.2',
176
+ });
177
+ const tlsSocket = new tls.TLSSocket(plainSocket, {
178
+ secureContext,
179
+ isServer: true,
180
+ });
181
+ resetTimeout(session, 'tls');
182
+ tlsSocket.on('secure', () => {
183
+ logger.info({ sessionId: session.id, protocol: tlsSocket.getProtocol() }, 'TLS handshake complete');
184
+ // Replace socket and reset state per RFC 3207
185
+ session.socket = tlsSocket;
186
+ session.tlsActive = true;
187
+ session.ehloHostname = undefined;
188
+ resetTransaction(session);
189
+ session.state = 'greeting';
190
+ session.authenticated = false;
191
+ session.authenticatedUser = undefined;
192
+ session.authAttempts = 0;
193
+ // Re-attach data handler
194
+ tlsSocket.on('data', (chunk) => handleData(session, chunk));
195
+ tlsSocket.on('close', () => handleClose(session));
196
+ tlsSocket.on('error', (err) => handleError(session, err));
197
+ resetTimeout(session, 'greeting');
198
+ });
199
+ tlsSocket.on('error', (err) => {
200
+ logger.error({ err, sessionId: session.id }, 'TLS handshake failed');
201
+ plainSocket.destroy();
202
+ });
203
+ // Remove old listeners from plain socket (TLSSocket wraps it)
204
+ plainSocket.removeAllListeners('data');
205
+ plainSocket.removeAllListeners('close');
206
+ plainSocket.removeAllListeners('error');
207
+ plainSocket.removeAllListeners('timeout');
208
+ }
209
+ // ─── AUTH handling ───────────────────────────────────────────────────
210
+ async function handleAuthPlain(session, initialResponse) {
211
+ let decoded;
212
+ if (!initialResponse) {
213
+ // Challenge-response mode
214
+ reply(session, 334, '');
215
+ session.state = 'auth_login_user';
216
+ session.authMechanism = 'PLAIN';
217
+ return;
218
+ }
219
+ try {
220
+ decoded = Buffer.from(initialResponse, 'base64').toString('utf-8');
221
+ }
222
+ catch {
223
+ reply(session, 501, '5.5.2 Invalid BASE64 encoding');
224
+ return;
225
+ }
226
+ // PLAIN format: \0<username>\0<password> or <authzid>\0<username>\0<password>
227
+ const parts = decoded.split('\0');
228
+ if (parts.length < 3) {
229
+ reply(session, 501, '5.5.2 Invalid AUTH PLAIN format');
230
+ return;
231
+ }
232
+ const username = parts[1];
233
+ const password = parts[2];
234
+ await verifyCredentials(session, username, password);
235
+ }
236
+ async function handleAuthLoginStep(session, line) {
237
+ let decoded;
238
+ try {
239
+ decoded = Buffer.from(line, 'base64').toString('utf-8');
240
+ }
241
+ catch {
242
+ reply(session, 501, '5.5.2 Invalid BASE64 encoding');
243
+ session.state = 'ready';
244
+ return;
245
+ }
246
+ if (session.state === 'auth_login_user') {
247
+ session.authPartialUser = decoded;
248
+ reply(session, 334, Buffer.from('Password:').toString('base64'));
249
+ session.state = 'auth_login_pass';
250
+ }
251
+ else if (session.state === 'auth_login_pass') {
252
+ const username = session.authPartialUser;
253
+ session.authPartialUser = undefined;
254
+ await verifyCredentials(session, username, decoded);
255
+ }
256
+ }
257
+ async function verifyCredentials(session, username, password) {
258
+ session.authAttempts++;
259
+ if (session.authAttempts > maxAuthAttempts) {
260
+ reply(session, 421, '4.7.0 Too many authentication attempts');
261
+ session.socket.destroy();
262
+ return;
263
+ }
264
+ let accepted = false;
265
+ if (authVerifier) {
266
+ try {
267
+ accepted = await authVerifier(username, password, {
268
+ remoteAddress: session.socket.remoteAddress ?? '',
269
+ remotePort: session.socket.remotePort ?? 0,
270
+ tlsActive: session.tlsActive,
271
+ });
272
+ }
273
+ catch (err) {
274
+ logger.error({ err, sessionId: session.id }, 'Auth verifier error');
275
+ reply(session, 454, '4.7.0 Temporary authentication failure');
276
+ session.state = 'ready';
277
+ return;
278
+ }
279
+ }
280
+ else {
281
+ // Delegate to router procedure
282
+ try {
283
+ const requestId = sid();
284
+ const authAbort = new AbortController();
285
+ const ctx = await createAbortableContextAsync(requestId, {
286
+ protocol: 'smtp',
287
+ input: { body: { username, password } },
288
+ }, authAbort);
289
+ const envelope = {
290
+ id: requestId,
291
+ procedure: authProcedure,
292
+ type: 'request',
293
+ payload: { username, password },
294
+ metadata: {
295
+ 'smtp.remote-address': session.socket.remoteAddress ?? '',
296
+ 'smtp.tls-active': String(session.tlsActive),
297
+ },
298
+ context: ctx,
299
+ };
300
+ const result = await router.handle(envelope);
301
+ const payload = result?.payload;
302
+ accepted = payload?.accepted === true;
303
+ }
304
+ catch {
305
+ accepted = false;
306
+ }
307
+ }
308
+ if (accepted) {
309
+ session.authenticated = true;
310
+ session.authenticatedUser = username;
311
+ logger.info({ sessionId: session.id, user: username }, 'AUTH successful');
312
+ reply(session, 235, '2.7.0 Authentication successful');
313
+ }
314
+ else {
315
+ logger.warn({ sessionId: session.id, user: username }, 'AUTH failed');
316
+ reply(session, 535, '5.7.8 Authentication credentials invalid');
317
+ }
318
+ session.state = 'ready';
319
+ }
320
+ // ─── MAIL delivery ──────────────────────────────────────────────────
321
+ async function deliverMessage(session, rawMessage) {
322
+ const headers = parseMessageHeaders(rawMessage);
323
+ const headerEnd = rawMessage.indexOf(CRLF + CRLF);
324
+ const body = headerEnd >= 0 ? rawMessage.slice(headerEnd + 4) : '';
325
+ const requestId = sid();
326
+ const smtpCapability = {
327
+ kind: 'smtp',
328
+ remoteAddress: session.socket.remoteAddress,
329
+ remotePort: session.socket.remotePort,
330
+ sender: session.sender?.address,
331
+ recipients: session.recipients.map((r) => r.address),
332
+ authenticated: session.authenticated,
333
+ authenticatedUser: session.authenticatedUser,
334
+ tlsActive: session.tlsActive,
335
+ ehloHostname: session.ehloHostname,
336
+ };
337
+ const ctx = await createAbortableContextAsync(requestId, mergeContextSeeds({
338
+ protocol: 'smtp',
339
+ input: {
340
+ body: {
341
+ sender: session.sender?.address ?? '',
342
+ recipients: session.recipients.map((r) => r.address),
343
+ rawMessage,
344
+ headers,
345
+ body,
346
+ size: rawMessage.length,
347
+ smtpUtf8: session.smtpUtf8,
348
+ bodyType: session.bodyType,
349
+ },
350
+ metadata: {
351
+ 'smtp.sender': session.sender?.address ?? '',
352
+ 'smtp.recipients': session.recipients.map((r) => r.address).join(','),
353
+ 'smtp.tls': String(session.tlsActive),
354
+ 'smtp.ehlo': session.ehloHostname ?? '',
355
+ ...(session.authenticated
356
+ ? { 'smtp.auth-user': session.authenticatedUser ?? '' }
357
+ : {}),
358
+ },
359
+ },
360
+ }, await options.contextFactory?.(session.socket)), session.abortController);
361
+ const envelope = {
362
+ id: requestId,
363
+ procedure: deliverProcedure,
364
+ type: 'request',
365
+ payload: {
366
+ sender: session.sender?.address ?? '',
367
+ recipients: session.recipients.map((r) => r.address),
368
+ rawMessage,
369
+ headers,
370
+ body,
371
+ size: rawMessage.length,
372
+ smtpUtf8: session.smtpUtf8,
373
+ bodyType: session.bodyType,
374
+ authenticated: session.authenticated,
375
+ authenticatedUser: session.authenticatedUser,
376
+ tlsActive: session.tlsActive,
377
+ },
378
+ metadata: {
379
+ 'smtp.sender': session.sender?.address ?? '',
380
+ 'smtp.recipients': session.recipients.map((r) => r.address).join(','),
381
+ 'smtp.tls': String(session.tlsActive),
382
+ },
383
+ context: ctx,
384
+ };
385
+ logger.debug({
386
+ sessionId: session.id,
387
+ sender: session.sender?.address,
388
+ recipients: session.recipients.length,
389
+ size: rawMessage.length,
390
+ }, 'Delivering message');
391
+ try {
392
+ const result = await router.handle(envelope);
393
+ const resultEnvelope = result;
394
+ // Check for error envelope from router
395
+ if (resultEnvelope?.type === 'error' || resultEnvelope?.type === 'stream:error') {
396
+ const errPayload = resultEnvelope.payload;
397
+ const code = errPayload.code ?? '';
398
+ // Client errors (4xx) map to 5xx SMTP permanent failures
399
+ // Server errors (5xx / INTERNAL_ERROR) map to 4xx SMTP temporary failures
400
+ const isServerError = (errPayload.status ?? 500) >= 500
401
+ || code === 'INTERNAL_ERROR'
402
+ || code === 'UNAVAILABLE'
403
+ || code === 'DEADLINE_EXCEEDED';
404
+ if (isServerError) {
405
+ reply(session, 451, `4.3.0 ${errPayload.message ?? 'Temporary delivery failure'}`);
406
+ }
407
+ else {
408
+ reply(session, 550, `5.7.1 ${errPayload.message ?? 'Message rejected'}`);
409
+ }
410
+ return;
411
+ }
412
+ const payload = resultEnvelope?.payload;
413
+ if (payload?.rejected) {
414
+ reply(session, 550, `5.7.1 ${payload.message ?? 'Message rejected'}`);
415
+ }
416
+ else {
417
+ const queueId = sid();
418
+ reply(session, 250, `2.0.0 OK queued as ${queueId}`);
419
+ }
420
+ }
421
+ catch (err) {
422
+ logger.error({ err, sessionId: session.id }, 'Delivery handler error');
423
+ reply(session, 451, '4.3.0 Temporary delivery failure');
424
+ }
425
+ finally {
426
+ // Reset transaction after delivery (allow next MAIL FROM on same connection)
427
+ resetTransaction(session);
428
+ }
429
+ }
430
+ // ─── Command Processing ─────────────────────────────────────────────
431
+ async function processCommand(session, line) {
432
+ // Handle AUTH multi-step states
433
+ if (session.state === 'auth_login_user' && session.authMechanism === 'PLAIN') {
434
+ await handleAuthPlain(session, line);
435
+ return;
436
+ }
437
+ if (session.state === 'auth_login_user' || session.state === 'auth_login_pass') {
438
+ await handleAuthLoginStep(session, line);
439
+ return;
440
+ }
441
+ // Handle DATA content
442
+ if (session.state === 'data') {
443
+ handleDataContent(session, line);
444
+ return;
445
+ }
446
+ // Parse command
447
+ const spaceIdx = line.indexOf(' ');
448
+ const command = (spaceIdx > 0 ? line.slice(0, spaceIdx) : line).toUpperCase();
449
+ const args = spaceIdx > 0 ? line.slice(spaceIdx + 1).trim() : '';
450
+ switch (command) {
451
+ case 'EHLO':
452
+ handleEhlo(session, args);
453
+ break;
454
+ case 'HELO':
455
+ handleHelo(session, args);
456
+ break;
457
+ case 'STARTTLS':
458
+ handleStarttls(session);
459
+ break;
460
+ case 'AUTH':
461
+ await handleAuth(session, args);
462
+ break;
463
+ case 'MAIL':
464
+ await handleMailFrom(session, args);
465
+ break;
466
+ case 'RCPT':
467
+ await handleRcptTo(session, args);
468
+ break;
469
+ case 'DATA':
470
+ handleDataStart(session);
471
+ break;
472
+ case 'BDAT':
473
+ handleBdatStart(session, args);
474
+ break;
475
+ case 'RSET':
476
+ handleRset(session);
477
+ break;
478
+ case 'NOOP':
479
+ reply(session, 250, '2.0.0 OK');
480
+ break;
481
+ case 'QUIT':
482
+ handleQuit(session);
483
+ break;
484
+ case 'VRFY':
485
+ await handleVrfy(session, args);
486
+ break;
487
+ case 'HELP':
488
+ handleHelp(session);
489
+ break;
490
+ case 'EXPN':
491
+ reply(session, 502, '5.5.1 EXPN not supported');
492
+ break;
493
+ default:
494
+ reply(session, 500, '5.5.2 Unrecognized command');
495
+ break;
496
+ }
497
+ }
498
+ // ─── Individual command handlers ─────────────────────────────────────
499
+ function handleEhlo(session, clientHostname) {
500
+ if (!clientHostname) {
501
+ reply(session, 501, '5.5.4 EHLO requires a hostname');
502
+ return;
503
+ }
504
+ session.ehloHostname = clientHostname;
505
+ resetTransaction(session);
506
+ const caps = getCapabilities(session);
507
+ reply(session, 250, ...caps);
508
+ resetTimeout(session, 'command');
509
+ }
510
+ function handleHelo(session, clientHostname) {
511
+ if (!clientHostname) {
512
+ reply(session, 501, '5.5.4 HELO requires a hostname');
513
+ return;
514
+ }
515
+ session.ehloHostname = clientHostname;
516
+ resetTransaction(session);
517
+ reply(session, 250, `${hostname} Hello ${clientHostname}`);
518
+ resetTimeout(session, 'command');
519
+ }
520
+ function handleStarttls(session) {
521
+ if (!options.tls) {
522
+ reply(session, 502, '5.5.1 STARTTLS not available');
523
+ return;
524
+ }
525
+ if (session.tlsActive) {
526
+ reply(session, 503, '5.5.1 TLS already active');
527
+ return;
528
+ }
529
+ if (!session.ehloHostname) {
530
+ reply(session, 503, '5.5.1 EHLO required first');
531
+ return;
532
+ }
533
+ upgradeToTls(session);
534
+ }
535
+ async function handleAuth(session, args) {
536
+ if (!session.ehloHostname) {
537
+ reply(session, 503, '5.5.1 EHLO required first');
538
+ return;
539
+ }
540
+ if (session.authenticated) {
541
+ reply(session, 503, '5.5.1 Already authenticated');
542
+ return;
543
+ }
544
+ if (authRequiresTls && !session.tlsActive) {
545
+ reply(session, 538, '5.7.11 Encryption required for authentication');
546
+ return;
547
+ }
548
+ if (session.sender) {
549
+ reply(session, 503, '5.5.1 AUTH not allowed during mail transaction');
550
+ return;
551
+ }
552
+ const spaceIdx = args.indexOf(' ');
553
+ const mechanism = (spaceIdx > 0 ? args.slice(0, spaceIdx) : args).toUpperCase();
554
+ const initialResponse = spaceIdx > 0 ? args.slice(spaceIdx + 1).trim() : undefined;
555
+ switch (mechanism) {
556
+ case 'PLAIN':
557
+ await handleAuthPlain(session, initialResponse);
558
+ break;
559
+ case 'LOGIN': {
560
+ session.authMechanism = 'LOGIN';
561
+ if (initialResponse) {
562
+ // Initial response is the username
563
+ let decoded;
564
+ try {
565
+ decoded = Buffer.from(initialResponse, 'base64').toString('utf-8');
566
+ }
567
+ catch {
568
+ reply(session, 501, '5.5.2 Invalid BASE64 encoding');
569
+ return;
570
+ }
571
+ session.authPartialUser = decoded;
572
+ reply(session, 334, Buffer.from('Password:').toString('base64'));
573
+ session.state = 'auth_login_pass';
574
+ }
575
+ else {
576
+ reply(session, 334, Buffer.from('Username:').toString('base64'));
577
+ session.state = 'auth_login_user';
578
+ }
579
+ break;
580
+ }
581
+ default:
582
+ reply(session, 504, '5.5.4 Unrecognized authentication mechanism');
583
+ break;
584
+ }
585
+ }
586
+ async function handleMailFrom(session, args) {
587
+ if (!session.ehloHostname) {
588
+ reply(session, 503, '5.5.1 EHLO/HELO required first');
589
+ return;
590
+ }
591
+ if (requireTls && !session.tlsActive) {
592
+ reply(session, 530, '5.7.0 Must issue STARTTLS first');
593
+ return;
594
+ }
595
+ if (requireAuth && !session.authenticated) {
596
+ reply(session, 530, '5.7.0 Authentication required');
597
+ return;
598
+ }
599
+ if (session.sender) {
600
+ reply(session, 503, '5.5.1 Sender already specified (use RSET to reset)');
601
+ return;
602
+ }
603
+ // Parse "FROM:<addr> [params]"
604
+ const fromMatch = args.match(/^FROM:\s*(.*)/i);
605
+ if (!fromMatch) {
606
+ reply(session, 501, '5.5.4 Syntax: MAIL FROM:<address>');
607
+ return;
608
+ }
609
+ const parsed = parseAddress(fromMatch[1].trim());
610
+ if (!parsed) {
611
+ reply(session, 501, '5.1.7 Invalid sender address');
612
+ return;
613
+ }
614
+ // Check SIZE parameter
615
+ if (parsed.params.SIZE) {
616
+ const size = parseInt(parsed.params.SIZE, 10);
617
+ if (isNaN(size) || size < 0) {
618
+ reply(session, 501, '5.5.4 Invalid SIZE parameter');
619
+ return;
620
+ }
621
+ if (size > maxMessageSize) {
622
+ reply(session, 552, `5.3.4 Message size ${size} exceeds limit ${maxMessageSize}`);
623
+ return;
624
+ }
625
+ session.declaredSize = size;
626
+ }
627
+ // Check BODY parameter
628
+ if (parsed.params.BODY) {
629
+ const bodyType = parsed.params.BODY.toUpperCase();
630
+ if (bodyType !== '7BIT' && bodyType !== '8BITMIME') {
631
+ reply(session, 501, '5.5.4 Invalid BODY parameter');
632
+ return;
633
+ }
634
+ session.bodyType = bodyType;
635
+ }
636
+ // Check SMTPUTF8 parameter
637
+ if ('SMTPUTF8' in parsed.params) {
638
+ session.smtpUtf8 = true;
639
+ }
640
+ session.sender = parsed;
641
+ session.state = 'mail';
642
+ reply(session, 250, '2.1.0 OK');
643
+ resetTimeout(session, 'command');
644
+ }
645
+ async function handleRcptTo(session, args) {
646
+ if (!session.sender) {
647
+ reply(session, 503, '5.5.1 MAIL FROM required first');
648
+ return;
649
+ }
650
+ // Parse "TO:<addr>"
651
+ const toMatch = args.match(/^TO:\s*(.*)/i);
652
+ if (!toMatch) {
653
+ reply(session, 501, '5.5.4 Syntax: RCPT TO:<address>');
654
+ return;
655
+ }
656
+ const parsed = parseAddress(toMatch[1].trim());
657
+ if (!parsed || !parsed.address) {
658
+ reply(session, 501, '5.1.3 Invalid recipient address');
659
+ return;
660
+ }
661
+ if (session.recipients.length >= maxRecipients) {
662
+ reply(session, 452, `4.5.3 Too many recipients (max ${maxRecipients})`);
663
+ return;
664
+ }
665
+ // Validate recipient
666
+ if (recipientValidator) {
667
+ try {
668
+ const accepted = await recipientValidator(parsed.address, session.sender.address, {
669
+ remoteAddress: session.socket.remoteAddress ?? '',
670
+ authenticated: session.authenticated,
671
+ authenticatedUser: session.authenticatedUser,
672
+ });
673
+ if (!accepted) {
674
+ reply(session, 550, `5.1.1 <${parsed.address}> recipient rejected`);
675
+ return;
676
+ }
677
+ }
678
+ catch (err) {
679
+ logger.error({ err, sessionId: session.id }, 'Recipient validator error');
680
+ reply(session, 451, '4.3.0 Temporary failure validating recipient');
681
+ return;
682
+ }
683
+ }
684
+ session.recipients.push(parsed);
685
+ session.state = 'rcpt';
686
+ reply(session, 250, '2.1.5 OK');
687
+ resetTimeout(session, 'command');
688
+ }
689
+ function handleDataStart(session) {
690
+ if (session.recipients.length === 0) {
691
+ reply(session, 503, '5.5.1 RCPT TO required first');
692
+ return;
693
+ }
694
+ session.state = 'data';
695
+ session.dataBuffer = [];
696
+ session.dataSize = 0;
697
+ reply(session, 354, 'Start mail input; end with <CRLF>.<CRLF>');
698
+ resetTimeout(session, 'data');
699
+ }
700
+ function handleDataContent(session, line) {
701
+ // Check for end-of-data marker
702
+ if (line === '.') {
703
+ // Message complete — undo dot-stuffing and deliver
704
+ const rawMessage = undotStuff(session.dataBuffer);
705
+ session.state = 'ready';
706
+ resetTimeout(session, 'command');
707
+ deliverMessage(session, rawMessage).catch((err) => {
708
+ logger.error({ err, sessionId: session.id }, 'Unhandled delivery error');
709
+ reply(session, 451, '4.3.0 Temporary delivery failure');
710
+ });
711
+ return;
712
+ }
713
+ // Enforce line length
714
+ if (line.length > MAX_LINE_LENGTH) {
715
+ reply(session, 500, '5.5.2 Line too long');
716
+ session.state = 'ready';
717
+ return;
718
+ }
719
+ // Enforce message size
720
+ session.dataSize += line.length + 2; // +2 for CRLF
721
+ if (session.dataSize > maxMessageSize) {
722
+ reply(session, 552, `5.3.4 Message exceeds maximum size of ${maxMessageSize}`);
723
+ resetTransaction(session);
724
+ return;
725
+ }
726
+ session.dataBuffer.push(line);
727
+ // Keep resetting timeout during data receipt
728
+ resetTimeout(session, 'data');
729
+ }
730
+ function handleBdatStart(session, args) {
731
+ if (session.recipients.length === 0 && session.state !== 'bdat') {
732
+ reply(session, 503, '5.5.1 RCPT TO required first');
733
+ return;
734
+ }
735
+ const parts = args.trim().split(/\s+/);
736
+ const size = parseInt(parts[0] ?? '', 10);
737
+ const isLast = (parts[1] ?? '').toUpperCase() === 'LAST';
738
+ if (isNaN(size) || size < 0) {
739
+ reply(session, 501, '5.5.4 Invalid BDAT size');
740
+ return;
741
+ }
742
+ const projectedTotal = session.bdatTotal + size;
743
+ if (projectedTotal > maxMessageSize) {
744
+ reply(session, 552, `5.3.4 Message exceeds maximum size of ${maxMessageSize}`);
745
+ resetTransaction(session);
746
+ return;
747
+ }
748
+ session.state = 'bdat';
749
+ session.bdatRemaining = size;
750
+ session.bdatLast = isLast;
751
+ session.bdatTotal += size;
752
+ resetTimeout(session, 'data');
753
+ // If size is 0 and LAST, finalize
754
+ if (size === 0 && isLast) {
755
+ finalizeBdat(session);
756
+ }
757
+ }
758
+ function finalizeBdat(session) {
759
+ const rawMessage = Buffer.concat(session.bdatChunks).toString('utf-8');
760
+ resetTimeout(session, 'command');
761
+ deliverMessage(session, rawMessage).catch((err) => {
762
+ logger.error({ err, sessionId: session.id }, 'Unhandled BDAT delivery error');
763
+ reply(session, 451, '4.3.0 Temporary delivery failure');
764
+ });
765
+ session.bdatChunks = [];
766
+ session.bdatTotal = 0;
767
+ session.bdatRemaining = 0;
768
+ session.state = 'ready';
769
+ }
770
+ function handleRset(session) {
771
+ resetTransaction(session);
772
+ reply(session, 250, '2.0.0 OK');
773
+ resetTimeout(session, 'command');
774
+ }
775
+ function handleQuit(session) {
776
+ session.state = 'closing';
777
+ reply(session, 221, `2.0.0 ${hostname} closing connection`);
778
+ setTimeout(() => {
779
+ if (!session.socket.destroyed) {
780
+ session.socket.destroy();
781
+ }
782
+ }, timeouts.quit);
783
+ }
784
+ async function handleVrfy(session, args) {
785
+ if (!args.trim()) {
786
+ reply(session, 501, '5.5.4 VRFY requires an argument');
787
+ return;
788
+ }
789
+ try {
790
+ const requestId = sid();
791
+ const vrfyAbort = new AbortController();
792
+ const ctx = await createAbortableContextAsync(requestId, {
793
+ protocol: 'smtp',
794
+ input: { body: { address: args.trim() } },
795
+ }, vrfyAbort);
796
+ const envelope = {
797
+ id: requestId,
798
+ procedure: verifyProcedure,
799
+ type: 'request',
800
+ payload: { address: args.trim() },
801
+ metadata: {},
802
+ context: ctx,
803
+ };
804
+ const result = await router.handle(envelope);
805
+ const payload = result?.payload;
806
+ if (payload?.exists) {
807
+ reply(session, 250, `2.1.5 ${payload.address ?? args.trim()}`);
808
+ }
809
+ else {
810
+ reply(session, 550, '5.1.1 User not found');
811
+ }
812
+ }
813
+ catch {
814
+ // RFC 5321 §3.5.3: server MAY refuse VRFY
815
+ reply(session, 252, '2.5.2 Cannot VRFY user, but will accept message');
816
+ }
817
+ }
818
+ function handleHelp(session) {
819
+ reply(session, 214, '2.0.0 Supported commands:', 'EHLO HELO MAIL RCPT DATA BDAT', 'RSET NOOP VRFY HELP QUIT', 'STARTTLS AUTH');
820
+ }
821
+ // ─── Data handling (CRLF framing + BDAT binary) ─────────────────────
822
+ function handleData(session, chunk) {
823
+ // BDAT binary mode — read exact bytes
824
+ if (session.state === 'bdat' && session.bdatRemaining > 0) {
825
+ handleBdatData(session, chunk);
826
+ return;
827
+ }
828
+ // Line-based mode
829
+ const text = session.lineBuffer + chunk.toString('utf-8');
830
+ const lines = text.split(CRLF);
831
+ // Last element is incomplete (no trailing CRLF)
832
+ session.lineBuffer = lines.pop();
833
+ // Guard against infinitely long lines
834
+ if (session.lineBuffer.length > MAX_LINE_LENGTH + 100) {
835
+ reply(session, 500, '5.5.2 Line too long');
836
+ session.socket.destroy();
837
+ return;
838
+ }
839
+ for (const line of lines) {
840
+ processCommand(session, line).catch((err) => {
841
+ logger.error({ err, sessionId: session.id }, 'Command processing error');
842
+ reply(session, 451, '4.3.0 Internal error');
843
+ });
844
+ }
845
+ }
846
+ function handleBdatData(session, chunk) {
847
+ if (chunk.length <= session.bdatRemaining) {
848
+ session.bdatChunks.push(chunk);
849
+ session.bdatRemaining -= chunk.length;
850
+ if (session.bdatRemaining === 0) {
851
+ reply(session, 250, `2.0.0 ${session.bdatTotal} bytes received`);
852
+ if (session.bdatLast) {
853
+ finalizeBdat(session);
854
+ }
855
+ }
856
+ }
857
+ else {
858
+ // Chunk exceeds BDAT boundary — split
859
+ const bdatPart = chunk.subarray(0, session.bdatRemaining);
860
+ const remainder = chunk.subarray(session.bdatRemaining);
861
+ session.bdatChunks.push(bdatPart);
862
+ session.bdatRemaining = 0;
863
+ reply(session, 250, `2.0.0 ${session.bdatTotal} bytes received`);
864
+ if (session.bdatLast) {
865
+ finalizeBdat(session);
866
+ }
867
+ // Process remainder as line-based commands
868
+ handleData(session, remainder);
869
+ }
870
+ }
871
+ // ─── Connection lifecycle ───────────────────────────────────────────
872
+ function handleClose(session) {
873
+ logger.info({ sessionId: session.id }, 'SMTP client disconnected');
874
+ if (session.timeout)
875
+ clearTimeout(session.timeout);
876
+ session.abortController.abort('Client disconnected');
877
+ sessions.delete(session.id);
878
+ }
879
+ function handleError(session, err) {
880
+ logger.error({ err, sessionId: session.id }, 'SMTP socket error');
881
+ }
882
+ function handleConnection(socket) {
883
+ const sessionId = sid();
884
+ const session = {
885
+ id: sessionId,
886
+ socket,
887
+ state: 'greeting',
888
+ recipients: [],
889
+ dataBuffer: [],
890
+ dataSize: 0,
891
+ tlsActive: implicitTls,
892
+ authenticated: false,
893
+ authAttempts: 0,
894
+ bdatRemaining: 0,
895
+ bdatChunks: [],
896
+ bdatTotal: 0,
897
+ bdatLast: false,
898
+ smtpUtf8: false,
899
+ bodyType: '7BIT',
900
+ declaredSize: 0,
901
+ timeout: null,
902
+ lineBuffer: '',
903
+ abortController: new AbortController(),
904
+ };
905
+ sessions.set(sessionId, session);
906
+ const remoteAddress = `${socket.remoteAddress}:${socket.remotePort}`;
907
+ logger.info({ sessionId, remoteAddress }, 'SMTP client connected');
908
+ socket.setKeepAlive(true, 30000);
909
+ socket.setNoDelay(true);
910
+ socket.on('data', (chunk) => {
911
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
912
+ handleData(session, data);
913
+ });
914
+ socket.on('close', () => handleClose(session));
915
+ socket.on('error', (err) => handleError(session, err));
916
+ socket.on('timeout', () => {
917
+ logger.warn({ sessionId }, 'SMTP socket timeout');
918
+ reply(session, 421, `4.4.2 ${hostname} timeout`);
919
+ socket.destroy();
920
+ });
921
+ // Send greeting
922
+ const bannerText = banner ? ` ${banner}` : ' ESMTP Raffel';
923
+ reply(session, 220, `${hostname}${bannerText}`);
924
+ session.state = 'greeting';
925
+ resetTimeout(session, 'greeting');
926
+ }
927
+ return {
928
+ handleConnection(socket) {
929
+ handleConnection(socket);
930
+ },
931
+ closeAllConnections() {
932
+ for (const [, session] of sessions) {
933
+ if (session.timeout)
934
+ clearTimeout(session.timeout);
935
+ session.abortController.abort('Server shutdown');
936
+ session.socket.destroy();
937
+ }
938
+ sessions.clear();
939
+ },
940
+ get clientCount() {
941
+ return sessions.size;
942
+ },
943
+ };
944
+ }
945
+ // ─── Adapter Factory ─────────────────────────────────────────────────────────
946
+ export function createSmtpAdapter(router, options) {
947
+ const { port, host = '0.0.0.0' } = options;
948
+ let server = null;
949
+ const connectionHandler = createSmtpConnectionHandler(router, options);
950
+ function handleConnection(socket) {
951
+ if (options.filter) {
952
+ const filter = options.filter;
953
+ const remoteHost = socket.remoteAddress ?? '';
954
+ const remotePort = socket.remotePort ?? 0;
955
+ checkConnectionFilter(filter, remoteHost, remotePort)
956
+ .then(({ allowed, reason }) => {
957
+ if (!allowed) {
958
+ filter.onDenied?.({ host: remoteHost, port: remotePort, reason: reason });
959
+ socket.destroy();
960
+ return;
961
+ }
962
+ if (options.implicitTls && options.tls) {
963
+ wrapImplicitTls(socket, options.tls, (tlsSocket) => {
964
+ connectionHandler.handleConnection(tlsSocket);
965
+ });
966
+ }
967
+ else {
968
+ connectionHandler.handleConnection(socket);
969
+ }
970
+ })
971
+ .catch(() => {
972
+ socket.destroy();
973
+ });
974
+ return;
975
+ }
976
+ if (options.implicitTls && options.tls) {
977
+ wrapImplicitTls(socket, options.tls, (tlsSocket) => {
978
+ connectionHandler.handleConnection(tlsSocket);
979
+ });
980
+ }
981
+ else {
982
+ connectionHandler.handleConnection(socket);
983
+ }
984
+ }
985
+ return {
986
+ async start() {
987
+ return new Promise((resolve, reject) => {
988
+ server = createServer(handleConnection);
989
+ server.on('error', (err) => {
990
+ logger.error({ err }, 'SMTP server error');
991
+ reject(err);
992
+ });
993
+ server.listen(port, host, () => {
994
+ logger.info({ port, host }, 'SMTP server listening');
995
+ resolve();
996
+ });
997
+ });
998
+ },
999
+ async stop() {
1000
+ return new Promise((resolve) => {
1001
+ connectionHandler.closeAllConnections();
1002
+ if (server) {
1003
+ server.close(() => {
1004
+ logger.info('SMTP server stopped');
1005
+ server = null;
1006
+ resolve();
1007
+ });
1008
+ }
1009
+ else {
1010
+ resolve();
1011
+ }
1012
+ });
1013
+ },
1014
+ get clientCount() {
1015
+ return connectionHandler.clientCount;
1016
+ },
1017
+ get server() {
1018
+ return server;
1019
+ },
1020
+ };
1021
+ }
1022
+ // ─── Implicit TLS wrapper (port 465) ─────────────────────────────────────────
1023
+ function wrapImplicitTls(socket, tlsConfig, onSecure) {
1024
+ const secureContext = tls.createSecureContext({
1025
+ cert: tlsConfig.cert,
1026
+ key: tlsConfig.key,
1027
+ ca: tlsConfig.ca,
1028
+ minVersion: tlsConfig.minVersion ?? 'TLSv1.2',
1029
+ });
1030
+ const tlsSocket = new tls.TLSSocket(socket, {
1031
+ secureContext,
1032
+ isServer: true,
1033
+ });
1034
+ tlsSocket.on('secure', () => onSecure(tlsSocket));
1035
+ tlsSocket.on('error', (err) => {
1036
+ logger.error({ err }, 'Implicit TLS handshake failed');
1037
+ socket.destroy();
1038
+ });
1039
+ }
1040
+ // ─── Test Client ─────────────────────────────────────────────────────────────
1041
+ /**
1042
+ * Helper: Create an SMTP client for testing/usage
1043
+ */
1044
+ export function createSmtpClient(options) {
1045
+ const { host, port } = options;
1046
+ let socket = null;
1047
+ let lineBuffer = '';
1048
+ let responseResolve = null;
1049
+ function processIncoming(text) {
1050
+ lineBuffer += text;
1051
+ // Check for complete response (line ending with CRLF where code is followed by space)
1052
+ const lines = lineBuffer.split(CRLF);
1053
+ for (let i = 0; i < lines.length - 1; i++) {
1054
+ const line = lines[i];
1055
+ // Multi-line response: code followed by '-', last line has space
1056
+ if (line.length >= 4 && line[3] === ' ') {
1057
+ // Last line of response
1058
+ const fullResponse = lines.slice(0, i + 1).join(CRLF);
1059
+ lineBuffer = lines.slice(i + 1).join(CRLF);
1060
+ if (responseResolve) {
1061
+ const resolve = responseResolve;
1062
+ responseResolve = null;
1063
+ resolve(fullResponse);
1064
+ }
1065
+ return;
1066
+ }
1067
+ }
1068
+ }
1069
+ function waitForResponse() {
1070
+ return new Promise((resolve) => {
1071
+ responseResolve = resolve;
1072
+ // Check if we already have a complete response buffered
1073
+ if (lineBuffer) {
1074
+ processIncoming('');
1075
+ }
1076
+ });
1077
+ }
1078
+ return {
1079
+ async connect() {
1080
+ return new Promise((resolve, reject) => {
1081
+ const onConnect = () => {
1082
+ socket.on('data', (chunk) => {
1083
+ processIncoming(chunk.toString('utf-8'));
1084
+ });
1085
+ // Wait for greeting
1086
+ waitForResponse().then(resolve).catch(reject);
1087
+ };
1088
+ if (options.tls) {
1089
+ const tlsSocket = tls.connect({ host, port, rejectUnauthorized: false, ...options.tlsOptions }, onConnect);
1090
+ socket = tlsSocket;
1091
+ tlsSocket.on('error', reject);
1092
+ }
1093
+ else {
1094
+ const { createConnection } = require('node:net');
1095
+ socket = createConnection({ host, port }, onConnect);
1096
+ socket.on('error', reject);
1097
+ }
1098
+ });
1099
+ },
1100
+ async command(cmd) {
1101
+ if (!socket || socket.destroyed)
1102
+ throw new Error('Not connected');
1103
+ socket.write(cmd + CRLF);
1104
+ return waitForResponse();
1105
+ },
1106
+ async sendMail(options) {
1107
+ const recipients = Array.isArray(options.to) ? options.to : [options.to];
1108
+ await this.command(`MAIL FROM:<${options.from}>`);
1109
+ for (const rcpt of recipients) {
1110
+ await this.command(`RCPT TO:<${rcpt}>`);
1111
+ }
1112
+ await this.command('DATA');
1113
+ // Build message
1114
+ const headerLines = [];
1115
+ headerLines.push(`From: ${options.from}`);
1116
+ headerLines.push(`To: ${recipients.join(', ')}`);
1117
+ if (options.subject)
1118
+ headerLines.push(`Subject: ${options.subject}`);
1119
+ headerLines.push(`Date: ${new Date().toUTCString()}`);
1120
+ headerLines.push(`Message-ID: <${sid()}@raffel>`);
1121
+ if (options.headers) {
1122
+ for (const [key, value] of Object.entries(options.headers)) {
1123
+ headerLines.push(`${key}: ${value}`);
1124
+ }
1125
+ }
1126
+ const message = headerLines.join(CRLF) + CRLF + CRLF + (options.body ?? '');
1127
+ // Dot-stuff and send
1128
+ const stuffed = message
1129
+ .split(CRLF)
1130
+ .map((line) => (line.startsWith('.') ? '.' + line : line))
1131
+ .join(CRLF);
1132
+ socket.write(stuffed + CRLF + '.' + CRLF);
1133
+ return waitForResponse();
1134
+ },
1135
+ disconnect() {
1136
+ if (socket) {
1137
+ socket.destroy();
1138
+ socket = null;
1139
+ }
1140
+ },
1141
+ };
1142
+ }
1143
+ //# sourceMappingURL=smtp.js.map