@upyo/smtp 0.1.0-dev.10

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/dist/index.js ADDED
@@ -0,0 +1,641 @@
1
+ import { Socket } from "node:net";
2
+ import { connect } from "node:tls";
3
+ import { formatAddress } from "@upyo/core";
4
+
5
+ //#region src/config.ts
6
+ /**
7
+ * Creates a resolved SMTP configuration by applying default values to optional fields.
8
+ *
9
+ * This function takes a partial SMTP configuration and returns a complete
10
+ * configuration with all optional fields filled with sensible defaults.
11
+ *
12
+ * @param config - The SMTP configuration with optional fields
13
+ * @returns A resolved configuration with all defaults applied
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const resolved = createSmtpConfig({
18
+ * host: 'smtp.example.com',
19
+ * auth: { user: 'user', pass: 'pass' }
20
+ * });
21
+ *
22
+ * // resolved.port will be 587 (default)
23
+ * // resolved.secure will be true (default)
24
+ * // resolved.poolSize will be 5 (default)
25
+ * ```
26
+ */
27
+ function createSmtpConfig(config) {
28
+ return {
29
+ host: config.host,
30
+ port: config.port ?? 587,
31
+ secure: config.secure ?? true,
32
+ auth: config.auth,
33
+ tls: config.tls,
34
+ connectionTimeout: config.connectionTimeout ?? 6e4,
35
+ socketTimeout: config.socketTimeout ?? 6e4,
36
+ localName: config.localName ?? "localhost",
37
+ pool: config.pool ?? true,
38
+ poolSize: config.poolSize ?? 5
39
+ };
40
+ }
41
+
42
+ //#endregion
43
+ //#region src/smtp-connection.ts
44
+ var SmtpConnection = class {
45
+ socket = null;
46
+ config;
47
+ authenticated = false;
48
+ capabilities = [];
49
+ constructor(config) {
50
+ this.config = createSmtpConfig(config);
51
+ }
52
+ connect(signal) {
53
+ if (this.socket) throw new Error("Connection already established");
54
+ signal?.throwIfAborted();
55
+ return new Promise((resolve, reject) => {
56
+ const timeout = setTimeout(() => {
57
+ this.socket?.destroy();
58
+ reject(/* @__PURE__ */ new Error("Connection timeout"));
59
+ }, this.config.connectionTimeout);
60
+ const onConnect = () => {
61
+ clearTimeout(timeout);
62
+ resolve();
63
+ };
64
+ const onError = (error) => {
65
+ clearTimeout(timeout);
66
+ reject(error);
67
+ };
68
+ if (this.config.secure) this.socket = connect({
69
+ host: this.config.host,
70
+ port: this.config.port,
71
+ rejectUnauthorized: this.config.tls?.rejectUnauthorized ?? true,
72
+ ca: this.config.tls?.ca,
73
+ key: this.config.tls?.key,
74
+ cert: this.config.tls?.cert,
75
+ minVersion: this.config.tls?.minVersion,
76
+ maxVersion: this.config.tls?.maxVersion
77
+ });
78
+ else {
79
+ this.socket = new Socket();
80
+ this.socket.connect(this.config.port, this.config.host);
81
+ }
82
+ this.socket.setTimeout(this.config.socketTimeout);
83
+ this.socket.once("connect", onConnect);
84
+ this.socket.once("error", onError);
85
+ this.socket.once("timeout", () => {
86
+ clearTimeout(timeout);
87
+ this.socket?.destroy();
88
+ reject(/* @__PURE__ */ new Error("Socket timeout"));
89
+ });
90
+ });
91
+ }
92
+ sendCommand(command, signal) {
93
+ if (!this.socket) throw new Error("Not connected");
94
+ signal?.throwIfAborted();
95
+ return new Promise((resolve, reject) => {
96
+ let buffer = "";
97
+ const timeout = setTimeout(() => {
98
+ reject(/* @__PURE__ */ new Error("Command timeout"));
99
+ }, this.config.socketTimeout);
100
+ const onData = (data) => {
101
+ buffer += data.toString();
102
+ const lines = buffer.split("\r\n");
103
+ const incompleteLine = lines.pop() || "";
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const line = lines[i];
106
+ if (line.length >= 4 && line[3] === " ") {
107
+ const code = parseInt(line.substring(0, 3), 10);
108
+ const message = line.substring(4);
109
+ const fullResponse = lines.slice(0, i + 1).join("\r\n");
110
+ cleanup();
111
+ resolve({
112
+ code,
113
+ message,
114
+ raw: fullResponse
115
+ });
116
+ return;
117
+ }
118
+ }
119
+ buffer = incompleteLine;
120
+ };
121
+ const onError = (error) => {
122
+ cleanup();
123
+ reject(error);
124
+ };
125
+ const cleanup = () => {
126
+ clearTimeout(timeout);
127
+ this.socket?.off("data", onData);
128
+ this.socket?.off("error", onError);
129
+ };
130
+ this.socket.on("data", onData);
131
+ this.socket.on("error", onError);
132
+ this.socket.write(command + "\r\n");
133
+ });
134
+ }
135
+ greeting(signal) {
136
+ if (!this.socket) throw new Error("Not connected");
137
+ signal?.throwIfAborted();
138
+ return new Promise((resolve, reject) => {
139
+ let buffer = "";
140
+ const timeout = setTimeout(() => {
141
+ reject(/* @__PURE__ */ new Error("Greeting timeout"));
142
+ }, this.config.socketTimeout);
143
+ const onData = (data) => {
144
+ buffer += data.toString();
145
+ const lines = buffer.split("\r\n");
146
+ for (const line of lines) if (line.length >= 4 && line[3] === " ") {
147
+ const code = parseInt(line.substring(0, 3), 10);
148
+ const message = line.substring(4);
149
+ cleanup();
150
+ resolve({
151
+ code,
152
+ message,
153
+ raw: buffer
154
+ });
155
+ return;
156
+ }
157
+ };
158
+ const onError = (error) => {
159
+ cleanup();
160
+ reject(error);
161
+ };
162
+ const cleanup = () => {
163
+ clearTimeout(timeout);
164
+ this.socket?.off("data", onData);
165
+ this.socket?.off("error", onError);
166
+ };
167
+ this.socket.on("data", onData);
168
+ this.socket.on("error", onError);
169
+ });
170
+ }
171
+ async ehlo(signal) {
172
+ const response = await this.sendCommand(`EHLO ${this.config.localName}`, signal);
173
+ if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
174
+ this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).map((line) => line.substring(4)).filter((line) => line.length > 0);
175
+ }
176
+ async authenticate(signal) {
177
+ if (!this.config.auth) return;
178
+ if (this.authenticated) return;
179
+ const authMethod = this.config.auth.method ?? "plain";
180
+ if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new Error("Server does not support authentication");
181
+ switch (authMethod) {
182
+ case "plain":
183
+ await this.authPlain(signal);
184
+ break;
185
+ case "login":
186
+ await this.authLogin(signal);
187
+ break;
188
+ default: throw new Error(`Unsupported authentication method: ${authMethod}`);
189
+ }
190
+ this.authenticated = true;
191
+ }
192
+ async authPlain(signal) {
193
+ const { user, pass } = this.config.auth;
194
+ const credentials = btoa(`\0${user}\0${pass}`);
195
+ const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal);
196
+ if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`);
197
+ }
198
+ async authLogin(signal) {
199
+ const { user, pass } = this.config.auth;
200
+ let response = await this.sendCommand("AUTH LOGIN", signal);
201
+ if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`);
202
+ response = await this.sendCommand(btoa(user), signal);
203
+ if (response.code !== 334) throw new Error(`Username authentication failed: ${response.message}`);
204
+ response = await this.sendCommand(btoa(pass), signal);
205
+ if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`);
206
+ }
207
+ async sendMessage(message, signal) {
208
+ const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
209
+ if (mailResponse.code !== 250) throw new Error(`MAIL FROM failed: ${mailResponse.message}`);
210
+ for (const recipient of message.envelope.to) {
211
+ signal?.throwIfAborted();
212
+ const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
213
+ if (rcptResponse.code !== 250) throw new Error(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`);
214
+ }
215
+ const dataResponse = await this.sendCommand("DATA", signal);
216
+ if (dataResponse.code !== 354) throw new Error(`DATA failed: ${dataResponse.message}`);
217
+ const content = message.raw.replace(/\n\./g, "\n..");
218
+ const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
219
+ if (finalResponse.code !== 250) throw new Error(`Message send failed: ${finalResponse.message}`);
220
+ const messageId = this.extractMessageId(finalResponse.message);
221
+ return messageId;
222
+ }
223
+ extractMessageId(response) {
224
+ const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
225
+ return match ? match[1] : `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
226
+ }
227
+ async quit() {
228
+ if (!this.socket) return;
229
+ try {
230
+ await this.sendCommand("QUIT");
231
+ } catch {}
232
+ this.socket.destroy();
233
+ this.socket = null;
234
+ this.authenticated = false;
235
+ this.capabilities = [];
236
+ }
237
+ async reset(signal) {
238
+ if (!this.socket) throw new Error("Not connected");
239
+ const response = await this.sendCommand("RSET", signal);
240
+ if (response.code !== 250) throw new Error(`RESET failed: ${response.message}`);
241
+ }
242
+ };
243
+
244
+ //#endregion
245
+ //#region src/message-converter.ts
246
+ function convertMessage(message) {
247
+ const envelope = {
248
+ from: message.sender.address,
249
+ to: [
250
+ ...message.recipients.map((r) => r.address),
251
+ ...message.ccRecipients.map((r) => r.address),
252
+ ...message.bccRecipients.map((r) => r.address)
253
+ ]
254
+ };
255
+ const raw = buildRawMessage(message);
256
+ return {
257
+ envelope,
258
+ raw
259
+ };
260
+ }
261
+ function buildRawMessage(message) {
262
+ const lines = [];
263
+ const boundary = generateBoundary();
264
+ const hasAttachments = message.attachments.length > 0;
265
+ const hasHtml = "html" in message.content;
266
+ const hasText = "text" in message.content;
267
+ const isMultipart = hasAttachments || hasHtml && hasText;
268
+ lines.push(`From: ${formatAddress(message.sender)}`);
269
+ lines.push(`To: ${message.recipients.map(formatAddress).join(", ")}`);
270
+ if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(formatAddress).join(", ")}`);
271
+ if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(formatAddress).join(", ")}`);
272
+ lines.push(`Subject: ${encodeHeaderValue(message.subject)}`);
273
+ lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
274
+ lines.push(`Message-ID: <${generateMessageId()}>`);
275
+ if (message.priority !== "normal") {
276
+ const priorityValue = message.priority === "high" ? "1" : "5";
277
+ lines.push(`X-Priority: ${priorityValue}`);
278
+ lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
279
+ }
280
+ for (const [key, value] of message.headers) lines.push(`${key}: ${encodeHeaderValue(value)}`);
281
+ lines.push("MIME-Version: 1.0");
282
+ if (isMultipart) {
283
+ lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
284
+ lines.push("");
285
+ lines.push("This is a multi-part message in MIME format.");
286
+ lines.push("");
287
+ lines.push(`--${boundary}`);
288
+ if (hasHtml && hasText) {
289
+ const contentBoundary = generateBoundary();
290
+ lines.push(`Content-Type: multipart/alternative; boundary="${contentBoundary}"`);
291
+ lines.push("");
292
+ lines.push(`--${contentBoundary}`);
293
+ lines.push("Content-Type: text/plain; charset=utf-8");
294
+ lines.push("Content-Transfer-Encoding: quoted-printable");
295
+ lines.push("");
296
+ lines.push(encodeQuotedPrintable(message.content.text));
297
+ lines.push("");
298
+ lines.push(`--${contentBoundary}`);
299
+ lines.push("Content-Type: text/html; charset=utf-8");
300
+ lines.push("Content-Transfer-Encoding: quoted-printable");
301
+ lines.push("");
302
+ lines.push(encodeQuotedPrintable(message.content.html));
303
+ lines.push("");
304
+ lines.push(`--${contentBoundary}--`);
305
+ } else if (hasHtml) {
306
+ lines.push("Content-Type: text/html; charset=utf-8");
307
+ lines.push("Content-Transfer-Encoding: quoted-printable");
308
+ lines.push("");
309
+ lines.push(encodeQuotedPrintable(message.content.html));
310
+ } else {
311
+ lines.push("Content-Type: text/plain; charset=utf-8");
312
+ lines.push("Content-Transfer-Encoding: quoted-printable");
313
+ lines.push("");
314
+ lines.push(encodeQuotedPrintable(message.content.text));
315
+ }
316
+ for (const attachment of message.attachments) {
317
+ lines.push("");
318
+ lines.push(`--${boundary}`);
319
+ lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
320
+ lines.push("Content-Transfer-Encoding: base64");
321
+ if (attachment.inline) {
322
+ lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`);
323
+ lines.push(`Content-ID: <${attachment.contentId}>`);
324
+ } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
325
+ lines.push("");
326
+ lines.push(encodeBase64(attachment.content));
327
+ }
328
+ lines.push("");
329
+ lines.push(`--${boundary}--`);
330
+ } else if (hasHtml) {
331
+ lines.push("Content-Type: text/html; charset=utf-8");
332
+ lines.push("Content-Transfer-Encoding: quoted-printable");
333
+ lines.push("");
334
+ lines.push(encodeQuotedPrintable(message.content.html));
335
+ } else {
336
+ lines.push("Content-Type: text/plain; charset=utf-8");
337
+ lines.push("Content-Transfer-Encoding: quoted-printable");
338
+ lines.push("");
339
+ lines.push(encodeQuotedPrintable(message.content.text));
340
+ }
341
+ return lines.join("\r\n");
342
+ }
343
+ function generateBoundary() {
344
+ return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
345
+ }
346
+ function generateMessageId() {
347
+ const timestamp = Date.now();
348
+ const random = Math.random().toString(36).substr(2, 9);
349
+ return `${timestamp}.${random}@upyo.local`;
350
+ }
351
+ function encodeHeaderValue(value) {
352
+ if (!/^[\x20-\x7E]*$/.test(value)) {
353
+ const utf8Bytes = new TextEncoder().encode(value);
354
+ const base64 = btoa(String.fromCharCode(...utf8Bytes));
355
+ return `=?UTF-8?B?${base64}?=`;
356
+ }
357
+ return value;
358
+ }
359
+ function encodeQuotedPrintable(text) {
360
+ return text.replace(/[^\x20-\x7E]/g, (char) => {
361
+ const code = char.charCodeAt(0);
362
+ if (code < 256) return `=${code.toString(16).toUpperCase().padStart(2, "0")}`;
363
+ const utf8 = new TextEncoder().encode(char);
364
+ return Array.from(utf8).map((byte) => `=${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
365
+ }).replace(/=$/gm, "=3D").replace(/^\./, "=2E");
366
+ }
367
+ function encodeBase64(data) {
368
+ const base64 = btoa(String.fromCharCode(...data));
369
+ return base64.replace(/(.{76})/g, "$1\r\n").trim();
370
+ }
371
+
372
+ //#endregion
373
+ //#region src/smtp-transport.ts
374
+ /**
375
+ * SMTP transport implementation for sending emails via SMTP protocol.
376
+ *
377
+ * This transport provides efficient email delivery with connection pooling,
378
+ * support for authentication, TLS/SSL encryption, and batch sending capabilities.
379
+ *
380
+ * @example
381
+ * ```typescript
382
+ * import { SmtpTransport } from '@upyo/smtp';
383
+ *
384
+ * // Automatic resource cleanup with using statement
385
+ * await using transport = new SmtpTransport({
386
+ * host: 'smtp.gmail.com',
387
+ * port: 465,
388
+ * secure: true, // Use TLS from start
389
+ * auth: {
390
+ * user: 'user@gmail.com',
391
+ * pass: 'app-password'
392
+ * }
393
+ * });
394
+ *
395
+ * const receipt = await transport.send(message);
396
+ * // Connections are automatically closed here
397
+ *
398
+ * // Or manual management
399
+ * const transport2 = new SmtpTransport(config);
400
+ * try {
401
+ * await transport2.send(message);
402
+ * } finally {
403
+ * await transport2.closeAllConnections();
404
+ * }
405
+ * ```
406
+ */
407
+ var SmtpTransport = class {
408
+ config;
409
+ connectionPool = [];
410
+ poolSize;
411
+ /**
412
+ * Creates a new SMTP transport instance.
413
+ *
414
+ * @param config SMTP configuration including server details, authentication,
415
+ * and options.
416
+ */
417
+ constructor(config) {
418
+ this.config = config;
419
+ this.poolSize = config.poolSize ?? 5;
420
+ }
421
+ /**
422
+ * Sends a single email message via SMTP.
423
+ *
424
+ * This method converts the message to SMTP format, establishes a connection
425
+ * to the SMTP server, sends the message, and returns a receipt with the result.
426
+ *
427
+ * @example
428
+ * ```typescript
429
+ * const receipt = await transport.send({
430
+ * sender: { address: 'from@example.com' },
431
+ * recipients: [{ address: 'to@example.com' }],
432
+ * subject: 'Hello',
433
+ * content: { text: 'Hello World!' }
434
+ * });
435
+ *
436
+ * if (receipt.successful) {
437
+ * console.log('Message sent with ID:', receipt.messageId);
438
+ * }
439
+ * ```
440
+ *
441
+ * @param message The email message to send.
442
+ * @param options Optional transport options including `AbortSignal` for
443
+ * cancellation.
444
+ * @returns A promise that resolves to a receipt indicating success or
445
+ * failure.
446
+ */
447
+ async send(message, options) {
448
+ options?.signal?.throwIfAborted();
449
+ const connection = await this.getConnection(options?.signal);
450
+ try {
451
+ options?.signal?.throwIfAborted();
452
+ const smtpMessage = convertMessage(message);
453
+ options?.signal?.throwIfAborted();
454
+ const messageId = await connection.sendMessage(smtpMessage, options?.signal);
455
+ await this.returnConnection(connection);
456
+ return {
457
+ messageId,
458
+ errorMessages: [],
459
+ successful: true
460
+ };
461
+ } catch (error) {
462
+ await this.discardConnection(connection);
463
+ return {
464
+ messageId: "",
465
+ errorMessages: [error instanceof Error ? error.message : String(error)],
466
+ successful: false
467
+ };
468
+ }
469
+ }
470
+ /**
471
+ * Sends multiple email messages efficiently using a single SMTP connection.
472
+ *
473
+ * This method is optimized for bulk email sending by reusing a single SMTP
474
+ * connection for all messages, which significantly improves performance
475
+ * compared to sending each message individually.
476
+ *
477
+ * @example
478
+ * ```typescript
479
+ * const messages = [
480
+ * { subject: 'Message 1', recipients: [{ address: 'user1@example.com' }], ... },
481
+ * { subject: 'Message 2', recipients: [{ address: 'user2@example.com' }], ... }
482
+ * ];
483
+ *
484
+ * for await (const receipt of transport.sendMany(messages)) {
485
+ * if (receipt.successful) {
486
+ * console.log('Sent:', receipt.messageId);
487
+ * } else {
488
+ * console.error('Failed:', receipt.errorMessages);
489
+ * }
490
+ * }
491
+ * ```
492
+ *
493
+ * @param messages An iterable or async iterable of messages to send.
494
+ * @param options Optional transport options including `AbortSignal` for
495
+ * cancellation.
496
+ * @returns An async iterable of receipts, one for each message.
497
+ */
498
+ async *sendMany(messages, options) {
499
+ options?.signal?.throwIfAborted();
500
+ const connection = await this.getConnection(options?.signal);
501
+ let connectionValid = true;
502
+ try {
503
+ const isAsyncIterable = Symbol.asyncIterator in messages;
504
+ if (isAsyncIterable) for await (const message of messages) {
505
+ options?.signal?.throwIfAborted();
506
+ if (!connectionValid) {
507
+ yield {
508
+ messageId: "",
509
+ errorMessages: ["Connection is no longer valid"],
510
+ successful: false
511
+ };
512
+ continue;
513
+ }
514
+ try {
515
+ const smtpMessage = convertMessage(message);
516
+ options?.signal?.throwIfAborted();
517
+ const messageId = await connection.sendMessage(smtpMessage, options?.signal);
518
+ yield {
519
+ messageId,
520
+ errorMessages: [],
521
+ successful: true
522
+ };
523
+ } catch (error) {
524
+ connectionValid = false;
525
+ yield {
526
+ messageId: "",
527
+ errorMessages: [error instanceof Error ? error.message : String(error)],
528
+ successful: false
529
+ };
530
+ }
531
+ }
532
+ else for (const message of messages) {
533
+ options?.signal?.throwIfAborted();
534
+ if (!connectionValid) {
535
+ yield {
536
+ messageId: "",
537
+ errorMessages: ["Connection is no longer valid"],
538
+ successful: false
539
+ };
540
+ continue;
541
+ }
542
+ try {
543
+ const smtpMessage = convertMessage(message);
544
+ options?.signal?.throwIfAborted();
545
+ const messageId = await connection.sendMessage(smtpMessage, options?.signal);
546
+ yield {
547
+ messageId,
548
+ errorMessages: [],
549
+ successful: true
550
+ };
551
+ } catch (error) {
552
+ connectionValid = false;
553
+ yield {
554
+ messageId: "",
555
+ errorMessages: [error instanceof Error ? error.message : String(error)],
556
+ successful: false
557
+ };
558
+ }
559
+ }
560
+ if (connectionValid) await this.returnConnection(connection);
561
+ else await this.discardConnection(connection);
562
+ } catch (error) {
563
+ await this.discardConnection(connection);
564
+ throw error;
565
+ }
566
+ }
567
+ async getConnection(signal) {
568
+ signal?.throwIfAborted();
569
+ if (this.connectionPool.length > 0) return this.connectionPool.pop();
570
+ const connection = new SmtpConnection(this.config);
571
+ await this.connectAndSetup(connection, signal);
572
+ return connection;
573
+ }
574
+ async connectAndSetup(connection, signal) {
575
+ signal?.throwIfAborted();
576
+ await connection.connect(signal);
577
+ signal?.throwIfAborted();
578
+ const greeting = await connection.greeting(signal);
579
+ if (greeting.code !== 220) throw new Error(`Server greeting failed: ${greeting.message}`);
580
+ signal?.throwIfAborted();
581
+ await connection.ehlo(signal);
582
+ signal?.throwIfAborted();
583
+ await connection.authenticate(signal);
584
+ }
585
+ async returnConnection(connection) {
586
+ if (!this.config.pool) {
587
+ await connection.quit();
588
+ return;
589
+ }
590
+ if (this.connectionPool.length < this.poolSize) try {
591
+ await connection.reset();
592
+ this.connectionPool.push(connection);
593
+ } catch {
594
+ await this.discardConnection(connection);
595
+ }
596
+ else await connection.quit();
597
+ }
598
+ async discardConnection(connection) {
599
+ try {
600
+ await connection.quit();
601
+ } catch {}
602
+ }
603
+ /**
604
+ * Closes all active SMTP connections in the connection pool.
605
+ *
606
+ * This method should be called when shutting down the application
607
+ * to ensure all connections are properly closed and resources are freed.
608
+ *
609
+ * @example
610
+ * ```typescript
611
+ * // At application shutdown
612
+ * await transport.closeAllConnections();
613
+ * ```
614
+ */
615
+ async closeAllConnections() {
616
+ const connections = [...this.connectionPool];
617
+ this.connectionPool = [];
618
+ await Promise.all(connections.map((connection) => this.discardConnection(connection)));
619
+ }
620
+ /**
621
+ * Implements AsyncDisposable interface for automatic resource cleanup.
622
+ *
623
+ * This method is called automatically when using the `using` keyword,
624
+ * ensuring that all SMTP connections are properly closed when the
625
+ * transport goes out of scope.
626
+ *
627
+ * @example
628
+ * ```typescript
629
+ * // Automatic cleanup with using statement
630
+ * await using transport = new SmtpTransport(config);
631
+ * await transport.send(message);
632
+ * // Connections are automatically closed here
633
+ * ```
634
+ */
635
+ async [Symbol.asyncDispose]() {
636
+ await this.closeAllConnections();
637
+ }
638
+ };
639
+
640
+ //#endregion
641
+ export { SmtpTransport, createSmtpConfig };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@upyo/smtp",
3
+ "version": "0.1.0-dev.10+c222d7c6",
4
+ "description": "SMTP transport for Upyo email library",
5
+ "keywords": [
6
+ "email",
7
+ "mail",
8
+ "sendmail",
9
+ "smtp"
10
+ ],
11
+ "license": "MIT",
12
+ "author": {
13
+ "name": "Hong Minhee",
14
+ "email": "hong@minhee.org",
15
+ "url": "https://hongminhee.org/"
16
+ },
17
+ "homepage": "https://upyo.org/",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/dahlia/upyo.git",
21
+ "directory": "packages/smtp/"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/dahlia/upyo/issues"
25
+ },
26
+ "funding": [
27
+ "https://github.com/sponsors/dahlia"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20.0.0",
31
+ "bun": ">=1.2.0",
32
+ "deno": ">=2.3.0"
33
+ },
34
+ "files": [
35
+ "dist/",
36
+ "package.json",
37
+ "README.md"
38
+ ],
39
+ "type": "module",
40
+ "module": "./dist/index.js",
41
+ "main": "./dist/index.cjs",
42
+ "types": "./dist/index.d.ts",
43
+ "exports": {
44
+ ".": {
45
+ "types": {
46
+ "import": "./dist/index.d.ts",
47
+ "require": "./dist/index.d.cts"
48
+ },
49
+ "import": "./dist/index.js",
50
+ "require": "./dist/index.cjs"
51
+ },
52
+ "./package.json": "./package.json"
53
+ },
54
+ "sideEffects": false,
55
+ "peerDependencies": {
56
+ "@upyo/core": "0.1.0-dev.10+c222d7c6"
57
+ },
58
+ "devDependencies": {
59
+ "@dotenvx/dotenvx": "^1.47.3",
60
+ "tsdown": "^0.12.7",
61
+ "typescript": "5.8.3"
62
+ },
63
+ "scripts": {
64
+ "build": "tsdown",
65
+ "prepublish": "tsdown",
66
+ "test": "tsdown && dotenvx run --ignore=MISSING_ENV_FILE -- node --experimental-transform-types --test",
67
+ "test:bun": "tsdown && bun test --timeout=30000 --env-file=.env",
68
+ "test:deno": "deno test --allow-env --allow-net --env-file=.env",
69
+ "mailpit:start": "docker run -d --name upyo-mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit:latest",
70
+ "mailpit:stop": "docker stop upyo-mailpit && docker rm upyo-mailpit",
71
+ "mailpit:logs": "docker logs upyo-mailpit",
72
+ "dev:mailpit": "docker run --rm -p 1025:1025 -p 8025:8025 axllent/mailpit:latest"
73
+ }
74
+ }