@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/README.md +178 -0
- package/dist/index.cjs +665 -0
- package/dist/index.d.cts +355 -0
- package/dist/index.d.ts +355 -0
- package/dist/index.js +641 -0
- package/package.json +74 -0
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
|
+
}
|